diff --git a/image/directory/directory_dest.go b/image/directory/directory_dest.go index fc82c969f1..0949eff7b5 100644 --- a/image/directory/directory_dest.go +++ b/image/directory/directory_dest.go @@ -151,7 +151,15 @@ func (d *dirImageDestination) PutBlobWithOptions(ctx context.Context, stream io. } }() - digester, stream := putblobdigest.DigestIfCanonicalUnknown(stream, inputInfo) + digester, stream := putblobdigest.DigestIfUnknown(stream, inputInfo) + + var canonicalDigester digest.Digester + computeCanonical := inputInfo.Digest != "" && inputInfo.Digest.Algorithm() != digest.Canonical + if computeCanonical { + canonicalDigester = digest.Canonical.Digester() + stream = io.TeeReader(stream, canonicalDigester.Hash()) + } + // TODO: This can take quite some time, and should ideally be cancellable using ctx.Done(). size, err := io.Copy(blobFile, stream) if err != nil { @@ -185,6 +193,18 @@ func (d *dirImageDestination) PutBlobWithOptions(ctx context.Context, stream io. if err := os.Rename(blobFile.Name(), blobPath); err != nil { return private.UploadedBlob{}, err } + + if computeCanonical { + canonicalDigest := canonicalDigester.Digest() + canonicalPath, err := d.ref.layerPath(canonicalDigest) + if err != nil { + return private.UploadedBlob{}, err + } + if err := os.Link(blobPath, canonicalPath); err != nil && !os.IsExist(err) { + return private.UploadedBlob{}, fmt.Errorf("creating canonical digest link: %w", err) + } + } + succeeded = true return private.UploadedBlob{Digest: blobDigest, Size: size}, nil } diff --git a/image/directory/directory_transport.go b/image/directory/directory_transport.go index 77c0e7be57..ce3f2f6fb6 100644 --- a/image/directory/directory_transport.go +++ b/image/directory/directory_transport.go @@ -176,8 +176,15 @@ func (ref dirReference) layerPath(digest digest.Digest) (string, error) { if err := digest.Validate(); err != nil { // digest.Digest.Encoded() panics on failure, and could possibly result in a path with ../, so validate explicitly. return "", err } - // FIXME: Should we keep the digest identification? - return filepath.Join(ref.path, digest.Encoded()), nil + + var filename string + if digest.Algorithm().String() == "sha256" { + filename = digest.Encoded() + } else { + filename = digest.Algorithm().String() + "-" + digest.Encoded() + } + + return filepath.Join(ref.path, filename), nil } // signaturePath returns a path for a signature within a directory using our conventions. diff --git a/image/directory/directory_transport_test.go b/image/directory/directory_transport_test.go index bcafff5352..82fd3ed2b3 100644 --- a/image/directory/directory_transport_test.go +++ b/image/directory/directory_transport_test.go @@ -209,14 +209,21 @@ func TestReferenceManifestPath(t *testing.T) { } func TestReferenceLayerPath(t *testing.T) { - const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + const hex256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + const hex512 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ref, tmpDir := refToTempDir(t) dirRef, ok := ref.(dirReference) require.True(t, ok) - res, err := dirRef.layerPath("sha256:" + hex) + + res, err := dirRef.layerPath("sha256:" + hex256) + require.NoError(t, err) + assert.Equal(t, tmpDir+"/"+hex256, res) + + res, err = dirRef.layerPath("sha512:" + hex512) require.NoError(t, err) - assert.Equal(t, tmpDir+"/"+hex, res) + assert.Equal(t, tmpDir+"/sha512-"+hex512, res) + _, err = dirRef.layerPath(digest.Digest("sha256:../hello")) assert.Error(t, err) } diff --git a/image/internal/image/digest_validation.go b/image/internal/image/digest_validation.go new file mode 100644 index 0000000000..88a870ac34 --- /dev/null +++ b/image/internal/image/digest_validation.go @@ -0,0 +1,26 @@ +package image + +import ( + "fmt" + + "github.com/opencontainers/go-digest" +) + +func validateBlobAgainstDigest(blob []byte, expectedDigest digest.Digest) error { + if expectedDigest == "" { + return fmt.Errorf("expected digest is empty") + } + err := expectedDigest.Validate() + if err != nil { + return fmt.Errorf("invalid digest format %q: %w", expectedDigest, err) + } + digestAlgorithm := expectedDigest.Algorithm() + if !digestAlgorithm.Available() { + return fmt.Errorf("unsupported digest algorithm: %s", digestAlgorithm) + } + computedDigest := digestAlgorithm.FromBytes(blob) + if computedDigest != expectedDigest { + return fmt.Errorf("blob digest %s does not match expected %s", computedDigest, expectedDigest) + } + return nil +} diff --git a/image/internal/image/digest_validation_test.go b/image/internal/image/digest_validation_test.go new file mode 100644 index 0000000000..bda48ba1fa --- /dev/null +++ b/image/internal/image/digest_validation_test.go @@ -0,0 +1,101 @@ +package image + +import ( + "testing" + + "github.com/opencontainers/go-digest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateBlobAgainstDigest(t *testing.T) { + testBlob := []byte("test data") + + tests := []struct { + name string + blob []byte + expectedDigest digest.Digest + expectError bool + errorContains string + }{ + { + name: "empty digest", + blob: testBlob, + expectedDigest: "", + expectError: true, + errorContains: "expected digest is empty", + }, + { + name: "invalid digest format - no algorithm", + blob: testBlob, + expectedDigest: "invalidsyntax", + expectError: true, + errorContains: "invalid digest format", + }, + { + name: "invalid digest format - sha256 prefix w/ malformed hex", + blob: testBlob, + expectedDigest: "sha256:notahexstring!@#", + expectError: true, + errorContains: "invalid digest format", + }, + { + name: "invalid digest format - sha512 prefix w/ malformed hex", + blob: testBlob, + expectedDigest: "sha512:notahexstring!@#", + expectError: true, + errorContains: "invalid digest format", + }, + { + name: "invalid digest format - unknown algorithm", + blob: testBlob, + expectedDigest: "unknown-algo:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + expectError: true, + errorContains: "invalid digest format", + }, + { + name: "digest mismatch", + blob: testBlob, + expectedDigest: digest.SHA256.FromBytes([]byte("different data")), + expectError: true, + errorContains: "blob digest", + }, + { + name: "empty blob with matching digest", + blob: []byte{}, + expectedDigest: digest.SHA256.FromBytes([]byte{}), + expectError: false, + }, + { + name: "unavailable algorithm - blake2b", + blob: testBlob, + expectedDigest: "blake2b:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + expectError: true, + errorContains: "invalid digest format", + }, + { + name: "sha256 digest success", + blob: testBlob, + expectedDigest: digest.SHA256.FromBytes(testBlob), + expectError: false, + }, + { + name: "sha512 digest success", + blob: testBlob, + expectedDigest: digest.SHA512.FromBytes(testBlob), + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateBlobAgainstDigest(tt.blob, tt.expectedDigest) + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorContains) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/image/internal/image/docker_schema2.go b/image/internal/image/docker_schema2.go index 1586d67900..b40f4fc71e 100644 --- a/image/internal/image/docker_schema2.go +++ b/image/internal/image/docker_schema2.go @@ -110,9 +110,8 @@ func (m *manifestSchema2) ConfigBlob(ctx context.Context) ([]byte, error) { if err != nil { return nil, err } - computedDigest := digest.FromBytes(blob) - if computedDigest != m.m.ConfigDescriptor.Digest { - return nil, fmt.Errorf("Download config.json digest %s does not match expected %s", computedDigest, m.m.ConfigDescriptor.Digest) + if err := validateBlobAgainstDigest(blob, m.m.ConfigDescriptor.Digest); err != nil { + return nil, fmt.Errorf("config validation failed: %w", err) } m.configBlob = blob } diff --git a/image/internal/image/oci.go b/image/internal/image/oci.go index 56a1a6d64e..8ddb2875e0 100644 --- a/image/internal/image/oci.go +++ b/image/internal/image/oci.go @@ -8,7 +8,6 @@ import ( "slices" ociencspec "github.com/containers/ocicrypt/spec" - "github.com/opencontainers/go-digest" imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1" "go.podman.io/image/v5/docker/reference" "go.podman.io/image/v5/internal/iolimits" @@ -74,9 +73,8 @@ func (m *manifestOCI1) ConfigBlob(ctx context.Context) ([]byte, error) { if err != nil { return nil, err } - computedDigest := digest.FromBytes(blob) - if computedDigest != m.m.Config.Digest { - return nil, fmt.Errorf("Download config.json digest %s does not match expected %s", computedDigest, m.m.Config.Digest) + if err := validateBlobAgainstDigest(blob, m.m.Config.Digest); err != nil { + return nil, fmt.Errorf("config validation failed: %w", err) } m.configBlob = blob }