From 90f5d037976734a1137ae71430eb780475ab66df Mon Sep 17 00:00:00 2001 From: Toddy Mladenov Date: Wed, 28 Oct 2026 14:18:47 -0700 Subject: [PATCH] feat(referrer): resolve tag references before listing referrers Reuse the manifest HEAD operation, preserve digest passthrough, and report lookup failures before querying referrers or writing output. Cover flag combinations and library wiring, update CLI documentation, and add hosted Python test validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/tests.yml | 27 +++ CONTRIBUTING.md | 13 ++ specs/cli/referrer.md | 42 +++-- src/regshape/cli/referrer.py | 61 ++++-- src/regshape/tests/test_referrer_cli.py | 239 +++++++++++++++++++++++- 5 files changed, 339 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..47c8c93 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,27 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + pytest: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: ${{ matrix.python-version }} + - name: Install package and test dependencies + run: python -m pip install -e ".[dev]" + - name: Run tests + run: python -m pytest -q diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb96883..0ad5e73 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,19 @@ Create an `.env` file in the root directory of the project and add the following PYTHONPATH=/src ``` +## Testing + +Install the package and its test dependencies, then run the test suite: + +```bash +python -m pip install -e ".[dev]" +python -m pytest -q +``` + +The [test workflow](.github/workflows/tests.yml) runs the suite on Python 3.10, +3.11, and 3.12 for pull requests and pushes to `main`. Tests include mocked +registry interactions; they do not require a live registry or Docker daemon. + ## Releasing ### Version Tagging Convention 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