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
1818import (
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