diff --git a/tests/imagetools.go b/tests/imagetools.go index 1cf14bb99..867f9a4be 100644 --- a/tests/imagetools.go +++ b/tests/imagetools.go @@ -34,6 +34,7 @@ var imagetoolsTests = []func(t *testing.T, sb integration.Sandbox){ testImagetoolsCreatePlatformFilter, testImagetoolsOCILayoutInspect, testImagetoolsOCILayoutCreateSourceAndTarget, + testImagetoolsOCILayoutReferrers, testImagetoolsOCILayoutMergeSources, testImagetoolsOCILayoutTargetDigest, testImagetoolsAppend, @@ -450,6 +451,121 @@ func testImagetoolsOCILayoutCreateSourceAndTarget(t *testing.T, sb integration.S require.Equal(t, sourceDigest, digest.FromBytes(dt)) } +// testImagetoolsOCILayoutReferrers verifies standalone referrers are recorded +// directly in OCI layout index.json with a subject annotation, while reachable +// attestation manifests are not duplicated there. +func testImagetoolsOCILayoutReferrers(t *testing.T, sb integration.Sandbox) { + if !isDockerContainerWorker(sb) { + t.Skip("only testing with docker-container worker, imagetools only runs on docker-container") + } + + dir := createDockerfileWithArches(t, "amd64", "arm64") + registry1, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + require.NoError(t, err) + registry2, err := sb.NewRegistry() + require.NoError(t, err) + + source := registry1 + "/buildx/imtools-oci-layout-referrers-src:latest" + out, err := buildCmd(sb, withArgs( + "--output", "type=image,name="+source+",push=true,oci-mediatypes=true,oci-artifact=true", + "--platform=linux/amd64,linux/arm64", + "--provenance=mode=min", + dir, + )) + require.NoError(t, err, string(out)) + + cmd := buildxCmd(sb, withArgs("imagetools", "inspect", source, "--raw")) + dt, err := cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + + var srcIdx ocispecs.Index + err = json.Unmarshal(dt, &srcIdx) + require.NoError(t, err) + + var attestations []ocispecs.Descriptor + for _, mfst := range srcIdx.Manifests { + if mfst.Annotations["vnd.docker.reference.type"] == "attestation-manifest" { + attestations = append(attestations, mfst) + } + } + require.Len(t, attestations, 2) + + signatures := make([]ocispecs.Descriptor, 0, len(attestations)) + for _, attestation := range attestations { + signatures = append(signatures, pushFakeSignatureReferrer(t, source, attestation)) + } + + layoutPath := filepath.Join(dir, "layout-referrers") + layoutRef := "oci-layout://" + layoutPath + ":latest" + cmd = buildxCmd(sb, withArgs("imagetools", "create", "-t", layoutRef, source)) + dt, err = cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + + idxBytes, err := os.ReadFile(filepath.Join(layoutPath, "index.json")) + require.NoError(t, err) + + var layoutIdx ocispecs.Index + err = json.Unmarshal(idxBytes, &layoutIdx) + require.NoError(t, err) + + directReferrers := map[digest.Digest]ocispecs.Descriptor{} + directReferrerCount := 0 + for _, desc := range layoutIdx.Manifests { + if desc.Annotations["io.containerd.manifest.subject"] != "" { + directReferrerCount++ + directReferrers[desc.Digest] = desc + } + } + require.Len(t, directReferrers, directReferrerCount) + require.Len(t, directReferrers, len(signatures)) + for i, sig := range signatures { + desc, ok := directReferrers[sig.Digest] + require.True(t, ok) + require.Equal(t, attestations[i].Digest.String(), desc.Annotations["io.containerd.manifest.subject"]) + } + for _, attestation := range attestations { + _, ok := directReferrers[attestation.Digest] + require.False(t, ok) + } + + target := registry2 + "/buildx/imtools-oci-layout-referrers-dst:latest" + cmd = buildxCmd(sb, withArgs("imagetools", "create", "-t", target, layoutRef)) + dt, err = cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + + cmd = buildxCmd(sb, withArgs("imagetools", "inspect", target, "--raw")) + dt, err = cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + + var dstIdx ocispecs.Index + err = json.Unmarshal(dt, &dstIdx) + require.NoError(t, err) + + copiedAttestations := map[digest.Digest]struct{}{} + for _, mfst := range dstIdx.Manifests { + if mfst.Annotations["vnd.docker.reference.type"] == "attestation-manifest" { + copiedAttestations[mfst.Digest] = struct{}{} + } + } + require.Len(t, copiedAttestations, len(attestations)) + + for _, sig := range signatures { + cmd = buildxCmd(sb, withArgs("imagetools", "inspect", target+"@"+sig.Digest.String(), "--raw")) + dt, err = cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + + var sigManifest ocispecs.Manifest + err = json.Unmarshal(dt, &sigManifest) + require.NoError(t, err) + require.NotNil(t, sigManifest.Subject) + _, ok := copiedAttestations[sigManifest.Subject.Digest] + require.True(t, ok) + } +} + // testImagetoolsOCILayoutMergeSources verifies create merges registry and local OCI layout sources. func testImagetoolsOCILayoutMergeSources(t *testing.T, sb integration.Sandbox) { if !isDockerContainerWorker(sb) { @@ -871,7 +987,6 @@ func testImagetoolsCopyAttestationWithSignature(t *testing.T, sb integration.San require.NotNil(t, signatureManifest.Subject) require.Equal(t, attestationDesc.Digest, signatureManifest.Subject.Digest) require.Equal(t, "dsse-envelope", signatureManifest.Annotations["dev.sigstore.bundle.content"]) - } // Only attestation signatures should be present after the copy. The diff --git a/util/imagetools/create.go b/util/imagetools/create.go index 0bf60b0b7..ccf2c26cb 100644 --- a/util/imagetools/create.go +++ b/util/imagetools/create.go @@ -273,7 +273,7 @@ func (r *Resolver) Copy(ctx context.Context, src *Source, dest *Location) error return err } - recorder := &recordingReferrersProvider{base: referrersFunc(func(ctx context.Context, subject ocispecs.Descriptor) ([]ocispecs.Descriptor, error) { + referrers := &referrersProvider{base: referrersFunc(func(ctx context.Context, subject ocispecs.Descriptor) ([]ocispecs.Descriptor, error) { descs, err := r.FetchReferrers(ctx, src.Ref, subject.Digest) if err != nil { return nil, err @@ -287,12 +287,14 @@ func (r *Resolver) Copy(ctx context.Context, src *Source, dest *Location) error return filtered, nil })} - err = contentutil.CopyChain(ctx, ingester, provider, desc, contentutil.WithReferrers(recorder)) + err = contentutil.CopyChain(ctx, ingester, provider, desc, contentutil.WithReferrers(referrers)) if err != nil { return err } if dest.IsOCILayout() { - return r.writeRecordedReferrers(ctx, dest, recorder) + for subject, descs := range referrers.refs { + r.ociReferrers.record(dest.OCILayout().Path, subject, descs) + } } return nil } @@ -490,23 +492,40 @@ func (r *Resolver) filterPlatforms(ctx context.Context, dt []byte, desc ocispecs return idxBytes, desc, mfstsWithSource, nil } -type recordingReferrersProvider struct { +type referrersProvider struct { base referrersFunc refs map[digest.Digest][]ocispecs.Descriptor } -func (r *recordingReferrersProvider) Referrers(ctx context.Context, desc ocispecs.Descriptor) ([]ocispecs.Descriptor, error) { +func (r *referrersProvider) Referrers(ctx context.Context, desc ocispecs.Descriptor) ([]ocispecs.Descriptor, error) { out, err := r.base(ctx, desc) if err != nil { return nil, err } + out = dedupeDescriptors(out) if r.refs == nil { r.refs = map[digest.Digest][]ocispecs.Descriptor{} } - r.refs[desc.Digest] = append(r.refs[desc.Digest], out...) + r.refs[desc.Digest] = dedupeDescriptors(append(r.refs[desc.Digest], out...)) return out, nil } +func dedupeDescriptors(descs []ocispecs.Descriptor) []ocispecs.Descriptor { + if len(descs) < 2 { + return descs + } + seen := make(map[digest.Digest]struct{}, len(descs)) + out := descs[:0] + for _, desc := range descs { + if _, ok := seen[desc.Digest]; ok { + continue + } + seen[desc.Digest] = struct{}{} + out = append(out, desc) + } + return out +} + func (r *Resolver) ingesterForLocation(loc *Location) (content.Ingester, error) { if loc.IsRegistry() { p, err := r.registryResolver().Pusher(context.TODO(), loc.Name()) @@ -552,52 +571,19 @@ func (r *Resolver) pushOCILayout(ctx context.Context, ref *Location, desc ocispe idx := ociindex.NewStoreIndex(ref.OCILayout().Path) switch { case ref.Digest() != "": - return idx.Put(desc) + if err := idx.Put(desc); err != nil { + return err + } case ref.Tag() != "": - return idx.Put(desc, ociindex.Tag(ref.Tag())) + if err := idx.Put(desc, ociindex.Tag(ref.Tag())); err != nil { + return err + } default: - return idx.Put(desc, ociindex.Tag("latest")) - } -} - -func (r *Resolver) writeRecordedReferrers(ctx context.Context, loc *Location, refs *recordingReferrersProvider) error { - if refs == nil || len(refs.refs) == 0 { - return nil - } - store, err := r.localStore(loc.OCILayout().Path) - if err != nil { - return err - } - idx := ociindex.NewStoreIndex(loc.OCILayout().Path) - for subject, manifests := range refs.refs { - fallback := ocispecs.Index{ - Versioned: specs.Versioned{SchemaVersion: 2}, - MediaType: ocispecs.MediaTypeImageIndex, - Manifests: manifests, - } - dt, err := json.Marshal(fallback) - if err != nil { - return err - } - desc := ocispecs.Descriptor{ - MediaType: ocispecs.MediaTypeImageIndex, - Digest: digest.FromBytes(dt), - Size: int64(len(dt)), - } - w, err := store.Writer(ctx, content.WithRef(desc.Digest.String()), content.WithDescriptor(desc)) - if err != nil && !errdefs.IsAlreadyExists(err) { - return err - } - if err == nil { - if err := content.Copy(ctx, w, bytes.NewReader(dt), desc.Size, desc.Digest); err != nil && !errdefs.IsAlreadyExists(err) { - return err - } - } - if err := idx.Put(desc, ociindex.Tag("sha256-"+subject.Encoded())); err != nil { + if err := idx.Put(desc, ociindex.Tag("latest")); err != nil { return err } } - return nil + return writePendingOCILayoutReferrers(ctx, r.ociReferrers.take(ref.OCILayout().Path), r.GetDescriptor, idx, ref) } func detectMediaType(dt []byte) (string, error) { diff --git a/util/imagetools/inspect.go b/util/imagetools/inspect.go index a178b0737..2b22b6e98 100644 --- a/util/imagetools/inspect.go +++ b/util/imagetools/inspect.go @@ -3,7 +3,6 @@ package imagetools import ( "bytes" "context" - "encoding/json" "io" "net/http" "sync" @@ -38,6 +37,7 @@ type Resolver struct { buffer contentutil.Buffer localStoreMu sync.Mutex localStores map[string]content.Store + ociReferrers ociLayoutReferrerRecorder } func New(opt Opt) *Resolver { @@ -172,7 +172,7 @@ func (r *Resolver) localStore(path string) (content.Store, error) { func (r *Resolver) FetchReferrers(ctx context.Context, loc *Location, dgst digest.Digest, opts ...remotes.FetchReferrersOpt) ([]ocispecs.Descriptor, error) { if loc.IsOCILayout() { - return r.fetchOCILayoutReferrers(ctx, loc, dgst) + return fetchOCILayoutReferrers(ctx, r.GetDescriptor, loc, dgst) } f, err := r.registryResolver().Fetcher(ctx, loc.String()) if err != nil { @@ -248,30 +248,6 @@ func (r *Resolver) resolveOCILayout(ctx context.Context, loc *Location) (string, return loc.String(), *desc, nil } -func (r *Resolver) fetchOCILayoutReferrers(ctx context.Context, loc *Location, dgst digest.Digest) ([]ocispecs.Descriptor, error) { - idx := ociindex.NewStoreIndex(loc.OCILayout().Path) - // TODO: temporary fallback tag, should use annotations instead - desc, err := idx.Get("sha256-" + dgst.Encoded()) - if err != nil { - return nil, err - } - if desc == nil { - return nil, errors.WithStack(errdefs.ErrNotFound) - } - dt, err := r.GetDescriptor(ctx, loc, *desc) - if err != nil { - return nil, err - } - if desc.MediaType != ocispecs.MediaTypeImageIndex { - return nil, errors.Errorf("unsupported referrers media type %s", desc.MediaType) - } - var referrersIndex ocispecs.Index - if err := json.Unmarshal(dt, &referrersIndex); err != nil { - return nil, err - } - return referrersIndex.Manifests, nil -} - func parseRef(s string) (reference.Named, error) { ref, err := reference.ParseNormalizedNamed(s) if err != nil { diff --git a/util/imagetools/ocilayout_referrers.go b/util/imagetools/ocilayout_referrers.go new file mode 100644 index 000000000..5b9bb7f9b --- /dev/null +++ b/util/imagetools/ocilayout_referrers.go @@ -0,0 +1,212 @@ +package imagetools + +import ( + "context" + "encoding/json" + "sync" + + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/errdefs" + "github.com/moby/buildkit/client/ociindex" + "github.com/moby/buildkit/util/attestation" + "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" +) + +type ociLayoutReferrerRecorder struct { + mu sync.Mutex + refs map[string]map[digest.Digest][]ocispecs.Descriptor +} + +func (r *ociLayoutReferrerRecorder) record(path string, subject digest.Digest, descs []ocispecs.Descriptor) { + if len(descs) == 0 { + return + } + r.mu.Lock() + defer r.mu.Unlock() + + if r.refs == nil { + r.refs = map[string]map[digest.Digest][]ocispecs.Descriptor{} + } + if r.refs[path] == nil { + r.refs[path] = map[digest.Digest][]ocispecs.Descriptor{} + } + r.refs[path][subject] = dedupeDescriptors(append(r.refs[path][subject], descs...)) +} + +func (r *ociLayoutReferrerRecorder) take(path string) map[digest.Digest][]ocispecs.Descriptor { + r.mu.Lock() + defer r.mu.Unlock() + + if r.refs == nil { + return nil + } + out := r.refs[path] + delete(r.refs, path) + return out +} + +func hasSubjectAnnotation(desc ocispecs.Descriptor) bool { + return desc.Annotations[images.AnnotationManifestSubject] != "" +} + +// fetchOCILayoutReferrers resolves referrers for a subject from an OCI layout by +// combining directly indexed subject entries with referrers reachable from the +// regular named roots in index.json. +func fetchOCILayoutReferrers(ctx context.Context, getDescriptor func(context.Context, *Location, ocispecs.Descriptor) ([]byte, error), loc *Location, subject digest.Digest) ([]ocispecs.Descriptor, error) { + idx, err := ociindex.NewStoreIndex(loc.OCILayout().Path).Read() + if err != nil { + return nil, err + } + + out := map[digest.Digest]ocispecs.Descriptor{} + visited := map[digest.Digest]struct{}{} + for _, desc := range idx.Manifests { + if hasSubjectAnnotation(desc) { + continue + } + if err := collectReachableOCILayoutReferrers(ctx, getDescriptor, loc, desc, subject, visited, out); err != nil { + return nil, err + } + } + for _, desc := range idx.Manifests { + if desc.Annotations[images.AnnotationManifestSubject] == subject.String() { + out[desc.Digest] = desc + } + } + + if len(out) == 0 { + return nil, errors.WithStack(errdefs.ErrNotFound) + } + + refs := make([]ocispecs.Descriptor, 0, len(out)) + for _, desc := range out { + refs = append(refs, desc) + } + return refs, nil +} + +// collectReachableOCILayoutReferrers walks a regular OCI layout root and records +// referrer manifests for the requested subject that are already reachable from it. +func collectReachableOCILayoutReferrers(ctx context.Context, getDescriptor func(context.Context, *Location, ocispecs.Descriptor) ([]byte, error), loc *Location, desc ocispecs.Descriptor, subject digest.Digest, visited map[digest.Digest]struct{}, out map[digest.Digest]ocispecs.Descriptor) error { + if _, ok := visited[desc.Digest]; ok { + return nil + } + visited[desc.Digest] = struct{}{} + + if desc.Annotations[attestation.DockerAnnotationReferenceDigest] == subject.String() { + out[desc.Digest] = desc + } + + switch desc.MediaType { + case ocispecs.MediaTypeImageIndex: + dt, err := getDescriptor(ctx, loc, desc) + if err != nil { + return err + } + var idx ocispecs.Index + if err := json.Unmarshal(dt, &idx); err != nil { + return errors.WithStack(err) + } + for _, child := range idx.Manifests { + if err := collectReachableOCILayoutReferrers(ctx, getDescriptor, loc, child, subject, visited, out); err != nil { + return err + } + } + case ocispecs.MediaTypeImageManifest: + dt, err := getDescriptor(ctx, loc, desc) + if err != nil { + return err + } + var mfst ocispecs.Manifest + if err := json.Unmarshal(dt, &mfst); err != nil { + return errors.WithStack(err) + } + if mfst.Subject != nil && mfst.Subject.Digest == subject { + out[desc.Digest] = desc + } + } + + return nil +} + +// writePendingOCILayoutReferrers adds copied referrers to index.json only when +// they are not already reachable from the regular top-level roots. +func writePendingOCILayoutReferrers( + ctx context.Context, + pending map[digest.Digest][]ocispecs.Descriptor, + getDescriptor func(context.Context, *Location, ocispecs.Descriptor) ([]byte, error), + idx ociindex.StoreIndex, + loc *Location, +) error { + if len(pending) == 0 { + return nil + } + current, err := idx.Read() + if err != nil { + return err + } + + reachable := map[digest.Digest]struct{}{} + visited := map[digest.Digest]struct{}{} + for _, desc := range current.Manifests { + if err := collectReachableDigests(ctx, getDescriptor, loc, desc, visited, reachable); err != nil { + return err + } + } + + for subject, manifests := range pending { + for _, desc := range manifests { + if err := putSubjectReferrerIndexEntry(idx, reachable, subject, desc); err != nil { + return err + } + } + } + return nil +} + +// collectReachableDigests records descriptors reachable from regular OCI layout +// roots so standalone subject-indexed referrers are not duplicated in index.json. +func collectReachableDigests(ctx context.Context, getDescriptor func(context.Context, *Location, ocispecs.Descriptor) ([]byte, error), loc *Location, desc ocispecs.Descriptor, visited map[digest.Digest]struct{}, reachable map[digest.Digest]struct{}) error { + if hasSubjectAnnotation(desc) { + return nil + } + if _, ok := visited[desc.Digest]; ok { + return nil + } + visited[desc.Digest] = struct{}{} + reachable[desc.Digest] = struct{}{} + + if desc.MediaType != ocispecs.MediaTypeImageIndex { + return nil + } + + dt, err := getDescriptor(ctx, loc, desc) + if err != nil { + return err + } + var idx ocispecs.Index + if err := json.Unmarshal(dt, &idx); err != nil { + return errors.WithStack(err) + } + for _, child := range idx.Manifests { + if err := collectReachableDigests(ctx, getDescriptor, loc, child, visited, reachable); err != nil { + return err + } + } + return nil +} + +func putSubjectReferrerIndexEntry(idx ociindex.StoreIndex, reachable map[digest.Digest]struct{}, subject digest.Digest, desc ocispecs.Descriptor) error { + if _, ok := reachable[desc.Digest]; ok { + return nil + } + if desc.Annotations == nil { + desc.Annotations = map[string]string{} + } + if !hasSubjectAnnotation(desc) { + desc.Annotations[images.AnnotationManifestSubject] = subject.String() + } + return idx.Put(desc) +}