diff --git a/pkg/oci/remote/options.go b/pkg/oci/remote/options.go index 616a92a8eff..e1652aeaf8f 100644 --- a/pkg/oci/remote/options.go +++ b/pkg/oci/remote/options.go @@ -20,6 +20,7 @@ import ( "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/sigstore/cosign/v3/pkg/cosign/env" ) @@ -42,6 +43,7 @@ type options struct { SBOMSuffix string TagPrefix string TargetRepository name.Repository + SubjectDescriptor *v1.Descriptor ROpt []remote.Option NameOpts []name.Option OriginalOptions []Option @@ -129,6 +131,18 @@ func WithTargetRepository(repo name.Repository) Option { } } +// WithSubjectDescriptor is a functional option for providing the subject +// descriptor to embed when writing an OCI referrer, instead of resolving it +// via a HEAD request against the subject reference. This permits writing +// referrers whose subject manifest is not present in the registry. The +// descriptor is used verbatim; the caller is responsible for providing a +// valid descriptor whose digest matches the subject reference. +func WithSubjectDescriptor(desc *v1.Descriptor) Option { + return func(o *options) { + o.SubjectDescriptor = desc + } +} + // TargetRepositoryFromOptions extracts the TargetRepository that a // WithTargetRepository option would have set in the provided options. // Returns the zero name.Repository if no override was provided. diff --git a/pkg/oci/remote/write.go b/pkg/oci/remote/write.go index 9c88a442c9f..db638de48e6 100644 --- a/pkg/oci/remote/write.go +++ b/pkg/oci/remote/write.go @@ -320,12 +320,7 @@ func writeEmptyConfigLayer(o *options) (v1.Hash, int64, error) { func WriteReferrer(d name.Digest, artifactType string, layers []v1.Layer, annotations map[string]string, opts ...Option) error { o := makeOptions(d.Repository, opts...) - signTarget := d.String() - ref, err := name.ParseReference(signTarget, o.NameOpts...) - if err != nil { - return err - } - desc, err := remoteHead(ref, o.ROpt...) + subject, err := subjectDescriptor(d, o) if err != nil { return err } @@ -381,12 +376,8 @@ func WriteReferrer(d name.Digest, artifactType string, layers []v1.Layer, annota Digest: configDigest, Size: configSize, }, - Layers: layerDescriptors, - Subject: &v1.Descriptor{ - MediaType: desc.MediaType, - Digest: desc.Digest, - Size: desc.Size, - }, + Layers: layerDescriptors, + Subject: subject, Annotations: annotations, }, artifactType} @@ -402,6 +393,37 @@ func WriteReferrer(d name.Digest, artifactType string, layers []v1.Layer, annota return nil } +// subjectDescriptor returns the descriptor to embed as the referrer's subject. +// A descriptor provided via WithSubjectDescriptor is used verbatim, allowing +// referrers whose subject manifest is not present in the registry; otherwise +// the subject is resolved with a HEAD request. +func subjectDescriptor(d name.Digest, o *options) (*v1.Descriptor, error) { + if subject := o.SubjectDescriptor; subject != nil { + dig, err := v1.NewHash(d.DigestStr()) + if err != nil { + return nil, err + } + if subject.Digest != dig { + return nil, fmt.Errorf("subject descriptor digest %q does not match %q", subject.Digest, d.String()) + } + return subject, nil + } + + ref, err := name.ParseReference(d.String(), o.NameOpts...) + if err != nil { + return nil, err + } + desc, err := remoteHead(ref, o.ROpt...) + if err != nil { + return nil, err + } + return &v1.Descriptor{ + MediaType: desc.MediaType, + Digest: desc.Digest, + Size: desc.Size, + }, nil +} + func WriteAttestationNewBundleFormat(d name.Digest, bundleBytes []byte, predicateType string, opts ...Option) error { // generate bundle media type string bundleMediaType, err := sgbundle.MediaTypeString("0.3") diff --git a/pkg/oci/remote/write_test.go b/pkg/oci/remote/write_test.go index af5f18ab364..bd451c6078b 100644 --- a/pkg/oci/remote/write_test.go +++ b/pkg/oci/remote/write_test.go @@ -17,6 +17,7 @@ package remote import ( "fmt" + "reflect" "strings" "testing" @@ -253,6 +254,98 @@ func TestWriteAttestationNewBundleFormat(t *testing.T) { } } +func TestWriteAttestationNewBundleFormatWithSubjectDescriptor(t *testing.T) { + // Save original functions + origHead := remoteHead + origWriteLayer := remoteWriteLayer + origPut := remotePut + t.Cleanup(func() { + remoteHead = origHead + remoteWriteLayer = origWriteLayer + remotePut = origPut + }) + + bundleBytes := []byte(`{"payload":"test","signatures":[]}`) + predicateType := "https://test.predicate.type" + digest := name.MustParseReference("gcr.io/test/image@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef").(name.Digest) + subjectHash := v1.Hash{Algorithm: "sha256", Hex: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"} + + // The subject manifest does not exist in the registry; remoteHead must not + // be consulted when a subject descriptor is provided. + remoteHead = func(name.Reference, ...remote.Option) (*v1.Descriptor, error) { + t.Error("remoteHead should not be called when WithSubjectDescriptor is used") + return nil, fmt.Errorf("MANIFEST_UNKNOWN") + } + + remoteWriteLayer = func(name.Repository, v1.Layer, ...remote.Option) error { + return nil + } + + var capturedManifest remote.Taggable + remotePut = func(_ name.Reference, manifest remote.Taggable, _ ...remote.Option) error { + capturedManifest = manifest + return nil + } + + t.Run("descriptor used verbatim", func(t *testing.T) { + capturedManifest = nil + subject := &v1.Descriptor{ + MediaType: types.OCIImageIndex, + Digest: subjectHash, + Size: 123, + } + + err := WriteAttestationNewBundleFormat(digest, bundleBytes, predicateType, WithSubjectDescriptor(subject)) + if err != nil { + t.Fatalf("WriteAttestationNewBundleFormat() = %v", err) + } + + refManifest, ok := capturedManifest.(referrerManifest) + if !ok { + t.Fatalf("Expected referrerManifest, got %T", capturedManifest) + } + if refManifest.Subject == nil { + t.Fatal("Expected subject to be set") + } + if !reflect.DeepEqual(refManifest.Subject, subject) { + t.Errorf("Subject = %+v, want %+v", *refManifest.Subject, *subject) + } + }) + + t.Run("zero digest rejected", func(t *testing.T) { + capturedManifest = nil + subject := &v1.Descriptor{ + MediaType: types.OCIManifestSchema1, + Size: 456, + } + + err := WriteAttestationNewBundleFormat(digest, bundleBytes, predicateType, WithSubjectDescriptor(subject)) + if err == nil { + t.Fatal("Expected error for subject descriptor without digest") + } + if capturedManifest != nil { + t.Error("Expected no manifest upload for subject descriptor without digest") + } + }) + + t.Run("digest mismatch rejected", func(t *testing.T) { + capturedManifest = nil + subject := &v1.Descriptor{ + MediaType: types.OCIManifestSchema1, + Digest: v1.Hash{Algorithm: "sha256", Hex: "feedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedface"}, + Size: 789, + } + + err := WriteAttestationNewBundleFormat(digest, bundleBytes, predicateType, WithSubjectDescriptor(subject)) + if err == nil { + t.Fatal("Expected error for mismatched subject digest") + } + if capturedManifest != nil { + t.Error("Expected no manifest upload on digest mismatch") + } + }) +} + func TestWriteAttestationsReferrer(t *testing.T) { // Save original functions origHead := remoteHead