@@ -85,6 +85,66 @@ pub fn process_entry<ObjectID: FsVerityHashValue>(
8585 Ok ( ( ) )
8686}
8787
88+ /// Compute per-layer composefs digests for an OCI image.
89+ ///
90+ /// For each layer, builds a single-layer filesystem and computes its EROFS fsverity digest.
91+ /// These digests can be stored in a composefs signature artifact.
92+ ///
93+ /// Per-layer digests are computed without `transform_for_oci()` since individual layers
94+ /// typically don't have the `/usr` directory needed for the OCI root metadata transform.
95+ ///
96+ /// The final merged digest (the digest of the complete flattened filesystem with
97+ /// `transform_for_oci()` applied) can be obtained from `seal()` or `create_filesystem()`.
98+ ///
99+ /// **Security note**: When `config_verity` is `None`, layer content is not verified against
100+ /// the config's diff_ids. Callers MUST provide a trusted `config_verity` when computing
101+ /// digests that will be used in signature artifacts. Without verity, a compromised repository
102+ /// could cause digests to be computed over substituted layer content.
103+ #[ context( "Computing per-layer digests" ) ]
104+ pub fn compute_per_layer_digests < ObjectID : FsVerityHashValue > (
105+ repo : & Repository < ObjectID > ,
106+ config_name : & str ,
107+ config_verity : Option < & ObjectID > ,
108+ ) -> Result < Vec < ObjectID > > {
109+ let ( config, map) = crate :: open_config ( repo, config_name, config_verity) ?;
110+
111+ let mut layer_digests = Vec :: with_capacity ( config. rootfs ( ) . diff_ids ( ) . len ( ) ) ;
112+
113+ for diff_id in config. rootfs ( ) . diff_ids ( ) {
114+ let layer_verity = map
115+ . get ( diff_id. as_str ( ) )
116+ . context ( "OCI config splitstream missing named ref to layer" ) ?;
117+
118+ let mut single_fs = FileSystem :: new ( Stat :: uninitialized ( ) ) ;
119+ let mut layer_stream =
120+ repo. open_stream ( "" , Some ( layer_verity) , Some ( TAR_LAYER_CONTENT_TYPE ) ) ?;
121+ while let Some ( entry) = crate :: tar:: get_entry ( & mut layer_stream) ? {
122+ process_entry ( & mut single_fs, entry) ?;
123+ }
124+ layer_digests. push ( single_fs. compute_image_id ( ) ) ;
125+ }
126+
127+ Ok ( layer_digests)
128+ }
129+
130+ /// Computes the composefs merged digest (image ID) for an OCI container.
131+ ///
132+ /// This is the fs-verity digest of the merged filesystem created from all layers.
133+ /// This digest is deterministic for a given OCI image and is used in signature
134+ /// artifacts as the "merged" entry.
135+ ///
136+ /// If `config_verity` is given, it is used for fast lookup. Otherwise, the config
137+ /// and layers will be hashed to verify their content.
138+ #[ context( "Computing merged digest" ) ]
139+ pub fn compute_merged_digest < ObjectID : FsVerityHashValue > (
140+ repo : & Repository < ObjectID > ,
141+ config_name : & str ,
142+ config_verity : Option < & ObjectID > ,
143+ ) -> Result < ObjectID > {
144+ let fs = create_filesystem ( repo, config_name, config_verity) ?;
145+ Ok ( fs. compute_image_id ( ) )
146+ }
147+
88148/// Creates a filesystem from the given OCI container. No special transformations are performed to
89149/// make the filesystem bootable.
90150///
@@ -144,7 +204,12 @@ mod test {
144204 fsverity:: Sha256HashValue ,
145205 tree:: { LeafContent , RegularFile , Stat } ,
146206 } ;
147- use std:: { cell:: RefCell , collections:: BTreeMap , io:: BufRead , io:: Read , path:: PathBuf } ;
207+ use std:: {
208+ cell:: RefCell ,
209+ collections:: BTreeMap ,
210+ io:: BufRead ,
211+ path:: { Path , PathBuf } ,
212+ } ;
148213
149214 use super :: * ;
150215
@@ -339,7 +404,7 @@ mod test {
339404 let by_path = |p : & str | -> & TarEntry < Sha256HashValue > {
340405 entries
341406 . iter ( )
342- . find ( |e| e. path == PathBuf :: from ( p) )
407+ . find ( |e| e. path == Path :: new ( p) )
343408 . unwrap_or_else ( || panic ! ( "missing entry for {p}" ) )
344409 } ;
345410
@@ -472,7 +537,7 @@ mod test {
472537 // Find the *last* /bin entry, which should be the symlink.
473538 let bin_entries: Vec < _ > = entries
474539 . iter ( )
475- . filter ( |e| e. path == PathBuf :: from ( "/bin" ) )
540+ . filter ( |e| e. path == Path :: new ( "/bin" ) )
476541 . collect ( ) ;
477542 assert ! (
478543 bin_entries. len( ) >= 2 ,
@@ -503,6 +568,94 @@ mod test {
503568 Ok ( ( ) )
504569 }
505570
571+ /// Helper to import a baseimage layer and create an OCI config for it.
572+ /// Returns (config_digest, config_verity, diff_id).
573+ fn import_baseimage_with_config (
574+ repo : & std:: sync:: Arc < Repository < Sha256HashValue > > ,
575+ ) -> ( String , Sha256HashValue , String ) {
576+ use oci_spec:: image:: { ImageConfigurationBuilder , RootFsBuilder } ;
577+
578+ let ( layer_data, diff_id) = build_baseimage ( ) ;
579+ let layer_verity =
580+ crate :: import_layer ( repo, & diff_id, None , & mut layer_data. as_slice ( ) ) . unwrap ( ) ;
581+
582+ let rootfs = RootFsBuilder :: default ( )
583+ . typ ( "layers" )
584+ . diff_ids ( vec ! [ diff_id. clone( ) ] )
585+ . build ( )
586+ . unwrap ( ) ;
587+ let config = ImageConfigurationBuilder :: default ( )
588+ . architecture ( "amd64" )
589+ . os ( "linux" )
590+ . rootfs ( rootfs)
591+ . build ( )
592+ . unwrap ( ) ;
593+
594+ let mut refs = std:: collections:: HashMap :: new ( ) ;
595+ refs. insert ( Box :: from ( diff_id. as_str ( ) ) , layer_verity) ;
596+
597+ let ( config_digest, config_verity) = crate :: write_config ( repo, & config, refs) . unwrap ( ) ;
598+ ( config_digest, config_verity, diff_id)
599+ }
600+
601+ #[ test]
602+ fn test_compute_per_layer_digests ( ) {
603+ use composefs:: { repository:: Repository , test:: tempdir} ;
604+ use rustix:: fs:: CWD ;
605+ use std:: sync:: Arc ;
606+
607+ let repo_dir = tempdir ( ) ;
608+ let repo = Arc :: new ( Repository :: < Sha256HashValue > :: open_path ( CWD , & repo_dir) . unwrap ( ) ) ;
609+
610+ let ( config_digest, config_verity, _diff_id) = import_baseimage_with_config ( & repo) ;
611+
612+ // Compute per-layer digests (with verity)
613+ let digests =
614+ compute_per_layer_digests ( & repo, & config_digest, Some ( & config_verity) ) . unwrap ( ) ;
615+ assert_eq ! ( digests. len( ) , 1 , "expected exactly 1 per-layer digest" ) ;
616+
617+ // Determinism: calling again should produce the same result
618+ let digests2 =
619+ compute_per_layer_digests ( & repo, & config_digest, Some ( & config_verity) ) . unwrap ( ) ;
620+ assert_eq ! (
621+ digests, digests2,
622+ "per-layer digests should be deterministic"
623+ ) ;
624+
625+ // Also works without verity (slower path that verifies content hashes)
626+ let digests3 = compute_per_layer_digests ( & repo, & config_digest, None ) . unwrap ( ) ;
627+ assert_eq ! (
628+ digests, digests3,
629+ "verity and non-verity paths should agree"
630+ ) ;
631+ }
632+
633+ #[ test]
634+ fn test_per_layer_digest_differs_from_merged ( ) {
635+ use composefs:: { repository:: Repository , test:: tempdir} ;
636+ use rustix:: fs:: CWD ;
637+ use std:: sync:: Arc ;
638+
639+ let repo_dir = tempdir ( ) ;
640+ let repo = Arc :: new ( Repository :: < Sha256HashValue > :: open_path ( CWD , & repo_dir) . unwrap ( ) ) ;
641+
642+ let ( config_digest, config_verity, _diff_id) = import_baseimage_with_config ( & repo) ;
643+
644+ let per_layer =
645+ compute_per_layer_digests ( & repo, & config_digest, Some ( & config_verity) ) . unwrap ( ) ;
646+ assert_eq ! ( per_layer. len( ) , 1 ) ;
647+
648+ let merged_fs = create_filesystem ( & repo, & config_digest, Some ( & config_verity) ) . unwrap ( ) ;
649+ let merged_digest = merged_fs. compute_image_id ( ) ;
650+
651+ // The merged filesystem applies transform_for_oci() which copies /usr metadata
652+ // to the root, so the digests should differ.
653+ assert_ne ! (
654+ per_layer[ 0 ] , merged_digest,
655+ "per-layer and merged digests should differ because of transform_for_oci"
656+ ) ;
657+ }
658+
506659 #[ test]
507660 fn test_process_entry ( ) -> Result < ( ) > {
508661 let mut fs = FileSystem :: < Sha256HashValue > :: new ( Stat :: uninitialized ( ) ) ;
0 commit comments