From c5d97fd0741ac9ac7b0b7a2338519bf27f198d5a Mon Sep 17 00:00:00 2001 From: Jeff Rescignano Date: Fri, 31 Oct 2025 23:08:26 -0400 Subject: [PATCH 01/10] feat: add support for container images from local oci layout Signed-off-by: Jeff Rescignano --- .../crafter/materials/oci_image.go | 199 +++++++++++- .../crafter/materials/oci_image_test.go | 295 ++++++++++++++++++ 2 files changed, 493 insertions(+), 1 deletion(-) create mode 100644 pkg/attestation/crafter/materials/oci_image_test.go diff --git a/pkg/attestation/crafter/materials/oci_image.go b/pkg/attestation/crafter/materials/oci_image.go index b26f77a65..5204168c7 100644 --- a/pkg/attestation/crafter/materials/oci_image.go +++ b/pkg/attestation/crafter/materials/oci_image.go @@ -18,7 +18,10 @@ package materials import ( "context" "encoding/base64" + "encoding/json" "fmt" + "os" + "path/filepath" "strings" "sync" @@ -29,6 +32,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/layout" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/rs/zerolog" cosigntypes "github.com/sigstore/cosign/v2/pkg/types" @@ -83,7 +87,15 @@ func NewOCIImageCrafter(schema *schemaapi.CraftingSchema_Material, ociAuth authn return c, nil } -func (i *OCIImageCrafter) Craft(_ context.Context, imageRef string) (*api.Attestation_Material, error) { +func (i *OCIImageCrafter) Craft(ctx context.Context, imageRef string) (*api.Attestation_Material, error) { + // Check if imageRef is a path to an OCI layout directory + layoutPath, digestSelector := parseLayoutReference(imageRef) + if i.isOCILayoutPath(layoutPath) { + i.logger.Debug().Str("path", layoutPath).Str("digest", digestSelector).Msg("detected OCI layout directory") + return i.craftFromLayout(ctx, layoutPath, digestSelector) + } + + // Otherwise, treat as remote registry reference i.logger.Debug().Str("name", imageRef).Msg("retrieving container image digest from remote") ref, err := name.ParseReference(imageRef) @@ -281,3 +293,188 @@ func (i *OCIImageCrafter) isLatestTag(ref name.Reference, currentDigest string) i.logger.Debug().Str("name", latestRef.String()).Msg("image does not have a 'latest' tag") return false } + +// parseLayoutReference parses a layout reference that may include a digest selector. +func parseLayoutReference(ref string) (string, string) { + // Check for @digest suffix + if idx := strings.LastIndex(ref, "@"); idx != -1 { + return ref[:idx], ref[idx+1:] + } + return ref, "" +} + +// isOCILayoutPath checks if the given path is a valid OCI layout directory. +func (i *OCIImageCrafter) isOCILayoutPath(path string) bool { + // Check if path exists and is a directory + info, err := os.Stat(path) + if err != nil || !info.IsDir() { + return false + } + + // Check for oci-layout file + layoutFile := filepath.Join(path, "oci-layout") + if _, err := os.Stat(layoutFile); err != nil { + return false + } + + return true +} + +// craftFromLayout creates a material from an OCI layout directory. +// If digestSelector is provided, it will look for that specific digest in the layout. +// Otherwise, it uses the first manifest in the index. +func (i *OCIImageCrafter) craftFromLayout(_ context.Context, layoutPath, digestSelector string) (*api.Attestation_Material, error) { + // Read the OCI layout + layoutPath, err := filepath.Abs(layoutPath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %w", err) + } + + path, err := layout.FromPath(layoutPath) + if err != nil { + return nil, fmt.Errorf("failed to read OCI layout: %w", err) + } + + // Get the image index + index, err := path.ImageIndex() + if err != nil { + return nil, fmt.Errorf("failed to read image index: %w", err) + } + + indexManifest, err := index.IndexManifest() + if err != nil { + return nil, fmt.Errorf("failed to read index manifest: %w", err) + } + + if len(indexManifest.Manifests) == 0 { + return nil, fmt.Errorf("no manifests found in OCI layout") + } + + // Select the manifest based on digest selector + var manifest v1.Descriptor + if digestSelector != "" { + // Find manifest with matching digest + found := false + for _, m := range indexManifest.Manifests { + if m.Digest.String() == digestSelector { + manifest = m + found = true + break + } + } + if !found { + return nil, fmt.Errorf("digest %s not found in OCI layout", digestSelector) + } + i.logger.Debug().Str("digest", digestSelector).Msg("selected image by digest") + } else { + // If multiple images exist, require explicit selection to avoid mistakes + if len(indexManifest.Manifests) > 1 { + var digests []string + for _, m := range indexManifest.Manifests { + digests = append(digests, m.Digest.String()) + } + return nil, fmt.Errorf("OCI layout contains %d images, please specify which one to use with @digest. Available digests: %s", + len(indexManifest.Manifests), strings.Join(digests, ", ")) + } + // Only one image, safe to use it + manifest = indexManifest.Manifests[0] + i.logger.Debug().Msg("using only image in layout") + } + + digest := manifest.Digest.String() + + // Extract repository name from annotations if available + repoName := "oci-layout" + if manifest.Annotations != nil { + if name, ok := manifest.Annotations["org.opencontainers.image.ref.name"]; ok { + repoName = name + } + } + + // Extract tag from annotations + tag := "" + if manifest.Annotations != nil { + if t, ok := manifest.Annotations["io.containerd.image.name"]; ok { + // Extract tag from full reference (e.g., "registry/repo:tag" -> "tag") + parts := strings.Split(t, ":") + if len(parts) > 1 { + tag = parts[len(parts)-1] + } + } + } + + // Validate artifact type if specified + if i.artifactTypeValidation != "" { + i.logger.Debug().Str("path", layoutPath).Str("want", i.artifactTypeValidation).Msg("validating artifact type") + if manifest.ArtifactType != i.artifactTypeValidation { + return nil, fmt.Errorf("artifact type %s does not match expected type %s", manifest.ArtifactType, i.artifactTypeValidation) + } + } + + i.logger.Debug().Str("path", layoutPath).Str("digest", digest).Msg("OCI layout image resolved") + + // Check for signatures in the layout + signatureInfo := i.checkForSignatureInLayout(indexManifest.Manifests, digest) + + containerImage := &api.Attestation_Material_ContainerImage{ + Id: i.input.Name, + Name: repoName, + Digest: digest, + IsSubject: i.input.Output, + Tag: tag, + } + + // Add signature information if found + if signatureInfo != nil { + containerImage.SignatureDigest = signatureInfo.digest + containerImage.Signature = signatureInfo.payload + containerImage.SignatureProvider = string(signatureInfo.provider) + } + + return &api.Attestation_Material{ + MaterialType: i.input.Type, + M: &api.Attestation_Material_ContainerImage_{ + ContainerImage: containerImage}, + }, nil +} + +// checkForSignatureInLayout checks for signatures in the OCI layout manifests. +func (i *OCIImageCrafter) checkForSignatureInLayout(manifests []v1.Descriptor, imageDigest string) *containerSignatureInfo { + // Look for signature artifacts that reference the image digest + for _, m := range manifests { + // Check if this manifest references our image + if m.Annotations != nil { + if subject, ok := m.Annotations["org.opencontainers.image.base.digest"]; ok && subject == imageDigest { + // Check for Cosign signature + if m.ArtifactType == cosigntypes.SimpleSigningMediaType { + i.logger.Debug().Str("digest", m.Digest.String()).Msg("found Cosign signature artifact in OCI layout") + return i.encodeLayoutSignature(m, cosignSignatureProvider) + } + // Check for Notary signature + if m.ArtifactType == notarySignatureMimeType { + i.logger.Debug().Str("digest", m.Digest.String()).Msg("found Notary signature artifact in OCI layout") + return i.encodeLayoutSignature(m, notarySignatureProvider) + } + } + } + } + + i.logger.Debug().Str("digest", imageDigest).Msg("no signature found in OCI layout") + return nil +} + +// encodeLayoutSignature encodes a signature descriptor as base64. +func (i *OCIImageCrafter) encodeLayoutSignature(desc v1.Descriptor, provider signatureProvider) *containerSignatureInfo { + // Marshal the descriptor to JSON for the payload + manifestBytes, err := json.Marshal(desc) + if err != nil { + i.logger.Debug().Err(err).Msg("failed to marshal signature descriptor") + return nil + } + + return &containerSignatureInfo{ + digest: desc.Digest.String(), + provider: provider, + payload: base64.StdEncoding.EncodeToString(manifestBytes), + } +} diff --git a/pkg/attestation/crafter/materials/oci_image_test.go b/pkg/attestation/crafter/materials/oci_image_test.go new file mode 100644 index 000000000..7309c01c2 --- /dev/null +++ b/pkg/attestation/crafter/materials/oci_image_test.go @@ -0,0 +1,295 @@ +// +// Copyright 2024 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package materials_test + +import ( + "context" + "testing" + + contractAPI "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewOCIImageCrafter(t *testing.T) { + testCases := []struct { + name string + input *contractAPI.CraftingSchema_Material + wantErr bool + }{ + { + name: "container image type", + input: &contractAPI.CraftingSchema_Material{ + Type: contractAPI.CraftingSchema_Material_CONTAINER_IMAGE, + }, + }, + { + name: "helm chart type", + input: &contractAPI.CraftingSchema_Material{ + Type: contractAPI.CraftingSchema_Material_HELM_CHART, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + l := zerolog.Nop() + _, err := materials.NewOCIImageCrafter(tc.input, nil, &l) + if tc.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + }) + } +} + +func TestOCIImageCraft_Layout(t *testing.T) { + testCases := []struct { + name string + setupLayout func(t *testing.T) string + wantErr string + wantDigest string + wantName string + wantTag string + }{ + { + name: "valid OCI layout", + setupLayout: func(t *testing.T) string { + return createTestOCILayout(t, "test-image", "v1.0.0") + }, + wantName: "test-image", + wantTag: "v1.0.0", + }, + { + name: "OCI layout without annotations", + setupLayout: func(t *testing.T) string { + return createTestOCILayout(t, "", "") + }, + wantName: "oci-layout", + wantTag: "", + }, + { + name: "non-existent path", + setupLayout: func(_ *testing.T) string { + return "/non/existent/path" + }, + wantErr: "UNAUTHORIZED", + }, + { + name: "empty directory", + setupLayout: func(t *testing.T) string { + dir := t.TempDir() + return dir + }, + wantErr: "could not parse reference", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + layoutPath := tc.setupLayout(t) + + schema := &contractAPI.CraftingSchema_Material{ + Name: "test", + Type: contractAPI.CraftingSchema_Material_CONTAINER_IMAGE, + } + l := zerolog.Nop() + crafter, err := materials.NewOCIImageCrafter(schema, nil, &l) + require.NoError(t, err) + + got, err := crafter.Craft(context.TODO(), layoutPath) + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, contractAPI.CraftingSchema_Material_CONTAINER_IMAGE.String(), got.MaterialType.String()) + + // Check container image fields + containerImage := got.GetContainerImage() + require.NotNil(t, containerImage) + assert.Equal(t, tc.wantName, containerImage.Name) + assert.Equal(t, tc.wantTag, containerImage.Tag) + assert.NotEmpty(t, containerImage.Digest) + assert.True(t, len(containerImage.Digest) > 0, "digest should not be empty") + }) + } +} + +func TestOCIImageCraft_LayoutWithDigestSelector(t *testing.T) { + testCases := []struct { + name string + setupLayout func(t *testing.T) (string, string) // returns (layoutPath, digestToSelect) + wantErr string + wantName string + wantTag string + }{ + { + name: "select second image by digest", + setupLayout: func(t *testing.T) (string, string) { + layoutPath, digests := createTestOCILayoutMultiple(t, []imageSpec{ + {name: "first-image", tag: "v1.0.0"}, + {name: "second-image", tag: "v2.0.0"}, + }) + return layoutPath, digests[1] // Select second image + }, + wantName: "second-image", + wantTag: "v2.0.0", + }, + { + name: "digest not found", + setupLayout: func(t *testing.T) (string, string) { + layoutPath, _ := createTestOCILayoutMultiple(t, []imageSpec{ + {name: "test-image", tag: "v1.0.0"}, + }) + return layoutPath, "sha256:nonexistent" + }, + wantErr: "not found in OCI layout", + }, + { + name: "multiple images without digest selector", + setupLayout: func(t *testing.T) (string, string) { + layoutPath, _ := createTestOCILayoutMultiple(t, []imageSpec{ + {name: "first-image", tag: "v1.0.0"}, + {name: "second-image", tag: "v2.0.0"}, + {name: "third-image", tag: "v3.0.0"}, + }) + return layoutPath, "" // No digest selector + }, + wantErr: "contains 3 images, please specify which one", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + layoutPath, digest := tc.setupLayout(t) + imageRef := layoutPath + if digest != "" { + imageRef = layoutPath + "@" + digest + } + + schema := &contractAPI.CraftingSchema_Material{ + Name: "test", + Type: contractAPI.CraftingSchema_Material_CONTAINER_IMAGE, + } + l := zerolog.Nop() + crafter, err := materials.NewOCIImageCrafter(schema, nil, &l) + require.NoError(t, err) + + got, err := crafter.Craft(context.TODO(), imageRef) + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, contractAPI.CraftingSchema_Material_CONTAINER_IMAGE.String(), got.MaterialType.String()) + + // Check container image fields + containerImage := got.GetContainerImage() + require.NotNil(t, containerImage) + assert.Equal(t, tc.wantName, containerImage.Name) + assert.Equal(t, tc.wantTag, containerImage.Tag) + assert.NotEmpty(t, containerImage.Digest) + }) + } +} + +type imageSpec struct { + name string + tag string +} + +// createTestOCILayoutMultiple creates an OCI layout with multiple images for testing +func createTestOCILayoutMultiple(t *testing.T, specs []imageSpec) (string, []string) { + t.Helper() + + layoutPath := t.TempDir() + path, err := layout.Write(layoutPath, empty.Index) + require.NoError(t, err) + + digests := make([]string, 0, len(specs)) + for _, spec := range specs { + img, err := random.Image(1024, 1) + require.NoError(t, err) + + var opts []layout.Option + if spec.name != "" || spec.tag != "" { + annotations := make(map[string]string) + if spec.name != "" { + annotations["org.opencontainers.image.ref.name"] = spec.name + } + if spec.tag != "" { + annotations["io.containerd.image.name"] = spec.name + ":" + spec.tag + } + opts = append(opts, layout.WithAnnotations(annotations)) + } + + err = path.AppendImage(img, opts...) + require.NoError(t, err) + + // Get the digest of the image we just added + index, err := path.ImageIndex() + require.NoError(t, err) + manifest, err := index.IndexManifest() + require.NoError(t, err) + // The last manifest is the one we just added + digests = append(digests, manifest.Manifests[len(manifest.Manifests)-1].Digest.String()) + } + + return layoutPath, digests +} + +// createTestOCILayout creates a minimal valid OCI layout directory for testing +func createTestOCILayout(t *testing.T, imageName, tag string) string { + t.Helper() + + layoutPath := t.TempDir() + + // Use go-containerregistry to create a random image + img, err := random.Image(1024, 1) + require.NoError(t, err) + + // Write layout with empty index first + path, err := layout.Write(layoutPath, empty.Index) + require.NoError(t, err) + + // Append the image with annotations if provided + var opts []layout.Option + if imageName != "" || tag != "" { + annotations := make(map[string]string) + if imageName != "" { + annotations["org.opencontainers.image.ref.name"] = imageName + } + if tag != "" { + annotations["io.containerd.image.name"] = imageName + ":" + tag + } + opts = append(opts, layout.WithAnnotations(annotations)) + } + + err = path.AppendImage(img, opts...) + require.NoError(t, err) + + return layoutPath +} From c0806731c7e8e30a124059c77d56e924f4daff05 Mon Sep 17 00:00:00 2001 From: Jeff Rescignano Date: Sat, 1 Nov 2025 12:15:26 -0400 Subject: [PATCH 02/10] update copyright year to 2025 Signed-off-by: Jeff Rescignano --- pkg/attestation/crafter/materials/oci_image.go | 2 +- pkg/attestation/crafter/materials/oci_image_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/attestation/crafter/materials/oci_image.go b/pkg/attestation/crafter/materials/oci_image.go index 5204168c7..deb564666 100644 --- a/pkg/attestation/crafter/materials/oci_image.go +++ b/pkg/attestation/crafter/materials/oci_image.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2025 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/attestation/crafter/materials/oci_image_test.go b/pkg/attestation/crafter/materials/oci_image_test.go index 7309c01c2..a2a6ae646 100644 --- a/pkg/attestation/crafter/materials/oci_image_test.go +++ b/pkg/attestation/crafter/materials/oci_image_test.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2025 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. From 94d98048f53d4cbd63b2a14d329db9d3ace5b15a Mon Sep 17 00:00:00 2001 From: Jeff Rescignano Date: Sun, 2 Nov 2025 21:27:27 -0500 Subject: [PATCH 03/10] add constant for ociLayoutRepoName Signed-off-by: Jeff Rescignano --- pkg/attestation/crafter/materials/oci_image.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/attestation/crafter/materials/oci_image.go b/pkg/attestation/crafter/materials/oci_image.go index deb564666..b49c6dad8 100644 --- a/pkg/attestation/crafter/materials/oci_image.go +++ b/pkg/attestation/crafter/materials/oci_image.go @@ -47,6 +47,8 @@ const ( notarySignatureMimeType = "application/vnd.cncf.notary.signature" // latestTag is the tag name for the latest image. latestTag = "latest" + // ociLayoutRepoName is the default repository name for OCI layout images. + ociLayoutRepoName = "oci-layout" ) // signatureProvider is the type for the signature provider of a container image. @@ -384,7 +386,7 @@ func (i *OCIImageCrafter) craftFromLayout(_ context.Context, layoutPath, digestS digest := manifest.Digest.String() // Extract repository name from annotations if available - repoName := "oci-layout" + repoName := ociLayoutRepoName if manifest.Annotations != nil { if name, ok := manifest.Annotations["org.opencontainers.image.ref.name"]; ok { repoName = name From fd616e762f9f07fa9eabb7343dbe0c9c68dd3e98 Mon Sep 17 00:00:00 2001 From: Jeff Rescignano Date: Sun, 2 Nov 2025 21:29:22 -0500 Subject: [PATCH 04/10] fix copyright year for oci_image_test.go Signed-off-by: Jeff Rescignano --- pkg/attestation/crafter/materials/oci_image_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/attestation/crafter/materials/oci_image_test.go b/pkg/attestation/crafter/materials/oci_image_test.go index a2a6ae646..c94c90041 100644 --- a/pkg/attestation/crafter/materials/oci_image_test.go +++ b/pkg/attestation/crafter/materials/oci_image_test.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2025 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. From 2dd5fd4e58275f31cb2bde498c125e7659ebe28f Mon Sep 17 00:00:00 2001 From: Jeff Rescignano Date: Sun, 2 Nov 2025 21:34:52 -0500 Subject: [PATCH 05/10] improve manifest selection logic in OCI image crafter Signed-off-by: Jeff Rescignano --- .../crafter/materials/oci_image.go | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/pkg/attestation/crafter/materials/oci_image.go b/pkg/attestation/crafter/materials/oci_image.go index b49c6dad8..3b5e178d2 100644 --- a/pkg/attestation/crafter/materials/oci_image.go +++ b/pkg/attestation/crafter/materials/oci_image.go @@ -353,10 +353,10 @@ func (i *OCIImageCrafter) craftFromLayout(_ context.Context, layoutPath, digestS } // Select the manifest based on digest selector - var manifest v1.Descriptor + // If a specific digest is requested, find it if digestSelector != "" { - // Find manifest with matching digest found := false + var manifest v1.Descriptor for _, m := range indexManifest.Manifests { if m.Digest.String() == digestSelector { manifest = m @@ -368,21 +368,30 @@ func (i *OCIImageCrafter) craftFromLayout(_ context.Context, layoutPath, digestS return nil, fmt.Errorf("digest %s not found in OCI layout", digestSelector) } i.logger.Debug().Str("digest", digestSelector).Msg("selected image by digest") - } else { - // If multiple images exist, require explicit selection to avoid mistakes - if len(indexManifest.Manifests) > 1 { - var digests []string - for _, m := range indexManifest.Manifests { - digests = append(digests, m.Digest.String()) - } - return nil, fmt.Errorf("OCI layout contains %d images, please specify which one to use with @digest. Available digests: %s", - len(indexManifest.Manifests), strings.Join(digests, ", ")) + + return i.buildMaterialFromManifest(layoutPath, manifest, indexManifest.Manifests) + } + + // No digest specified - if multiple images exist, require explicit selection + if len(indexManifest.Manifests) > 1 { + var digests []string + for _, m := range indexManifest.Manifests { + digests = append(digests, m.Digest.String()) } - // Only one image, safe to use it - manifest = indexManifest.Manifests[0] - i.logger.Debug().Msg("using only image in layout") + return nil, fmt.Errorf("OCI layout contains %d images, please specify which one to use with @digest. Available digests: %s", + len(indexManifest.Manifests), strings.Join(digests, ", ")) } + // Only one image, safe to use it + manifest := indexManifest.Manifests[0] + i.logger.Debug().Msg("using only image in layout") + + return i.buildMaterialFromManifest(layoutPath, manifest, indexManifest.Manifests) +} + +// buildMaterialFromManifest constructs the attestation material from a manifest descriptor. +func (i *OCIImageCrafter) buildMaterialFromManifest(layoutPath string, manifest v1.Descriptor, allManifests []v1.Descriptor) (*api.Attestation_Material, error) { + digest := manifest.Digest.String() // Extract repository name from annotations if available @@ -416,7 +425,7 @@ func (i *OCIImageCrafter) craftFromLayout(_ context.Context, layoutPath, digestS i.logger.Debug().Str("path", layoutPath).Str("digest", digest).Msg("OCI layout image resolved") // Check for signatures in the layout - signatureInfo := i.checkForSignatureInLayout(indexManifest.Manifests, digest) + signatureInfo := i.checkForSignatureInLayout(allManifests, digest) containerImage := &api.Attestation_Material_ContainerImage{ Id: i.input.Name, From cd70e2a8d142ea7d52268ccdf9160c924487cd18 Mon Sep 17 00:00:00 2001 From: Jeff Rescignano Date: Sun, 2 Nov 2025 21:42:07 -0500 Subject: [PATCH 06/10] use real OCI layout testdata instead of generated fixtures Signed-off-by: Jeff Rescignano --- .../crafter/materials/oci_image_test.go | 232 ++++++------------ .../testdata/oci-layouts/crane/index.json | 11 + .../testdata/oci-layouts/crane/oci-layout | 3 + .../testdata/oci-layouts/oras/index.json | 1 + .../testdata/oci-layouts/oras/oci-layout | 1 + .../oci-layouts/skopeo-alt/index.json | 1 + .../oci-layouts/skopeo-alt/oci-layout | 1 + .../testdata/oci-layouts/skopeo/index.json | 1 + .../testdata/oci-layouts/skopeo/oci-layout | 1 + .../testdata/oci-layouts/zarf/index.json | 1 + .../testdata/oci-layouts/zarf/oci-layout | 1 + 11 files changed, 99 insertions(+), 155 deletions(-) create mode 100755 pkg/attestation/crafter/materials/testdata/oci-layouts/crane/index.json create mode 100755 pkg/attestation/crafter/materials/testdata/oci-layouts/crane/oci-layout create mode 100644 pkg/attestation/crafter/materials/testdata/oci-layouts/oras/index.json create mode 100644 pkg/attestation/crafter/materials/testdata/oci-layouts/oras/oci-layout create mode 100644 pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo-alt/index.json create mode 100644 pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo-alt/oci-layout create mode 100644 pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo/index.json create mode 100644 pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo/oci-layout create mode 100644 pkg/attestation/crafter/materials/testdata/oci-layouts/zarf/index.json create mode 100644 pkg/attestation/crafter/materials/testdata/oci-layouts/zarf/oci-layout diff --git a/pkg/attestation/crafter/materials/oci_image_test.go b/pkg/attestation/crafter/materials/oci_image_test.go index c94c90041..edd137abc 100644 --- a/pkg/attestation/crafter/materials/oci_image_test.go +++ b/pkg/attestation/crafter/materials/oci_image_test.go @@ -21,9 +21,6 @@ import ( contractAPI "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials" - "github.com/google/go-containerregistry/pkg/v1/empty" - "github.com/google/go-containerregistry/pkg/v1/layout" - "github.com/google/go-containerregistry/pkg/v1/random" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -65,50 +62,40 @@ func TestNewOCIImageCrafter(t *testing.T) { func TestOCIImageCraft_Layout(t *testing.T) { testCases := []struct { - name string - setupLayout func(t *testing.T) string - wantErr string - wantDigest string - wantName string - wantTag string + name string + layoutPath string + wantErr string + wantDigest string + wantName string + wantTag string }{ { - name: "valid OCI layout", - setupLayout: func(t *testing.T) string { - return createTestOCILayout(t, "test-image", "v1.0.0") - }, - wantName: "test-image", - wantTag: "v1.0.0", + name: "crane - single image with annotations", + layoutPath: "testdata/oci-layouts/crane", + wantName: "oci-layout", + wantDigest: "sha256:fa6d9058c3d65a33ff565c0e35172f2d99e76fbf8358d91ffaa2208eff2be400", }, { - name: "OCI layout without annotations", - setupLayout: func(t *testing.T) string { - return createTestOCILayout(t, "", "") - }, - wantName: "oci-layout", - wantTag: "", + name: "skopeo - single image with tag annotation", + layoutPath: "testdata/oci-layouts/skopeo", + wantName: "v1.51.0", + wantDigest: "sha256:fa6d9058c3d65a33ff565c0e35172f2d99e76fbf8358d91ffaa2208eff2be400", }, { - name: "non-existent path", - setupLayout: func(_ *testing.T) string { - return "/non/existent/path" - }, - wantErr: "UNAUTHORIZED", + name: "skopeo-alt - alternative format", + layoutPath: "testdata/oci-layouts/skopeo-alt", + wantName: "v1.51.0", + wantDigest: "sha256:a5303ef28a4bd9b6e06aa92c07831dd151ac64172695971226bdba4a11fc1b88", }, { - name: "empty directory", - setupLayout: func(t *testing.T) string { - dir := t.TempDir() - return dir - }, - wantErr: "could not parse reference", + name: "non-existent path", + layoutPath: "/non/existent/path", + wantErr: "UNAUTHORIZED", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - layoutPath := tc.setupLayout(t) - schema := &contractAPI.CraftingSchema_Material{ Name: "test", Type: contractAPI.CraftingSchema_Material_CONTAINER_IMAGE, @@ -117,7 +104,7 @@ func TestOCIImageCraft_Layout(t *testing.T) { crafter, err := materials.NewOCIImageCrafter(schema, nil, &l) require.NoError(t, err) - got, err := crafter.Craft(context.TODO(), layoutPath) + got, err := crafter.Craft(context.TODO(), tc.layoutPath) if tc.wantErr != "" { assert.ErrorContains(t, err, tc.wantErr) return @@ -130,63 +117,71 @@ func TestOCIImageCraft_Layout(t *testing.T) { containerImage := got.GetContainerImage() require.NotNil(t, containerImage) assert.Equal(t, tc.wantName, containerImage.Name) - assert.Equal(t, tc.wantTag, containerImage.Tag) - assert.NotEmpty(t, containerImage.Digest) - assert.True(t, len(containerImage.Digest) > 0, "digest should not be empty") + if tc.wantTag != "" { + assert.Equal(t, tc.wantTag, containerImage.Tag) + } + if tc.wantDigest != "" { + assert.Equal(t, tc.wantDigest, containerImage.Digest) + } else { + assert.NotEmpty(t, containerImage.Digest) + } }) } } func TestOCIImageCraft_LayoutWithDigestSelector(t *testing.T) { testCases := []struct { - name string - setupLayout func(t *testing.T) (string, string) // returns (layoutPath, digestToSelect) - wantErr string - wantName string - wantTag string + name string + layoutPath string + digestSelector string + wantErr string + wantName string + wantDigest string }{ { - name: "select second image by digest", - setupLayout: func(t *testing.T) (string, string) { - layoutPath, digests := createTestOCILayoutMultiple(t, []imageSpec{ - {name: "first-image", tag: "v1.0.0"}, - {name: "second-image", tag: "v2.0.0"}, - }) - return layoutPath, digests[1] // Select second image - }, - wantName: "second-image", - wantTag: "v2.0.0", + name: "oras - select first image by digest", + layoutPath: "testdata/oci-layouts/oras", + digestSelector: "sha256:b1747c197a0ab3cb89e109f60a3c5d4ede6946e447fd468fa82d85fa94c6c6e5", + wantName: "oci-layout", + wantDigest: "sha256:b1747c197a0ab3cb89e109f60a3c5d4ede6946e447fd468fa82d85fa94c6c6e5", }, { - name: "digest not found", - setupLayout: func(t *testing.T) (string, string) { - layoutPath, _ := createTestOCILayoutMultiple(t, []imageSpec{ - {name: "test-image", tag: "v1.0.0"}, - }) - return layoutPath, "sha256:nonexistent" - }, - wantErr: "not found in OCI layout", + name: "oras - select second image by digest", + layoutPath: "testdata/oci-layouts/oras", + digestSelector: "sha256:f333056ac987169b2a121c16d06112d88ec3d7cb50b098bb17b0f14b0c52f6f3", + wantName: "oci-layout", + wantDigest: "sha256:f333056ac987169b2a121c16d06112d88ec3d7cb50b098bb17b0f14b0c52f6f3", }, { - name: "multiple images without digest selector", - setupLayout: func(t *testing.T) (string, string) { - layoutPath, _ := createTestOCILayoutMultiple(t, []imageSpec{ - {name: "first-image", tag: "v1.0.0"}, - {name: "second-image", tag: "v2.0.0"}, - {name: "third-image", tag: "v3.0.0"}, - }) - return layoutPath, "" // No digest selector - }, - wantErr: "contains 3 images, please specify which one", + name: "zarf - select specific image from bundle", + layoutPath: "testdata/oci-layouts/zarf", + digestSelector: "sha256:e8ac056f7b9b44b07935fe23b8383e5e550d479dc5c6261941e76449a8f7e926", + wantName: "ghcr.io/chainloop-dev/chainloop/artifact-cas:v1.51.0", + wantDigest: "sha256:e8ac056f7b9b44b07935fe23b8383e5e550d479dc5c6261941e76449a8f7e926", + }, + { + name: "digest not found", + layoutPath: "testdata/oci-layouts/oras", + digestSelector: "sha256:nonexistent", + wantErr: "not found in OCI layout", + }, + { + name: "oras - multiple images without digest selector", + layoutPath: "testdata/oci-layouts/oras", + wantErr: "contains 3 images, please specify which one", + }, + { + name: "zarf - multiple images without digest selector", + layoutPath: "testdata/oci-layouts/zarf", + wantErr: "contains 3 images, please specify which one", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - layoutPath, digest := tc.setupLayout(t) - imageRef := layoutPath - if digest != "" { - imageRef = layoutPath + "@" + digest + imageRef := tc.layoutPath + if tc.digestSelector != "" { + imageRef = tc.layoutPath + "@" + tc.digestSelector } schema := &contractAPI.CraftingSchema_Material{ @@ -209,87 +204,14 @@ func TestOCIImageCraft_LayoutWithDigestSelector(t *testing.T) { // Check container image fields containerImage := got.GetContainerImage() require.NotNil(t, containerImage) - assert.Equal(t, tc.wantName, containerImage.Name) - assert.Equal(t, tc.wantTag, containerImage.Tag) - assert.NotEmpty(t, containerImage.Digest) - }) - } -} - -type imageSpec struct { - name string - tag string -} - -// createTestOCILayoutMultiple creates an OCI layout with multiple images for testing -func createTestOCILayoutMultiple(t *testing.T, specs []imageSpec) (string, []string) { - t.Helper() - - layoutPath := t.TempDir() - path, err := layout.Write(layoutPath, empty.Index) - require.NoError(t, err) - - digests := make([]string, 0, len(specs)) - for _, spec := range specs { - img, err := random.Image(1024, 1) - require.NoError(t, err) - - var opts []layout.Option - if spec.name != "" || spec.tag != "" { - annotations := make(map[string]string) - if spec.name != "" { - annotations["org.opencontainers.image.ref.name"] = spec.name + if tc.wantName != "" { + assert.Equal(t, tc.wantName, containerImage.Name) } - if spec.tag != "" { - annotations["io.containerd.image.name"] = spec.name + ":" + spec.tag + if tc.wantDigest != "" { + assert.Equal(t, tc.wantDigest, containerImage.Digest) + } else { + assert.NotEmpty(t, containerImage.Digest) } - opts = append(opts, layout.WithAnnotations(annotations)) - } - - err = path.AppendImage(img, opts...) - require.NoError(t, err) - - // Get the digest of the image we just added - index, err := path.ImageIndex() - require.NoError(t, err) - manifest, err := index.IndexManifest() - require.NoError(t, err) - // The last manifest is the one we just added - digests = append(digests, manifest.Manifests[len(manifest.Manifests)-1].Digest.String()) - } - - return layoutPath, digests -} - -// createTestOCILayout creates a minimal valid OCI layout directory for testing -func createTestOCILayout(t *testing.T, imageName, tag string) string { - t.Helper() - - layoutPath := t.TempDir() - - // Use go-containerregistry to create a random image - img, err := random.Image(1024, 1) - require.NoError(t, err) - - // Write layout with empty index first - path, err := layout.Write(layoutPath, empty.Index) - require.NoError(t, err) - - // Append the image with annotations if provided - var opts []layout.Option - if imageName != "" || tag != "" { - annotations := make(map[string]string) - if imageName != "" { - annotations["org.opencontainers.image.ref.name"] = imageName - } - if tag != "" { - annotations["io.containerd.image.name"] = imageName + ":" + tag - } - opts = append(opts, layout.WithAnnotations(annotations)) + }) } - - err = path.AppendImage(img, opts...) - require.NoError(t, err) - - return layoutPath } diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/crane/index.json b/pkg/attestation/crafter/materials/testdata/oci-layouts/crane/index.json new file mode 100755 index 000000000..9ccaf78b2 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/crane/index.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json", + "size": 743, + "digest": "sha256:fa6d9058c3d65a33ff565c0e35172f2d99e76fbf8358d91ffaa2208eff2be400" + } + ] +} \ No newline at end of file diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/crane/oci-layout b/pkg/attestation/crafter/materials/testdata/oci-layouts/crane/oci-layout new file mode 100755 index 000000000..224a86981 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/crane/oci-layout @@ -0,0 +1,3 @@ +{ + "imageLayoutVersion": "1.0.0" +} \ No newline at end of file diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/oras/index.json b/pkg/attestation/crafter/materials/testdata/oci-layouts/oras/index.json new file mode 100644 index 000000000..0d590ca51 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/oras/index.json @@ -0,0 +1 @@ +{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"sha256:b1747c197a0ab3cb89e109f60a3c5d4ede6946e447fd468fa82d85fa94c6c6e5","size":1578,"platform":{"architecture":"arm64","os":"linux"}},{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"sha256:f333056ac987169b2a121c16d06112d88ec3d7cb50b098bb17b0f14b0c52f6f3","size":1578,"platform":{"architecture":"amd64","os":"linux"}},{"mediaType":"application/vnd.docker.distribution.manifest.list.v2+json","digest":"sha256:fa6d9058c3d65a33ff565c0e35172f2d99e76fbf8358d91ffaa2208eff2be400","size":743}]} \ No newline at end of file diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/oras/oci-layout b/pkg/attestation/crafter/materials/testdata/oci-layouts/oras/oci-layout new file mode 100644 index 000000000..1343d370f --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/oras/oci-layout @@ -0,0 +1 @@ +{"imageLayoutVersion":"1.0.0"} \ No newline at end of file diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo-alt/index.json b/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo-alt/index.json new file mode 100644 index 000000000..9a762889c --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo-alt/index.json @@ -0,0 +1 @@ +{"schemaVersion":2,"manifests":[{"mediaType":"application/vnd.oci.image.index.v1+json","digest":"sha256:a5303ef28a4bd9b6e06aa92c07831dd151ac64172695971226bdba4a11fc1b88","size":493,"annotations":{"org.opencontainers.image.ref.name":"v1.51.0"}}]} \ No newline at end of file diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo-alt/oci-layout b/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo-alt/oci-layout new file mode 100644 index 000000000..1343d370f --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo-alt/oci-layout @@ -0,0 +1 @@ +{"imageLayoutVersion":"1.0.0"} \ No newline at end of file diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo/index.json b/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo/index.json new file mode 100644 index 000000000..77f1db87c --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo/index.json @@ -0,0 +1 @@ +{"schemaVersion":2,"manifests":[{"mediaType":"application/vnd.docker.distribution.manifest.list.v2+json","digest":"sha256:fa6d9058c3d65a33ff565c0e35172f2d99e76fbf8358d91ffaa2208eff2be400","size":743,"annotations":{"org.opencontainers.image.ref.name":"v1.51.0"}}]} \ No newline at end of file diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo/oci-layout b/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo/oci-layout new file mode 100644 index 000000000..1343d370f --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo/oci-layout @@ -0,0 +1 @@ +{"imageLayoutVersion":"1.0.0"} \ No newline at end of file diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/zarf/index.json b/pkg/attestation/crafter/materials/testdata/oci-layouts/zarf/index.json new file mode 100644 index 000000000..560cdb55b --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/zarf/index.json @@ -0,0 +1 @@ +{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"sha256:8cb1d2d12cb680d67998ae917462779ba35ff6969ddc09fc6cdf825889bcd357","size":737,"annotations":{"org.opencontainers.image.base.name":"ghcr.io/chainloop-dev/chainloop/control-plane-migrations:v1.51.0","org.opencontainers.image.ref.name":"ghcr.io/chainloop-dev/chainloop/control-plane-migrations:v1.51.0"},"platform":{"architecture":"amd64","os":"linux"}},{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"sha256:e8ac056f7b9b44b07935fe23b8383e5e550d479dc5c6261941e76449a8f7e926","size":739,"annotations":{"org.opencontainers.image.base.name":"ghcr.io/chainloop-dev/chainloop/artifact-cas:v1.51.0","org.opencontainers.image.ref.name":"ghcr.io/chainloop-dev/chainloop/artifact-cas:v1.51.0"},"platform":{"architecture":"amd64","os":"linux"}},{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"sha256:f333056ac987169b2a121c16d06112d88ec3d7cb50b098bb17b0f14b0c52f6f3","size":1578,"annotations":{"org.opencontainers.image.base.name":"ghcr.io/chainloop-dev/chainloop/control-plane:v1.51.0","org.opencontainers.image.ref.name":"ghcr.io/chainloop-dev/chainloop/control-plane:v1.51.0"},"platform":{"architecture":"amd64","os":"linux"}}]} \ No newline at end of file diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/zarf/oci-layout b/pkg/attestation/crafter/materials/testdata/oci-layouts/zarf/oci-layout new file mode 100644 index 000000000..1343d370f --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/zarf/oci-layout @@ -0,0 +1 @@ +{"imageLayoutVersion":"1.0.0"} \ No newline at end of file From eddfb2b610d6d0bbc914b9262a74bf9bd5bbb90a Mon Sep 17 00:00:00 2001 From: Jeff Rescignano Date: Mon, 3 Nov 2025 09:53:49 -0500 Subject: [PATCH 07/10] prefix value with oci-layout Signed-off-by: Jeff Rescignano --- pkg/attestation/crafter/materials/oci_image.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/pkg/attestation/crafter/materials/oci_image.go b/pkg/attestation/crafter/materials/oci_image.go index 3b5e178d2..d290869d5 100644 --- a/pkg/attestation/crafter/materials/oci_image.go +++ b/pkg/attestation/crafter/materials/oci_image.go @@ -314,7 +314,7 @@ func (i *OCIImageCrafter) isOCILayoutPath(path string) bool { } // Check for oci-layout file - layoutFile := filepath.Join(path, "oci-layout") + layoutFile := filepath.Join(path, ociLayoutRepoName) if _, err := os.Stat(layoutFile); err != nil { return false } @@ -395,10 +395,17 @@ func (i *OCIImageCrafter) buildMaterialFromManifest(layoutPath string, manifest digest := manifest.Digest.String() // Extract repository name from annotations if available - repoName := ociLayoutRepoName + repoName := ociLayoutRepoName + ":" if manifest.Annotations != nil { - if name, ok := manifest.Annotations["org.opencontainers.image.ref.name"]; ok { - repoName = name + // Try annotation keys in preference order + for _, key := range []string{ + "org.opencontainers.image.ref.name", + "org.opencontainers.image.base.name", + } { + if name, ok := manifest.Annotations[key]; ok { + repoName = repoName + name + break + } } } From 5b55c9d1786c76e0c498bd7650dd91c3e252c4e4 Mon Sep 17 00:00:00 2001 From: Jeff Rescignano Date: Mon, 3 Nov 2025 10:03:53 -0500 Subject: [PATCH 08/10] address linter findings Signed-off-by: Jeff Rescignano --- pkg/attestation/crafter/materials/oci_image.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/attestation/crafter/materials/oci_image.go b/pkg/attestation/crafter/materials/oci_image.go index d290869d5..96b4e7f4f 100644 --- a/pkg/attestation/crafter/materials/oci_image.go +++ b/pkg/attestation/crafter/materials/oci_image.go @@ -391,7 +391,6 @@ func (i *OCIImageCrafter) craftFromLayout(_ context.Context, layoutPath, digestS // buildMaterialFromManifest constructs the attestation material from a manifest descriptor. func (i *OCIImageCrafter) buildMaterialFromManifest(layoutPath string, manifest v1.Descriptor, allManifests []v1.Descriptor) (*api.Attestation_Material, error) { - digest := manifest.Digest.String() // Extract repository name from annotations if available @@ -403,7 +402,7 @@ func (i *OCIImageCrafter) buildMaterialFromManifest(layoutPath string, manifest "org.opencontainers.image.base.name", } { if name, ok := manifest.Annotations[key]; ok { - repoName = repoName + name + repoName += name break } } From 381bd363d5cc3e588ae2ef7e93eb72fbcedf4064 Mon Sep 17 00:00:00 2001 From: Jeff Rescignano Date: Mon, 3 Nov 2025 10:05:55 -0500 Subject: [PATCH 09/10] fallback to unknown if no annotations are found Signed-off-by: Jeff Rescignano --- pkg/attestation/crafter/materials/oci_image.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/attestation/crafter/materials/oci_image.go b/pkg/attestation/crafter/materials/oci_image.go index 96b4e7f4f..8b3e71df9 100644 --- a/pkg/attestation/crafter/materials/oci_image.go +++ b/pkg/attestation/crafter/materials/oci_image.go @@ -395,6 +395,7 @@ func (i *OCIImageCrafter) buildMaterialFromManifest(layoutPath string, manifest // Extract repository name from annotations if available repoName := ociLayoutRepoName + ":" + imageName := "unknown" if manifest.Annotations != nil { // Try annotation keys in preference order for _, key := range []string{ @@ -402,11 +403,12 @@ func (i *OCIImageCrafter) buildMaterialFromManifest(layoutPath string, manifest "org.opencontainers.image.base.name", } { if name, ok := manifest.Annotations[key]; ok { - repoName += name + imageName = name break } } } + repoName += imageName // Extract tag from annotations tag := "" From 0c0e9b0b4cea654969573c9983680f18ada16222 Mon Sep 17 00:00:00 2001 From: Jeff Rescignano Date: Mon, 3 Nov 2025 10:14:00 -0500 Subject: [PATCH 10/10] update unit tests to reflect new naming convention Signed-off-by: Jeff Rescignano --- pkg/attestation/crafter/materials/oci_image_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/attestation/crafter/materials/oci_image_test.go b/pkg/attestation/crafter/materials/oci_image_test.go index edd137abc..cef29b411 100644 --- a/pkg/attestation/crafter/materials/oci_image_test.go +++ b/pkg/attestation/crafter/materials/oci_image_test.go @@ -72,19 +72,19 @@ func TestOCIImageCraft_Layout(t *testing.T) { { name: "crane - single image with annotations", layoutPath: "testdata/oci-layouts/crane", - wantName: "oci-layout", + wantName: "oci-layout:unknown", wantDigest: "sha256:fa6d9058c3d65a33ff565c0e35172f2d99e76fbf8358d91ffaa2208eff2be400", }, { name: "skopeo - single image with tag annotation", layoutPath: "testdata/oci-layouts/skopeo", - wantName: "v1.51.0", + wantName: "oci-layout:v1.51.0", wantDigest: "sha256:fa6d9058c3d65a33ff565c0e35172f2d99e76fbf8358d91ffaa2208eff2be400", }, { name: "skopeo-alt - alternative format", layoutPath: "testdata/oci-layouts/skopeo-alt", - wantName: "v1.51.0", + wantName: "oci-layout:v1.51.0", wantDigest: "sha256:a5303ef28a4bd9b6e06aa92c07831dd151ac64172695971226bdba4a11fc1b88", }, { @@ -142,21 +142,21 @@ func TestOCIImageCraft_LayoutWithDigestSelector(t *testing.T) { name: "oras - select first image by digest", layoutPath: "testdata/oci-layouts/oras", digestSelector: "sha256:b1747c197a0ab3cb89e109f60a3c5d4ede6946e447fd468fa82d85fa94c6c6e5", - wantName: "oci-layout", + wantName: "oci-layout:unknown", wantDigest: "sha256:b1747c197a0ab3cb89e109f60a3c5d4ede6946e447fd468fa82d85fa94c6c6e5", }, { name: "oras - select second image by digest", layoutPath: "testdata/oci-layouts/oras", digestSelector: "sha256:f333056ac987169b2a121c16d06112d88ec3d7cb50b098bb17b0f14b0c52f6f3", - wantName: "oci-layout", + wantName: "oci-layout:unknown", wantDigest: "sha256:f333056ac987169b2a121c16d06112d88ec3d7cb50b098bb17b0f14b0c52f6f3", }, { name: "zarf - select specific image from bundle", layoutPath: "testdata/oci-layouts/zarf", digestSelector: "sha256:e8ac056f7b9b44b07935fe23b8383e5e550d479dc5c6261941e76449a8f7e926", - wantName: "ghcr.io/chainloop-dev/chainloop/artifact-cas:v1.51.0", + wantName: "oci-layout:ghcr.io/chainloop-dev/chainloop/artifact-cas:v1.51.0", wantDigest: "sha256:e8ac056f7b9b44b07935fe23b8383e5e550d479dc5c6261941e76449a8f7e926", }, {