feat(marketplace): accept @sha256 digest references in tap connect

parseOCIRef (CLI) and parseConnectURL (API) now accept a pinned digest
reference (oci://host/org/repo@sha256:...), with an optional informational
tag before it. The digest must be a full sha256:<64 hex>, the same contract
the platform source's digest= reference enforces. A reference carrying only
a digest no longer defaults its tag to latest, since the digest is the
authoritative reference Flux resolves before any tag.

Assisted-by: LLM
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
This commit is contained in:
IvanHunters committed 2026-10-22 21:55:45 +03:00
1 parent 215e582f03
commit b8a129e356
4 files changed
+85 -23

No files matched your search

+24 -6
View File
@@ -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
+20 -12
View File
@@ -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)
}
}
}
+21 -4
View File
@@ -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
+20 -1
View File
@@ -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)
}
}