diff --git a/cmd/cozypkg/cmd/tap.go b/cmd/cozypkg/cmd/tap.go index 107ab3ad8..d13a886f3 100644 --- a/cmd/cozypkg/cmd/tap.go +++ b/cmd/cozypkg/cmd/tap.go @@ -65,10 +65,18 @@ type ociRef struct { // Repo is the last path segment. Repo string Tag string + // Digest, when set, pins the exact content (sha256:<64 hex>). It takes + // precedence over Tag at pull time (Flux resolves digest before tag), so a + // digest-pinned reference never follows a moved tag. + Digest string } var sanitizeName = regexp.MustCompile(`[^a-z0-9-]+`) +// ociDigestRe matches a full OCI manifest digest (sha256:<64 hex>), the same +// shape the platform source's "digest=" reference enforces. +var ociDigestRe = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + // parseOCIRef splits an oci:// reference into its URL (without tag), org, repo, // and tag. A missing tag defaults to "latest". func parseOCIRef(ref string) (ociRef, error) { @@ -77,11 +85,20 @@ func parseOCIRef(ref string) (ociRef, error) { } body := strings.TrimPrefix(ref, "oci://") - tag := "" - // Digest pins are not tag-addressable; tap is tag-based for now. - if strings.Contains(body, "@") { - return ociRef{}, fmt.Errorf("digest references are not supported by tap yet; use a tag (oci://host/org/repo:tag)") + // A digest pins the exact content (oci://host/org/repo@sha256:...). An + // optional tag may precede it and is then informational only. The digest + // must be a full sha256:<64 hex>, the same contract the platform source's + // "digest=" reference enforces. + digest := "" + if at := strings.LastIndex(body, "@"); at >= 0 { + digest = body[at+1:] + body = body[:at] + if !ociDigestRe.MatchString(digest) { + return ociRef{}, fmt.Errorf("invalid digest %q in reference %q: want sha256:<64 hex>", digest, ref) + } } + + tag := "" if colon := strings.LastIndex(body, ":"); colon >= 0 && !strings.Contains(body[colon:], "/") { tag = body[colon+1:] body = body[:colon] @@ -96,10 +113,11 @@ func parseOCIRef(ref string) (ociRef, error) { if len(segs) >= 3 { org = segs[len(segs)-2] } - if tag == "" { + // Default to latest only when neither a tag nor a digest pins the content. + if tag == "" && digest == "" { tag = "latest" } - return ociRef{URL: "oci://" + body, Org: org, Repo: repo, Tag: tag}, nil + return ociRef{URL: "oci://" + body, Org: org, Repo: repo, Tag: tag, Digest: digest}, nil } // fluxSourceName is the RFC-1123 name of the OCIRepository tap creates. It diff --git a/cmd/cozypkg/cmd/tap_test.go b/cmd/cozypkg/cmd/tap_test.go index 27d25ff37..2441ea607 100644 --- a/cmd/cozypkg/cmd/tap_test.go +++ b/cmd/cozypkg/cmd/tap_test.go @@ -36,18 +36,26 @@ import ( ) func TestParseOCIRef(t *testing.T) { + const digest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" cases := []struct { - in string - wantURL, wantOrg, wantRepo, wantTag string - wantErr bool + in string + wantURL, wantOrg, wantRepo, wantTag, wantDigest string + wantErr bool }{ - {"oci://ghcr.io/foo/bar:v1", "oci://ghcr.io/foo/bar", "foo", "bar", "v1", false}, - {"oci://ghcr.io/foo/bar", "oci://ghcr.io/foo/bar", "foo", "bar", "latest", false}, - {"oci://ghcr.io/foo/group/bar:1.2.3", "oci://ghcr.io/foo/group/bar", "group", "bar", "1.2.3", false}, - {"oci://registry.example.com/solo:tag", "oci://registry.example.com/solo", "", "solo", "tag", false}, - {"https://ghcr.io/foo/bar", "", "", "", "", true}, - {"oci://ghcr.io/foo/bar@sha256:abc", "", "", "", "", true}, - {"oci://ghcr.io", "", "", "", "", true}, + {"oci://ghcr.io/foo/bar:v1", "oci://ghcr.io/foo/bar", "foo", "bar", "v1", "", false}, + {"oci://ghcr.io/foo/bar", "oci://ghcr.io/foo/bar", "foo", "bar", "latest", "", false}, + {"oci://ghcr.io/foo/group/bar:1.2.3", "oci://ghcr.io/foo/group/bar", "group", "bar", "1.2.3", "", false}, + {"oci://registry.example.com/solo:tag", "oci://registry.example.com/solo", "", "solo", "tag", "", false}, + // A digest pin is accepted; the tag is left empty (not defaulted to + // latest) because the digest is the authoritative reference. + {"oci://ghcr.io/foo/bar@" + digest, "oci://ghcr.io/foo/bar", "foo", "bar", "", digest, false}, + // A tag may precede the digest; the tag is then informational only. + {"oci://ghcr.io/foo/bar:v1@" + digest, "oci://ghcr.io/foo/bar", "foo", "bar", "v1", digest, false}, + {"https://ghcr.io/foo/bar", "", "", "", "", "", true}, + // A malformed digest (not sha256:<64 hex>) is still rejected. + {"oci://ghcr.io/foo/bar@sha256:abc", "", "", "", "", "", true}, + {"oci://ghcr.io/foo/bar@notadigest", "", "", "", "", "", true}, + {"oci://ghcr.io", "", "", "", "", "", true}, } for _, c := range cases { got, err := parseOCIRef(c.in) @@ -61,8 +69,8 @@ func TestParseOCIRef(t *testing.T) { t.Errorf("parseOCIRef(%q) unexpected error: %v", c.in, err) continue } - if got.URL != c.wantURL || got.Org != c.wantOrg || got.Repo != c.wantRepo || got.Tag != c.wantTag { - t.Errorf("parseOCIRef(%q) = %+v, want url=%q org=%q repo=%q tag=%q", c.in, got, c.wantURL, c.wantOrg, c.wantRepo, c.wantTag) + if got.URL != c.wantURL || got.Org != c.wantOrg || got.Repo != c.wantRepo || got.Tag != c.wantTag || got.Digest != c.wantDigest { + t.Errorf("parseOCIRef(%q) = %+v, want url=%q org=%q repo=%q tag=%q digest=%q", c.in, got, c.wantURL, c.wantOrg, c.wantRepo, c.wantTag, c.wantDigest) } } } diff --git a/pkg/registry/core/tap/compute.go b/pkg/registry/core/tap/compute.go index b4e17f4e0..9df414f76 100644 --- a/pkg/registry/core/tap/compute.go +++ b/pkg/registry/core/tap/compute.go @@ -20,12 +20,18 @@ import ( var sanitizeDNSRe = regexp.MustCompile(`[^a-z0-9-]+`) +var ociDigestRe = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + // connectTarget is the parsed form of a connect request's oci:// URL. type connectTarget struct { URL string // reference without a tag Org string Repo string Tag string + // Digest, when set, pins the exact content (sha256:<64 hex>); it takes + // precedence over Tag at pull time, so a digest-pinned connect never + // follows a moved tag. Mirrors the CLI's ociRef.Digest. + Digest string // FluxSourceName is the RFC-1123 name of the tap's OCIRepository. The tapped // repository keeps its own declared PackageSource name(s), which are not // known until the artifact is materialized, so the source (this name) is the @@ -41,9 +47,19 @@ func parseConnectURL(url, tagOverride string) (connectTarget, error) { return connectTarget{}, fmt.Errorf("url %q must start with oci://", url) } body := strings.TrimPrefix(url, "oci://") - if strings.Contains(body, "@") { - return connectTarget{}, fmt.Errorf("digest references are not supported; use a tag") + + // A digest pins the exact content (oci://host/org/repo@sha256:...); an + // optional tag may precede it and is then informational only. Mirrors the + // CLI's parseOCIRef so the API and CLI agree on digest handling. + digest := "" + if at := strings.LastIndex(body, "@"); at >= 0 { + digest = body[at+1:] + body = body[:at] + if !ociDigestRe.MatchString(digest) { + return connectTarget{}, fmt.Errorf("invalid digest %q in url %q: want sha256:<64 hex>", digest, url) + } } + tag := tagOverride if colon := strings.LastIndex(body, ":"); colon >= 0 && !strings.Contains(body[colon:], "/") { if tag == "" { @@ -55,11 +71,12 @@ func parseConnectURL(url, tagOverride string) (connectTarget, error) { if len(segs) < 2 { return connectTarget{}, fmt.Errorf("url %q must include a host and a repository path", url) } - t := connectTarget{URL: "oci://" + body, Repo: segs[len(segs)-1], Tag: tag} + t := connectTarget{URL: "oci://" + body, Repo: segs[len(segs)-1], Tag: tag, Digest: digest} if len(segs) >= 3 { t.Org = segs[len(segs)-2] } - if t.Tag == "" { + // Default to latest only when neither a tag nor a digest pins the content. + if t.Tag == "" && digest == "" { t.Tag = "latest" } fluxBase := t.Repo diff --git a/pkg/registry/core/tap/rest_test.go b/pkg/registry/core/tap/rest_test.go index e0ef88962..d05c13512 100644 --- a/pkg/registry/core/tap/rest_test.go +++ b/pkg/registry/core/tap/rest_test.go @@ -240,8 +240,27 @@ func TestParseConnectURL(t *testing.T) { if _, err := parseConnectURL("ghcr.io/foo/bar", ""); err == nil { t.Error("expected error for non-oci url") } + // A malformed digest (not sha256:<64 hex>) is rejected. if _, err := parseConnectURL("oci://ghcr.io/foo/bar@sha256:abc", ""); err == nil { - t.Error("expected error for digest ref") + t.Error("expected error for malformed digest ref") + } + // A valid digest pins the content: the tag is left empty (not defaulted to + // latest), mirroring the CLI's parseOCIRef. + const digest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + d, err := parseConnectURL("oci://ghcr.io/foo/bar@"+digest, "") + if err != nil { + t.Fatalf("digest ref err: %v", err) + } + if d.URL != "oci://ghcr.io/foo/bar" || d.Tag != "" || d.Digest != digest || d.FluxSourceName != "tap-foo-bar" { + t.Fatalf("unexpected digest parse: %+v", d) + } + // A tag may precede the digest and is then informational. + td, err := parseConnectURL("oci://ghcr.io/foo/bar:v1@"+digest, "") + if err != nil { + t.Fatalf("tag+digest ref err: %v", err) + } + if td.Tag != "v1" || td.Digest != digest { + t.Fatalf("unexpected tag+digest parse: %+v", td) } }