Skip to content

Commit 112aec3

Browse files
authored
feat: add support for container images from local oci layout (#2507)
Signed-off-by: Jeff Rescignano <jeffr@defenseunicorns.com>
1 parent 8bcddcf commit 112aec3

File tree

12 files changed

+457
-2
lines changed

12 files changed

+457
-2
lines changed

pkg/attestation/crafter/materials/oci_image.go

Lines changed: 218 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2024 The Chainloop Authors.
2+
// Copyright 2024-2025 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -18,7 +18,10 @@ package materials
1818
import (
1919
"context"
2020
"encoding/base64"
21+
"encoding/json"
2122
"fmt"
23+
"os"
24+
"path/filepath"
2225
"strings"
2326
"sync"
2427

@@ -29,6 +32,7 @@ import (
2932
"github.com/google/go-containerregistry/pkg/authn"
3033
"github.com/google/go-containerregistry/pkg/name"
3134
v1 "github.com/google/go-containerregistry/pkg/v1"
35+
"github.com/google/go-containerregistry/pkg/v1/layout"
3236
"github.com/google/go-containerregistry/pkg/v1/remote"
3337
"github.com/rs/zerolog"
3438
cosigntypes "github.com/sigstore/cosign/v2/pkg/types"
@@ -43,6 +47,8 @@ const (
4347
notarySignatureMimeType = "application/vnd.cncf.notary.signature"
4448
// latestTag is the tag name for the latest image.
4549
latestTag = "latest"
50+
// ociLayoutRepoName is the default repository name for OCI layout images.
51+
ociLayoutRepoName = "oci-layout"
4652
)
4753

4854
// signatureProvider is the type for the signature provider of a container image.
@@ -83,7 +89,15 @@ func NewOCIImageCrafter(schema *schemaapi.CraftingSchema_Material, ociAuth authn
8389
return c, nil
8490
}
8591

86-
func (i *OCIImageCrafter) Craft(_ context.Context, imageRef string) (*api.Attestation_Material, error) {
92+
func (i *OCIImageCrafter) Craft(ctx context.Context, imageRef string) (*api.Attestation_Material, error) {
93+
// Check if imageRef is a path to an OCI layout directory
94+
layoutPath, digestSelector := parseLayoutReference(imageRef)
95+
if i.isOCILayoutPath(layoutPath) {
96+
i.logger.Debug().Str("path", layoutPath).Str("digest", digestSelector).Msg("detected OCI layout directory")
97+
return i.craftFromLayout(ctx, layoutPath, digestSelector)
98+
}
99+
100+
// Otherwise, treat as remote registry reference
87101
i.logger.Debug().Str("name", imageRef).Msg("retrieving container image digest from remote")
88102

89103
ref, err := name.ParseReference(imageRef)
@@ -281,3 +295,205 @@ func (i *OCIImageCrafter) isLatestTag(ref name.Reference, currentDigest string)
281295
i.logger.Debug().Str("name", latestRef.String()).Msg("image does not have a 'latest' tag")
282296
return false
283297
}
298+
299+
// parseLayoutReference parses a layout reference that may include a digest selector.
300+
func parseLayoutReference(ref string) (string, string) {
301+
// Check for @digest suffix
302+
if idx := strings.LastIndex(ref, "@"); idx != -1 {
303+
return ref[:idx], ref[idx+1:]
304+
}
305+
return ref, ""
306+
}
307+
308+
// isOCILayoutPath checks if the given path is a valid OCI layout directory.
309+
func (i *OCIImageCrafter) isOCILayoutPath(path string) bool {
310+
// Check if path exists and is a directory
311+
info, err := os.Stat(path)
312+
if err != nil || !info.IsDir() {
313+
return false
314+
}
315+
316+
// Check for oci-layout file
317+
layoutFile := filepath.Join(path, ociLayoutRepoName)
318+
if _, err := os.Stat(layoutFile); err != nil {
319+
return false
320+
}
321+
322+
return true
323+
}
324+
325+
// craftFromLayout creates a material from an OCI layout directory.
326+
// If digestSelector is provided, it will look for that specific digest in the layout.
327+
// Otherwise, it uses the first manifest in the index.
328+
func (i *OCIImageCrafter) craftFromLayout(_ context.Context, layoutPath, digestSelector string) (*api.Attestation_Material, error) {
329+
// Read the OCI layout
330+
layoutPath, err := filepath.Abs(layoutPath)
331+
if err != nil {
332+
return nil, fmt.Errorf("failed to get absolute path: %w", err)
333+
}
334+
335+
path, err := layout.FromPath(layoutPath)
336+
if err != nil {
337+
return nil, fmt.Errorf("failed to read OCI layout: %w", err)
338+
}
339+
340+
// Get the image index
341+
index, err := path.ImageIndex()
342+
if err != nil {
343+
return nil, fmt.Errorf("failed to read image index: %w", err)
344+
}
345+
346+
indexManifest, err := index.IndexManifest()
347+
if err != nil {
348+
return nil, fmt.Errorf("failed to read index manifest: %w", err)
349+
}
350+
351+
if len(indexManifest.Manifests) == 0 {
352+
return nil, fmt.Errorf("no manifests found in OCI layout")
353+
}
354+
355+
// Select the manifest based on digest selector
356+
// If a specific digest is requested, find it
357+
if digestSelector != "" {
358+
found := false
359+
var manifest v1.Descriptor
360+
for _, m := range indexManifest.Manifests {
361+
if m.Digest.String() == digestSelector {
362+
manifest = m
363+
found = true
364+
break
365+
}
366+
}
367+
if !found {
368+
return nil, fmt.Errorf("digest %s not found in OCI layout", digestSelector)
369+
}
370+
i.logger.Debug().Str("digest", digestSelector).Msg("selected image by digest")
371+
372+
return i.buildMaterialFromManifest(layoutPath, manifest, indexManifest.Manifests)
373+
}
374+
375+
// No digest specified - if multiple images exist, require explicit selection
376+
if len(indexManifest.Manifests) > 1 {
377+
var digests []string
378+
for _, m := range indexManifest.Manifests {
379+
digests = append(digests, m.Digest.String())
380+
}
381+
return nil, fmt.Errorf("OCI layout contains %d images, please specify which one to use with @digest. Available digests: %s",
382+
len(indexManifest.Manifests), strings.Join(digests, ", "))
383+
}
384+
385+
// Only one image, safe to use it
386+
manifest := indexManifest.Manifests[0]
387+
i.logger.Debug().Msg("using only image in layout")
388+
389+
return i.buildMaterialFromManifest(layoutPath, manifest, indexManifest.Manifests)
390+
}
391+
392+
// buildMaterialFromManifest constructs the attestation material from a manifest descriptor.
393+
func (i *OCIImageCrafter) buildMaterialFromManifest(layoutPath string, manifest v1.Descriptor, allManifests []v1.Descriptor) (*api.Attestation_Material, error) {
394+
digest := manifest.Digest.String()
395+
396+
// Extract repository name from annotations if available
397+
repoName := ociLayoutRepoName + ":"
398+
imageName := "unknown"
399+
if manifest.Annotations != nil {
400+
// Try annotation keys in preference order
401+
for _, key := range []string{
402+
"org.opencontainers.image.ref.name",
403+
"org.opencontainers.image.base.name",
404+
} {
405+
if name, ok := manifest.Annotations[key]; ok {
406+
imageName = name
407+
break
408+
}
409+
}
410+
}
411+
repoName += imageName
412+
413+
// Extract tag from annotations
414+
tag := ""
415+
if manifest.Annotations != nil {
416+
if t, ok := manifest.Annotations["io.containerd.image.name"]; ok {
417+
// Extract tag from full reference (e.g., "registry/repo:tag" -> "tag")
418+
parts := strings.Split(t, ":")
419+
if len(parts) > 1 {
420+
tag = parts[len(parts)-1]
421+
}
422+
}
423+
}
424+
425+
// Validate artifact type if specified
426+
if i.artifactTypeValidation != "" {
427+
i.logger.Debug().Str("path", layoutPath).Str("want", i.artifactTypeValidation).Msg("validating artifact type")
428+
if manifest.ArtifactType != i.artifactTypeValidation {
429+
return nil, fmt.Errorf("artifact type %s does not match expected type %s", manifest.ArtifactType, i.artifactTypeValidation)
430+
}
431+
}
432+
433+
i.logger.Debug().Str("path", layoutPath).Str("digest", digest).Msg("OCI layout image resolved")
434+
435+
// Check for signatures in the layout
436+
signatureInfo := i.checkForSignatureInLayout(allManifests, digest)
437+
438+
containerImage := &api.Attestation_Material_ContainerImage{
439+
Id: i.input.Name,
440+
Name: repoName,
441+
Digest: digest,
442+
IsSubject: i.input.Output,
443+
Tag: tag,
444+
}
445+
446+
// Add signature information if found
447+
if signatureInfo != nil {
448+
containerImage.SignatureDigest = signatureInfo.digest
449+
containerImage.Signature = signatureInfo.payload
450+
containerImage.SignatureProvider = string(signatureInfo.provider)
451+
}
452+
453+
return &api.Attestation_Material{
454+
MaterialType: i.input.Type,
455+
M: &api.Attestation_Material_ContainerImage_{
456+
ContainerImage: containerImage},
457+
}, nil
458+
}
459+
460+
// checkForSignatureInLayout checks for signatures in the OCI layout manifests.
461+
func (i *OCIImageCrafter) checkForSignatureInLayout(manifests []v1.Descriptor, imageDigest string) *containerSignatureInfo {
462+
// Look for signature artifacts that reference the image digest
463+
for _, m := range manifests {
464+
// Check if this manifest references our image
465+
if m.Annotations != nil {
466+
if subject, ok := m.Annotations["org.opencontainers.image.base.digest"]; ok && subject == imageDigest {
467+
// Check for Cosign signature
468+
if m.ArtifactType == cosigntypes.SimpleSigningMediaType {
469+
i.logger.Debug().Str("digest", m.Digest.String()).Msg("found Cosign signature artifact in OCI layout")
470+
return i.encodeLayoutSignature(m, cosignSignatureProvider)
471+
}
472+
// Check for Notary signature
473+
if m.ArtifactType == notarySignatureMimeType {
474+
i.logger.Debug().Str("digest", m.Digest.String()).Msg("found Notary signature artifact in OCI layout")
475+
return i.encodeLayoutSignature(m, notarySignatureProvider)
476+
}
477+
}
478+
}
479+
}
480+
481+
i.logger.Debug().Str("digest", imageDigest).Msg("no signature found in OCI layout")
482+
return nil
483+
}
484+
485+
// encodeLayoutSignature encodes a signature descriptor as base64.
486+
func (i *OCIImageCrafter) encodeLayoutSignature(desc v1.Descriptor, provider signatureProvider) *containerSignatureInfo {
487+
// Marshal the descriptor to JSON for the payload
488+
manifestBytes, err := json.Marshal(desc)
489+
if err != nil {
490+
i.logger.Debug().Err(err).Msg("failed to marshal signature descriptor")
491+
return nil
492+
}
493+
494+
return &containerSignatureInfo{
495+
digest: desc.Digest.String(),
496+
provider: provider,
497+
payload: base64.StdEncoding.EncodeToString(manifestBytes),
498+
}
499+
}

0 commit comments

Comments
 (0)