From 8c4a26e71cc11dcb9f08829d5bdd51c470bc8724 Mon Sep 17 00:00:00 2001 From: Cory O'Daniel Date: Tue, 31 Mar 2026 15:53:31 -0700 Subject: [PATCH 1/8] starting point: resume referrers support work Snapshot of in-progress work before resuming development. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 7 +++++ .tool-versions | 2 ++ lib/oci/plug.ex | 15 +++++++--- lib/oci/plug/handler.ex | 12 ++++++++ lib/oci/registry.ex | 19 ++++++++++-- lib/oci/storage/local.ex | 48 +++++++++++++++++++++---------- test/support/conformance_suite.ex | 2 +- 7 files changed, 82 insertions(+), 23 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .tool-versions diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1a11670 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(asdf install:*)" + ] + } +} diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..def9100 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +elixir 1.18.4-otp-27 +erlang 27.2 diff --git a/lib/oci/plug.ex b/lib/oci/plug.ex index 73828cc..1e0827f 100644 --- a/lib/oci/plug.ex +++ b/lib/oci/plug.ex @@ -24,16 +24,13 @@ defmodule OCI.Plug do @impl true def call(%{script_name: [@api_version]} = conn, %{registry: registry}) do conn + |> maybe_enable_inspection() |> OCI.Plug.Context.call() |> put_private(:oci_registry, registry) |> authenticate() |> fetch_query_params() |> authorize() - # |> OCI.Inspector.log_info(nil, "before:handle/1") - |> OCI.Inspector.inspect("before:handle/1") |> OCI.Plug.Handler.handle() - - # |> OCI.Inspector.log_info(nil, "after:handle/1") end def call(conn, _opts) do @@ -98,4 +95,14 @@ defmodule OCI.Plug do |> send_resp(error.http_status, body) |> halt() end + + defp maybe_enable_inspection(conn) do + if System.get_env("OCI_TEST_ENABLE_HTTP_PRY") == "true" do + # You can set this inspect call anywhere on the conn and it will trigger + # Inspector.pry calls wherever they are placed based on the HTTP request. + conn |> OCI.Inspector.inspect("OCI_TEST_ENABLE_HTTP_PRY enabled!") + else + conn + end + end end diff --git a/lib/oci/plug/handler.ex b/lib/oci/plug/handler.ex index e90066f..e12afdf 100644 --- a/lib/oci/plug/handler.ex +++ b/lib/oci/plug/handler.ex @@ -239,7 +239,19 @@ defmodule OCI.Plug.Handler do manifest_digest = conn.assigns[:oci_digest] with :ok <- Registry.store_manifest(registry, repo, reference, manifest, manifest_digest, ctx) do + # TODO: hack bypass OCI-Subject tests for now (impl coming in next PR, but it blocks LIST Tag tests) + maybe_set_oci_subject = fn conn -> + case get_in(conn.params, ["subject", "digest"]) do + nil -> + conn + + subject_digest -> + put_resp_header(conn, "oci-subject", subject_digest) + end + end + conn + |> maybe_set_oci_subject.() |> put_resp_header("location", Registry.manifests_reference_path(repo, reference)) |> send_resp(201, "") end diff --git a/lib/oci/registry.ex b/lib/oci/registry.ex index fbb6c34..5814cbf 100644 --- a/lib/oci/registry.ex +++ b/lib/oci/registry.ex @@ -93,7 +93,7 @@ defmodule OCI.Registry do {:ok, reg} end - def repo_exists?(%{storage: storage}, repo, ctx) do + def repo_exists?(storage, repo, ctx) do adapter(storage).repo_exists?(storage, repo, ctx) end @@ -226,7 +226,20 @@ defmodule OCI.Registry do end def list_tags(%{storage: storage}, repo, pagination, ctx) do - adapter(storage).list_tags(storage, repo, pagination, ctx) + # YOU ARE HERE --> adding support for referrers, we need to parse the OCI Subject its being attached + # to and manage a reverse index. + # TODO: + # * [x] validate name + # * [x] format json({name, tags}) + # * [ ] stub OCI-Subject so the fluke test will pass + # * [-] handle Link header logic. + # * [ ] referrers support (uncomment tests in 03_discovery) + + if repo_exists?(storage, repo, ctx) do + adapter(storage).list_tags(storage, repo, pagination, ctx) + else + {:error, :NAME_UNKNOWN, %{repo: repo}} + end end @doc """ @@ -282,7 +295,7 @@ defmodule OCI.Registry do Returns {:ok, location} on success, {:error, :BLOB_UNKNOWN} if the source blob doesn't exist. """ def mount_blob(%__MODULE__{storage: storage} = registry, repo, digest, from_repo, ctx) do - if repo_exists?(registry, from_repo, ctx) do + if repo_exists?(storage, from_repo, ctx) do if blob_exists?(registry, from_repo, digest, ctx) do # credo:disable-for-next-line with :ok <- adapter(storage).mount_blob(storage, repo, digest, from_repo, ctx) do diff --git a/lib/oci/storage/local.ex b/lib/oci/storage/local.ex index dec86c8..7ef4593 100644 --- a/lib/oci/storage/local.ex +++ b/lib/oci/storage/local.ex @@ -195,21 +195,15 @@ defmodule OCI.Storage.Local do @impl true def list_tags(storage, repo, pagination, _ctx) do - if File.dir?(tags_dir(storage, repo)) do - tags = - tags_dir(storage, repo) - |> File.ls!() - |> Enum.sort() - - paginated_tags = - tags - |> cursor(pagination.last) - |> limit(pagination.n) - - {:ok, paginated_tags} - else - {:error, :NAME_UNKNOWN} - end + paginated_tags = + storage + |> tags_dir(repo) + |> File.ls!() + |> Enum.sort() + |> cursor(pagination.last) + |> limit(pagination.n) + + {:ok, paginated_tags} end @impl true @@ -232,6 +226,30 @@ defmodule OCI.Storage.Local do def store_manifest(storage, repo, reference, manifest, manifest_digest, ctx) do blobs = [manifest["config"]["digest"]] ++ Enum.map(manifest["layers"], & &1["digest"]) + # YOU ARE HERE + + # IMPLEMENT IT HERE FIRST AND SEE HOW MUCH IT IS LAME. + # We need to store image indexes as well, it would be nice for the registry layer to + # handle as much of this as possible so we dont have to reimpl index v image in ever storage layer. + + # PROBABLY SHOULD SET THE PATTERN AS PATTERN MATCHING ON THE PRESENTS OF SUBJECT OR THE + # See "Where is the error occurring?" chat session. + + # The problem is that the code is trying to process manifest["layers"] with Enum.map(), but manifest["layers"] is nil. This is happening because the manifest being processed is an OCI Image Index (with mediaType: "application/vnd.oci.image.index.v1+json"), not an OCI Image Manifest. + # The key difference is: + + # OCI Image Manifest has: + # config field (single config object) + # layers field (array of layers) + + # OCI Image Index has: + # manifests field (array of manifests) + # No config field + # No layers field + + # When the code tries to access manifest["layers"] on an Image Index, it gets nil, and then Enum.map(nil, ...) fails with the Protocol.UndefinedError because nil doesn't implement the Enumerable protocol. + # The store_manifest function needs to be updated to handle both manifest types - Image Manifests and Image Index manifests - differently, since they have different structures and blob validation requirements. + if Enum.any?(blobs, fn digest -> !blob_exists?(storage, repo, digest, ctx) end) do diff --git a/test/support/conformance_suite.ex b/test/support/conformance_suite.ex index 5b082fe..292d11c 100644 --- a/test/support/conformance_suite.ex +++ b/test/support/conformance_suite.ex @@ -72,7 +72,7 @@ defmodule ConformanceSuite do {"OCI_PASSWORD", "mypass"}, {"OCI_TEST_PULL", "1"}, {"OCI_TEST_PUSH", "1"}, - {"OCI_TEST_CONTENT_DISCOVERY", "0"}, + {"OCI_TEST_CONTENT_DISCOVERY", "1"}, {"OCI_TEST_CONTENT_MANAGEMENT", "0"}, {"OCI_AUTOMATIC_CROSSMOUNT", "0"}, {"OCI_DELETE_MANIFEST_BEFORE_BLOBS", "0"}, From 6b71749a43c82d470e40a87f9cd7d545031b7d6e Mon Sep 17 00:00:00 2001 From: Cory O'Daniel Date: Tue, 31 Mar 2026 16:17:34 -0700 Subject: [PATCH 2/8] Handle image index manifests, move blob validation to registry layer User prompts: - "lets work through it adding support" - "fix this warning: Module.eval_quoted/2 is deprecated" Changes: - Parser: handle application/vnd.oci.image.index.v1+json content type - Registry: move blob validation from storage adapter to registry layer with referenced_blobs/1 that handles manifest vs index types - Local storage: store_manifest now just stores, no blob validation - Fix deprecated Module.eval_quoted -> Code.eval_quoted - Update TODO checklists in registry.ex and handler.ex Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/oci/plug/handler.ex | 2 +- lib/oci/plug/parser.ex | 17 +++++++----- lib/oci/registry.ex | 44 +++++++++++++++++++++++-------- lib/oci/storage/local.ex | 55 +++++++-------------------------------- test/conformance_test.exs | 2 +- 5 files changed, 56 insertions(+), 64 deletions(-) diff --git a/lib/oci/plug/handler.ex b/lib/oci/plug/handler.ex index e12afdf..3e8a88e 100644 --- a/lib/oci/plug/handler.ex +++ b/lib/oci/plug/handler.ex @@ -239,7 +239,7 @@ defmodule OCI.Plug.Handler do manifest_digest = conn.assigns[:oci_digest] with :ok <- Registry.store_manifest(registry, repo, reference, manifest, manifest_digest, ctx) do - # TODO: hack bypass OCI-Subject tests for now (impl coming in next PR, but it blocks LIST Tag tests) + # TODO: OCI-Subject is read directly from the manifest body. Replace with proper referrers index lookup. maybe_set_oci_subject = fn conn -> case get_in(conn.params, ["subject", "digest"]) do nil -> diff --git a/lib/oci/plug/parser.ex b/lib/oci/plug/parser.ex index a6417f5..a460f2f 100644 --- a/lib/oci/plug/parser.ex +++ b/lib/oci/plug/parser.ex @@ -65,14 +65,21 @@ defmodule OCI.Plug.Parser do end end - def parse(conn, "application", "vnd.oci.image.manifest.v1+json", _headers, opts) do + def parse(conn, "application", "vnd.oci.image.manifest.v1+json", headers, opts) do + parse_oci_manifest(conn, headers, opts) + end + + def parse(conn, "application", "vnd.oci.image.index.v1+json", headers, opts) do + parse_oci_manifest(conn, headers, opts) + end + + def parse(conn, _type, _subtype, _headers, _opts), do: {:next, conn} + + defp parse_oci_manifest(conn, _headers, opts) do read_full_body(conn, opts, "") |> case do {:ok, full_body, conn} -> digest = :crypto.hash(:sha256, full_body) |> Base.encode16(case: :lower) - - # Note: this is not the 'digest' as is in the query string, but the byte-for-byte digest of the body. - # before it is ready by the json decoder. conn = Plug.Conn.assign(conn, :oci_digest, "sha256:#{digest}") decoder = Keyword.fetch!(opts, :json_decoder) @@ -90,8 +97,6 @@ defmodule OCI.Plug.Parser do end end - def parse(conn, _type, _subtype, _headers, _opts), do: {:next, conn} - @spec read_full_body(Plug.Conn.t(), opts(), String.t()) :: {:ok, String.t(), Plug.Conn.t()} | {:error, term()} defp read_full_body(conn, opts, acc) do diff --git a/lib/oci/registry.ex b/lib/oci/registry.ex index 5814cbf..dd8ae79 100644 --- a/lib/oci/registry.ex +++ b/lib/oci/registry.ex @@ -207,16 +207,40 @@ defmodule OCI.Registry do end def store_manifest(%{storage: storage}, repo, reference, manifest, manifest_digest, ctx) do - adapter(storage).store_manifest( - storage, - repo, - reference, - manifest, - manifest_digest, - ctx - ) + required_blobs = referenced_blobs(manifest) + + missing = + Enum.filter(required_blobs, fn digest -> + !adapter(storage).blob_exists?(storage, repo, digest, ctx) + end) + + if missing != [] do + {:error, :MANIFEST_BLOB_UNKNOWN, %{missing: missing}} + else + adapter(storage).store_manifest( + storage, + repo, + reference, + manifest, + manifest_digest, + ctx + ) + end end + @doc """ + Extracts the list of blob digests referenced by a manifest. + + Image manifests reference a config blob and layer blobs. + Image indexes reference other manifests (not blobs), so they return an empty list. + """ + def referenced_blobs(%{"layers" => layers, "config" => config}) when is_list(layers) do + [config["digest"] | Enum.map(layers, & &1["digest"])] + |> Enum.reject(&is_nil/1) + end + + def referenced_blobs(_manifest), do: [] + def get_manifest(%{storage: storage}, repo, reference, ctx) do adapter(storage).get_manifest(storage, repo, reference, ctx) end @@ -226,12 +250,10 @@ defmodule OCI.Registry do end def list_tags(%{storage: storage}, repo, pagination, ctx) do - # YOU ARE HERE --> adding support for referrers, we need to parse the OCI Subject its being attached - # to and manage a reverse index. # TODO: # * [x] validate name # * [x] format json({name, tags}) - # * [ ] stub OCI-Subject so the fluke test will pass + # * [x] stub OCI-Subject so the conformance test passes (handler.ex) # * [-] handle Link header logic. # * [ ] referrers support (uncomment tests in 03_discovery) diff --git a/lib/oci/storage/local.ex b/lib/oci/storage/local.ex index 7ef4593..ac51ffb 100644 --- a/lib/oci/storage/local.ex +++ b/lib/oci/storage/local.ex @@ -223,54 +223,19 @@ defmodule OCI.Storage.Local do end @impl true - def store_manifest(storage, repo, reference, manifest, manifest_digest, ctx) do - blobs = [manifest["config"]["digest"]] ++ Enum.map(manifest["layers"], & &1["digest"]) + def store_manifest(storage, repo, reference, manifest, manifest_digest, _ctx) do + manifest_json = Jason.encode!(manifest) - # YOU ARE HERE + :ok = File.mkdir_p!(manifests_dir(storage, repo)) + File.write!(digest_path(storage, repo, manifest_digest), manifest_json) - # IMPLEMENT IT HERE FIRST AND SEE HOW MUCH IT IS LAME. - # We need to store image indexes as well, it would be nice for the registry layer to - # handle as much of this as possible so we dont have to reimpl index v image in ever storage layer. - - # PROBABLY SHOULD SET THE PATTERN AS PATTERN MATCHING ON THE PRESENTS OF SUBJECT OR THE - # See "Where is the error occurring?" chat session. - - # The problem is that the code is trying to process manifest["layers"] with Enum.map(), but manifest["layers"] is nil. This is happening because the manifest being processed is an OCI Image Index (with mediaType: "application/vnd.oci.image.index.v1+json"), not an OCI Image Manifest. - # The key difference is: - - # OCI Image Manifest has: - # config field (single config object) - # layers field (array of layers) - - # OCI Image Index has: - # manifests field (array of manifests) - # No config field - # No layers field - - # When the code tries to access manifest["layers"] on an Image Index, it gets nil, and then Enum.map(nil, ...) fails with the Protocol.UndefinedError because nil doesn't implement the Enumerable protocol. - # The store_manifest function needs to be updated to handle both manifest types - Image Manifests and Image Index manifests - differently, since they have different structures and blob validation requirements. - - if Enum.any?(blobs, fn digest -> - !blob_exists?(storage, repo, digest, ctx) - end) do - # TODO; return which blobs are missing. - # TODO: is this the right error or MANIFEST_INVALID? - {:error, :MANIFEST_BLOB_UNKNOWN, ""} - else - # Store manifest by digest - manifest_json = Jason.encode!(manifest) - - :ok = File.mkdir_p!(manifests_dir(storage, repo)) - File.write!(digest_path(storage, repo, manifest_digest), manifest_json) - - # If reference is a tag, create a tag reference - if !String.starts_with?(reference, "sha256:") do - :ok = File.mkdir_p!(tags_dir(storage, repo)) - File.write!(tag_path(storage, repo, reference), manifest_digest) - end - - :ok + # If reference is a tag, create a tag reference + if !String.starts_with?(reference, "sha256:") do + :ok = File.mkdir_p!(tags_dir(storage, repo)) + File.write!(tag_path(storage, repo, reference), manifest_digest) end + + :ok end @impl true diff --git a/test/conformance_test.exs b/test/conformance_test.exs index 51f8b14..54beddc 100644 --- a/test/conformance_test.exs +++ b/test/conformance_test.exs @@ -42,6 +42,6 @@ defmodule OCI.ConformanceTest do end end - Module.eval_quoted(__MODULE__, quote) + Code.eval_quoted(quote, [], __ENV__) end) end From d8bd64f625aff2c421b22615c4853f843b5ca6f7 Mon Sep 17 00:00:00 2001 From: Cory O'Daniel Date: Tue, 31 Mar 2026 16:32:04 -0700 Subject: [PATCH 3/8] Add referrers API support User prompts: - "finish the referrers marking todos off as you go" - "Enable appropriate tests suite in generate_report/0" Changes: - Add GET /v2//referrers/ endpoint - Index subject->referrer relationship on manifest store - Add list_referrers/put_referrer to storage adapter behaviour - Implement referrer storage in local adapter (JSON files per subject) - Build referrer descriptors with mediaType, digest, size, artifactType, annotations - Support artifactType query param filtering with OCI-Filters-Applied header - Uncomment referrers conformance tests in 03_discovery_test.go - All 4 referrers conformance tests passing Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/oci/plug/context.ex | 1 + lib/oci/plug/handler.ex | 30 +++++++++++++++++++ lib/oci/registry.ex | 61 +++++++++++++++++++++++++++++++------- lib/oci/storage/adapter.ex | 23 ++++++++++++++ lib/oci/storage/local.ex | 39 ++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 10 deletions(-) diff --git a/lib/oci/plug/context.ex b/lib/oci/plug/context.ex index 85fd943..21ca1cb 100644 --- a/lib/oci/plug/context.ex +++ b/lib/oci/plug/context.ex @@ -23,6 +23,7 @@ defmodule OCI.Plug.Context do [uuid, "uploads", "blobs" | rest] -> {rest, :blobs_uploads, uuid} [digest, "blobs" | rest] -> {rest, :blobs, digest} [reference, "manifests" | rest] -> {rest, :manifests, reference} + [digest, "referrers" | rest] -> {rest, :referrers, digest} end # Reverse the path info, and the last parts after the known API path portions is the repo name. diff --git a/lib/oci/plug/handler.ex b/lib/oci/plug/handler.ex index 3e8a88e..5d4ab58 100644 --- a/lib/oci/plug/handler.ex +++ b/lib/oci/plug/handler.ex @@ -234,6 +234,36 @@ defmodule OCI.Plug.Handler do end end + defp dispatch(%{method: "GET"} = conn, :referrers, registry, repo, digest, ctx) do + with {:ok, referrers} <- Registry.list_referrers(registry, repo, digest, ctx) do + artifact_type_filter = conn.query_params["artifactType"] + + {filtered, filter_applied} = + if artifact_type_filter do + {Enum.filter(referrers, &(&1["artifactType"] == artifact_type_filter)), true} + else + {referrers, false} + end + + index = %{ + "schemaVersion" => 2, + "mediaType" => "application/vnd.oci.image.index.v1+json", + "manifests" => filtered + } + + conn = + if filter_applied do + put_resp_header(conn, "oci-filters-applied", "artifactType") + else + conn + end + + conn + |> put_resp_header("content-type", "application/vnd.oci.image.index.v1+json") + |> send_resp(200, Jason.encode!(index)) + end + end + defp dispatch(%{method: "PUT"} = conn, :manifests, registry, repo, reference, ctx) do manifest = conn.params manifest_digest = conn.assigns[:oci_digest] diff --git a/lib/oci/registry.ex b/lib/oci/registry.ex index dd8ae79..89c4951 100644 --- a/lib/oci/registry.ex +++ b/lib/oci/registry.ex @@ -217,14 +217,18 @@ defmodule OCI.Registry do if missing != [] do {:error, :MANIFEST_BLOB_UNKNOWN, %{missing: missing}} else - adapter(storage).store_manifest( - storage, - repo, - reference, - manifest, - manifest_digest, - ctx - ) + with :ok <- + adapter(storage).store_manifest( + storage, + repo, + reference, + manifest, + manifest_digest, + ctx + ) do + maybe_index_referrer(storage, repo, manifest, manifest_digest, ctx) + :ok + end end end @@ -234,6 +238,43 @@ defmodule OCI.Registry do Image manifests reference a config blob and layer blobs. Image indexes reference other manifests (not blobs), so they return an empty list. """ + def list_referrers(%{storage: storage}, repo, digest, ctx) do + adapter(storage).list_referrers(storage, repo, digest, ctx) + end + + defp maybe_index_referrer(storage, repo, manifest, manifest_digest, ctx) do + case manifest["subject"] do + %{"digest" => subject_digest} when is_binary(subject_digest) -> + descriptor = build_referrer_descriptor(manifest, manifest_digest) + adapter(storage).put_referrer(storage, repo, subject_digest, descriptor, ctx) + + _ -> + :ok + end + end + + defp build_referrer_descriptor(manifest, manifest_digest) do + manifest_json = Jason.encode!(manifest) + + artifact_type = + manifest["artifactType"] || get_in(manifest, ["config", "mediaType"]) + + descriptor = %{ + "mediaType" => manifest["mediaType"], + "digest" => manifest_digest, + "size" => byte_size(manifest_json), + "artifactType" => artifact_type + } + + case manifest["annotations"] do + annotations when is_map(annotations) and map_size(annotations) > 0 -> + Map.put(descriptor, "annotations", annotations) + + _ -> + descriptor + end + end + def referenced_blobs(%{"layers" => layers, "config" => config}) when is_list(layers) do [config["digest"] | Enum.map(layers, & &1["digest"])] |> Enum.reject(&is_nil/1) @@ -253,9 +294,9 @@ defmodule OCI.Registry do # TODO: # * [x] validate name # * [x] format json({name, tags}) - # * [x] stub OCI-Subject so the conformance test passes (handler.ex) + # * [x] OCI-Subject header on manifest PUT (handler.ex) # * [-] handle Link header logic. - # * [ ] referrers support (uncomment tests in 03_discovery) + # * [x] referrers support (GET /v2//referrers/) if repo_exists?(storage, repo, ctx) do adapter(storage).list_tags(storage, repo, pagination, ctx) diff --git a/lib/oci/storage/adapter.ex b/lib/oci/storage/adapter.ex index 2a8c094..d1dd4b7 100644 --- a/lib/oci/storage/adapter.ex +++ b/lib/oci/storage/adapter.ex @@ -231,6 +231,29 @@ defmodule OCI.Storage.Adapter do | {:error, atom()} | {:error, atom(), error_details_t} + @doc """ + Lists manifest descriptors that reference the given subject digest. + """ + @callback list_referrers( + storage :: t(), + repo :: String.t(), + subject_digest :: String.t(), + ctx :: OCI.Context.t() + ) :: + {:ok, [map()]} + + @doc """ + Stores a referrer descriptor for a given subject digest. + """ + @callback put_referrer( + storage :: t(), + repo :: String.t(), + subject_digest :: String.t(), + descriptor :: map(), + ctx :: OCI.Context.t() + ) :: + :ok + @doc """ Checks if an upload exists. """ diff --git a/lib/oci/storage/local.ex b/lib/oci/storage/local.ex index ac51ffb..d7b3fe2 100644 --- a/lib/oci/storage/local.ex +++ b/lib/oci/storage/local.ex @@ -193,6 +193,37 @@ defmodule OCI.Storage.Local do {:ok, uuid} end + @impl true + def list_referrers(storage, repo, subject_digest, _ctx) do + path = referrer_path(storage, repo, subject_digest) + + case File.read(path) do + {:ok, content} -> {:ok, Jason.decode!(content)} + {:error, :enoent} -> {:ok, []} + end + end + + @impl true + def put_referrer(storage, repo, subject_digest, descriptor, _ctx) do + dir = referrers_dir(storage, repo) + path = referrer_path(storage, repo, subject_digest) + + :ok = File.mkdir_p!(dir) + + existing = + case File.read(path) do + {:ok, content} -> Jason.decode!(content) + {:error, :enoent} -> [] + end + + # Avoid duplicates by digest + unless Enum.any?(existing, &(&1["digest"] == descriptor["digest"])) do + File.write!(path, Jason.encode!(existing ++ [descriptor])) + end + + :ok + end + @impl true def list_tags(storage, repo, pagination, _ctx) do paginated_tags = @@ -301,6 +332,14 @@ defmodule OCI.Storage.Local do Path.join([repo_dir(storage, repo), "manifests"]) end + defp referrers_dir(storage, repo) do + Path.join([repo_dir(storage, repo), "referrers"]) + end + + defp referrer_path(storage, repo, subject_digest) do + Path.join([referrers_dir(storage, repo), subject_digest]) + end + defp repo_dir(storage, repo) do Path.join([storage.path, repo]) end From 9f45f6a8b8fa6dabac2a08924a81fd0e28b1e67d Mon Sep 17 00:00:00 2001 From: Cory O'Daniel Date: Tue, 31 Mar 2026 16:33:25 -0700 Subject: [PATCH 4/8] Move artifactType filtering into storage adapter User prompts: - "pass the filters to list_referrers so the adapter can do the filtering" Changes: - Add filters map param to list_referrers in adapter behaviour, registry, and local impl - Handler builds filters from query params and passes down - Adapter can filter efficiently at query time Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/oci/plug/handler.ex | 19 ++++++++----------- lib/oci/registry.ex | 4 ++-- lib/oci/storage/adapter.ex | 4 ++++ lib/oci/storage/local.ex | 19 ++++++++++++++----- 4 files changed, 28 insertions(+), 18 deletions(-) diff --git a/lib/oci/plug/handler.ex b/lib/oci/plug/handler.ex index 5d4ab58..7c6d13f 100644 --- a/lib/oci/plug/handler.ex +++ b/lib/oci/plug/handler.ex @@ -235,24 +235,21 @@ defmodule OCI.Plug.Handler do end defp dispatch(%{method: "GET"} = conn, :referrers, registry, repo, digest, ctx) do - with {:ok, referrers} <- Registry.list_referrers(registry, repo, digest, ctx) do - artifact_type_filter = conn.query_params["artifactType"] - - {filtered, filter_applied} = - if artifact_type_filter do - {Enum.filter(referrers, &(&1["artifactType"] == artifact_type_filter)), true} - else - {referrers, false} - end + filters = + case conn.query_params["artifactType"] do + nil -> %{} + artifact_type -> %{"artifactType" => artifact_type} + end + with {:ok, referrers} <- Registry.list_referrers(registry, repo, digest, filters, ctx) do index = %{ "schemaVersion" => 2, "mediaType" => "application/vnd.oci.image.index.v1+json", - "manifests" => filtered + "manifests" => referrers } conn = - if filter_applied do + if filters["artifactType"] do put_resp_header(conn, "oci-filters-applied", "artifactType") else conn diff --git a/lib/oci/registry.ex b/lib/oci/registry.ex index 89c4951..679f5d2 100644 --- a/lib/oci/registry.ex +++ b/lib/oci/registry.ex @@ -238,8 +238,8 @@ defmodule OCI.Registry do Image manifests reference a config blob and layer blobs. Image indexes reference other manifests (not blobs), so they return an empty list. """ - def list_referrers(%{storage: storage}, repo, digest, ctx) do - adapter(storage).list_referrers(storage, repo, digest, ctx) + def list_referrers(%{storage: storage}, repo, digest, filters, ctx) do + adapter(storage).list_referrers(storage, repo, digest, filters, ctx) end defp maybe_index_referrer(storage, repo, manifest, manifest_digest, ctx) do diff --git a/lib/oci/storage/adapter.ex b/lib/oci/storage/adapter.ex index d1dd4b7..8c14dec 100644 --- a/lib/oci/storage/adapter.ex +++ b/lib/oci/storage/adapter.ex @@ -233,11 +233,15 @@ defmodule OCI.Storage.Adapter do @doc """ Lists manifest descriptors that reference the given subject digest. + + The `filters` map may contain: + - `"artifactType"` - filter referrers by artifact type """ @callback list_referrers( storage :: t(), repo :: String.t(), subject_digest :: String.t(), + filters :: map(), ctx :: OCI.Context.t() ) :: {:ok, [map()]} diff --git a/lib/oci/storage/local.ex b/lib/oci/storage/local.ex index d7b3fe2..d8709de 100644 --- a/lib/oci/storage/local.ex +++ b/lib/oci/storage/local.ex @@ -194,13 +194,22 @@ defmodule OCI.Storage.Local do end @impl true - def list_referrers(storage, repo, subject_digest, _ctx) do + def list_referrers(storage, repo, subject_digest, filters, _ctx) do path = referrer_path(storage, repo, subject_digest) - case File.read(path) do - {:ok, content} -> {:ok, Jason.decode!(content)} - {:error, :enoent} -> {:ok, []} - end + referrers = + case File.read(path) do + {:ok, content} -> Jason.decode!(content) + {:error, :enoent} -> [] + end + + filtered = + case Map.get(filters, "artifactType") do + nil -> referrers + artifact_type -> Enum.filter(referrers, &(&1["artifactType"] == artifact_type)) + end + + {:ok, filtered} end @impl true From 581b14b4749a95f50288af2bb332a3749338fcaf Mon Sep 17 00:00:00 2001 From: Cory O'Daniel Date: Tue, 31 Mar 2026 16:44:35 -0700 Subject: [PATCH 5/8] Add Link header for paginated tags, clean up TODOs, bump to 0.1.0 Changes: - Add Link header with rel="next" for paginated tag list responses - Remove OCI-Subject TODO (reading from manifest body is correct) - Remove completed TODO checklist from list_tags - Bump version to 0.1.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/oci/plug/handler.ex | 14 ++++++++++++-- lib/oci/registry.ex | 7 ------- mix.exs | 2 +- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/lib/oci/plug/handler.ex b/lib/oci/plug/handler.ex index 7c6d13f..137ba76 100644 --- a/lib/oci/plug/handler.ex +++ b/lib/oci/plug/handler.ex @@ -57,7 +57,18 @@ defmodule OCI.Plug.Handler do ctx :: OCI.Context.t() ) :: Plug.Conn.t() | {:error, atom()} | {:error, atom(), String.t()} defp dispatch(%{method: "GET"} = conn, :tags_list, registry, repo, _id, ctx) do - with {:ok, tags} <- Registry.list_tags(registry, repo, pagination(conn.query_params), ctx) do + pag = pagination(conn.query_params) + + with {:ok, tags} <- Registry.list_tags(registry, repo, pag, ctx) do + conn = + if pag.n != nil and length(tags) == pag.n do + last = List.last(tags) + link = "; rel=\"next\"" + put_resp_header(conn, "link", link) + else + conn + end + conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(%{name: repo, tags: tags})) @@ -266,7 +277,6 @@ defmodule OCI.Plug.Handler do manifest_digest = conn.assigns[:oci_digest] with :ok <- Registry.store_manifest(registry, repo, reference, manifest, manifest_digest, ctx) do - # TODO: OCI-Subject is read directly from the manifest body. Replace with proper referrers index lookup. maybe_set_oci_subject = fn conn -> case get_in(conn.params, ["subject", "digest"]) do nil -> diff --git a/lib/oci/registry.ex b/lib/oci/registry.ex index 679f5d2..2d26f49 100644 --- a/lib/oci/registry.ex +++ b/lib/oci/registry.ex @@ -291,13 +291,6 @@ defmodule OCI.Registry do end def list_tags(%{storage: storage}, repo, pagination, ctx) do - # TODO: - # * [x] validate name - # * [x] format json({name, tags}) - # * [x] OCI-Subject header on manifest PUT (handler.ex) - # * [-] handle Link header logic. - # * [x] referrers support (GET /v2//referrers/) - if repo_exists?(storage, repo, ctx) do adapter(storage).list_tags(storage, repo, pagination, ctx) else diff --git a/mix.exs b/mix.exs index 750fe6b..778b423 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule OCI.MixProject do def project do [ app: :oci, - version: "0.0.6", + version: "0.1.0", elixir: "~> 1.15", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, From c48761aeb36ed8869b442088f971ed6d405f1557 Mon Sep 17 00:00:00 2001 From: Cory O'Daniel Date: Tue, 31 Mar 2026 16:50:16 -0700 Subject: [PATCH 6/8] Remove .claude/settings.local.json from repo, add to gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 7 ------- .gitignore | 1 + 2 files changed, 1 insertion(+), 7 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 1a11670..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(asdf install:*)" - ] - } -} diff --git a/.gitignore b/.gitignore index b16a14c..c64e3bc 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,5 @@ TODO.md # Local things .elixir_ls +.claude/settings.local.json priv/plts From 26421832754ad40fa0545a30fa49285e29e77d12 Mon Sep 17 00:00:00 2001 From: Cory O'Daniel Date: Tue, 31 Mar 2026 16:51:23 -0700 Subject: [PATCH 7/8] Format Link header line Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/oci/plug/handler.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/oci/plug/handler.ex b/lib/oci/plug/handler.ex index 137ba76..fd2c718 100644 --- a/lib/oci/plug/handler.ex +++ b/lib/oci/plug/handler.ex @@ -63,7 +63,10 @@ defmodule OCI.Plug.Handler do conn = if pag.n != nil and length(tags) == pag.n do last = List.last(tags) - link = "; rel=\"next\"" + + link = + "; rel=\"next\"" + put_resp_header(conn, "link", link) else conn From 1edff42320bf245f145b1a9c423d73d48fc6cfe8 Mon Sep 17 00:00:00 2001 From: Cory O'Daniel Date: Tue, 31 Mar 2026 16:55:11 -0700 Subject: [PATCH 8/8] Replace TODO with issue link for swallowed error (credo fix) See https://github.com/massdriver-cloud/oci/issues/12 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/oci/plug/handler.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/oci/plug/handler.ex b/lib/oci/plug/handler.ex index fd2c718..a33b8af 100644 --- a/lib/oci/plug/handler.ex +++ b/lib/oci/plug/handler.ex @@ -127,7 +127,7 @@ defmodule OCI.Plug.Handler do {:ok, _, _} -> :ok - # TODO: we are swallowing this error!!! Yikes. + # Swallowed error: https://github.com/massdriver-cloud/oci/issues/12 err -> err end