diff --git a/image/oci/layout/fixtures/delete_image_with_signature/blobs/sha256/0c8b263642b51b5c1dc40fe402ae2e97119c6007b6e52146419985ec1f0092dc b/image/oci/layout/fixtures/delete_image_with_signature/blobs/sha256/0c8b263642b51b5c1dc40fe402ae2e97119c6007b6e52146419985ec1f0092dc new file mode 100644 index 0000000000..e7e64ba41b --- /dev/null +++ b/image/oci/layout/fixtures/delete_image_with_signature/blobs/sha256/0c8b263642b51b5c1dc40fe402ae2e97119c6007b6e52146419985ec1f0092dc @@ -0,0 +1 @@ +insert binary content here #9671 diff --git a/image/oci/layout/fixtures/delete_image_with_signature/blobs/sha256/44353f0bf0dd9507c2e9daea7ad4f8a5f0e23bc16068d612227507e54599c18a b/image/oci/layout/fixtures/delete_image_with_signature/blobs/sha256/44353f0bf0dd9507c2e9daea7ad4f8a5f0e23bc16068d612227507e54599c18a new file mode 100644 index 0000000000..bf54928cb8 --- /dev/null +++ b/image/oci/layout/fixtures/delete_image_with_signature/blobs/sha256/44353f0bf0dd9507c2e9daea7ad4f8a5f0e23bc16068d612227507e54599c18a @@ -0,0 +1 @@ +{"architecture":"","os":"","config":{},"rootfs":{"type":"","diff_ids":["sha256:6f06dd0e26608013eff30bb1e951cda7de3fdd9e78e907470e0dd5c0ed25e273"]}} \ No newline at end of file diff --git a/image/oci/layout/fixtures/delete_image_with_signature/blobs/sha256/6f06dd0e26608013eff30bb1e951cda7de3fdd9e78e907470e0dd5c0ed25e273 b/image/oci/layout/fixtures/delete_image_with_signature/blobs/sha256/6f06dd0e26608013eff30bb1e951cda7de3fdd9e78e907470e0dd5c0ed25e273 new file mode 100644 index 0000000000..adb6ebe808 --- /dev/null +++ b/image/oci/layout/fixtures/delete_image_with_signature/blobs/sha256/6f06dd0e26608013eff30bb1e951cda7de3fdd9e78e907470e0dd5c0ed25e273 @@ -0,0 +1 @@ +test-payload \ No newline at end of file diff --git a/image/oci/layout/fixtures/delete_image_with_signature/blobs/sha256/a527179158cd5cebc11c152b8637b47ce96c838ba2aa0de66d14f45cedc11423 b/image/oci/layout/fixtures/delete_image_with_signature/blobs/sha256/a527179158cd5cebc11c152b8637b47ce96c838ba2aa0de66d14f45cedc11423 new file mode 100644 index 0000000000..f0f06201be --- /dev/null +++ b/image/oci/layout/fixtures/delete_image_with_signature/blobs/sha256/a527179158cd5cebc11c152b8637b47ce96c838ba2aa0de66d14f45cedc11423 @@ -0,0 +1,30 @@ +{ + "created": "2019-08-20T20:19:55.211423266Z", + "architecture": "amd64", + "os": "linux", + "config": { + "Env": [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ], + "Cmd": [ + "/bin/sh" + ] + }, + "rootfs": { + "type": "layers", + "diff_ids": [ + "sha256:03901b4a2ea88eeaad62dbe59b072b28b6efa00491962b8741081c5df50c65e0" + ] + }, + "history": [ + { + "created": "2019-08-20T20:19:55.062606894Z", + "created_by": "/bin/sh -c #(nop) ADD file:fe64057fbb83dccb960efabbf1cd8777920ef279a7fa8dbca0a8801c651bdf7c in / " + }, + { + "created": "2019-08-20T20:19:55.211423266Z", + "created_by": "/bin/sh -c #(nop) CMD [\"/bin/sh\"]", + "empty_layer": true + } + ] +} diff --git a/image/oci/layout/fixtures/delete_image_with_signature/blobs/sha256/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 b/image/oci/layout/fixtures/delete_image_with_signature/blobs/sha256/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 new file mode 100644 index 0000000000..aa7a15becc --- /dev/null +++ b/image/oci/layout/fixtures/delete_image_with_signature/blobs/sha256/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 @@ -0,0 +1,27 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:44353f0bf0dd9507c2e9daea7ad4f8a5f0e23bc16068d612227507e54599c18a", + "size": 147 + }, + "layers": [ + { + "mediaType": "application/vnd.dev.cosign.simplesigning.v1+json", + "digest": "sha256:6f06dd0e26608013eff30bb1e951cda7de3fdd9e78e907470e0dd5c0ed25e273", + "size": 12, + "annotations": { + "dev.cosignproject.cosign/signature": "test-signature" + } + } + ], + "subject": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:eaa95f3cfaac07c8a5153eb77c933269586ad0226c83405776be08547e4d2a18", + "size": 1506, + "annotations": { + "org.opencontainers.image.ref.name": "imageValue" + } + } +} diff --git a/image/oci/layout/fixtures/delete_image_with_signature/blobs/sha256/eaa95f3cfaac07c8a5153eb77c933269586ad0226c83405776be08547e4d2a18 b/image/oci/layout/fixtures/delete_image_with_signature/blobs/sha256/eaa95f3cfaac07c8a5153eb77c933269586ad0226c83405776be08547e4d2a18 new file mode 100644 index 0000000000..1ff195d0f3 --- /dev/null +++ b/image/oci/layout/fixtures/delete_image_with_signature/blobs/sha256/eaa95f3cfaac07c8a5153eb77c933269586ad0226c83405776be08547e4d2a18 @@ -0,0 +1,16 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:a527179158cd5cebc11c152b8637b47ce96c838ba2aa0de66d14f45cedc11423", + "size": 585 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:0c8b263642b51b5c1dc40fe402ae2e97119c6007b6e52146419985ec1f0092dc", + "size": 33 + } + ] +} diff --git a/image/oci/layout/fixtures/delete_image_with_signature/index.json b/image/oci/layout/fixtures/delete_image_with_signature/index.json new file mode 100644 index 0000000000..94c28500b2 --- /dev/null +++ b/image/oci/layout/fixtures/delete_image_with_signature/index.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 2, + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:eaa95f3cfaac07c8a5153eb77c933269586ad0226c83405776be08547e4d2a18", + "size": 476, + "annotations": { + "org.opencontainers.image.ref.name": "latest" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 704, + "annotations": { + "org.opencontainers.image.ref.name": "sha256-eaa95f3cfaac07c8a5153eb77c933269586ad0226c83405776be08547e4d2a18.sig" + } + } + ] +} diff --git a/image/oci/layout/fixtures/delete_image_with_signature/oci-layout b/image/oci/layout/fixtures/delete_image_with_signature/oci-layout new file mode 100644 index 0000000000..21b1439d1c --- /dev/null +++ b/image/oci/layout/fixtures/delete_image_with_signature/oci-layout @@ -0,0 +1 @@ +{"imageLayoutVersion": "1.0.0"} \ No newline at end of file diff --git a/image/oci/layout/fixtures/single_image_layout/blobs/sha256/0c8b263642b51b5c1dc40fe402ae2e97119c6007b6e52146419985ec1f0092dc b/image/oci/layout/fixtures/single_image_layout/blobs/sha256/0c8b263642b51b5c1dc40fe402ae2e97119c6007b6e52146419985ec1f0092dc new file mode 100644 index 0000000000..e7e64ba41b --- /dev/null +++ b/image/oci/layout/fixtures/single_image_layout/blobs/sha256/0c8b263642b51b5c1dc40fe402ae2e97119c6007b6e52146419985ec1f0092dc @@ -0,0 +1 @@ +insert binary content here #9671 diff --git a/image/oci/layout/fixtures/single_image_layout/blobs/sha256/a527179158cd5cebc11c152b8637b47ce96c838ba2aa0de66d14f45cedc11423 b/image/oci/layout/fixtures/single_image_layout/blobs/sha256/a527179158cd5cebc11c152b8637b47ce96c838ba2aa0de66d14f45cedc11423 new file mode 100644 index 0000000000..f0f06201be --- /dev/null +++ b/image/oci/layout/fixtures/single_image_layout/blobs/sha256/a527179158cd5cebc11c152b8637b47ce96c838ba2aa0de66d14f45cedc11423 @@ -0,0 +1,30 @@ +{ + "created": "2019-08-20T20:19:55.211423266Z", + "architecture": "amd64", + "os": "linux", + "config": { + "Env": [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ], + "Cmd": [ + "/bin/sh" + ] + }, + "rootfs": { + "type": "layers", + "diff_ids": [ + "sha256:03901b4a2ea88eeaad62dbe59b072b28b6efa00491962b8741081c5df50c65e0" + ] + }, + "history": [ + { + "created": "2019-08-20T20:19:55.062606894Z", + "created_by": "/bin/sh -c #(nop) ADD file:fe64057fbb83dccb960efabbf1cd8777920ef279a7fa8dbca0a8801c651bdf7c in / " + }, + { + "created": "2019-08-20T20:19:55.211423266Z", + "created_by": "/bin/sh -c #(nop) CMD [\"/bin/sh\"]", + "empty_layer": true + } + ] +} diff --git a/image/oci/layout/fixtures/single_image_layout/blobs/sha256/eaa95f3cfaac07c8a5153eb77c933269586ad0226c83405776be08547e4d2a18 b/image/oci/layout/fixtures/single_image_layout/blobs/sha256/eaa95f3cfaac07c8a5153eb77c933269586ad0226c83405776be08547e4d2a18 new file mode 100644 index 0000000000..1ff195d0f3 --- /dev/null +++ b/image/oci/layout/fixtures/single_image_layout/blobs/sha256/eaa95f3cfaac07c8a5153eb77c933269586ad0226c83405776be08547e4d2a18 @@ -0,0 +1,16 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:a527179158cd5cebc11c152b8637b47ce96c838ba2aa0de66d14f45cedc11423", + "size": 585 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:0c8b263642b51b5c1dc40fe402ae2e97119c6007b6e52146419985ec1f0092dc", + "size": 33 + } + ] +} diff --git a/image/oci/layout/fixtures/single_image_layout/index.json b/image/oci/layout/fixtures/single_image_layout/index.json new file mode 100644 index 0000000000..b0a0c98478 --- /dev/null +++ b/image/oci/layout/fixtures/single_image_layout/index.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 2, + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:eaa95f3cfaac07c8a5153eb77c933269586ad0226c83405776be08547e4d2a18", + "size": 476, + "annotations": { + "org.opencontainers.image.ref.name": "latest" + } + } + ] +} diff --git a/image/oci/layout/fixtures/single_image_layout/oci-layout b/image/oci/layout/fixtures/single_image_layout/oci-layout new file mode 100644 index 0000000000..21b1439d1c --- /dev/null +++ b/image/oci/layout/fixtures/single_image_layout/oci-layout @@ -0,0 +1 @@ +{"imageLayoutVersion": "1.0.0"} \ No newline at end of file diff --git a/image/oci/layout/oci_delete.go b/image/oci/layout/oci_delete.go index 7eaf6f0889..1ebd452f6e 100644 --- a/image/oci/layout/oci_delete.go +++ b/image/oci/layout/oci_delete.go @@ -3,6 +3,7 @@ package layout import ( "context" "encoding/json" + "errors" "fmt" "io/fs" "os" @@ -42,7 +43,15 @@ func (ref ociReference) DeleteImage(ctx context.Context, sys *types.SystemContex return err } - return ref.deleteReferenceFromIndex(descriptorIndex) + err = ref.deleteReferenceFromIndex(descriptorIndex) + if err != nil { + return err + } + + if isSigstoreTag(ref.image) { + return nil + } + return ref.deleteSignatures(ctx, sys, descriptor.Digest) } // countBlobsForDescriptor updates dest with usage counts of blobs required for descriptor, INCLUDING descriptor itself. @@ -187,3 +196,22 @@ func saveJSON(path string, content any) (retErr error) { return json.NewEncoder(file).Encode(content) } + +// deleteSignatures delete sigstore signatures of the given manifest digest. +func (ref ociReference) deleteSignatures(ctx context.Context, sys *types.SystemContext, d digest.Digest) error { + signTag, err := sigstoreAttachmentTag(d) + if err != nil { + return err + } + + signRef, err := newReference(ref.dir, signTag, -1) + if err != nil { + return err + } + + err = signRef.DeleteImage(ctx, sys) + if err != nil && errors.As(err, &ImageNotFoundError{}) { + return nil + } + return err +} diff --git a/image/oci/layout/oci_delete_test.go b/image/oci/layout/oci_delete_test.go index a80bf04177..9f2f54f0c9 100644 --- a/image/oci/layout/oci_delete_test.go +++ b/image/oci/layout/oci_delete_test.go @@ -40,6 +40,33 @@ func TestReferenceDeleteImage_onlyOneImage(t *testing.T) { require.Equal(t, 0, len(index.Manifests)) } +func TestReferenceDeleteImage_onlyOneImageWithSignatures(t *testing.T) { + tmpDir := loadFixture(t, "delete_image_with_signature") + + ref, err := NewReference(tmpDir, "latest") + require.NoError(t, err) + + ociRef, ok := ref.(ociReference) + require.True(t, ok) + index, err := ociRef.getIndex() + require.NoError(t, err) + require.Equal(t, 2, len(index.Manifests)) + + err = ref.DeleteImage(context.Background(), nil) + require.NoError(t, err) + + // Check that all blobs were deleted + blobsDir := filepath.Join(tmpDir, "blobs") + files, err := os.ReadDir(filepath.Join(blobsDir, "sha256")) + require.NoError(t, err) + require.Empty(t, files) + + // Check that the index is empty as there is only one image in the fixture + index, err = ociRef.getIndex() + require.NoError(t, err) + require.Equal(t, 0, len(index.Manifests)) +} + func TestReferenceDeleteImage_onlyOneImage_emptyImageName(t *testing.T) { tmpDir := loadFixture(t, "delete_image_only_one_image") diff --git a/image/oci/layout/oci_dest.go b/image/oci/layout/oci_dest.go index 48fe812df5..902ae9c052 100644 --- a/image/oci/layout/oci_dest.go +++ b/image/oci/layout/oci_dest.go @@ -1,25 +1,32 @@ package layout import ( + "bytes" "context" "encoding/json" "errors" "fmt" "io" "io/fs" + "maps" "os" "path/filepath" "runtime" "slices" - digest "github.com/opencontainers/go-digest" + "github.com/opencontainers/go-digest" imgspec "github.com/opencontainers/image-spec/specs-go" imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/sirupsen/logrus" "go.podman.io/image/v5/internal/imagedestination/impl" "go.podman.io/image/v5/internal/imagedestination/stubs" - "go.podman.io/image/v5/internal/manifest" + "go.podman.io/image/v5/internal/iolimits" "go.podman.io/image/v5/internal/private" "go.podman.io/image/v5/internal/putblobdigest" + "go.podman.io/image/v5/internal/set" + "go.podman.io/image/v5/internal/signature" + "go.podman.io/image/v5/manifest" + "go.podman.io/image/v5/pkg/blobinfocache/none" "go.podman.io/image/v5/types" "go.podman.io/storage/pkg/fileutils" ) @@ -29,11 +36,14 @@ type ociImageDestination struct { impl.PropertyMethodsInitialize stubs.IgnoresOriginalOCIConfig stubs.NoPutBlobPartialInitialize - stubs.NoSignaturesInitialize - ref ociReference - index imgspecv1.Index - sharedBlobDir string + ref ociReference + index imgspecv1.Index + sharedBlobDir string + sys *types.SystemContext + manifestDigest digest.Digest + // blobsToDelete is a set of digests which may be deleted + blobsToDelete *set.Set[digest.Digest] } // newImageDestination returns an ImageDestination for writing to an existing directory. @@ -75,10 +85,11 @@ func newImageDestination(sys *types.SystemContext, ref ociReference) (private.Im HasThreadSafePutBlob: true, }), NoPutBlobPartialInitialize: stubs.NoPutBlobPartial(ref), - NoSignaturesInitialize: stubs.NoSignatures("Pushing signatures for OCI images is not supported"), - ref: ref, - index: *index, + ref: ref, + index: *index, + sys: sys, + blobsToDelete: set.New[digest.Digest](), } d.Compat = impl.AddCompat(d) if sys != nil { @@ -251,6 +262,7 @@ func (d *ociImageDestination) PutManifest(ctx context.Context, m []byte, instanc if err := os.WriteFile(blobPath, m, 0644); err != nil { return err } + d.manifestDigest = digest if instanceDigest != nil { return nil @@ -301,6 +313,33 @@ func (d *ociImageDestination) addManifest(desc *imgspecv1.Descriptor) { d.index.Manifests = append(slices.Clone(d.index.Manifests), *desc) } +// addSignatureManifest is similar to addManifest, but replace the entry based on imgspecv1.AnnotationRefName +// and returns the old digest to delete it later. +func (d *ociImageDestination) addSignatureManifest(desc *imgspecv1.Descriptor) (*imgspecv1.Descriptor, error) { + if desc.Annotations == nil || desc.Annotations[imgspecv1.AnnotationRefName] == "" { + return nil, errors.New("cannot add signature manifest without ref.name") + } + for i, m := range d.index.Manifests { + if m.Annotations[imgspecv1.AnnotationRefName] == desc.Annotations[imgspecv1.AnnotationRefName] { + // Replace it completely. + oldDesc := d.index.Manifests[i] + d.index.Manifests[i] = *desc + return &oldDesc, nil + } + } + // It shouldn't happen, but if there's no entry with the same ref name, but the same digest, just replace it. + for i, m := range d.index.Manifests { + if m.Digest == desc.Digest && m.Annotations[imgspecv1.AnnotationRefName] == "" { + // Replace it completely. + d.index.Manifests[i] = *desc + return nil, nil + } + } + // It's a new entry to be added to the index. Use slices.Clone() to avoid a remote dependency on how d.index was created. + d.index.Manifests = append(slices.Clone(d.index.Manifests), *desc) + return nil, nil +} + // CommitWithOptions marks the process of storing the image as successful and asks for the image to be persisted. // WARNING: This does not have any transactional semantics: // - Uploaded data MAY be visible to others before CommitWithOptions() is called @@ -312,6 +351,26 @@ func (d *ociImageDestination) CommitWithOptions(ctx context.Context, options pri if err != nil { return err } + // Delete unreferenced blobs (e.g. old signature manifest and its config) + if !d.blobsToDelete.Empty() { + count := make(map[digest.Digest]int) + err = d.ref.countBlobsReferencedByIndex(count, &d.index, d.sharedBlobDir) + if err != nil { + return fmt.Errorf("error counting blobs to delete: %w", err) + } + // Don't delete blobs which are referenced + actualBlobsToDelete := set.New[digest.Digest]() + for dgst := range d.blobsToDelete.All() { + if count[dgst] == 0 { + actualBlobsToDelete.Add(dgst) + } + } + err := d.ref.deleteBlobs(actualBlobsToDelete) + if err != nil { + return fmt.Errorf("error deleting blobs: %w", err) + } + d.blobsToDelete = set.New[digest.Digest]() + } if err := os.WriteFile(d.ref.ociLayoutPath(), layoutBytes, 0644); err != nil { return err } @@ -322,6 +381,182 @@ func (d *ociImageDestination) CommitWithOptions(ctx context.Context, options pri return os.WriteFile(d.ref.indexPath(), indexJSON, 0644) } +func (d *ociImageDestination) PutSignaturesWithFormat(ctx context.Context, signatures []signature.Signature, instanceDigest *digest.Digest) error { + if instanceDigest == nil { + if d.manifestDigest == "" { + return errors.New("unknown manifest digest, can't add signatures") + } + instanceDigest = &d.manifestDigest + } + + var sigstoreSignatures []signature.Sigstore + for _, sig := range signatures { + if sigstoreSig, ok := sig.(signature.Sigstore); ok { + sigstoreSignatures = append(sigstoreSignatures, sigstoreSig) + } else { + return errors.New("OCI Layout only supports sigstoreSignatures") + } + } + + err := d.putSignaturesToSigstoreAttachment(ctx, sigstoreSignatures, *instanceDigest) + if err != nil { + return err + } + + return nil +} + +func (d *ociImageDestination) putSignaturesToSigstoreAttachment(ctx context.Context, signatures []signature.Sigstore, manifestDigest digest.Digest) error { + var signConfig imgspecv1.Image // Most fields empty by default + + signManifest, err := d.ref.getSigstoreAttachmentManifest(manifestDigest, &d.index, d.sharedBlobDir) + if err != nil { + return err + } + if signManifest == nil { + signManifest = manifest.OCI1FromComponents(imgspecv1.Descriptor{ + MediaType: imgspecv1.MediaTypeImageConfig, + Digest: "", // We will fill this in later. + Size: 0, + }, nil) + signConfig.RootFS.Type = "layers" + } else { + logrus.Debugf("Fetching sigstore attachment config %s", signManifest.Config.Digest.String()) + configBlob, err := d.ref.getOCIDescriptorContents(signManifest.Config, iolimits.MaxConfigBodySize, d.sharedBlobDir) + if err != nil { + return err + } + if err := json.Unmarshal(configBlob, &signConfig); err != nil { + return fmt.Errorf("parsing sigstore attachment config %s in %s: %w", signManifest.Config.Digest.String(), + d.ref.StringWithinTransport(), err) + } + // The config of the signature manifest will be updated and unreferenced when a new config is created. + d.blobsToDelete.Add(signManifest.Config.Digest) + } + + desc, err := d.getDescriptor(&manifestDigest) + if err != nil { + return err + } + signManifest.Subject = desc + + // To make sure we can safely append to the slices of signManifest, without adding a remote dependency on the code that creates it. + signManifest.Layers = slices.Clone(signManifest.Layers) + for _, sig := range signatures { + mimeType := sig.UntrustedMIMEType() + payloadBlob := sig.UntrustedPayload() + annotations := sig.UntrustedAnnotations() + + // Skip if the signature is already on the registry. + if slices.ContainsFunc(signManifest.Layers, func(layer imgspecv1.Descriptor) bool { + return layerMatchesSigstoreSignature(layer, mimeType, payloadBlob, annotations) + }) { + continue + } + + signDesc, err := d.putBlobBytesAsOCI(ctx, payloadBlob, mimeType, private.PutBlobOptions{ + Cache: none.NoCache, + IsConfig: false, + EmptyLayer: false, + LayerIndex: nil, + }) + if err != nil { + return err + } + signDesc.Annotations = annotations + signManifest.Layers = append(signManifest.Layers, signDesc) + signConfig.RootFS.DiffIDs = append(signConfig.RootFS.DiffIDs, signDesc.Digest) + logrus.Debugf("Adding new signature, digest %s", signDesc.Digest.String()) + } + + configBlob, err := json.Marshal(signConfig) + if err != nil { + return err + } + logrus.Debugf("Creating updated sigstore attachment config") + configDesc, err := d.putBlobBytesAsOCI(ctx, configBlob, imgspecv1.MediaTypeImageConfig, private.PutBlobOptions{ + Cache: none.NoCache, + IsConfig: true, + EmptyLayer: false, + LayerIndex: nil, + }) + if err != nil { + return err + } + + signManifest.Config = configDesc + signManifestBlob, err := signManifest.Serialize() + if err != nil { + return err + } + logrus.Debugf("Creating sigstore attachment manifest") + signDigest := digest.FromBytes(signManifestBlob) + if err = d.PutManifest(ctx, signManifestBlob, &signDigest); err != nil { + return err + } + signTag, err := sigstoreAttachmentTag(manifestDigest) + if err != nil { + return err + } + oldDesc, err := d.addSignatureManifest(&imgspecv1.Descriptor{ + MediaType: signManifest.MediaType, + Digest: signDigest, + Size: int64(len(signManifestBlob)), + Annotations: map[string]string{ + imgspecv1.AnnotationRefName: signTag, + }, + }) + if err != nil { + return err + } + // If it overwrote an existing signature manifest, delete blobs referenced by the old manifest. + if oldDesc != nil { + referencedBlobs := make(map[digest.Digest]int) + err = d.ref.countBlobsForDescriptor(referencedBlobs, oldDesc, d.sharedBlobDir) + if err != nil { + return fmt.Errorf("error counting blobs for digest %s: %w", oldDesc.Digest.String(), err) + } + d.blobsToDelete.AddSeq(maps.Keys(referencedBlobs)) + } + return nil +} + +func (d *ociImageDestination) getDescriptor(digest *digest.Digest) (*imgspecv1.Descriptor, error) { + if digest == nil { + return nil, errors.New("digest is nil") + } + for _, desc := range d.index.Manifests { + if desc.Digest == *digest { + return &desc, nil + } + } + return nil, fmt.Errorf("manifest %s not found in index", digest.String()) +} + +// putBlobBytesAsOCI uploads a blob with the specified contents, and returns an appropriate +// OCI descriptor. +func (d *ociImageDestination) putBlobBytesAsOCI(ctx context.Context, contents []byte, mimeType string, options private.PutBlobOptions) (imgspecv1.Descriptor, error) { + blobDigest := digest.FromBytes(contents) + info, err := d.PutBlobWithOptions(ctx, bytes.NewReader(contents), + types.BlobInfo{ + Digest: blobDigest, + Size: int64(len(contents)), + MediaType: mimeType, + }, options) + if err != nil { + return imgspecv1.Descriptor{}, fmt.Errorf("writing blob %s: %w", blobDigest.String(), err) + } + return imgspecv1.Descriptor{ + MediaType: mimeType, + Digest: info.Digest, + Size: info.Size, + }, nil +} + +func (d *ociImageDestination) SupportsSignatures(ctx context.Context) error { + return nil +} + // PutBlobFromLocalFileOption is unused but may receive functionality in the future. type PutBlobFromLocalFileOption struct{} @@ -412,3 +647,18 @@ func indexExists(ref ociReference) bool { } return true } + +func layerMatchesSigstoreSignature(layer imgspecv1.Descriptor, mimeType string, + payloadBlob []byte, annotations map[string]string) bool { + if layer.MediaType != mimeType || + layer.Size != int64(len(payloadBlob)) || + // This is not quite correct, we should use the layer’s digest algorithm. + // But right now we don’t want to deal with corner cases like bad digest formats + // or unavailable algorithms; in the worst case we end up with duplicate signature + // entries. + layer.Digest.String() != digest.FromBytes(payloadBlob).String() || + !maps.Equal(layer.Annotations, annotations) { + return false + } + return true +} diff --git a/image/oci/layout/oci_dest_test.go b/image/oci/layout/oci_dest_test.go index 464fc32d3e..c9c5631c44 100644 --- a/image/oci/layout/oci_dest_test.go +++ b/image/oci/layout/oci_dest_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.podman.io/image/v5/internal/private" + "go.podman.io/image/v5/internal/signature" "go.podman.io/image/v5/pkg/blobinfocache/memory" "go.podman.io/image/v5/types" ) @@ -216,3 +217,136 @@ func TestPutblobFromLocalFile(t *testing.T) { err = ociDest.CommitWithOptions(context.Background(), private.CommitOptions{}) require.NoError(t, err) } + +// TestPutSignaturesWithFormat tests that sigstore signatures are properly stored in OCI layout +func TestPutSignaturesWithFormat(t *testing.T) { + tmpDir := loadFixture(t, "single_image_layout") + ref, err := NewReference(tmpDir, "latest") + require.NoError(t, err) + dest, err := ref.NewImageDestination(context.Background(), nil) + require.NoError(t, err) + defer dest.Close() + ociDest, ok := dest.(*ociImageDestination) + require.True(t, ok) + + desc, _, err := ociDest.ref.getManifestDescriptor() + require.NoError(t, err) + require.NotNil(t, desc) + + sigstoreSign := signature.SigstoreFromComponents( + "application/vnd.dev.cosign.simplesigning.v1+json", + []byte("test-payload"), + map[string]string{"dev.cosignproject.cosign/signature": "test-signature"}, + ) + + err = ociDest.PutSignaturesWithFormat(context.Background(), []signature.Signature{sigstoreSign}, &desc.Digest) + require.NoError(t, err) + + err = ociDest.Commit(context.Background(), nil) + require.NoError(t, err) + + src, err := ref.NewImageSource(context.Background(), nil) + require.NoError(t, err) + ociSrc, ok := src.(*ociImageSource) + require.True(t, ok) + sign, err := ociSrc.GetSignaturesWithFormat(context.Background(), &desc.Digest) + require.NoError(t, err) + require.Len(t, sign, 1) + require.Equal(t, sigstoreSign, sign[0]) +} + +// TestPutSignaturesWithFormatTwice tests PutSignaturesWithFormat twice and checks +func TestPutSignaturesWithFormatTwice(t *testing.T) { + tmpDir := loadFixture(t, "single_image_layout") + ref, err := NewReference(tmpDir, "latest") + require.NoError(t, err) + dest, err := ref.NewImageDestination(context.Background(), nil) + require.NoError(t, err) + defer dest.Close() + ociDest, ok := dest.(*ociImageDestination) + require.True(t, ok) + + desc, _, err := ociDest.ref.getManifestDescriptor() + require.NoError(t, err) + require.NotNil(t, desc) + + sigstoreSign := signature.SigstoreFromComponents( + "application/vnd.dev.cosign.simplesigning.v1+json", + []byte("test-payload"), + map[string]string{"dev.cosignproject.cosign/signature": "test-signature"}, + ) + sigstoreSign2 := signature.SigstoreFromComponents( + "application/vnd.dev.cosign.simplesigning.v1+json", + []byte("test-payload2"), + map[string]string{"dev.cosignproject.cosign/signature": "test-signature"}, + ) + + err = ociDest.PutSignaturesWithFormat(context.Background(), []signature.Signature{sigstoreSign}, &desc.Digest) + require.NoError(t, err) + + err = ociDest.Commit(context.Background(), nil) + require.NoError(t, err) + + err = ociDest.PutSignaturesWithFormat(context.Background(), []signature.Signature{sigstoreSign, sigstoreSign2}, &desc.Digest) + require.NoError(t, err) + + err = ociDest.Commit(context.Background(), nil) + require.NoError(t, err) + + src, err := ref.NewImageSource(context.Background(), nil) + require.NoError(t, err) + ociSrc, ok := src.(*ociImageSource) + require.True(t, ok) + sign, err := ociSrc.GetSignaturesWithFormat(context.Background(), &desc.Digest) + require.NoError(t, err) + require.Len(t, sign, 2) + require.Equal(t, sigstoreSign, sign[0]) + require.Equal(t, sigstoreSign2, sign[1]) +} + +// TestPutSignaturesWithFormatNilDigest tests error handling when instanceDigest is nil +func TestPutSignaturesWithFormatNilDigest(t *testing.T) { + ref, _ := refToTempOCI(t, false) + + dest, err := ref.NewImageDestination(context.Background(), nil) + require.NoError(t, err) + defer dest.Close() + + // Cast to ociImageDestination to access PutSignaturesWithFormat + ociDest, ok := dest.(*ociImageDestination) + require.True(t, ok) + + // Create a test signature + testPayload := []byte(`{"test": "payload"}`) + testAnnotations := map[string]string{ + "dev.cosignproject.cosign/signature": "test-signature", + } + sig := signature.SigstoreFromComponents("application/vnd.dev.cosign.simplesigning.v1+json", testPayload, testAnnotations) + + // Test that PutSignaturesWithFormat fails when instanceDigest is nil + err = ociDest.PutSignaturesWithFormat(context.Background(), []signature.Signature{sig}, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "unknown manifest digest, can't add signatures") +} + +// TestPutSignaturesWithFormatNonSigstore tests error handling for non-sigstore signatures +func TestPutSignaturesWithFormatNonSigstore(t *testing.T) { + ref, _ := refToTempOCI(t, false) + + dest, err := ref.NewImageDestination(context.Background(), nil) + require.NoError(t, err) + defer dest.Close() + + // Cast to ociImageDestination to access PutSignaturesWithFormat + ociDest, ok := dest.(*ociImageDestination) + require.True(t, ok) + + // Create a non-sigstore signature (simple signing) + simpleSig := signature.SimpleSigningFromBlob([]byte("simple signature data")) + testDigest := digest.FromString("test-manifest") + + // Test that PutSignaturesWithFormat fails for non-sigstore signatures + err = ociDest.PutSignaturesWithFormat(context.Background(), []signature.Signature{simpleSig}, &testDigest) + require.Error(t, err) + require.Contains(t, err.Error(), "OCI Layout only supports sigstoreSignatures") +} diff --git a/image/oci/layout/oci_src.go b/image/oci/layout/oci_src.go index f265a21d70..c8c01c1db2 100644 --- a/image/oci/layout/oci_src.go +++ b/image/oci/layout/oci_src.go @@ -16,8 +16,11 @@ import ( imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1" "go.podman.io/image/v5/internal/imagesource/impl" "go.podman.io/image/v5/internal/imagesource/stubs" - "go.podman.io/image/v5/internal/manifest" + "go.podman.io/image/v5/internal/iolimits" "go.podman.io/image/v5/internal/private" + "go.podman.io/image/v5/internal/signature" + "go.podman.io/image/v5/manifest" + "go.podman.io/image/v5/pkg/blobinfocache/none" "go.podman.io/image/v5/pkg/tlsclientconfig" "go.podman.io/image/v5/types" "go.podman.io/storage/pkg/fileutils" @@ -158,20 +161,7 @@ func (s *ociImageSource) GetBlob(ctx context.Context, info types.BlobInfo, cache } } - path, err := s.ref.blobPath(info.Digest, s.sharedBlobDir) - if err != nil { - return nil, 0, err - } - - r, err := os.Open(path) - if err != nil { - return nil, 0, err - } - fi, err := r.Stat() - if err != nil { - return nil, 0, err - } - return r, fi.Size(), nil + return s.ref.getBlob(info.Digest, s.sharedBlobDir) } // getExternalBlob returns the reader of the first available blob URL from urls, which must not be empty. @@ -246,3 +236,47 @@ func GetLocalBlobPath(ctx context.Context, src types.ImageSource, digest digest. return path, nil } + +func (s *ociImageSource) GetSignaturesWithFormat(ctx context.Context, instanceDigest *digest.Digest) ([]signature.Signature, error) { + if instanceDigest == nil { + if s.descriptor.Digest == "" { + return nil, errors.New("unknown manifest digest, can't get signatures") + } + instanceDigest = &s.descriptor.Digest + } + + ociManifest, err := s.ref.getSigstoreAttachmentManifest(*instanceDigest, s.index, s.sharedBlobDir) + if err != nil { + return nil, err + } + if ociManifest == nil { + // No signature found + return nil, nil + } + + signatures := make([]signature.Signature, 0, len(ociManifest.Layers)) + for _, layer := range ociManifest.Layers { + layerBlob, _, err := s.GetBlob(ctx, types.BlobInfo{Digest: layer.Digest}, none.NoCache) + if err != nil { + return nil, err + } + defer layerBlob.Close() + payload, err := iolimits.ReadAtMost(layerBlob, iolimits.MaxSignatureBodySize) + if err != nil { + return nil, fmt.Errorf("reading blob %s in %s: %w", layer.Digest.String(), instanceDigest, err) + } + if err := layer.Digest.Validate(); err != nil { + return nil, fmt.Errorf("invalid digest %q: %w", layer.Digest, err) + } + digestAlgorithm := layer.Digest.Algorithm() + if !digestAlgorithm.Available() { + return nil, fmt.Errorf("invalid digest %q: unsupported digest algorithm %q", layer.Digest.String(), digestAlgorithm.String()) + } + actualDigest := digestAlgorithm.FromBytes(payload) + if actualDigest != layer.Digest { + return nil, fmt.Errorf("digest mismatch, expected %q, got %q", layer.Digest.String(), actualDigest.String()) + } + signatures = append(signatures, signature.SigstoreFromComponents(layer.MediaType, payload, layer.Annotations)) + } + return signatures, nil +} diff --git a/image/oci/layout/oci_transport.go b/image/oci/layout/oci_transport.go index 7b5086cd88..18a484093d 100644 --- a/image/oci/layout/oci_transport.go +++ b/image/oci/layout/oci_transport.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "strings" @@ -14,7 +15,8 @@ import ( "go.podman.io/image/v5/directory/explicitfilepath" "go.podman.io/image/v5/docker/reference" "go.podman.io/image/v5/internal/image" - "go.podman.io/image/v5/internal/manifest" + "go.podman.io/image/v5/internal/iolimits" + "go.podman.io/image/v5/manifest" "go.podman.io/image/v5/oci/internal" "go.podman.io/image/v5/transports" "go.podman.io/image/v5/types" @@ -28,6 +30,9 @@ var ( // Transport is an ImageTransport for OCI directories. Transport = ociTransport{} + // ErrEmptyIndex is an error returned when the index includes no image. + ErrEmptyIndex = errors.New("no image in oci") + // ErrMoreThanOneImage is an error returned when the manifest includes // more than one image and the user should choose which one to use. ErrMoreThanOneImage = errors.New("more than one image in oci, choose an image") @@ -248,11 +253,33 @@ func (ref ociReference) getManifestDescriptor() (imgspecv1.Descriptor, int, erro default: // return manifest if only one image is in the oci directory - if len(index.Manifests) != 1 { - // ask user to choose image when more than one image in the oci directory + if len(index.Manifests) == 0 { + return imgspecv1.Descriptor{}, -1, ErrEmptyIndex + } + // if there's one image return it, even if it is a signature + if len(index.Manifests) == 1 { + return index.Manifests[0], 0, nil + } + // when there's more than one image, try to get a non-signature image + var desc imgspecv1.Descriptor + idx := -1 + for i, md := range index.Manifests { + if isSigstoreTag(md.Annotations[imgspecv1.AnnotationRefName]) { + continue + } + // More than one non-signature image was found + if idx != -1 { + // ask user to choose image when more than one image in the oci directory + return imgspecv1.Descriptor{}, -1, ErrMoreThanOneImage + } + desc = md + idx = i + } + // there's only multiple signature images + if idx == -1 { return imgspecv1.Descriptor{}, -1, ErrMoreThanOneImage } - return index.Manifests[0], 0, nil + return desc, idx, nil } } @@ -302,3 +329,100 @@ func (ref ociReference) blobPath(digest digest.Digest, sharedBlobDir string) (st } return filepath.Join(blobDir, digest.Algorithm().String(), digest.Encoded()), nil } + +// sigstoreAttachmentTag returns a sigstore attachment tag for the specified digest. +func sigstoreAttachmentTag(d digest.Digest) (string, error) { + if err := d.Validate(); err != nil { // Make sure d.String() doesn’t contain any unexpected characters + return "", err + } + return strings.Replace(d.String(), ":", "-", 1) + ".sig", nil +} + +func (ref ociReference) getSigstoreAttachmentManifest(d digest.Digest, idx *imgspecv1.Index, sharedBlobDir string) (*manifest.OCI1, error) { + signTag, err := sigstoreAttachmentTag(d) + if err != nil { + return nil, err + } + var signDesc *imgspecv1.Descriptor + for _, m := range idx.Manifests { + if m.Annotations[imgspecv1.AnnotationRefName] == signTag { + signDesc = &m + break + } + } + if signDesc == nil { + // No signature found + return nil, nil + } + blobReader, _, err := ref.getBlob(signDesc.Digest, sharedBlobDir) + if err != nil { + return nil, fmt.Errorf("failed to get Blob %s: %w", signTag, err) + } + defer blobReader.Close() + signBlob, err := iolimits.ReadAtMost(blobReader, iolimits.MaxManifestBodySize) + if err != nil { + return nil, fmt.Errorf("failed to read blob: %w", err) + } + mimeType := manifest.GuessMIMEType(signBlob) + if mimeType != imgspecv1.MediaTypeImageManifest { + return nil, fmt.Errorf("unexpected MIME type for sigstore attachment manifest %s: %q", + signTag, mimeType) + } + res, err := manifest.OCI1FromManifest(signBlob) + if err != nil { + return nil, fmt.Errorf("parsing manifest %s: %w", signDesc.Digest, err) + } + return res, nil +} + +func (ref ociReference) getBlob(d digest.Digest, sharedBlobDir string) (io.ReadCloser, int64, error) { + path, err := ref.blobPath(d, sharedBlobDir) + if err != nil { + return nil, 0, err + } + + r, err := os.Open(path) + if err != nil { + return nil, 0, err + } + fi, err := r.Stat() + if err != nil { + return nil, 0, err + } + return r, fi.Size(), nil +} + +func (ref ociReference) getOCIDescriptorContents(desc imgspecv1.Descriptor, maxSize int, sharedBlobDir string) ([]byte, error) { + if err := desc.Digest.Validate(); err != nil { // .Algorithm() might panic without this check + return nil, fmt.Errorf("invalid digest %q: %w", desc.Digest.String(), err) + } + digestAlgorithm := desc.Digest.Algorithm() + if !digestAlgorithm.Available() { + return nil, fmt.Errorf("invalid digest %q: unsupported digest algorithm %q", desc.Digest.String(), digestAlgorithm.String()) + } + + reader, _, err := ref.getBlob(desc.Digest, sharedBlobDir) + if err != nil { + return nil, err + } + defer reader.Close() + payload, err := iolimits.ReadAtMost(reader, maxSize) + if err != nil { + return nil, fmt.Errorf("reading blob %s in %s: %w", desc.Digest.String(), ref.image, err) + } + actualDigest := digestAlgorithm.FromBytes(payload) + if actualDigest != desc.Digest { + return nil, fmt.Errorf("digest mismatch, expected %q, got %q", desc.Digest.String(), actualDigest.String()) + } + return payload, nil +} + +// isSigstoreTag returns true if the tag is sigstore signature tag. +func isSigstoreTag(tag string) bool { + if !strings.HasSuffix(tag, ".sig") { + return false + } + digestPart := strings.TrimSuffix(tag, ".sig") + digestPart = strings.Replace(digestPart, "-", ":", 1) + return digest.Digest(digestPart).Validate() == nil +}