From c59ced04cc548c2fdfce6694512f076c03fbc06d Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Thu, 22 Oct 2026 22:16:39 +0300 Subject: [PATCH] feat(marketplace): record and pin a digest in community index entries An index entry can now carry a digest (sha256:<64 hex>) alongside its version. Resolving a short name pins that digest, so 'cozypkg tap ' consumes the exact content the CI gate verified rather than following a mutable tag; the version is kept informational. A malformed digest in an entry is a hard load error, consistent with the rest of the strict index decoding. Assisted-by: LLM Signed-off-by: IvanHunters --- cmd/cozypkg/cmd/index.go | 22 +++++++++++++++++----- cmd/cozypkg/cmd/index_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/cmd/cozypkg/cmd/index.go b/cmd/cozypkg/cmd/index.go index c9fe82714..aa684ed56 100644 --- a/cmd/cozypkg/cmd/index.go +++ b/cmd/cozypkg/cmd/index.go @@ -38,7 +38,11 @@ type IndexEntry struct { // OCIRef is the artifact location, e.g. oci://ghcr.io/org/repo. OCIRef string `json:"ociRef"` // Version is the OCI tag of the listed release; a version bump updates it. - Version string `json:"version,omitempty"` + Version string `json:"version,omitempty"` + // Digest pins the exact content the CI gate verified (sha256:<64 hex>). When + // set, tapping the entry's short name pins this digest, so a short-name tap + // does not follow a moved tag. The version is kept informational alongside it. + Digest string `json:"digest,omitempty"` Description string `json:"description,omitempty"` Homepage string `json:"homepage,omitempty"` Maintainer string `json:"maintainer,omitempty"` @@ -88,6 +92,9 @@ func loadIndex(dir string) ([]IndexEntry, error) { if entry.Name == "" || entry.OCIRef == "" { return fmt.Errorf("%s: index entry must set both name and ociRef", rel) } + if entry.Digest != "" && !ociDigestRe.MatchString(entry.Digest) { + return fmt.Errorf("%s: index entry digest %q must be sha256:<64 hex>", rel, entry.Digest) + } entries = append(entries, entry) return nil }) @@ -218,13 +225,18 @@ func resolveTapTarget(arg, indexRef string) (string, error) { if !ok { return "", fmt.Errorf("no index entry named %q", arg) } - // Pin to the entry's recorded version: the CI gate validated and + // Pin to the entry's recorded version and digest: the CI gate validated and // cosign-verified :, so tapping must consume that exact - // tag rather than defaulting to latest. + // release rather than defaulting to latest. A recorded digest pins the exact + // content (the strongest form); the version is kept informational alongside it. + ref := entry.OCIRef if entry.Version != "" { - return entry.OCIRef + ":" + entry.Version, nil + ref += ":" + entry.Version } - return entry.OCIRef, nil + if entry.Digest != "" { + ref += "@" + entry.Digest + } + return ref, nil } func init() { diff --git a/cmd/cozypkg/cmd/index_test.go b/cmd/cozypkg/cmd/index_test.go index e43ff13d5..082b2d748 100644 --- a/cmd/cozypkg/cmd/index_test.go +++ b/cmd/cozypkg/cmd/index_test.go @@ -124,6 +124,31 @@ func TestResolveTapTarget(t *testing.T) { } } +func TestResolveTapTargetPinsDigest(t *testing.T) { + const digest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + dir := t.TempDir() + writeFile(t, dir, "pinned.yaml", "name: pin.ned\nociRef: oci://ghcr.io/foo/bar\nversion: v1.2.3\ndigest: "+digest+"\n") + writeFile(t, dir, "digestonly.yaml", "name: dig.only\nociRef: oci://ghcr.io/baz/qux\ndigest: "+digest+"\n") + + // A recorded digest pins the exact content the CI gate verified; the version + // is kept informational alongside it. + if got, err := resolveTapTarget("pin.ned", dir); err != nil || got != "oci://ghcr.io/foo/bar:v1.2.3@"+digest { + t.Fatalf("version+digest resolve = %q err %v", got, err) + } + // A digest with no version resolves to the bare ref pinned by digest. + if got, err := resolveTapTarget("dig.only", dir); err != nil || got != "oci://ghcr.io/baz/qux@"+digest { + t.Fatalf("digest-only resolve = %q err %v", got, err) + } +} + +func TestLoadIndexRejectsBadDigest(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "bad.yaml", "name: x\nociRef: oci://r/x\ndigest: sha256:not-a-digest\n") + if _, err := loadIndex(dir); err == nil { + t.Fatal("expected error for a malformed digest in an index entry") + } +} + func TestDefaultIndexRef(t *testing.T) { if got := defaultIndexRef("/explicit"); got != "/explicit" { t.Errorf("flag should win, got %q", got)