diff --git a/pkg/oci/remote/remote.go b/pkg/oci/remote/remote.go index eab4e1f9b01..fc8ea148c5e 100644 --- a/pkg/oci/remote/remote.go +++ b/pkg/oci/remote/remote.go @@ -33,10 +33,13 @@ import ( // These enable mocking for unit testing without faking an entire registry. var ( - remoteImage = remote.Image - remoteIndex = remote.Index - remoteGet = remote.Get - remoteWrite = remote.Write + remoteImage = remote.Image + remoteIndex = remote.Index + remoteGet = remote.Get + remoteWrite = remote.Write + remoteHead = remote.Head + remoteWriteLayer = remote.WriteLayer + remotePut = remote.Put ) // EntityNotFoundError is the error that SignedEntity returns when the diff --git a/pkg/oci/remote/write.go b/pkg/oci/remote/write.go index d353c6b0883..20758b55261 100644 --- a/pkg/oci/remote/write.go +++ b/pkg/oci/remote/write.go @@ -146,7 +146,7 @@ func WriteSignaturesExperimentalOCI(d name.Digest, se oci.SignedEntity, opts ... if err != nil { return err } - desc, err := remote.Head(ref, o.ROpt...) + desc, err := remoteHead(ref, o.ROpt...) if err != nil { return err } @@ -161,7 +161,7 @@ func WriteSignaturesExperimentalOCI(d name.Digest, se oci.SignedEntity, opts ... return err } for _, v := range s { - if err := remote.WriteLayer(d.Repository, v, o.ROpt...); err != nil { + if err := remoteWriteLayer(d.Repository, v, o.ROpt...); err != nil { return err } } @@ -176,7 +176,7 @@ func WriteSignaturesExperimentalOCI(d name.Digest, se oci.SignedEntity, opts ... return err } configLayer := static.NewLayer(configBytes, configDesc.MediaType) - if err := remote.WriteLayer(d.Repository, configLayer, o.ROpt...); err != nil { + if err := remoteWriteLayer(d.Repository, configLayer, o.ROpt...); err != nil { return err } @@ -208,7 +208,7 @@ func WriteSignaturesExperimentalOCI(d name.Digest, se oci.SignedEntity, opts ... // TODO: use ui.Infof fmt.Fprintf(os.Stderr, "Uploading signature for [%s] to [%s] with config.mediaType [%s] layers[0].mediaType [%s].\n", d.String(), targetRef.String(), artifactType, ctypes.SimpleSigningMediaType) - return remote.Put(targetRef, &taggableManifest{raw: b, mediaType: m.MediaType}, o.ROpt...) + return remotePut(targetRef, &taggableManifest{raw: b, mediaType: m.MediaType}, o.ROpt...) } type taggableManifest struct { @@ -224,7 +224,9 @@ func (taggable taggableManifest) MediaType() (types.MediaType, error) { return taggable.mediaType, nil } -func WriteAttestationNewBundleFormat(d name.Digest, bundleBytes []byte, predicateType string, opts ...Option) error { +// WriteReferrer writes a referrer manifest for a given subject digest. +// It uploads the provided layers and creates a manifest that refers to the subject. +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() @@ -232,7 +234,7 @@ func WriteAttestationNewBundleFormat(d name.Digest, bundleBytes []byte, predicat if err != nil { return err } - desc, err := remote.Head(ref, o.ROpt...) + desc, err := remoteHead(ref, o.ROpt...) if err != nil { return err } @@ -247,32 +249,35 @@ func WriteAttestationNewBundleFormat(d name.Digest, bundleBytes []byte, predicat if err != nil { return fmt.Errorf("failed to calculate size: %w", err) } - err = remote.WriteLayer(d.Repository, configLayer, o.ROpt...) + err = remoteWriteLayer(d.Repository, configLayer, o.ROpt...) if err != nil { return fmt.Errorf("failed to upload layer: %w", err) } - // generate bundle media type string - bundleMediaType, err := sgbundle.MediaTypeString("0.3") - if err != nil { - return fmt.Errorf("failed to generate bundle media type string: %w", err) - } - - // Write the bundle layer - layer := static.NewLayer(bundleBytes, types.MediaType(bundleMediaType)) - blobDigest, err := layer.Digest() - if err != nil { - return fmt.Errorf("failed to calculate digest: %w", err) - } - - blobSize, err := layer.Size() - if err != nil { - return fmt.Errorf("failed to calculate size: %w", err) - } + layerDescriptors := make([]v1.Descriptor, len(layers)) + for i, layer := range layers { + mediaType, err := layer.MediaType() + if err != nil { + return fmt.Errorf("failed to get media type: %w", err) + } + layerDigest, err := layer.Digest() + if err != nil { + return fmt.Errorf("failed to calculate digest: %w", err) + } + layerSize, err := layer.Size() + if err != nil { + return fmt.Errorf("failed to calculate size: %w", err) + } - err = remote.WriteLayer(d.Repository, layer, o.ROpt...) - if err != nil { - return fmt.Errorf("failed to upload layer: %w", err) + err = remoteWriteLayer(d.Repository, layer, o.ROpt...) + if err != nil { + return fmt.Errorf("failed to upload layer: %w", err) + } + layerDescriptors[i] = v1.Descriptor{ + MediaType: mediaType, + Digest: layerDigest, + Size: layerSize, + } } // Create a manifest that includes the blob as a layer @@ -281,42 +286,76 @@ func WriteAttestationNewBundleFormat(d name.Digest, bundleBytes []byte, predicat MediaType: types.OCIManifestSchema1, Config: v1.Descriptor{ MediaType: types.MediaType("application/vnd.oci.empty.v1+json"), - ArtifactType: bundleMediaType, + ArtifactType: artifactType, Digest: configDigest, Size: configSize, }, - Layers: []v1.Descriptor{ - { - MediaType: types.MediaType(bundleMediaType), - Digest: blobDigest, - Size: blobSize, - }, - }, + Layers: layerDescriptors, Subject: &v1.Descriptor{ MediaType: desc.MediaType, Digest: desc.Digest, Size: desc.Size, }, - Annotations: map[string]string{ - "org.opencontainers.image.created": time.Now().UTC().Format(time.RFC3339), - "dev.sigstore.bundle.content": "dsse-envelope", - "dev.sigstore.bundle.predicateType": predicateType, - }, - }, bundleMediaType} + Annotations: annotations, + }, artifactType} targetRef, err := manifest.targetRef(d.Repository) if err != nil { return fmt.Errorf("failed to create target reference: %w", err) } - if err := remote.Put(targetRef, manifest, o.ROpt...); err != nil { + if err := remotePut(targetRef, manifest, o.ROpt...); err != nil { return fmt.Errorf("failed to upload manifest: %w", err) } return nil } -// referrerManifest implements Taggable for use in remote.Put. +func WriteAttestationNewBundleFormat(d name.Digest, bundleBytes []byte, predicateType string, opts ...Option) error { + // generate bundle media type string + bundleMediaType, err := sgbundle.MediaTypeString("0.3") + if err != nil { + return fmt.Errorf("failed to generate bundle media type string: %w", err) + } + + // Write the bundle layer + layer := static.NewLayer(bundleBytes, types.MediaType(bundleMediaType)) + + annotations := map[string]string{ + "org.opencontainers.image.created": time.Now().UTC().Format(time.RFC3339), + "dev.sigstore.bundle.content": "dsse-envelope", + "dev.sigstore.bundle.predicateType": predicateType, + } + + return WriteReferrer(d, bundleMediaType, []v1.Layer{layer}, annotations, opts...) +} + +// WriteAttestationsReferrer publishes the attestations attached to the given entity +// into the provided repository using the referrers API. +func WriteAttestationsReferrer(d name.Digest, se oci.SignedEntity, opts ...Option) error { + atts, err := se.Attestations() + if err != nil { + return err + } + layers, err := atts.Layers() + if err != nil { + return err + } + + annotations := map[string]string{ + "org.opencontainers.image.created": time.Now().UTC().Format(time.RFC3339), + } + + // We have to pick an artifactType for the referrer manifest. The attestation + // layers themselves are DSSE envelopes, which wrap in-toto statements. + // For discovery, the artifactType should describe the semantic content (the + // in-toto statement) rather than the wrapper format (the DSSE envelope). + // Using the in-toto media type is the most appropriate and conventional choice, + // as policy engines and other tools will query for attestations using this type. + return WriteReferrer(d, ctypes.IntotoPayloadType, layers, annotations, opts...) +} + +// referrerManifest implements Taggable for use in remotePut. // This type also augments the built-in v1.Manifest with an ArtifactType field // which is part of the OCI 1.1 Image Manifest spec but is unsupported by // go-containerregistry at this time. diff --git a/pkg/oci/remote/write_test.go b/pkg/oci/remote/write_test.go index 32be283fff1..89f3e58928d 100644 --- a/pkg/oci/remote/write_test.go +++ b/pkg/oci/remote/write_test.go @@ -17,15 +17,19 @@ package remote import ( "fmt" + "strings" "testing" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/random" "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/static" + "github.com/google/go-containerregistry/pkg/v1/types" "github.com/sigstore/cosign/v2/pkg/oci/mutate" "github.com/sigstore/cosign/v2/pkg/oci/signed" - "github.com/sigstore/cosign/v2/pkg/oci/static" + cosignstatic "github.com/sigstore/cosign/v2/pkg/oci/static" + ctypes "github.com/sigstore/cosign/v2/pkg/types" ) func TestWriteSignatures(t *testing.T) { @@ -41,7 +45,7 @@ func TestWriteSignatures(t *testing.T) { want := 6 // Add 6 signatures for i := 0; i < want; i++ { - sig, err := static.NewSignature(nil, fmt.Sprintf("%d", i)) + sig, err := cosignstatic.NewSignature(nil, fmt.Sprintf("%d", i)) if err != nil { t.Fatalf("static.NewSignature() = %v", err) } @@ -83,7 +87,7 @@ func TestWriteAttestations(t *testing.T) { want := 6 // Add 6 attestations for i := 0; i < want; i++ { - sig, err := static.NewAttestation([]byte(fmt.Sprintf("%d", i))) + sig, err := cosignstatic.NewAttestation([]byte(fmt.Sprintf("%d", i))) if err != nil { t.Fatalf("static.NewSignature() = %v", err) } @@ -111,3 +115,334 @@ func TestWriteAttestations(t *testing.T) { t.Fatalf("WriteAttestations() = %v", err) } } + +func TestReferrerManifest(t *testing.T) { + // Test referrerManifest.RawManifest() + rm := referrerManifest{ + Manifest: v1.Manifest{ + SchemaVersion: 2, + MediaType: types.OCIManifestSchema1, + Config: v1.Descriptor{ + MediaType: "application/vnd.oci.empty.v1+json", + Digest: v1.Hash{Algorithm: "sha256", Hex: "abc123"}, + Size: 100, + }, + Layers: []v1.Descriptor{}, + }, + ArtifactType: "test.artifact.type", + } + + manifestBytes, err := rm.RawManifest() + if err != nil { + t.Fatalf("RawManifest() = %v", err) + } + + if len(manifestBytes) == 0 { + t.Error("RawManifest returned empty bytes") + } + + // Test referrerManifest.MediaType() + mediaType, err := rm.MediaType() + if err != nil { + t.Fatalf("MediaType() = %v", err) + } + if mediaType != types.OCIManifestSchema1 { + t.Errorf("MediaType() = %s, want %s", mediaType, types.OCIManifestSchema1) + } + + // Test referrerManifest.targetRef() + repo := name.MustParseReference("gcr.io/test/repo").Context() + targetRef, err := rm.targetRef(repo) + if err != nil { + t.Fatalf("targetRef() = %v", err) + } + if targetRef == nil { + t.Error("targetRef returned nil") + } +} + +func TestTaggableManifest(t *testing.T) { + // Test taggableManifest.RawManifest() + tm := taggableManifest{ + raw: []byte(`{"test":"manifest"}`), + mediaType: types.DockerManifestSchema2, + } + + manifestBytes, err := tm.RawManifest() + if err != nil { + t.Fatalf("RawManifest() = %v", err) + } + if string(manifestBytes) != `{"test":"manifest"}` { + t.Errorf("RawManifest() = %s, want %s", string(manifestBytes), `{"test":"manifest"}`) + } + + // Test taggableManifest.MediaType() + mediaType, err := tm.MediaType() + if err != nil { + t.Fatalf("MediaType() = %v", err) + } + if mediaType != types.DockerManifestSchema2 { + t.Errorf("MediaType() = %s, want %s", mediaType, types.DockerManifestSchema2) + } +} + +func TestWriteAttestationNewBundleFormat(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) + + // Mock remoteHead to return a descriptor + remoteHead = func(name.Reference, ...remote.Option) (*v1.Descriptor, error) { + return &v1.Descriptor{ + MediaType: types.DockerManifestSchema2, + Digest: v1.Hash{Algorithm: "sha256", Hex: "abcdef1234567890"}, + Size: 100, + }, nil + } + + // Mock remoteWriteLayer to succeed + remoteWriteLayer = func(name.Repository, v1.Layer, ...remote.Option) error { + return nil + } + + // Mock remotePut to capture the manifest + var capturedManifest remote.Taggable + remotePut = func(_ name.Reference, manifest remote.Taggable, _ ...remote.Option) error { + capturedManifest = manifest + return nil + } + + err := WriteAttestationNewBundleFormat(digest, bundleBytes, predicateType) + if err != nil { + t.Fatalf("WriteAttestationNewBundleFormat() = %v", err) + } + + // Verify that a manifest was uploaded + if capturedManifest == nil { + t.Error("Expected manifest to be uploaded, but none was captured") + } + + // Verify it's a referrerManifest + refManifest, ok := capturedManifest.(referrerManifest) + if !ok { + t.Errorf("Expected referrerManifest, got %T", capturedManifest) + return + } + + // Verify the artifact type contains bundle media type + if refManifest.ArtifactType == "" { + t.Error("Expected ArtifactType to be set") + } + + // Verify annotations are set correctly + if refManifest.Annotations["dev.sigstore.bundle.content"] != "dsse-envelope" { + t.Errorf("Expected bundle.content annotation to be 'dsse-envelope', got %s", refManifest.Annotations["dev.sigstore.bundle.content"]) + } + if refManifest.Annotations["dev.sigstore.bundle.predicateType"] != predicateType { + t.Errorf("Expected predicateType annotation to be %s, got %s", predicateType, refManifest.Annotations["dev.sigstore.bundle.predicateType"]) + } +} + +func TestWriteAttestationsReferrer(t *testing.T) { + // Save original functions + origHead := remoteHead + origWriteLayer := remoteWriteLayer + origPut := remotePut + t.Cleanup(func() { + remoteHead = origHead + remoteWriteLayer = origWriteLayer + remotePut = origPut + }) + + digest := name.MustParseReference("gcr.io/test/image@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef").(name.Digest) + + // Create a test signed entity with attestations + i, err := random.Image(300, 1) + if err != nil { + t.Fatalf("random.Image() = %v", err) + } + si := signed.Image(i) + + // Add an attestation + att, err := cosignstatic.NewAttestation([]byte("test-attestation")) + if err != nil { + t.Fatalf("static.NewAttestation() = %v", err) + } + si, err = mutate.AttachAttestationToImage(si, att) + if err != nil { + t.Fatalf("AttachAttestationToImage() = %v", err) + } + + // Mock remoteHead to return a descriptor + remoteHead = func(name.Reference, ...remote.Option) (*v1.Descriptor, error) { + return &v1.Descriptor{ + MediaType: types.DockerManifestSchema2, + Digest: v1.Hash{Algorithm: "sha256", Hex: "abcdef1234567890"}, + Size: 100, + }, nil + } + + // Mock remoteWriteLayer to succeed + remoteWriteLayer = func(name.Repository, v1.Layer, ...remote.Option) error { + return nil + } + + // Mock remotePut to capture the manifest + var capturedManifest remote.Taggable + remotePut = func(_ name.Reference, manifest remote.Taggable, _ ...remote.Option) error { + capturedManifest = manifest + return nil + } + + err = WriteAttestationsReferrer(digest, si) + if err != nil { + t.Fatalf("WriteAttestationsReferrer() = %v", err) + } + + // Verify that a manifest was uploaded + if capturedManifest == nil { + t.Error("Expected manifest to be uploaded, but none was captured") + } + + // Verify it's a referrerManifest + refManifest, ok := capturedManifest.(referrerManifest) + if !ok { + t.Errorf("Expected referrerManifest, got %T", capturedManifest) + return + } + + // Verify the artifact type is set to in-toto payload type + if refManifest.ArtifactType != ctypes.IntotoPayloadType { + t.Errorf("Expected ArtifactType to be %s, got %s", ctypes.IntotoPayloadType, refManifest.ArtifactType) + } + + // Verify annotations include created timestamp + if _, exists := refManifest.Annotations["org.opencontainers.image.created"]; !exists { + t.Error("Expected created annotation to be set") + } + + // Verify we have at least one layer + if len(refManifest.Layers) == 0 { + t.Error("Expected at least one layer in manifest") + } +} + +func TestWriteReferrer(t *testing.T) { + // Save original functions + origHead := remoteHead + origWriteLayer := remoteWriteLayer + origPut := remotePut + t.Cleanup(func() { + remoteHead = origHead + remoteWriteLayer = origWriteLayer + remotePut = origPut + }) + + digest := name.MustParseReference("gcr.io/test/image@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef").(name.Digest) + + // Create a test layer + testLayer := static.NewLayer([]byte("test-data"), "application/octet-stream") + layers := []v1.Layer{testLayer} + annotations := map[string]string{ + "test.annotation": "test-value", + } + artifactType := "test.artifact.type" + + // Mock remoteHead to return a descriptor + remoteHead = func(name.Reference, ...remote.Option) (*v1.Descriptor, error) { + return &v1.Descriptor{ + MediaType: types.DockerManifestSchema2, + Digest: v1.Hash{Algorithm: "sha256", Hex: "abcdef1234567890"}, + Size: 100, + }, nil + } + + // Mock remoteWriteLayer to succeed + remoteWriteLayer = func(name.Repository, v1.Layer, ...remote.Option) error { + return nil + } + + // Mock remotePut to capture the manifest + var capturedManifest remote.Taggable + remotePut = func(_ name.Reference, manifest remote.Taggable, _ ...remote.Option) error { + capturedManifest = manifest + return nil + } + + err := WriteReferrer(digest, artifactType, layers, annotations) + if err != nil { + t.Fatalf("WriteReferrer() = %v", err) + } + + // Verify that a manifest was uploaded + if capturedManifest == nil { + t.Error("Expected manifest to be uploaded, but none was captured") + } + + // Verify it's a referrerManifest + refManifest, ok := capturedManifest.(referrerManifest) + if !ok { + t.Errorf("Expected referrerManifest, got %T", capturedManifest) + return + } + + // Verify the artifact type is set correctly + if refManifest.ArtifactType != artifactType { + t.Errorf("Expected ArtifactType to be %s, got %s", artifactType, refManifest.ArtifactType) + } + + // Verify annotations are passed through + if refManifest.Annotations["test.annotation"] != "test-value" { + t.Errorf("Expected annotation to be 'test-value', got %s", refManifest.Annotations["test.annotation"]) + } + + // Verify we have the expected number of layers + if len(refManifest.Layers) != 1 { + t.Errorf("Expected 1 layer, got %d", len(refManifest.Layers)) + } + + // Verify the subject is set + if refManifest.Subject == nil { + t.Error("Expected Subject to be set") + } + + // Verify config descriptor + if refManifest.Config.ArtifactType != artifactType { + t.Errorf("Expected Config.ArtifactType to be %s, got %s", artifactType, refManifest.Config.ArtifactType) + } +} + +func TestWriteReferrerErrorHandling(t *testing.T) { + // Save original functions + origHead := remoteHead + t.Cleanup(func() { + remoteHead = origHead + }) + + digest := name.MustParseReference("gcr.io/test/image@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef").(name.Digest) + layers := []v1.Layer{} + annotations := map[string]string{} + + // Mock remoteHead to return an error + remoteHead = func(name.Reference, ...remote.Option) (*v1.Descriptor, error) { + return nil, fmt.Errorf("remote head failed") + } + + err := WriteReferrer(digest, "test.type", layers, annotations) + if err == nil { + t.Error("Expected error from WriteReferrer when remoteHead fails") + } + if !strings.Contains(err.Error(), "remote head failed") { + t.Errorf("Expected error to contain 'remote head failed', got %v", err) + } +}