diff --git a/specs/cli/referrer.md b/specs/cli/referrer.md index ede20e6..3b613ee 100644 --- a/specs/cli/referrer.md +++ b/specs/cli/referrer.md @@ -8,6 +8,7 @@ OCI Referrers API. | Subcommand | Method | Endpoint | |---|---|---| +| `list` (tag resolution only) | `HEAD` | `/v2//manifests/` | | `list` | `GET` | `/v2//referrers/[?artifactType=]` | > **Note:** The referrers endpoint is read-only — referrers are created @@ -27,13 +28,15 @@ regshape referrer list [OPTIONS] ## Image Reference Format The `referrer list` subcommand accepts `--image-ref` / `-i` using the standard -container image reference syntax with the registry always embedded. The -reference **must** include a digest — tag-only references are rejected because -the referrers API requires a digest. +container image reference syntax with the registry always embedded. Tags are +resolved to a digest with a manifest `HEAD` request before querying the referrers +API. Digest references are used directly, with no manifest lookup. | Subcommand | Accepted formats | Notes | |---|---|---| -| `list` | `registry/repo@sha256:...` | Digest is required | +| `list` | `registry/repo:tag` | Resolve the tag using `Docker-Content-Digest` | +| `list` | `registry/repo@sha256:...`, `registry/repo@sha512:...` | Use the digest directly | +| `list` | `registry/repo` | Resolve the default tag, `latest` | > **Authentication:** Run `regshape auth login` before using referrer commands > against authenticated registries. Credentials are resolved automatically @@ -46,13 +49,13 @@ the referrers API requires a digest. ### `referrer list` -List all referrers for the manifest identified by digest in IMAGE_REF. +List referrers for the manifest identified by tag or digest in IMAGE_REF. #### Options | Option | Short | Type | Default | Description | |---|---|---|---|---| -| `--image-ref` | `-i` | string | required | Image reference with embedded registry and digest (e.g., `registry/repo@sha256:abc...`) | +| `--image-ref` | `-i` | string | required | Image reference with embedded registry and tag or digest (e.g., `registry/repo:v1.0` or `registry/repo@sha256:abc...`) | | `--artifact-type` | `-t` | string | none | Filter referrers to this artifact type (e.g., `application/vnd.example.sbom.v1`) | | `--all` | | flag | false | Follow pagination and return all referrers (default: single page only) | | `--json` | | flag | false | Output the full `ReferrerList` JSON object instead of one referrer per line | @@ -63,11 +66,15 @@ List all referrers for the manifest identified by digest in IMAGE_REF. #### Behavior -1. Parse `--image-ref` to extract registry, repository, and digest. Reject - tag-only references with exit code 2 and an error message directing the - user to use a digest reference. +1. Parse `--image-ref` to extract registry, repository, and reference. Explicit + digest references must use `sha256` or `sha512`; unsupported digest algorithms + are rejected with exit code 2. 2. Resolve credentials from the Docker credential store for the extracted - registry. + registry. For a tag reference, call `head_manifest()` using the same registry + client and accepting all supported manifest and index media types. Use the + returned `Docker-Content-Digest` as the subject digest. A missing or blank + digest header, authentication failure, missing tag, or transport failure + produces exit code 1 without querying referrers or writing the output file. 3. If `--all` is set, call `list_referrers_all()` which follows `Link` pagination headers to retrieve every page and returns a merged `ReferrerList`. Otherwise, call `list_referrers()` which returns a @@ -89,8 +96,8 @@ List all referrers for the manifest identified by digest in IMAGE_REF. | Code | Meaning | |---|---| | 0 | Success (including when there are zero referrers) | -| 1 | HTTP error (manifest not found, auth failure, connection error) | -| 2 | Invalid reference (tag-only reference instead of digest) | +| 1 | Reference parsing error, tag resolution failure (including a missing digest header), or registry/transport error | +| 2 | Command usage error or unsupported explicit digest algorithm | #### Examples @@ -98,6 +105,13 @@ List all referrers for the manifest identified by digest in IMAGE_REF. # Log in first regshape auth login -r acr.io -u alice +# List referrers by tag (automatically resolved to a digest) +regshape referrer list -i acr.io/myrepo/myimage:v1.0 + +# Resolve a tag, fetch all pages, filter, and save JSON +regshape referrer list -i acr.io/myrepo/myimage:v1.0 --all \ + --artifact-type application/vnd.example.sbom.v1 --json -o referrers.json + # List all referrers for a manifest by digest (single page) regshape referrer list -i acr.io/myrepo/myimage@sha256:abc123... @@ -162,8 +176,10 @@ Full OCI Image Index response: | Scenario | Message | Exit Code | |----------|---------|-----------| -| Tag-only reference | `Error []: referrer list requires a digest reference (registry/repo@sha256:...); use 'manifest get' to resolve a tag to a digest` | 2 | +| Unsupported digest algorithm | `Error []: digest references must use sha256 or sha512` | 2 | +| Tag HEAD response lacks a digest | `Error []: Cannot resolve tag ''` with detail `HEAD response is missing Docker-Content-Digest` | 1 | | Auth failure | `Error []: Authentication failed for : ` | 1 | +| Tag not found | `Error []: Manifest not found: /:` | 1 | | Manifest not found | `Error []: Manifest not found: /@` | 1 | | Connection error | `Error []: ` | 1 | diff --git a/src/regshape/cli/referrer.py b/src/regshape/cli/referrer.py index d4f5b09..19109e9 100644 --- a/src/regshape/cli/referrer.py +++ b/src/regshape/cli/referrer.py @@ -7,7 +7,7 @@ .. module:: regshape.cli.referrer :platform: Unix, Windows :synopsis: Click command group providing the ``list`` subcommand for - OCI referrer operations. + OCI referrer operations with automatic tag-to-digest resolution. .. moduleauthor:: ToddySM """ @@ -18,12 +18,13 @@ from regshape.cli.formatting import emit_error, emit_json, emit_table, emit_text from regshape.libs.decorators import telemetry_options from regshape.libs.decorators.scenario import track_scenario -from regshape.libs.errors import AuthError, ReferrerError -from regshape.libs.refs import parse_image_ref +from regshape.libs.errors import AuthError, ManifestError, ReferrerError +from regshape.libs.manifests import head_manifest +from regshape.libs.models.mediatype import ALL_MANIFEST_MEDIA_TYPES from regshape.libs.referrers import list_referrers, list_referrers_all +from regshape.libs.refs import parse_image_ref from regshape.libs.transport import RegistryClient, TransportConfig - # =========================================================================== # Public Click group # =========================================================================== @@ -46,9 +47,9 @@ def referrer(): required=True, metavar="IMAGE_REF", help=( - "Image reference with embedded registry and digest " - "(e.g. registry/repo@sha256:abc...). " - "Tag-only references are rejected — a digest is required." + "Image reference with embedded registry and tag or digest " + "(e.g. registry/repo:tag or registry/repo@sha256:abc...). " + "Tags are resolved to a digest automatically." ), ) @click.option( @@ -84,12 +85,21 @@ def referrer(): ) @click.pass_context @track_scenario("referrer list") -def referrer_list(ctx, image_ref, artifact_type, fetch_all, as_json, output): - """List referrers for the manifest identified by digest in IMAGE_REF. - - IMAGE_REF must embed the registry and use a digest reference - (``registry/repo@sha256:...``). Tag-only references are rejected — - use ``manifest get`` to resolve a tag to a digest first. +def referrer_list( + ctx: click.Context, + image_ref: str, + artifact_type: str | None, + fetch_all: bool, + as_json: bool, + output: str | None, +) -> None: + """List referrers for the manifest identified by IMAGE_REF. + + IMAGE_REF must embed the registry and may use a tag or digest reference. + Tags are resolved with a manifest HEAD request before querying referrers; + digest references are used directly without a manifest lookup. References + without a tag or digest resolve the ``latest`` tag. Resolution failures, + including a missing digest response header, exit with code 1. Use ``--artifact-type`` to filter by artifact type. Use ``--all`` to follow pagination and retrieve every referrer. Use ``--json`` @@ -102,19 +112,31 @@ def referrer_list(ctx, image_ref, artifact_type, fetch_all, as_json, output): except ValueError as exc: emit_error(image_ref, str(exc)) - # The referrers API requires a digest reference. - if not reference.startswith("sha256:") and not reference.startswith("sha512:"): + is_digest = "@" in image_ref + if is_digest and not reference.startswith(("sha256:", "sha512:")): emit_error( image_ref, - "referrer list requires a digest reference " - "(registry/repo@sha256:...); " - "use 'manifest get' to resolve a tag to a digest", + "digest references must use sha256 or sha512", exit_code=2, ) client = RegistryClient(TransportConfig(registry=registry, insecure=insecure)) try: + if not is_digest: + digest, _, _ = head_manifest( + client=client, + repo=repo, + reference=reference, + accept=",".join(sorted(ALL_MANIFEST_MEDIA_TYPES)), + ) + if not digest.strip(): + raise ManifestError( + f"Cannot resolve tag {reference!r}", + "HEAD response is missing Docker-Content-Digest", + ) + reference = digest + if fetch_all: result = list_referrers_all( client=client, @@ -129,7 +151,7 @@ def referrer_list(ctx, image_ref, artifact_type, fetch_all, as_json, output): digest=reference, artifact_type=artifact_type, ) - except (AuthError, ReferrerError, requests.exceptions.RequestException) as exc: + except (AuthError, ManifestError, ReferrerError, requests.exceptions.RequestException) as exc: emit_error(image_ref, str(exc)) if as_json: @@ -156,4 +178,3 @@ def referrer_list(ctx, image_ref, artifact_type, fetch_all, as_json, output): # Internal helpers — output and error # =========================================================================== - diff --git a/src/regshape/tests/test_referrer_cli.py b/src/regshape/tests/test_referrer_cli.py index 61dbc40..76b1bc8 100644 --- a/src/regshape/tests/test_referrer_cli.py +++ b/src/regshape/tests/test_referrer_cli.py @@ -3,17 +3,20 @@ """Tests for :mod:`regshape.cli.referrer`.""" import json +from collections.abc import Iterator +from pathlib import Path +from unittest.mock import MagicMock, call, patch import pytest import requests from click.testing import CliRunner -from unittest.mock import patch from regshape.cli.main import regshape -from regshape.libs.errors import AuthError, ReferrerError +from regshape.libs.errors import AuthError, ManifestError, ReferrerError from regshape.libs.models.descriptor import Descriptor +from regshape.libs.models.mediatype import ALL_MANIFEST_MEDIA_TYPES from regshape.libs.models.referrer import ReferrerList - +from regshape.libs.transport import TransportConfig # --------------------------------------------------------------------------- # Constants @@ -85,13 +88,6 @@ def test_list_empty_referrers(self): assert result.exit_code == 0 assert result.output.strip() == "" - def test_tag_reference_rejected_with_exit_code_2(self): - result = _runner().invoke(regshape, [ - "referrer", "list", "-i", TAG_REF, - ]) - assert result.exit_code == 2 - assert "digest reference" in result.output - def test_passes_artifact_type(self): rl = _referrer_list([_descriptor()]) with patch("regshape.cli.referrer.list_referrers", return_value=rl) as mock_fn: @@ -161,3 +157,226 @@ def test_request_exception_exits_1(self): ]) assert result.exit_code == 1 assert "Error" in result.output + + +def _response( + status_code: int, + body: str = "", + headers: dict[str, str] | None = None, +) -> requests.Response: + """Build an in-memory registry response for CLI/library integration tests.""" + response = requests.Response() + response.status_code = status_code + response._content = body.encode("utf-8") + response.headers.update(headers or {}) + return response + + +class TestReferrerReferenceResolution: + """Exercise tag resolution and digest passthrough without registry access.""" + + @pytest.fixture + def client(self) -> Iterator[MagicMock]: + """Isolate transport construction so tests never read stored credentials.""" + with patch("regshape.cli.referrer.RegistryClient", autospec=True) as factory: + client = factory.return_value + client.config = TransportConfig(registry=REGISTRY) + yield client + + @pytest.mark.parametrize( + ("image_ref", "tag"), + [(TAG_REF, "v1.0"), (f"{REGISTRY}/{REPO}", "latest")], + ) + def test_tag_resolves_once_before_listing( + self, client: MagicMock, image_ref: str, tag: str + ) -> None: + """Resolve explicit and default tags with the existing manifest helper.""" + referrers = _referrer_list([_descriptor()]) + with ( + patch("regshape.cli.referrer.head_manifest", + return_value=(DIGEST, MANIFEST_MT, 123)) as head, + patch("regshape.cli.referrer.list_referrers", + return_value=referrers) as list_fn, + ): + result = _runner().invoke(regshape, [ + "referrer", "list", "-i", image_ref, + ]) + + assert result.exit_code == 0, result.output + head.assert_called_once_with( + client=client, repo=REPO, reference=tag, + accept=",".join(sorted(ALL_MANIFEST_MEDIA_TYPES)), + ) + list_fn.assert_called_once_with( + client=client, repo=REPO, digest=DIGEST, artifact_type=None, + ) + assert DIGEST in result.output + + @pytest.mark.parametrize("digest", [DIGEST, "sha512:" + "b" * 128]) + @pytest.mark.parametrize("fetch_all", [False, True]) + def test_digest_bypasses_manifest_lookup( + self, client: MagicMock, digest: str, fetch_all: bool + ) -> None: + """Keep both supported digest forms unchanged without a HEAD request.""" + operation = "list_referrers_all" if fetch_all else "list_referrers" + args = ["referrer", "list", "-i", f"{REGISTRY}/{REPO}@{digest}"] + if fetch_all: + args.append("--all") + with ( + patch("regshape.cli.referrer.head_manifest") as head, + patch(f"regshape.cli.referrer.{operation}", + return_value=_referrer_list()) as list_fn, + ): + result = _runner().invoke(regshape, args) + + assert result.exit_code == 0, result.output + head.assert_not_called() + list_fn.assert_called_once_with( + client=client, repo=REPO, digest=digest, artifact_type=None, + ) + + def test_unsupported_digest_is_not_resolved_as_a_tag(self, client: MagicMock) -> None: + """Preserve usage errors for unsupported explicit digest references.""" + with patch("regshape.cli.referrer.head_manifest") as head: + result = _runner().invoke(regshape, [ + "referrer", "list", "-i", f"{REGISTRY}/{REPO}@md5:abc", + ]) + assert result.exit_code == 2 + assert "digest references must use sha256 or sha512" in result.stderr + head.assert_not_called() + client.head.assert_not_called() + + @pytest.mark.parametrize("fetch_all", [False, True]) + @pytest.mark.parametrize("as_json", [False, True]) + @pytest.mark.parametrize("write_file", [False, True]) + def test_tag_preserves_filter_pagination_and_output_options( + self, + client: MagicMock, + tmp_path: Path, + fetch_all: bool, + as_json: bool, + write_file: bool, + ) -> None: + """Apply every output/pagination combination to the resolved subject digest.""" + referrers = _referrer_list([_descriptor()]) + outfile = tmp_path / "referrers.txt" + operation = "list_referrers_all" if fetch_all else "list_referrers" + args = ["referrer", "list", "-i", TAG_REF, "--artifact-type", SBOM_TYPE] + if fetch_all: + args.append("--all") + if as_json: + args.append("--json") + if write_file: + args.extend(["--output", str(outfile)]) + with ( + patch("regshape.cli.referrer.head_manifest", + return_value=(DIGEST, MANIFEST_MT, 123)) as head, + patch(f"regshape.cli.referrer.{operation}", + return_value=referrers) as list_fn, + ): + result = _runner().invoke(regshape, args) + + assert result.exit_code == 0, result.output + head.assert_called_once() + list_fn.assert_called_once_with( + client=client, repo=REPO, digest=DIGEST, artifact_type=SBOM_TYPE, + ) + content = outfile.read_text() if write_file else result.stdout + if write_file: + assert result.stdout == "" + if as_json: + assert json.loads(content) == referrers.to_dict() + elif write_file: + assert content == f"{DIGEST} {SBOM_TYPE} 1234\n" + else: + assert content.splitlines()[-1].split() == [DIGEST, SBOM_TYPE, "1234"] + + @pytest.mark.parametrize("error", [ + ManifestError("Manifest not found"), + AuthError("Authentication failed"), + requests.exceptions.Timeout("Tag lookup timed out"), + ]) + def test_resolution_failure_stops_before_listing_or_writing( + self, client: MagicMock, tmp_path: Path, error: Exception + ) -> None: + """Report lookup failures on stderr without touching an existing output file.""" + outfile = tmp_path / "referrers.json" + outfile.write_text("unchanged\n") + with ( + patch("regshape.cli.referrer.head_manifest", side_effect=error), + patch("regshape.cli.referrer.list_referrers") as list_fn, + patch("regshape.cli.referrer.list_referrers_all") as list_all, + ): + result = _runner().invoke(regshape, [ + "referrer", "list", "-i", TAG_REF, "--all", "--json", + "--output", str(outfile), + ]) + + assert result.exit_code == 1 + assert result.stdout == "" + assert TAG_REF in result.stderr + assert str(error) in result.stderr + list_fn.assert_not_called() + list_all.assert_not_called() + assert outfile.read_text() == "unchanged\n" + + @pytest.mark.parametrize("digest", ["", " "]) + def test_missing_digest_header_stops_before_listing( + self, client: MagicMock, digest: str + ) -> None: + """Reject successful HEAD responses that cannot identify a subject digest.""" + with ( + patch("regshape.cli.referrer.head_manifest", + return_value=(digest, MANIFEST_MT, 123)), + patch("regshape.cli.referrer.list_referrers") as list_fn, + ): + result = _runner().invoke(regshape, [ + "referrer", "list", "-i", TAG_REF, + ]) + assert result.exit_code == 1 + assert "Docker-Content-Digest" in result.stderr + assert TAG_REF in result.stderr + list_fn.assert_not_called() + + @pytest.mark.parametrize( + ("status_code", "exit_code", "message"), + [(200, 0, ""), (404, 1, "Manifest not found"), + (401, 1, "Authentication failed"), (500, 1, "Registry error")], + ) + def test_cli_wires_manifest_head_to_referrer_get( + self, client: MagicMock, status_code: int, exit_code: int, message: str + ) -> None: + """Exercise real library operations and their HTTP error mapping from the CLI.""" + referrers = _referrer_list([_descriptor()]) + client.head.return_value = _response( + status_code, headers={"Docker-Content-Digest": DIGEST}, + ) + client.get.return_value = _response(200, referrers.to_json()) + + result = _runner().invoke(regshape, [ + "referrer", "list", "-i", TAG_REF, "--json", + ]) + + assert result.exit_code == exit_code, result.output + expected_calls = [ + call.head( + f"/v2/{REPO}/manifests/v1.0", + headers={"Accept": ",".join(sorted(ALL_MANIFEST_MEDIA_TYPES))}, + ), + ] + if exit_code == 0: + assert json.loads(result.stdout) == referrers.to_dict() + expected_calls.append(call.get(f"/v2/{REPO}/referrers/{DIGEST}", params=None)) + else: + assert message in result.stderr + assert TAG_REF in result.stderr + client.get.assert_not_called() + assert client.method_calls == expected_calls + + def test_help_describes_tag_and_digest_support(self) -> None: + """Expose the newly accepted tag form in the command help.""" + result = _runner().invoke(regshape, ["referrer", "list", "--help"]) + assert result.exit_code == 0 + assert "registry/repo:tag" in result.output + assert "registry/repo@sha256:abc..." in result.output + assert "Tag-only references are rejected" not in result.output