88//! - Pulling container images from registries using skopeo
99//! - Converting OCI image layers from tar format to composefs split streams
1010//! - Creating mountable filesystems from OCI image configurations
11+ //! - Importing from containers-storage with zero-copy reflinks (optional feature)
12+
1113#![ forbid( unsafe_code) ]
1214
1315pub mod boot;
@@ -67,27 +69,55 @@ pub use skopeo::pull_image;
6769/// Statistics from an image import operation.
6870#[ derive( Debug , Clone , Default ) ]
6971pub struct ImportStats {
70- /// Number of objects stored via copy.
72+ /// Number of layers in the image.
73+ pub layers : u64 ,
74+ /// Number of layers that were already present (skipped).
75+ pub layers_already_present : u64 ,
76+ /// Number of objects stored via regular copy.
7177 pub objects_copied : u64 ,
78+ /// Number of objects stored via reflink (zero-copy).
79+ pub objects_reflinked : u64 ,
80+ /// Number of objects stored via hardlink (zero-copy).
81+ pub objects_hardlinked : u64 ,
7282 /// Number of objects that already existed (deduplicated).
7383 pub objects_already_present : u64 ,
74- /// Total bytes stored as new objects .
84+ /// Total bytes stored via regular copy .
7585 pub bytes_copied : u64 ,
86+ /// Total bytes stored via reflink.
87+ pub bytes_reflinked : u64 ,
88+ /// Total bytes stored via hardlink.
89+ pub bytes_hardlinked : u64 ,
7690 /// Total bytes inlined in splitstreams (small files + headers).
7791 pub bytes_inlined : u64 ,
7892}
7993
8094impl ImportStats {
95+ /// Total number of new objects stored (copied + reflinked + hardlinked).
96+ pub fn new_objects ( & self ) -> u64 {
97+ self . objects_copied + self . objects_reflinked + self . objects_hardlinked
98+ }
99+
81100 /// Total number of objects processed (new + already present).
82101 pub fn total_objects ( & self ) -> u64 {
83- self . objects_copied + self . objects_already_present
102+ self . new_objects ( ) + self . objects_already_present
103+ }
104+
105+ /// Total bytes stored as new objects (copied + reflinked + hardlinked).
106+ pub fn new_bytes ( & self ) -> u64 {
107+ self . bytes_copied + self . bytes_reflinked + self . bytes_hardlinked
84108 }
85109
86110 /// Merge another `ImportStats` into this one.
87111 pub fn merge ( & mut self , other : & ImportStats ) {
112+ self . layers += other. layers ;
113+ self . layers_already_present += other. layers_already_present ;
88114 self . objects_copied += other. objects_copied ;
115+ self . objects_reflinked += other. objects_reflinked ;
116+ self . objects_hardlinked += other. objects_hardlinked ;
89117 self . objects_already_present += other. objects_already_present ;
90118 self . bytes_copied += other. bytes_copied ;
119+ self . bytes_reflinked += other. bytes_reflinked ;
120+ self . bytes_hardlinked += other. bytes_hardlinked ;
91121 self . bytes_inlined += other. bytes_inlined ;
92122 }
93123
@@ -103,6 +133,14 @@ impl ImportStats {
103133 stats. objects_copied += 1 ;
104134 stats. bytes_copied += size;
105135 }
136+ ObjectStoreMethod :: Reflinked => {
137+ stats. objects_reflinked += 1 ;
138+ stats. bytes_reflinked += size;
139+ }
140+ ObjectStoreMethod :: Hardlinked => {
141+ stats. objects_hardlinked += 1 ;
142+ stats. bytes_hardlinked += size;
143+ }
106144 ObjectStoreMethod :: AlreadyPresent => {
107145 stats. objects_already_present += 1 ;
108146 }
@@ -114,17 +152,83 @@ impl ImportStats {
114152
115153impl std:: fmt:: Display for ImportStats {
116154 fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
117- write ! (
118- f,
119- "{} new + {} already present objects; {} stored, {} inlined" ,
120- self . objects_copied,
121- self . objects_already_present,
122- indicatif:: HumanBytes ( self . bytes_copied) ,
123- indicatif:: HumanBytes ( self . bytes_inlined) ,
124- )
155+ let has_zerocopy = self . objects_reflinked > 0 || self . objects_hardlinked > 0 ;
156+ if has_zerocopy {
157+ // Show detailed breakdown when zero-copy methods were used
158+ let mut parts = Vec :: new ( ) ;
159+ if self . objects_reflinked > 0 {
160+ parts. push ( format ! ( "{} reflinked" , self . objects_reflinked) ) ;
161+ }
162+ if self . objects_hardlinked > 0 {
163+ parts. push ( format ! ( "{} hardlinked" , self . objects_hardlinked) ) ;
164+ }
165+ parts. push ( format ! ( "{} copied" , self . objects_copied) ) ;
166+ parts. push ( format ! ( "{} already present" , self . objects_already_present) ) ;
167+ write ! ( f, "{} objects; " , parts. join( " + " ) ) ?;
168+
169+ let mut byte_parts = Vec :: new ( ) ;
170+ if self . objects_reflinked > 0 {
171+ byte_parts. push ( format ! (
172+ "{} reflinked" ,
173+ indicatif:: HumanBytes ( self . bytes_reflinked)
174+ ) ) ;
175+ }
176+ if self . objects_hardlinked > 0 {
177+ byte_parts. push ( format ! (
178+ "{} hardlinked" ,
179+ indicatif:: HumanBytes ( self . bytes_hardlinked)
180+ ) ) ;
181+ }
182+ byte_parts. push ( format ! (
183+ "{} copied" ,
184+ indicatif:: HumanBytes ( self . bytes_copied)
185+ ) ) ;
186+ byte_parts. push ( format ! (
187+ "{} inlined" ,
188+ indicatif:: HumanBytes ( self . bytes_inlined)
189+ ) ) ;
190+ write ! ( f, "{}" , byte_parts. join( ", " ) )
191+ } else {
192+ write ! (
193+ f,
194+ "{} new + {} already present objects; {} stored, {} inlined" ,
195+ self . objects_copied,
196+ self . objects_already_present,
197+ indicatif:: HumanBytes ( self . bytes_copied) ,
198+ indicatif:: HumanBytes ( self . bytes_inlined) ,
199+ )
200+ }
125201 }
126202}
127203
204+ /// Options for a [`pull`] operation.
205+ ///
206+ /// Use `Default::default()` for the common case (skopeo transport, no
207+ /// zero-copy requirement).
208+ #[ derive( Debug , Default ) ]
209+ pub struct PullOptions < ' a > {
210+ /// Image proxy configuration passed to skopeo (ignored for
211+ /// `containers-storage:` references).
212+ pub img_proxy_config : Option < ImageProxyConfig > ,
213+
214+ /// If `true`, the containers-storage import path will error instead of
215+ /// falling back to a data copy when neither reflink nor hardlink succeeds.
216+ /// Intended for bootc's unified storage layout where the composefs repo
217+ /// and containers-storage are always on the same filesystem.
218+ pub zerocopy : bool ,
219+
220+ /// Explicit containers-storage root. When set, auto-discovery is skipped
221+ /// and only this path (plus any `additional_image_stores`) is searched.
222+ /// Only relevant for `containers-storage:` references.
223+ pub storage_root : Option < & ' a std:: path:: Path > ,
224+
225+ /// Additional read-only image stores to search beyond the primary
226+ /// (auto-discovered or explicit) store. Equivalent to the
227+ /// `additionalimagestore=` option in containers/storage.
228+ /// Only relevant for `containers-storage:` references.
229+ pub additional_image_stores : & ' a [ & ' a std:: path:: Path ] ,
230+ }
231+
128232/// Result of a pull operation.
129233#[ derive( Debug ) ]
130234pub struct PullResult < ObjectID > {
@@ -136,7 +240,8 @@ pub struct PullResult<ObjectID> {
136240 pub stats : ImportStats ,
137241}
138242
139- type ContentAndVerity < ObjectID > = ( OciDigest , ObjectID ) ;
243+ /// A tuple of (content digest, fs-verity ObjectID).
244+ pub type ContentAndVerity < ObjectID > = ( OciDigest , ObjectID ) ;
140245
141246/// Parsed OCI config and its associated references.
142247pub struct OpenConfig < ObjectID > {
@@ -160,11 +265,11 @@ impl<ObjectID: std::fmt::Debug> std::fmt::Debug for OpenConfig<ObjectID> {
160265 }
161266}
162267
163- fn layer_identifier ( diff_id : & OciDigest ) -> String {
268+ pub ( crate ) fn layer_identifier ( diff_id : & OciDigest ) -> String {
164269 format ! ( "oci-layer-{diff_id}" )
165270}
166271
167- fn config_identifier ( config : & OciDigest ) -> String {
272+ pub ( crate ) fn config_identifier ( config : & OciDigest ) -> String {
168273 format ! ( "oci-config-{config}" )
169274}
170275
@@ -223,14 +328,21 @@ pub fn ls_layer<ObjectID: FsVerityHashValue>(
223328
224329/// Pull the target image, and add the provided tag. If this is a mountable
225330/// image (i.e. not an artifact), it is *not* unpacked by default.
331+ ///
332+ /// When the `containers-storage` feature is enabled and the image reference
333+ /// starts with `containers-storage:`, this uses the native cstor import path
334+ /// which supports zero-copy reflinks/hardlinks. Otherwise, it uses skopeo.
335+ ///
336+ /// See [`PullOptions`] for tunable knobs (zero-copy mode, extra storage
337+ /// roots, image proxy configuration).
226338pub async fn pull < ObjectID : FsVerityHashValue > (
227339 repo : & Arc < Repository < ObjectID > > ,
228340 imgref : & str ,
229341 reference : Option < & str > ,
230- img_proxy_config : Option < ImageProxyConfig > ,
342+ opts : PullOptions < ' _ > ,
231343) -> Result < PullResult < ObjectID > > {
232344 let ( config_digest, config_verity, stats) =
233- skopeo:: pull ( repo, imgref, reference, img_proxy_config) . await ?;
345+ skopeo:: pull ( repo, imgref, reference, opts . img_proxy_config ) . await ?;
234346 Ok ( crate :: PullResult {
235347 config_digest,
236348 config_verity,
@@ -1075,23 +1187,83 @@ mod test {
10751187
10761188 #[ test]
10771189 fn test_import_stats_display ( ) {
1190+ // Copy-only stats (no reflinks)
10781191 let stats = ImportStats {
10791192 objects_copied : 42 ,
10801193 objects_already_present : 100 ,
10811194 bytes_copied : 1_500_000 ,
10821195 bytes_inlined : 800 ,
1196+ ..Default :: default ( )
10831197 } ;
10841198 assert_eq ! (
10851199 stats. to_string( ) ,
10861200 "42 new + 100 already present objects; 1.43 MiB stored, 800 B inlined"
10871201 ) ;
1202+ assert_eq ! ( stats. total_objects( ) , 142 ) ;
1203+ assert_eq ! ( stats. new_objects( ) , 42 ) ;
1204+ assert_eq ! ( stats. new_bytes( ) , 1_500_000 ) ;
1205+
1206+ // Stats with reflinks
1207+ let reflink_stats = ImportStats {
1208+ objects_reflinked : 30 ,
1209+ objects_copied : 12 ,
1210+ objects_already_present : 100 ,
1211+ bytes_reflinked : 1_000_000 ,
1212+ bytes_copied : 500_000 ,
1213+ bytes_inlined : 800 ,
1214+ ..Default :: default ( )
1215+ } ;
1216+ assert_eq ! (
1217+ reflink_stats. to_string( ) ,
1218+ "30 reflinked + 12 copied + 100 already present objects; 976.56 KiB reflinked, 488.28 KiB copied, 800 B inlined"
1219+ ) ;
1220+ assert_eq ! ( reflink_stats. total_objects( ) , 142 ) ;
1221+ assert_eq ! ( reflink_stats. new_objects( ) , 42 ) ;
1222+ assert_eq ! ( reflink_stats. new_bytes( ) , 1_500_000 ) ;
1223+
1224+ // Stats with hardlinks only
1225+ let hardlink_stats = ImportStats {
1226+ objects_hardlinked : 20 ,
1227+ objects_copied : 5 ,
1228+ objects_already_present : 50 ,
1229+ bytes_hardlinked : 800_000 ,
1230+ bytes_copied : 200_000 ,
1231+ bytes_inlined : 400 ,
1232+ ..Default :: default ( )
1233+ } ;
1234+ assert_eq ! (
1235+ hardlink_stats. to_string( ) ,
1236+ "20 hardlinked + 5 copied + 50 already present objects; 781.25 KiB hardlinked, 195.31 KiB copied, 400 B inlined"
1237+ ) ;
1238+ assert_eq ! ( hardlink_stats. total_objects( ) , 75 ) ;
1239+ assert_eq ! ( hardlink_stats. new_objects( ) , 25 ) ;
1240+ assert_eq ! ( hardlink_stats. new_bytes( ) , 1_000_000 ) ;
1241+
1242+ // Stats with both reflinks and hardlinks
1243+ let mixed_stats = ImportStats {
1244+ objects_reflinked : 10 ,
1245+ objects_hardlinked : 15 ,
1246+ objects_copied : 5 ,
1247+ objects_already_present : 70 ,
1248+ bytes_reflinked : 500_000 ,
1249+ bytes_hardlinked : 750_000 ,
1250+ bytes_copied : 250_000 ,
1251+ bytes_inlined : 600 ,
1252+ ..Default :: default ( )
1253+ } ;
1254+ assert_eq ! (
1255+ mixed_stats. to_string( ) ,
1256+ "10 reflinked + 15 hardlinked + 5 copied + 70 already present objects; 488.28 KiB reflinked, 732.42 KiB hardlinked, 244.14 KiB copied, 600 B inlined"
1257+ ) ;
1258+ assert_eq ! ( mixed_stats. total_objects( ) , 100 ) ;
1259+ assert_eq ! ( mixed_stats. new_objects( ) , 30 ) ;
1260+ assert_eq ! ( mixed_stats. new_bytes( ) , 1_500_000 ) ;
10881261
10891262 let empty = ImportStats :: default ( ) ;
10901263 assert_eq ! (
10911264 empty. to_string( ) ,
10921265 "0 new + 0 already present objects; 0 B stored, 0 B inlined"
10931266 ) ;
10941267 assert_eq ! ( empty. total_objects( ) , 0 ) ;
1095- assert_eq ! ( stats. total_objects( ) , 142 ) ;
10961268 }
10971269}
0 commit comments