1#![forbid(unsafe_code)]
14#![cfg_attr(not(test), deny(clippy::print_stdout, clippy::print_stderr))]
18
19pub mod boot;
20#[cfg(feature = "containers-storage")]
21pub mod cstor;
22pub(crate) mod delta;
23pub mod image;
24pub mod layer;
25pub mod oci_image;
26pub mod oci_layout;
27pub mod progress;
29pub mod skopeo;
30pub mod tar;
31
32#[cfg(any(test, feature = "test"))]
34#[allow(missing_docs, missing_debug_implementations)]
35#[doc(hidden)]
36pub mod test_util;
37
38pub use composefs;
40
41use std::io::Read;
42use std::{collections::HashMap, sync::Arc};
43
44use anyhow::{Context, Result, ensure};
45pub use containers_image_proxy::oci_spec::image::Digest as OciDigest;
49
50use containers_image_proxy::ImageProxyConfig;
51use containers_image_proxy::oci_spec::image::ImageConfiguration;
52use containers_image_proxy::oci_spec::image::{Descriptor, MediaType};
53use sha2::{Digest, Sha256};
54
55use composefs::{
56 erofs::format::{FormatEpoch, FormatVersion},
57 fsverity::FsVerityHashValue,
58 repository::{ObjectStoreMethod, Repository},
59 splitstream::SplitStreamStats,
60};
61
62use crate::skopeo::{OCI_CONFIG_CONTENT_TYPE, TAR_LAYER_CONTENT_TYPE};
63
64pub const IMAGE_REF_KEY: &str = "composefs.image";
66
67pub const IMAGE_REF_KEY_V1: &str = "composefs.image.v1";
69
70pub const BOOT_IMAGE_REF_KEY: &str = "composefs.image.boot";
72
73pub const BOOT_IMAGE_REF_KEY_V1: &str = "composefs.image.boot.v1";
75
76#[cfg(feature = "boot")]
78pub use boot::generate_boot_image;
79pub use boot::{boot_image, remove_boot_image};
80pub use oci_image::{
81 ImageInfo, LayerInfo, OCI_REF_PREFIX, OciFsckError, OciFsckResult, OciImage, OciImageNotFound,
82 OciRefNotFound, SplitstreamInfo, add_referrer, layer_dumpfile, layer_info, layer_tar,
83 list_images, list_referrers, list_refs, oci_fsck, oci_fsck_image, remove_referrer,
84 remove_referrers_for_subject, resolve_ref, tag_image, untag_image,
85};
86pub use progress::{ComponentId, NullReporter, ProgressEvent, ProgressReporter, SharedReporter};
87pub use skopeo::pull_image;
88
89#[derive(Debug, Clone, Default)]
91pub struct ImportStats {
92 pub layers: u64,
94 pub layers_already_present: u64,
96 pub objects_copied: u64,
98 pub objects_reflinked: u64,
100 pub objects_hardlinked: u64,
102 pub objects_already_present: u64,
104 pub bytes_copied: u64,
106 pub bytes_reflinked: u64,
108 pub bytes_hardlinked: u64,
110 pub bytes_inlined: u64,
112}
113
114impl ImportStats {
115 pub fn new_objects(&self) -> u64 {
117 self.objects_copied + self.objects_reflinked + self.objects_hardlinked
118 }
119
120 pub fn total_objects(&self) -> u64 {
122 self.new_objects() + self.objects_already_present
123 }
124
125 pub fn new_bytes(&self) -> u64 {
127 self.bytes_copied + self.bytes_reflinked + self.bytes_hardlinked
128 }
129
130 pub fn merge(&mut self, other: &ImportStats) {
132 self.layers += other.layers;
133 self.layers_already_present += other.layers_already_present;
134 self.objects_copied += other.objects_copied;
135 self.objects_reflinked += other.objects_reflinked;
136 self.objects_hardlinked += other.objects_hardlinked;
137 self.objects_already_present += other.objects_already_present;
138 self.bytes_copied += other.bytes_copied;
139 self.bytes_reflinked += other.bytes_reflinked;
140 self.bytes_hardlinked += other.bytes_hardlinked;
141 self.bytes_inlined += other.bytes_inlined;
142 }
143
144 pub(crate) fn from_split_stream_stats(ss: &SplitStreamStats) -> Self {
146 let mut stats = ImportStats {
147 bytes_inlined: ss.inline_bytes,
148 ..Default::default()
149 };
150 for &(size, method) in &ss.external_objects {
151 match method {
152 ObjectStoreMethod::Copied => {
153 stats.objects_copied += 1;
154 stats.bytes_copied += size;
155 }
156 ObjectStoreMethod::Reflinked => {
157 stats.objects_reflinked += 1;
158 stats.bytes_reflinked += size;
159 }
160 ObjectStoreMethod::Hardlinked => {
161 stats.objects_hardlinked += 1;
162 stats.bytes_hardlinked += size;
163 }
164 ObjectStoreMethod::AlreadyPresent => {
165 stats.objects_already_present += 1;
166 }
167 }
168 }
169 stats
170 }
171}
172
173impl std::fmt::Display for ImportStats {
174 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 let has_zerocopy = self.objects_reflinked > 0 || self.objects_hardlinked > 0;
176 if has_zerocopy {
177 let mut parts = Vec::new();
179 if self.objects_reflinked > 0 {
180 parts.push(format!("{} reflinked", self.objects_reflinked));
181 }
182 if self.objects_hardlinked > 0 {
183 parts.push(format!("{} hardlinked", self.objects_hardlinked));
184 }
185 parts.push(format!("{} copied", self.objects_copied));
186 parts.push(format!("{} already present", self.objects_already_present));
187 write!(f, "{} objects; ", parts.join(" + "))?;
188
189 let mut byte_parts = Vec::new();
190 if self.objects_reflinked > 0 {
191 byte_parts.push(format!(
192 "{} reflinked",
193 indicatif::HumanBytes(self.bytes_reflinked)
194 ));
195 }
196 if self.objects_hardlinked > 0 {
197 byte_parts.push(format!(
198 "{} hardlinked",
199 indicatif::HumanBytes(self.bytes_hardlinked)
200 ));
201 }
202 byte_parts.push(format!(
203 "{} copied",
204 indicatif::HumanBytes(self.bytes_copied)
205 ));
206 byte_parts.push(format!(
207 "{} inlined",
208 indicatif::HumanBytes(self.bytes_inlined)
209 ));
210 write!(f, "{}", byte_parts.join(", "))
211 } else {
212 write!(
213 f,
214 "{} new + {} already present objects; {} stored, {} inlined",
215 self.objects_copied,
216 self.objects_already_present,
217 indicatif::HumanBytes(self.bytes_copied),
218 indicatif::HumanBytes(self.bytes_inlined),
219 )
220 }
221 }
222}
223
224#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
227pub enum LocalFetchOpt {
228 #[default]
231 Disabled,
232 IfPossible,
235 ZeroCopy,
238}
239
240#[derive(Default)]
245pub struct PullOptions<'a> {
246 pub img_proxy_config: Option<ImageProxyConfig>,
250
251 pub local_fetch: LocalFetchOpt,
254
255 pub storage_root: Option<&'a std::path::Path>,
259
260 pub additional_image_stores: &'a [&'a std::path::Path],
265
266 pub progress: Option<SharedReporter>,
272}
273
274impl<'a> std::fmt::Debug for PullOptions<'a> {
275 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276 f.debug_struct("PullOptions")
277 .field("img_proxy_config", &self.img_proxy_config)
278 .field("local_fetch", &self.local_fetch)
279 .field("storage_root", &self.storage_root)
280 .field("additional_image_stores", &self.additional_image_stores)
281 .field(
282 "progress",
283 if self.progress.is_some() {
284 &"Some(<ProgressReporter>)"
285 } else {
286 &"None"
287 },
288 )
289 .finish()
290 }
291}
292
293#[derive(Debug)]
295pub struct PullResult<ObjectID> {
296 pub manifest_digest: OciDigest,
298 pub manifest_verity: ObjectID,
300 pub config_digest: OciDigest,
302 pub config_verity: ObjectID,
304 pub stats: ImportStats,
306}
307
308pub type ContentAndVerity<ObjectID> = (OciDigest, ObjectID);
310
311pub struct OpenConfig<ObjectID> {
313 pub config: ImageConfiguration,
315 pub layer_refs: HashMap<Box<str>, ObjectID>,
317 pub image_ref: Option<ObjectID>,
319 pub image_ref_v1: Option<ObjectID>,
321 pub boot_image_ref: Option<ObjectID>,
323 pub boot_image_ref_v1: Option<ObjectID>,
325}
326
327impl<ObjectID: std::fmt::Debug> std::fmt::Debug for OpenConfig<ObjectID> {
328 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329 f.debug_struct("OpenConfig")
330 .field("layer_refs", &self.layer_refs)
331 .field("image_ref", &self.image_ref)
332 .field("image_ref_v1", &self.image_ref_v1)
333 .field("boot_image_ref", &self.boot_image_ref)
334 .field("boot_image_ref_v1", &self.boot_image_ref_v1)
335 .finish_non_exhaustive()
336 }
337}
338
339pub(crate) fn layer_identifier(diff_id: &OciDigest) -> String {
340 format!("oci-layer-{diff_id}")
341}
342
343pub(crate) fn config_identifier(config: &OciDigest) -> String {
344 format!("oci-config-{config}")
345}
346
347pub async fn import_layer<ObjectID: FsVerityHashValue>(
354 repo: &Arc<Repository<ObjectID>>,
355 diff_id: &OciDigest,
356 name: Option<&str>,
357 tar_stream: impl tokio::io::AsyncRead + Unpin,
358) -> Result<(ObjectID, ImportStats)> {
359 let content_identifier = layer_identifier(diff_id);
360
361 if let Some(id) = repo.has_stream(&content_identifier)? {
363 if let Some(name) = name {
364 repo.name_stream(&content_identifier, name)?;
365 }
366 return Ok((id, ImportStats::default()));
367 }
368
369 let (object_id, stats) =
370 tar::split_async(tar_stream, repo.clone(), TAR_LAYER_CONTENT_TYPE).await?;
371
372 repo.register_stream(&object_id, &content_identifier, name)
374 .await?;
375
376 Ok((object_id, stats))
377}
378
379pub async fn pull<ObjectID: FsVerityHashValue>(
390 repo: &Arc<Repository<ObjectID>>,
391 imgref: &str,
392 reference: Option<&str>,
393 opts: PullOptions<'_>,
394) -> Result<PullResult<ObjectID>> {
395 let reporter: SharedReporter = opts
396 .progress
397 .unwrap_or_else(|| std::sync::Arc::new(NullReporter));
398
399 #[cfg(feature = "containers-storage")]
400 if opts.local_fetch != LocalFetchOpt::Disabled
401 && let Some(image_id) = cstor::parse_containers_storage_ref(imgref)
402 {
403 let zerocopy = opts.local_fetch == LocalFetchOpt::ZeroCopy;
404 let (((manifest_digest, manifest_verity), (config_digest, config_verity)), stats) =
405 cstor::import_from_containers_storage(
406 repo,
407 image_id,
408 reference,
409 zerocopy,
410 opts.storage_root,
411 opts.additional_image_stores,
412 reporter,
413 )
414 .await?;
415 return Ok(PullResult {
416 manifest_digest,
417 manifest_verity,
418 config_digest,
419 config_verity,
420 stats,
421 });
422 }
423
424 let (result, stats) =
425 skopeo::pull_image(repo, imgref, reference, opts.img_proxy_config, reporter).await?;
426 Ok(crate::PullResult {
427 manifest_digest: result.manifest_digest,
428 manifest_verity: result.manifest_verity,
429 config_digest: result.config_digest,
430 config_verity: result.config_verity,
431 stats,
432 })
433}
434
435pub(crate) fn sha256_output_to_digest(output: sha2::digest::Output<Sha256>) -> OciDigest {
437 let hex = hex::encode(output);
438 format!("sha256:{hex}")
439 .try_into()
440 .expect("sha256 hex should always produce a valid OCI digest")
441}
442
443pub(crate) fn sha256_content_digest(bytes: &[u8]) -> OciDigest {
446 let mut context = Sha256::new();
447 context.update(bytes);
448 sha256_output_to_digest(context.finalize())
449}
450
451fn hash_sha256(bytes: &[u8]) -> OciDigest {
452 sha256_content_digest(bytes)
453}
454
455pub(crate) fn extract_diff_ids(
465 media_type: &MediaType,
466 config_reader: impl Read,
467 manifest_layers: &[Descriptor],
468) -> Result<Vec<OciDigest>> {
469 if *media_type == MediaType::ImageConfig {
470 let config = ImageConfiguration::from_reader(config_reader)?;
471 config
472 .rootfs()
473 .diff_ids()
474 .iter()
475 .map(|s| s.parse().context("parsing diff_id from image config"))
476 .collect()
477 } else {
478 Ok(manifest_layers
479 .iter()
480 .map(|d: &Descriptor| d.digest().clone())
481 .collect())
482 }
483}
484
485pub fn open_config<ObjectID: FsVerityHashValue>(
502 repo: &Repository<ObjectID>,
503 config_digest: &OciDigest,
504 verity: Option<&ObjectID>,
505) -> Result<OpenConfig<ObjectID>> {
506 let (data, mut named_refs) = oci_image::read_external_splitstream(
507 repo,
508 &config_identifier(config_digest),
509 verity,
510 Some(OCI_CONFIG_CONTENT_TYPE),
511 )?;
512
513 if verity.is_none() {
514 let computed = hash_sha256(&data);
515 ensure!(
516 *config_digest == computed,
517 "Config integrity check failed: expected {config_digest}, got {computed}"
518 );
519 }
520
521 let image_ref = named_refs.remove(IMAGE_REF_KEY);
522 let image_ref_v1 = named_refs.remove(IMAGE_REF_KEY_V1);
523 let boot_image_ref = named_refs.remove(BOOT_IMAGE_REF_KEY);
524 let boot_image_ref_v1 = named_refs.remove(BOOT_IMAGE_REF_KEY_V1);
525 let config = ImageConfiguration::from_reader(&data[..])?;
526 Ok(OpenConfig {
527 config,
528 layer_refs: named_refs,
529 image_ref,
530 image_ref_v1,
531 boot_image_ref,
532 boot_image_ref_v1,
533 })
534}
535
536pub fn composefs_erofs_for_config<ObjectID: FsVerityHashValue>(
538 repo: &Repository<ObjectID>,
539 config_digest: &OciDigest,
540 verity: Option<&ObjectID>,
541 version: FormatVersion,
542) -> Result<Option<ObjectID>> {
543 let oc = open_config(repo, config_digest, verity)?;
544 Ok(match version.epoch() {
545 FormatEpoch::Epoch1 => oc.image_ref_v1,
546 FormatEpoch::Epoch2 => oc.image_ref,
547 })
548}
549
550pub fn composefs_erofs_for_manifest<ObjectID: FsVerityHashValue>(
555 repo: &Repository<ObjectID>,
556 manifest_digest: &OciDigest,
557 manifest_verity: Option<&ObjectID>,
558 version: FormatVersion,
559) -> Result<Option<ObjectID>> {
560 let img = oci_image::OciImage::open(repo, manifest_digest, manifest_verity)?;
561 Ok(img.image_ref(version).cloned())
562}
563
564pub fn composefs_boot_erofs_for_config<ObjectID: FsVerityHashValue>(
566 repo: &Repository<ObjectID>,
567 config_digest: &OciDigest,
568 verity: Option<&ObjectID>,
569 version: FormatVersion,
570) -> Result<Option<ObjectID>> {
571 let oc = open_config(repo, config_digest, verity)?;
572 Ok(match version.epoch() {
573 FormatEpoch::Epoch1 => oc.boot_image_ref_v1,
574 FormatEpoch::Epoch2 => oc.boot_image_ref,
575 })
576}
577
578pub fn composefs_boot_erofs_for_manifest<ObjectID: FsVerityHashValue>(
580 repo: &Repository<ObjectID>,
581 manifest_digest: &OciDigest,
582 manifest_verity: Option<&ObjectID>,
583 version: FormatVersion,
584) -> Result<Option<ObjectID>> {
585 let img = oci_image::OciImage::open(repo, manifest_digest, manifest_verity)?;
586 Ok(img.boot_image_ref(version).cloned())
587}
588
589#[derive(Debug, Clone, Default)]
591pub struct UpgradeResult {
592 pub already_current: u64,
594 pub upgraded: u64,
596 pub skipped_non_container: u64,
598}
599
600pub fn upgrade_repo<ObjectID: FsVerityHashValue>(
614 repo: &Arc<Repository<ObjectID>>,
615) -> Result<UpgradeResult> {
616 let mut result = UpgradeResult::default();
617
618 for (tag, manifest_digest) in oci_image::list_refs(repo)? {
619 let img = oci_image::OciImage::open(repo, &manifest_digest, None)
620 .with_context(|| format!("opening image {tag}"))?;
621
622 if !img.is_container_image() {
623 tracing::debug!("skipping non-container image {tag}");
624 result.skipped_non_container += 1;
625 continue;
626 }
627
628 if img.image_ref(repo.erofs_version()).is_some() {
629 tracing::debug!("image {tag} already has EROFS ref, skipping");
630 result.already_current += 1;
631 continue;
632 }
633
634 let erofs_id = ensure_oci_composefs_erofs(
635 repo,
636 &manifest_digest,
637 Some(img.manifest_verity()),
638 Some(&tag),
639 )
640 .with_context(|| format!("generating EROFS for image {tag}"))?;
641
642 if erofs_id.is_some() {
643 tracing::info!("upgraded image {tag}");
644 result.upgraded += 1;
645 } else {
646 tracing::debug!("image {tag} produced no EROFS (not a container image?)");
647 result.skipped_non_container += 1;
648 }
649 }
650
651 Ok(result)
652}
653
654pub fn write_config<ObjectID: FsVerityHashValue>(
670 repo: &Arc<Repository<ObjectID>>,
671 config: &ImageConfiguration,
672 refs: HashMap<Box<str>, ObjectID>,
673 image: Option<&ObjectID>,
674 image_v1: Option<&ObjectID>,
675 boot_image: Option<&ObjectID>,
676 boot_image_v1: Option<&ObjectID>,
677) -> Result<ContentAndVerity<ObjectID>> {
678 let json = config.to_string()?;
679 write_config_raw(
680 repo,
681 json.as_bytes(),
682 refs,
683 image,
684 image_v1,
685 boot_image,
686 boot_image_v1,
687 )
688}
689
690pub fn write_config_raw<ObjectID: FsVerityHashValue>(
697 repo: &Arc<Repository<ObjectID>>,
698 config_json: &[u8],
699 refs: HashMap<Box<str>, ObjectID>,
700 image: Option<&ObjectID>,
701 image_v1: Option<&ObjectID>,
702 boot_image: Option<&ObjectID>,
703 boot_image_v1: Option<&ObjectID>,
704) -> Result<ContentAndVerity<ObjectID>> {
705 let config_digest = hash_sha256(config_json);
706 let mut stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE)?;
707 let config = ImageConfiguration::from_reader(config_json)?;
710 for diff_id_str in config.rootfs().diff_ids() {
711 let value = refs.get(diff_id_str.as_str()).with_context(|| {
712 let keys: Vec<_> = refs.keys().collect();
713 format!(
714 "missing layer verity for diff_id {diff_id_str}. Available keys in refs: {keys:?}"
715 )
716 })?;
717 stream.add_named_stream_ref(diff_id_str, value);
718 }
719 if let Some(image_id) = image {
720 stream.add_named_stream_ref(IMAGE_REF_KEY, image_id);
721 }
722 if let Some(image_id_v1) = image_v1 {
723 stream.add_named_stream_ref(IMAGE_REF_KEY_V1, image_id_v1);
724 }
725 if let Some(boot_id) = boot_image {
726 stream.add_named_stream_ref(BOOT_IMAGE_REF_KEY, boot_id);
727 }
728 if let Some(boot_id_v1) = boot_image_v1 {
729 stream.add_named_stream_ref(BOOT_IMAGE_REF_KEY_V1, boot_id_v1);
730 }
731 stream.write_external(config_json)?;
732 let id = repo.write_stream(stream, &config_identifier(&config_digest), None)?;
733 Ok((config_digest, id))
734}
735
736fn ensure_oci_composefs_erofs<ObjectID: FsVerityHashValue>(
755 repo: &Arc<Repository<ObjectID>>,
756 manifest_digest: &OciDigest,
757 manifest_verity: Option<&ObjectID>,
758 tag: Option<&str>,
759) -> Result<Option<ObjectID>> {
760 let img = oci_image::OciImage::open(repo, manifest_digest, manifest_verity)?;
761 if !img.is_container_image() {
762 return Ok(None);
763 }
764
765 let fs = image::create_filesystem(repo, img.config_digest(), Some(img.config_verity()))?;
767
768 let mut erofs_map = fs.commit_images(repo, None)?;
771 let erofs_id_v2 = erofs_map.remove(&FormatVersion::V2);
772 let erofs_id_v1 = erofs_map.remove(&FormatVersion::V1);
773
774 let erofs_id = match repo.erofs_version().epoch() {
775 FormatEpoch::Epoch1 => erofs_id_v1.clone(),
776 FormatEpoch::Epoch2 => erofs_id_v2.clone(),
777 }
778 .ok_or_else(|| {
779 anyhow::anyhow!("commit_images did not produce the repository's default EROFS format")
780 })?;
781
782 let config_json = img.read_config_json(repo)?;
785
786 let (_config_digest, new_config_verity) = write_config_raw(
790 repo,
791 &config_json,
792 img.layer_refs().clone(),
793 erofs_id_v2.as_ref(),
794 erofs_id_v1.as_ref(),
795 img.boot_image_ref_v2(),
796 img.boot_image_ref_v1(),
797 )?;
798
799 let manifest_json = img.read_manifest_json(repo)?;
801
802 let layer_verities: Vec<_> = img
806 .layer_refs()
807 .iter()
808 .map(|(k, v)| (k.clone(), v.clone()))
809 .collect();
810
811 let (_new_manifest_digest, _new_manifest_verity) = oci_image::rewrite_manifest(
812 repo,
813 &manifest_json,
814 manifest_digest,
815 &new_config_verity,
816 &layer_verities,
817 tag,
818 )?;
819
820 Ok(Some(erofs_id))
821}
822
823#[cfg(feature = "boot")]
826fn ensure_oci_composefs_erofs_boot<ObjectID: FsVerityHashValue>(
827 repo: &Arc<Repository<ObjectID>>,
828 manifest_digest: &OciDigest,
829 manifest_verity: Option<&ObjectID>,
830 tag: Option<&str>,
831) -> Result<Option<ObjectID>> {
832 use composefs_boot::BootOps;
833
834 let img = oci_image::OciImage::open(repo, manifest_digest, manifest_verity)?;
835 if !img.is_container_image() {
836 return Ok(None);
837 }
838
839 let mut fs = image::create_filesystem(repo, img.config_digest(), Some(img.config_verity()))?;
841 fs.transform_for_boot(repo)?;
842
843 let mut boot_erofs_map = fs.commit_images(repo, None)?;
845 let boot_erofs_id_v2 = boot_erofs_map.remove(&FormatVersion::V2);
846 let boot_erofs_id_v1 = boot_erofs_map.remove(&FormatVersion::V1);
847
848 let boot_erofs_id = match repo.erofs_version().epoch() {
849 FormatEpoch::Epoch1 => boot_erofs_id_v1.clone(),
850 FormatEpoch::Epoch2 => boot_erofs_id_v2.clone(),
851 }
852 .ok_or_else(|| {
853 anyhow::anyhow!("commit_images did not produce the repository's default boot EROFS format")
854 })?;
855
856 let config_json = img.read_config_json(repo)?;
858
859 let (_config_digest, new_config_verity) = write_config_raw(
862 repo,
863 &config_json,
864 img.layer_refs().clone(),
865 img.image_ref_v2(),
866 img.image_ref_v1(),
867 boot_erofs_id_v2.as_ref(),
868 boot_erofs_id_v1.as_ref(),
869 )?;
870
871 let manifest_json = img.read_manifest_json(repo)?;
873
874 let layer_verities: Vec<_> = img
875 .layer_refs()
876 .iter()
877 .map(|(k, v)| (k.clone(), v.clone()))
878 .collect();
879
880 let (_new_manifest_digest, _new_manifest_verity) = oci_image::rewrite_manifest(
881 repo,
882 &manifest_json,
883 manifest_digest,
884 &new_config_verity,
885 &layer_verities,
886 tag,
887 )?;
888
889 Ok(Some(boot_erofs_id))
890}
891
892#[cfg(test)]
893mod test {
894 use std::{fmt::Write, io::Read};
895
896 use rustix::fs::CWD;
897
898 use composefs::{
899 fsverity::Sha256HashValue,
900 repository::{Repository, RepositoryConfig},
901 test::tempdir,
902 };
903
904 use super::*;
905
906 const EXPECTED_BASE_IMAGE_DUMPFILE: &str = "\
910/ 0 40755 6 0 0 0 0.0 - - -
911/etc 0 40755 2 0 0 0 0.0 - - -
912/etc/hostname 9 100644 1 0 0 0 0.0 - test-host -
913/etc/os-release 23 100644 1 0 0 0 0.0 - ID=test\\nVERSION_ID=1.0\\n -
914/etc/passwd 100 100644 1 0 0 0 0.0 f2/c4fd5735bd46db3b18d402ae87c5086c97c0e1321901cfd30f320b73ef25aa - f2c4fd5735bd46db3b18d402ae87c5086c97c0e1321901cfd30f320b73ef25aa
915/tmp 0 40755 2 0 0 0 0.0 - - -
916/usr 0 40755 5 0 0 0 0.0 - - -
917/usr/bin 0 40755 2 0 0 0 0.0 - - -
918/usr/bin/busybox 4096 100755 1 0 0 0 0.0 f0/f7e1e58fdd31f5792222087377a4a976760c416ecdf5f426193e608681b7a1 - f0f7e1e58fdd31f5792222087377a4a976760c416ecdf5f426193e608681b7a1
919/usr/bin/cat 7 120777 1 0 0 0 0.0 busybox - -
920/usr/bin/cp 7 120777 1 0 0 0 0.0 busybox - -
921/usr/bin/ls 7 120777 1 0 0 0 0.0 busybox - -
922/usr/bin/mv 7 120777 1 0 0 0 0.0 busybox - -
923/usr/bin/ping 7 120777 1 0 0 0 0.0 busybox - - security.capability=\\x02\\x00\\x00\\x02\\x00\\x20\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00
924/usr/bin/rm 7 120777 1 0 0 0 0.0 busybox - -
925/usr/bin/sh 7 120777 1 0 0 0 0.0 busybox - -
926/usr/lib 0 40755 2 0 0 0 0.0 - - -
927/usr/share 0 40755 3 0 0 0 0.0 - - -
928/usr/share/doc 0 40755 2 0 0 0 0.0 - - -
929/usr/share/doc/README 512 100644 1 0 0 0 0.0 51/44b8f80be57c3518f410d930e18c4e405387c82e4993c18265a1ba4a80263b - 5144b8f80be57c3518f410d930e18c4e405387c82e4993c18265a1ba4a80263b
930/var 0 40755 3 0 0 0 0.0 - - -
931/var/data 0 40755 2 0 0 0 0.0 - - -
932/var/data/app.json 256 100644 1 0 0 0 0.0 c9/21965b74ac1780bc437cec640b27186d85317b9afdb3dbb68626aed5ecd2b6 - c921965b74ac1780bc437cec640b27186d85317b9afdb3dbb68626aed5ecd2b6
933";
934
935 fn create_test_repo() -> (tempfile::TempDir, Arc<Repository<Sha256HashValue>>) {
937 let dir = tempdir();
938 let repo_path = dir.path().join("repo");
939 let (repo, _) =
940 Repository::init_path(CWD, &repo_path, RepositoryConfig::default().set_insecure())
941 .expect("initializing test repo");
942 (dir, Arc::new(repo))
943 }
944
945 fn append_data(builder: &mut ::tar::Builder<Vec<u8>>, name: &str, size: usize) {
946 let mut header = ::tar::Header::new_ustar();
947 header.set_uid(0);
948 header.set_gid(0);
949 header.set_mode(0o700);
950 header.set_entry_type(::tar::EntryType::Regular);
951 header.set_size(size as u64);
952 builder
953 .append_data(&mut header, name, std::io::repeat(0u8).take(size as u64))
954 .unwrap();
955 }
956
957 fn example_layer() -> Vec<u8> {
958 let mut builder = ::tar::Builder::new(vec![]);
959 append_data(&mut builder, "file0", 0);
960 append_data(&mut builder, "file4095", 4095);
961 append_data(&mut builder, "file4096", 4096);
962 append_data(&mut builder, "file4097", 4097);
963 builder.into_inner().unwrap()
964 }
965
966 #[tokio::test]
967 async fn test_layer() {
968 let layer = example_layer();
969 let layer_id = hash_sha256(&layer);
970
971 let (_repo_dir, repo) = create_test_repo();
972 let (id, _stats) = import_layer(&repo, &layer_id, Some("name"), &layer[..])
973 .await
974 .unwrap();
975
976 let mut dump = String::new();
977 let mut split_stream = repo.open_stream("refs/name", Some(&id), None).unwrap();
978 while let Some(entry) = tar::get_entry(&mut split_stream).unwrap() {
979 writeln!(dump, "{entry}").unwrap();
980 }
981 similar_asserts::assert_eq!(dump, "\
982/file0 0 100700 1 0 0 0 0.0 - - -
983/file4095 4095 100700 1 0 0 0 0.0 53/72beb83c78537c8970c8361e3254119fafdf1763854ecd57d3f0fe2da7c719 - 5372beb83c78537c8970c8361e3254119fafdf1763854ecd57d3f0fe2da7c719
984/file4096 4096 100700 1 0 0 0 0.0 ba/bc284ee4ffe7f449377fbf6692715b43aec7bc39c094a95878904d34bac97e - babc284ee4ffe7f449377fbf6692715b43aec7bc39c094a95878904d34bac97e
985/file4097 4097 100700 1 0 0 0 0.0 09/3756e4ea9683329106d4a16982682ed182c14bf076463a9e7f97305cbac743 - 093756e4ea9683329106d4a16982682ed182c14bf076463a9e7f97305cbac743
986");
987 }
988
989 #[tokio::test]
990 async fn test_layer_import_stats() {
991 let layer = example_layer();
992 let layer_id = hash_sha256(&layer);
993
994 let (_repo_dir, repo) = create_test_repo();
995 let (_id, stats) = import_layer(&repo, &layer_id, Some("name"), &layer[..])
996 .await
997 .unwrap();
998
999 assert_eq!(
1003 stats.objects_copied, 3,
1004 "three files above inline threshold should be external objects"
1005 );
1006 assert_eq!(stats.objects_already_present, 0);
1007 assert!(
1008 stats.bytes_copied > 0,
1009 "bytes_copied should be nonzero for external objects"
1010 );
1011 assert!(
1012 stats.bytes_inlined > 0,
1013 "bytes_inlined should be nonzero (tar headers + small file)"
1014 );
1015 }
1016
1017 #[tokio::test]
1018 async fn test_layer_import_deduplication_stats() {
1019 let layer = example_layer();
1020 let layer_id = hash_sha256(&layer);
1021
1022 let (_repo_dir, repo) = create_test_repo();
1023
1024 let (_id, stats1) = import_layer(&repo, &layer_id, None, &layer[..])
1026 .await
1027 .unwrap();
1028 assert_eq!(stats1.objects_copied, 3);
1029 assert_eq!(stats1.objects_already_present, 0);
1030
1031 let (_id, stats2) = import_layer(&repo, &layer_id, None, &layer[..])
1034 .await
1035 .unwrap();
1036 assert_eq!(stats2.objects_copied, 0);
1037 assert_eq!(stats2.objects_already_present, 0);
1038 assert_eq!(stats2.bytes_copied, 0);
1039 }
1040
1041 #[test]
1042 fn test_write_and_open_config() {
1043 use containers_image_proxy::oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
1044
1045 let (_repo_dir, repo) = create_test_repo();
1046
1047 let rootfs = RootFsBuilder::default()
1048 .typ("layers")
1049 .diff_ids(vec!["sha256:abc123def456".to_string()])
1050 .build()
1051 .unwrap();
1052
1053 let config = ImageConfigurationBuilder::default()
1054 .architecture("amd64")
1055 .os("linux")
1056 .rootfs(rootfs)
1057 .build()
1058 .unwrap();
1059
1060 let mut refs = HashMap::new();
1061 refs.insert("sha256:abc123def456".into(), Sha256HashValue::EMPTY);
1062
1063 let (config_digest, config_verity) =
1064 write_config(&repo, &config, refs.clone(), None, None, None, None).unwrap();
1065
1066 assert!(config_digest.as_ref().starts_with("sha256:"));
1067
1068 let oc = open_config(&repo, &config_digest, Some(&config_verity)).unwrap();
1069 assert_eq!(oc.config.architecture().to_string(), "amd64");
1070 assert_eq!(oc.config.os().to_string(), "linux");
1071 assert_eq!(oc.layer_refs.len(), 1);
1072 assert!(oc.layer_refs.contains_key("sha256:abc123def456"));
1073 assert!(oc.image_ref.is_none());
1074 assert!(oc.boot_image_ref.is_none());
1075
1076 let oc2 = open_config(&repo, &config_digest, None).unwrap();
1077 assert_eq!(oc2.config.architecture().to_string(), "amd64");
1078 }
1079
1080 #[test]
1081 fn test_config_stored_as_external_object() {
1082 use containers_image_proxy::oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
1083
1084 let (_repo_dir, repo) = create_test_repo();
1085
1086 let rootfs = RootFsBuilder::default()
1087 .typ("layers")
1088 .diff_ids(vec![])
1089 .build()
1090 .unwrap();
1091
1092 let config = ImageConfigurationBuilder::default()
1093 .architecture("amd64")
1094 .os("linux")
1095 .rootfs(rootfs)
1096 .build()
1097 .unwrap();
1098
1099 let (config_digest, config_verity) =
1100 write_config(&repo, &config, HashMap::new(), None, None, None, None).unwrap();
1101
1102 let mut stream = repo
1108 .open_stream(
1109 &config_identifier(&config_digest),
1110 Some(&config_verity),
1111 Some(crate::skopeo::OCI_CONFIG_CONTENT_TYPE),
1112 )
1113 .unwrap();
1114
1115 let mut object_refs = Vec::new();
1116 stream
1117 .get_object_refs(|id| object_refs.push(id.clone()))
1118 .unwrap();
1119
1120 assert_eq!(
1122 object_refs.len(),
1123 1,
1124 "Config should be stored as one external object, got {} refs",
1125 object_refs.len()
1126 );
1127
1128 let json_bytes = config.to_string().unwrap();
1131 let expected_verity: Sha256HashValue =
1132 composefs::fsverity::compute_verity(json_bytes.as_bytes());
1133 assert_eq!(
1134 object_refs[0], expected_verity,
1135 "External object verity should match independently computed verity of config JSON"
1136 );
1137 }
1138
1139 #[tokio::test]
1140 async fn test_config_verity_deterministic() -> Result<()> {
1141 use containers_image_proxy::oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
1142
1143 let (_repo_dir, repo) = create_test_repo();
1144
1145 let mut layers = Vec::new();
1147 for (name, size) in [("alpha", 1000), ("beta", 2000), ("gamma", 3000)] {
1148 let mut builder = ::tar::Builder::new(vec![]);
1149 append_data(&mut builder, name, size);
1150 let layer = builder.into_inner().unwrap();
1151
1152 let diff_id = hash_sha256(&layer);
1153
1154 let (verity, _stats) = import_layer(&repo, &diff_id, None, &mut layer.as_slice())
1155 .await
1156 .unwrap();
1157 layers.push((diff_id.to_string(), verity));
1158 }
1159
1160 let diff_ids: Vec<String> = layers.iter().map(|(d, _)| d.clone()).collect();
1161 let config = ImageConfigurationBuilder::default()
1162 .architecture("amd64")
1163 .os("linux")
1164 .rootfs(
1165 RootFsBuilder::default()
1166 .typ("layers")
1167 .diff_ids(diff_ids.clone())
1168 .build()
1169 .unwrap(),
1170 )
1171 .build()
1172 .unwrap();
1173
1174 let refs1: HashMap<Box<str>, Sha256HashValue> = layers
1177 .iter()
1178 .map(|(d, v)| (d.as_str().into(), v.clone()))
1179 .collect();
1180 let refs2: HashMap<Box<str>, Sha256HashValue> = layers
1181 .iter()
1182 .rev()
1183 .map(|(d, v)| (d.as_str().into(), v.clone()))
1184 .collect();
1185
1186 let (_digest1, verity1) = write_config(&repo, &config, refs1, None, None, None, None)?;
1187 let (_digest2, verity2) = write_config(&repo, &config, refs2, None, None, None, None)?;
1188
1189 assert_eq!(
1191 verity1, verity2,
1192 "config verity must be deterministic across calls"
1193 );
1194
1195 assert_eq!(
1197 verity1.to_hex(),
1198 "4839518dea22749f8ff233e7f7baec65f23dd5336462f46ad6884769af84bf95",
1199 "config verity changed unexpectedly"
1200 );
1201
1202 Ok(())
1203 }
1204
1205 #[test]
1206 fn test_open_config_bad_hash() {
1207 use containers_image_proxy::oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
1208
1209 let (_repo_dir, repo) = create_test_repo();
1210
1211 let rootfs = RootFsBuilder::default()
1212 .typ("layers")
1213 .diff_ids(vec![])
1214 .build()
1215 .unwrap();
1216
1217 let config = ImageConfigurationBuilder::default()
1218 .architecture("amd64")
1219 .os("linux")
1220 .rootfs(rootfs)
1221 .build()
1222 .unwrap();
1223
1224 let (config_digest, _config_verity) =
1225 write_config(&repo, &config, HashMap::new(), None, None, None, None).unwrap();
1226
1227 let bad_digest: OciDigest =
1228 "sha256:0000000000000000000000000000000000000000000000000000000000000000"
1229 .parse()
1230 .unwrap();
1231 let result = open_config::<Sha256HashValue>(&repo, &bad_digest, None);
1232 assert!(result.is_err());
1233
1234 let result = open_config::<Sha256HashValue>(&repo, &config_digest, None);
1235 assert!(result.is_ok());
1236 }
1237
1238 #[test]
1239 fn test_config_with_image_ref() {
1240 use containers_image_proxy::oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
1241
1242 let (_repo_dir, repo) = create_test_repo();
1243
1244 let rootfs = RootFsBuilder::default()
1245 .typ("layers")
1246 .diff_ids(vec!["sha256:abc123def456".to_string()])
1247 .build()
1248 .unwrap();
1249
1250 let config = ImageConfigurationBuilder::default()
1251 .architecture("amd64")
1252 .os("linux")
1253 .rootfs(rootfs)
1254 .build()
1255 .unwrap();
1256
1257 let mut refs = HashMap::new();
1258 let layer_id = Sha256HashValue::EMPTY;
1259 refs.insert("sha256:abc123def456".into(), layer_id);
1260
1261 let fake_erofs_id: Sha256HashValue =
1263 composefs::fsverity::compute_verity(b"fake-erofs-image");
1264
1265 let (config_digest, config_verity) = write_config(
1266 &repo,
1267 &config,
1268 refs.clone(),
1269 Some(&fake_erofs_id),
1270 None,
1271 None,
1272 None,
1273 )
1274 .unwrap();
1275
1276 let oc = open_config(&repo, &config_digest, Some(&config_verity)).unwrap();
1278 assert_eq!(
1279 oc.layer_refs.len(),
1280 1,
1281 "layer refs should not include image ref"
1282 );
1283 assert!(oc.layer_refs.contains_key("sha256:abc123def456"));
1284 assert_eq!(
1285 oc.image_ref,
1286 Some(fake_erofs_id.clone()),
1287 "image ref should be returned"
1288 );
1289 assert!(
1290 oc.image_ref_v1.is_none(),
1291 "expected no V1 image ref for a V2-only config"
1292 );
1293
1294 let img_ref = composefs_erofs_for_config(
1296 &repo,
1297 &config_digest,
1298 Some(&config_verity),
1299 repo.erofs_version(),
1300 )
1301 .unwrap();
1302 assert_eq!(img_ref, Some(fake_erofs_id));
1303 }
1304
1305 #[test]
1306 fn test_config_without_image_ref() {
1307 use containers_image_proxy::oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
1308
1309 let (_repo_dir, repo) = create_test_repo();
1310
1311 let rootfs = RootFsBuilder::default()
1312 .typ("layers")
1313 .diff_ids(vec!["sha256:abc123def456".to_string()])
1314 .build()
1315 .unwrap();
1316
1317 let config = ImageConfigurationBuilder::default()
1318 .architecture("amd64")
1319 .os("linux")
1320 .rootfs(rootfs)
1321 .build()
1322 .unwrap();
1323
1324 let mut refs = HashMap::new();
1325 refs.insert("sha256:abc123def456".into(), Sha256HashValue::EMPTY);
1326
1327 let (config_digest, config_verity) =
1328 write_config(&repo, &config, refs.clone(), None, None, None, None).unwrap();
1329
1330 let oc = open_config(&repo, &config_digest, Some(&config_verity)).unwrap();
1331 assert_eq!(oc.layer_refs.len(), 1);
1332 assert!(oc.layer_refs.contains_key("sha256:abc123def456"));
1333 assert!(oc.image_ref.is_none(), "no image ref should be present");
1334
1335 let img_ref = composefs_erofs_for_config(
1336 &repo,
1337 &config_digest,
1338 Some(&config_verity),
1339 repo.erofs_version(),
1340 )
1341 .unwrap();
1342 assert!(img_ref.is_none());
1343 }
1344
1345 #[tokio::test]
1346 async fn test_ensure_oci_composefs_erofs() {
1347 use composefs::test::TestRepo;
1348
1349 let test_repo = TestRepo::<Sha256HashValue>::new();
1350 let repo = &test_repo.repo;
1351
1352 let img = test_util::create_base_image(repo, Some("test:v1")).await;
1353
1354 let erofs_id = ensure_oci_composefs_erofs(
1356 repo,
1357 &img.manifest_digest,
1358 Some(&img.manifest_verity),
1359 Some("test:v1"),
1360 )
1361 .unwrap()
1362 .expect("container image should produce EROFS");
1363
1364 assert!(
1366 repo.open_image(&erofs_id.to_hex()).is_ok(),
1367 "EROFS image should be accessible"
1368 );
1369
1370 let oci = oci_image::OciImage::open_ref(repo, "test:v1").unwrap();
1372 assert_ne!(
1373 oci.manifest_verity(),
1374 &img.manifest_verity,
1375 "manifest should have been rewritten with new config verity"
1376 );
1377 assert_eq!(
1378 oci.image_ref(repo.erofs_version()),
1379 Some(&erofs_id),
1380 "config should reference the EROFS image"
1381 );
1382 let erofs_ref = composefs_erofs_for_config(
1384 repo,
1385 oci.config_digest(),
1386 Some(oci.config_verity()),
1387 repo.erofs_version(),
1388 )
1389 .unwrap();
1390 assert_eq!(erofs_ref, Some(erofs_id.clone()));
1391
1392 let erofs_ref2 = composefs_erofs_for_manifest(
1393 repo,
1394 &img.manifest_digest,
1395 Some(oci.manifest_verity()),
1396 repo.erofs_version(),
1397 )
1398 .unwrap();
1399 assert_eq!(erofs_ref2, Some(erofs_id.clone()));
1400
1401 let erofs_data = repo.read_object(&erofs_id).unwrap();
1403 let fs =
1404 composefs::erofs::reader::erofs_to_filesystem::<Sha256HashValue>(&erofs_data).unwrap();
1405 let mut dump = Vec::new();
1406 composefs::dumpfile::write_dumpfile(&mut dump, &fs).unwrap();
1407 let dump = String::from_utf8(dump).unwrap();
1408 similar_asserts::assert_eq!(dump, EXPECTED_BASE_IMAGE_DUMPFILE);
1409 }
1410
1411 #[tokio::test]
1414 async fn test_dual_format_both_image_refs() {
1415 use composefs::erofs::format::{FormatConfig, FormatVersion};
1416
1417 let dir = tempdir();
1419 let repo_path = dir.path().join("repo");
1420 let mut both_config = RepositoryConfig::default().set_insecure();
1421 both_config.erofs_formats = FormatConfig {
1422 default: FormatVersion::V1,
1423 extra: [FormatVersion::V2].into(),
1424 };
1425 let (repo_inner, _) = Repository::init_path(CWD, &repo_path, both_config)
1426 .expect("initializing dual-format test repo");
1427 let repo = std::sync::Arc::new(repo_inner);
1428
1429 assert_eq!(
1430 repo.default_format_config(),
1431 FormatConfig {
1432 default: FormatVersion::V1,
1433 extra: [FormatVersion::V2].into(),
1434 }
1435 );
1436
1437 let img = test_util::create_base_image(&repo, Some("dual:v1")).await;
1439 let primary_id = ensure_oci_composefs_erofs(
1440 &repo,
1441 &img.manifest_digest,
1442 Some(&img.manifest_verity),
1443 Some("dual:v1"),
1444 )
1445 .unwrap()
1446 .expect("container image should produce EROFS");
1447
1448 let oci = oci_image::OciImage::open_ref(&repo, "dual:v1").unwrap();
1450 let oc = open_config(&repo, oci.config_digest(), Some(oci.config_verity())).unwrap();
1451
1452 let id_v1 = oc
1454 .image_ref_v1
1455 .as_ref()
1456 .expect("V1 image ref should be set for dual-format repo");
1457 let id_v2 = oc
1458 .image_ref
1459 .as_ref()
1460 .expect("V2 image ref should be set for dual-format repo");
1461
1462 assert_ne!(
1464 id_v1, id_v2,
1465 "V1 and V2 EROFS images must have different digests"
1466 );
1467
1468 assert_eq!(&primary_id, id_v1, "primary ID should be the V1 digest");
1470
1471 let via_fn = composefs_erofs_for_config(
1473 &repo,
1474 oci.config_digest(),
1475 Some(oci.config_verity()),
1476 repo.erofs_version(),
1477 )
1478 .unwrap();
1479 assert_eq!(
1480 via_fn.as_ref(),
1481 Some(id_v1),
1482 "composefs_erofs_for_config should return repo default (V1)"
1483 );
1484
1485 assert_eq!(oci.image_ref(repo.erofs_version()), Some(id_v1));
1487 assert_eq!(oci.image_ref_v2(), Some(id_v2));
1488
1489 assert!(
1491 repo.open_image(&id_v1.to_hex()).is_ok(),
1492 "V1 EROFS image should exist in repo"
1493 );
1494 assert!(
1495 repo.open_image(&id_v2.to_hex()).is_ok(),
1496 "V2 EROFS image should exist in repo"
1497 );
1498
1499 let fs = image::create_filesystem(&repo, oci.config_digest(), Some(oci.config_verity()))
1501 .unwrap();
1502 let map = fs
1503 .commit_images(&repo, None)
1504 .expect("commit_images with dual-format config should succeed");
1505 assert!(map.contains_key(&FormatVersion::V1), "map must contain V1");
1506 assert!(map.contains_key(&FormatVersion::V2), "map must contain V2");
1507 assert_eq!(map[&FormatVersion::V1], *id_v1);
1508 assert_eq!(map[&FormatVersion::V2], *id_v2);
1509 }
1510
1511 #[tokio::test]
1512 async fn test_ensure_oci_composefs_erofs_gc() {
1513 use composefs::test::TestRepo;
1514
1515 let test_repo = TestRepo::<Sha256HashValue>::new();
1516 let repo = &test_repo.repo;
1517
1518 let img = test_util::create_base_image(repo, Some("gctest:v1")).await;
1519
1520 let dry = repo.gc_dry_run(&[]).unwrap();
1522 assert_eq!(dry.objects_removed, 0);
1523 assert_eq!(dry.streams_pruned, 0);
1524 assert_eq!(dry.images_pruned, 0);
1525
1526 let erofs_id = ensure_oci_composefs_erofs(
1527 repo,
1528 &img.manifest_digest,
1529 Some(&img.manifest_verity),
1530 Some("gctest:v1"),
1531 )
1532 .unwrap()
1533 .expect("container image should produce EROFS");
1534
1535 let gc1 = repo.gc(&[]).unwrap();
1538 assert_eq!(
1539 gc1.objects_removed, 2,
1540 "old config+manifest splitstream objects"
1541 );
1542 assert_eq!(gc1.streams_pruned, 0);
1543 assert_eq!(gc1.images_pruned, 0);
1544
1545 let dry = repo.gc_dry_run(&[]).unwrap();
1547 assert_eq!(dry.objects_removed, 0);
1548 assert!(
1549 repo.open_image(&erofs_id.to_hex()).is_ok(),
1550 "EROFS image should survive GC while tagged"
1551 );
1552
1553 oci_image::untag_image(repo, "gctest:v1").unwrap();
1555 let gc2 = repo.gc(&[]).unwrap();
1556 assert_eq!(gc2.objects_removed, 14, "all objects collected after untag");
1560 assert_eq!(gc2.streams_pruned, 7, "all stream symlinks pruned");
1562 assert_eq!(gc2.images_pruned, 1, "EROFS image symlink pruned");
1564
1565 assert!(
1566 repo.open_image(&erofs_id.to_hex()).is_err(),
1567 "EROFS image should be collected after untag + GC"
1568 );
1569
1570 let dry = repo.gc_dry_run(&[]).unwrap();
1572 assert_eq!(dry.objects_removed, 0);
1573 assert_eq!(dry.streams_pruned, 0);
1574 assert_eq!(dry.images_pruned, 0);
1575 }
1576
1577 #[tokio::test]
1586 async fn test_config_rewrite_preserves_noncanonical_json() {
1587 use composefs::test::TestRepo;
1588 use serde_json::ser::{PrettyFormatter, Serializer};
1589
1590 let test_repo = TestRepo::<Sha256HashValue>::new();
1591 let repo = &test_repo.repo;
1592
1593 let _img = test_util::create_base_image(repo, Some("nc:v1")).await;
1595
1596 let oci_before = oci_image::OciImage::open_ref(repo, "nc:v1").unwrap();
1598 let canonical_json = oci_before.read_config_json(repo).unwrap();
1599
1600 let value: serde_json::Value = serde_json::from_slice(&canonical_json).unwrap();
1604 let mut buf = Vec::new();
1605 let formatter = PrettyFormatter::with_indent(b"\t");
1606 let mut ser = Serializer::with_formatter(&mut buf, formatter);
1607 serde::Serialize::serialize(&value, &mut ser).unwrap();
1608 let noncanonical_json = buf;
1609
1610 assert_ne!(
1613 canonical_json.as_slice(),
1614 noncanonical_json.as_slice(),
1615 "pretty-printed JSON should differ from canonical"
1616 );
1617 let reparsed: serde_json::Value = serde_json::from_slice(&noncanonical_json).unwrap();
1618 assert_eq!(value, reparsed, "non-canonical JSON must parse identically");
1619
1620 let (_new_config_digest, new_config_verity) = write_config_raw(
1622 repo,
1623 &noncanonical_json,
1624 oci_before.layer_refs().clone(),
1625 None,
1626 None,
1627 None,
1628 None,
1629 )
1630 .unwrap();
1631 let new_config_digest = hash_sha256(&noncanonical_json);
1632
1633 use containers_image_proxy::oci_spec::image::{
1635 DescriptorBuilder, ImageManifestBuilder, MediaType,
1636 };
1637
1638 let old_manifest = oci_before.manifest();
1639 let config_descriptor = DescriptorBuilder::default()
1640 .media_type(MediaType::ImageConfig)
1641 .digest(new_config_digest.clone())
1642 .size(noncanonical_json.len() as u64)
1643 .build()
1644 .unwrap();
1645 let new_manifest = ImageManifestBuilder::default()
1646 .schema_version(2u32)
1647 .media_type(MediaType::ImageManifest)
1648 .config(config_descriptor)
1649 .layers(old_manifest.layers().clone())
1650 .build()
1651 .unwrap();
1652
1653 let new_manifest_json = new_manifest.to_string().unwrap();
1654 let new_manifest_digest = hash_sha256(new_manifest_json.as_bytes());
1655
1656 oci_image::untag_image(repo, "nc:v1").unwrap();
1657 let layer_verities: Vec<_> = oci_before
1658 .layer_refs()
1659 .iter()
1660 .map(|(k, v)| (k.clone(), v.clone()))
1661 .collect();
1662 let (_md, new_manifest_verity) = oci_image::write_manifest(
1663 repo,
1664 &new_manifest,
1665 &new_manifest_digest,
1666 &new_config_verity,
1667 &layer_verities,
1668 Some("nc:v1"),
1669 )
1670 .unwrap();
1671
1672 let erofs_id = ensure_oci_composefs_erofs(
1675 repo,
1676 &new_manifest_digest,
1677 Some(&new_manifest_verity),
1678 Some("nc:v1"),
1679 )
1680 .unwrap()
1681 .expect("should produce EROFS");
1682
1683 let oci_after = oci_image::OciImage::open_ref(repo, "nc:v1").unwrap();
1684 assert_eq!(
1685 oci_after.config_digest(),
1686 &new_config_digest,
1687 "config digest must be preserved after EROFS rewrite"
1688 );
1689 assert_eq!(oci_after.image_ref(repo.erofs_version()), Some(&erofs_id));
1690
1691 let stored_json = oci_after.read_config_json(repo).unwrap();
1692 assert_eq!(
1693 stored_json, noncanonical_json,
1694 "raw config JSON bytes must survive round-trip through EROFS rewrite"
1695 );
1696 }
1697
1698 #[test]
1699 fn test_import_stats_display() {
1700 let stats = ImportStats {
1702 objects_copied: 42,
1703 objects_already_present: 100,
1704 bytes_copied: 1_500_000,
1705 bytes_inlined: 800,
1706 ..Default::default()
1707 };
1708 assert_eq!(
1709 stats.to_string(),
1710 "42 new + 100 already present objects; 1.43 MiB stored, 800 B inlined"
1711 );
1712 assert_eq!(stats.total_objects(), 142);
1713 assert_eq!(stats.new_objects(), 42);
1714 assert_eq!(stats.new_bytes(), 1_500_000);
1715
1716 let reflink_stats = ImportStats {
1718 objects_reflinked: 30,
1719 objects_copied: 12,
1720 objects_already_present: 100,
1721 bytes_reflinked: 1_000_000,
1722 bytes_copied: 500_000,
1723 bytes_inlined: 800,
1724 ..Default::default()
1725 };
1726 assert_eq!(
1727 reflink_stats.to_string(),
1728 "30 reflinked + 12 copied + 100 already present objects; 976.56 KiB reflinked, 488.28 KiB copied, 800 B inlined"
1729 );
1730 assert_eq!(reflink_stats.total_objects(), 142);
1731 assert_eq!(reflink_stats.new_objects(), 42);
1732 assert_eq!(reflink_stats.new_bytes(), 1_500_000);
1733
1734 let hardlink_stats = ImportStats {
1736 objects_hardlinked: 20,
1737 objects_copied: 5,
1738 objects_already_present: 50,
1739 bytes_hardlinked: 800_000,
1740 bytes_copied: 200_000,
1741 bytes_inlined: 400,
1742 ..Default::default()
1743 };
1744 assert_eq!(
1745 hardlink_stats.to_string(),
1746 "20 hardlinked + 5 copied + 50 already present objects; 781.25 KiB hardlinked, 195.31 KiB copied, 400 B inlined"
1747 );
1748 assert_eq!(hardlink_stats.total_objects(), 75);
1749 assert_eq!(hardlink_stats.new_objects(), 25);
1750 assert_eq!(hardlink_stats.new_bytes(), 1_000_000);
1751
1752 let mixed_stats = ImportStats {
1754 objects_reflinked: 10,
1755 objects_hardlinked: 15,
1756 objects_copied: 5,
1757 objects_already_present: 70,
1758 bytes_reflinked: 500_000,
1759 bytes_hardlinked: 750_000,
1760 bytes_copied: 250_000,
1761 bytes_inlined: 600,
1762 ..Default::default()
1763 };
1764 assert_eq!(
1765 mixed_stats.to_string(),
1766 "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"
1767 );
1768 assert_eq!(mixed_stats.total_objects(), 100);
1769 assert_eq!(mixed_stats.new_objects(), 30);
1770 assert_eq!(mixed_stats.new_bytes(), 1_500_000);
1771
1772 let empty = ImportStats::default();
1773 assert_eq!(
1774 empty.to_string(),
1775 "0 new + 0 already present objects; 0 B stored, 0 B inlined"
1776 );
1777 assert_eq!(empty.total_objects(), 0);
1778 }
1779
1780 #[tokio::test]
1787 async fn test_whiteout_multi_layer_import() {
1788 use composefs::test::TestRepo;
1789 use containers_image_proxy::oci_spec::image::{
1790 ConfigBuilder, DescriptorBuilder, ImageConfigurationBuilder, ImageManifestBuilder,
1791 MediaType, RootFsBuilder,
1792 };
1793
1794 fn tar_dir(builder: &mut ::tar::Builder<Vec<u8>>, name: &str) {
1797 let mut header = ::tar::Header::new_ustar();
1798 header.set_uid(0);
1799 header.set_gid(0);
1800 header.set_mode(0o755);
1801 header.set_entry_type(::tar::EntryType::Directory);
1802 header.set_size(0);
1803 builder
1804 .append_data(&mut header, name, std::io::empty())
1805 .unwrap();
1806 }
1807
1808 fn tar_file(builder: &mut ::tar::Builder<Vec<u8>>, name: &str, content: &[u8]) {
1809 let mut header = ::tar::Header::new_ustar();
1810 header.set_uid(0);
1811 header.set_gid(0);
1812 header.set_mode(0o644);
1813 header.set_entry_type(::tar::EntryType::Regular);
1814 header.set_size(content.len() as u64);
1815 builder.append_data(&mut header, name, content).unwrap();
1816 }
1817
1818 fn tar_whiteout(builder: &mut ::tar::Builder<Vec<u8>>, name: &str) {
1820 tar_file(builder, name, &[]);
1821 }
1822
1823 let layer1 = {
1827 let mut b = ::tar::Builder::new(vec![]);
1828 tar_dir(&mut b, "etc");
1829 tar_file(&mut b, "etc/config.toml", b"[server]\nport = 8080\n");
1830 tar_file(&mut b, "etc/hosts", b"127.0.0.1 localhost\n");
1831 tar_dir(&mut b, "usr");
1832 tar_dir(&mut b, "usr/bin");
1833 tar_file(&mut b, "usr/bin/app", b"#!/bin/sh\necho hello\n");
1834 tar_dir(&mut b, "usr/lib");
1835 tar_file(&mut b, "usr/lib/old-lib.so", b"fake-old-lib-content");
1836 tar_file(&mut b, "usr/lib/shared.so", b"fake-shared-lib-content");
1837 tar_dir(&mut b, "tmp");
1838 tar_dir(&mut b, "tmp/cache");
1839 tar_file(&mut b, "tmp/cache/data.bin", b"cached-data-payload");
1840 tar_file(&mut b, "tmp/cache/index.db", b"cached-index-payload");
1841 b.into_inner().unwrap()
1842 };
1843
1844 let layer2 = {
1851 let mut b = ::tar::Builder::new(vec![]);
1852 tar_dir(&mut b, "etc");
1853 tar_whiteout(&mut b, "etc/.wh.hosts");
1854 tar_file(&mut b, "etc/hosts.new", b"127.0.0.1 localhost.new\n");
1855 tar_dir(&mut b, "usr");
1856 tar_dir(&mut b, "usr/lib");
1857 tar_whiteout(&mut b, "usr/lib/.wh.old-lib.so");
1858 tar_dir(&mut b, "tmp");
1859 tar_dir(&mut b, "tmp/cache");
1860 tar_whiteout(&mut b, "tmp/cache/.wh..wh..opq");
1861 tar_file(&mut b, "tmp/cache/fresh.bin", b"fresh-cache-content");
1862 b.into_inner().unwrap()
1863 };
1864
1865 let layer3 = {
1869 let mut b = ::tar::Builder::new(vec![]);
1870 tar_dir(&mut b, "usr");
1871 tar_dir(&mut b, "usr/bin");
1872 tar_whiteout(&mut b, "usr/bin/.wh.app");
1873 tar_file(&mut b, "usr/bin/app-v2", b"#!/bin/sh\necho hello v2\n");
1874 b.into_inner().unwrap()
1875 };
1876
1877 let test_repo = TestRepo::<Sha256HashValue>::new();
1880 let repo = &test_repo.repo;
1881
1882 let layers_data = [&layer1[..], &layer2[..], &layer3[..]];
1883 let mut layer_digests = Vec::new();
1884 let mut layer_verities_map: HashMap<Box<str>, composefs::fsverity::Sha256HashValue> =
1885 HashMap::new();
1886 let mut layer_descriptors = Vec::new();
1887
1888 for tar_data in &layers_data {
1889 let digest = hash_sha256(tar_data);
1890 let (verity, _stats) = import_layer(repo, &digest, None, *tar_data).await.unwrap();
1891
1892 let descriptor = DescriptorBuilder::default()
1893 .media_type(MediaType::ImageLayerGzip)
1894 .digest(digest.clone())
1895 .size(tar_data.len() as u64)
1896 .build()
1897 .unwrap();
1898
1899 layer_verities_map.insert(digest.to_string().into_boxed_str(), verity);
1900 layer_digests.push(digest.to_string());
1901 layer_descriptors.push(descriptor);
1902 }
1903
1904 let rootfs = RootFsBuilder::default()
1906 .typ("layers")
1907 .diff_ids(layer_digests.clone())
1908 .build()
1909 .unwrap();
1910
1911 let cfg = ConfigBuilder::default().build().unwrap();
1912
1913 let config = ImageConfigurationBuilder::default()
1914 .architecture("amd64")
1915 .os("linux")
1916 .rootfs(rootfs)
1917 .config(cfg)
1918 .build()
1919 .unwrap();
1920
1921 let config_json = config.to_string().unwrap();
1922 let config_digest = hash_sha256(config_json.as_bytes());
1923
1924 let mut config_stream = repo.create_stream(skopeo::OCI_CONFIG_CONTENT_TYPE).unwrap();
1925 for (digest, verity) in &layer_verities_map {
1926 config_stream.add_named_stream_ref(digest, verity);
1927 }
1928 config_stream
1929 .write_external(config_json.as_bytes())
1930 .unwrap();
1931 let config_verity = repo
1932 .write_stream(config_stream, &config_identifier(&config_digest), None)
1933 .unwrap();
1934
1935 let config_descriptor = DescriptorBuilder::default()
1937 .media_type(MediaType::ImageConfig)
1938 .digest(config_digest.clone())
1939 .size(config_json.len() as u64)
1940 .build()
1941 .unwrap();
1942
1943 let manifest = ImageManifestBuilder::default()
1944 .schema_version(2u32)
1945 .media_type(MediaType::ImageManifest)
1946 .config(config_descriptor)
1947 .layers(layer_descriptors)
1948 .build()
1949 .unwrap();
1950
1951 let manifest_json = manifest.to_string().unwrap();
1952 let manifest_digest = hash_sha256(manifest_json.as_bytes());
1953
1954 let layer_verities_vec: Vec<_> = layer_verities_map
1955 .iter()
1956 .map(|(k, v)| (k.clone(), v.clone()))
1957 .collect();
1958 let (_stored_digest, manifest_verity) = oci_image::write_manifest(
1959 repo,
1960 &manifest,
1961 &manifest_digest,
1962 &config_verity,
1963 &layer_verities_vec,
1964 Some("whiteout-test:v1"),
1965 )
1966 .unwrap();
1967
1968 let erofs_id = ensure_oci_composefs_erofs(
1971 repo,
1972 &manifest_digest,
1973 Some(&manifest_verity),
1974 Some("whiteout-test:v1"),
1975 )
1976 .unwrap()
1977 .expect("container image should produce EROFS");
1978
1979 let erofs_data = repo.read_object(&erofs_id).unwrap();
1982 let fs =
1983 composefs::erofs::reader::erofs_to_filesystem::<Sha256HashValue>(&erofs_data).unwrap();
1984 let mut dump = Vec::new();
1985 composefs::dumpfile::write_dumpfile(&mut dump, &fs).unwrap();
1986 let dump = String::from_utf8(dump).unwrap();
1987
1988 let paths: Vec<&str> = dump.lines().map(|l| l.split_once(' ').unwrap().0).collect();
1990
1991 let expected_present = [
1993 "/",
1994 "/etc",
1995 "/etc/config.toml", "/etc/hosts.new", "/tmp",
1998 "/tmp/cache",
1999 "/tmp/cache/fresh.bin", "/usr",
2001 "/usr/bin",
2002 "/usr/bin/app-v2", "/usr/lib",
2004 "/usr/lib/shared.so", ];
2006
2007 let must_not_exist = [
2009 "/etc/hosts", "/usr/lib/old-lib.so", "/usr/bin/app", "/tmp/cache/data.bin", "/tmp/cache/index.db", ];
2015
2016 similar_asserts::assert_eq!(paths, expected_present);
2017
2018 for path in &must_not_exist {
2019 assert!(
2020 !paths.contains(path),
2021 "{path} should have been removed by whiteout but is still present"
2022 );
2023 }
2024 }
2025
2026 #[tokio::test]
2033 async fn test_old_format_splitstream_oci_roundtrip() {
2034 use composefs::test::TestRepo;
2035
2036 let test_repo = TestRepo::<Sha256HashValue>::new();
2037 let repo = &test_repo.repo;
2038
2039 repo.set_write_old_splitstream_format(true);
2043 let img = test_util::create_base_image(repo, Some("old:v1")).await;
2044
2045 let oci = oci_image::OciImage::open_ref(repo, "old:v1").unwrap();
2047 let oc = open_config(repo, oci.config_digest(), Some(oci.config_verity())).unwrap();
2048 assert_eq!(oc.config.architecture().to_string(), "amd64");
2049 assert!(
2050 oc.image_ref.is_none(),
2051 "pre-EROFS image should have no image ref"
2052 );
2053
2054 let fs =
2056 image::create_filesystem(repo, oci.config_digest(), Some(oci.config_verity())).unwrap();
2057 let mut fs_dump = Vec::new();
2058 composefs::dumpfile::write_dumpfile(&mut fs_dump, &fs).unwrap();
2059 assert!(
2060 !fs_dump.is_empty(),
2061 "filesystem should contain entries from old-format layers"
2062 );
2063
2064 let erofs_id = ensure_oci_composefs_erofs(
2067 repo,
2068 &img.manifest_digest,
2069 Some(&img.manifest_verity),
2070 Some("old:v1"),
2071 )
2072 .unwrap()
2073 .expect("container image should produce EROFS");
2074
2075 let oci_after = oci_image::OciImage::open_ref(repo, "old:v1").unwrap();
2078 assert_eq!(
2079 oci_after.image_ref(repo.erofs_version()),
2080 Some(&erofs_id),
2081 "old-format rewritten config should reference the EROFS image"
2082 );
2083
2084 let erofs_data = repo.read_object(&erofs_id).unwrap();
2085 let erofs_fs =
2086 composefs::erofs::reader::erofs_to_filesystem::<Sha256HashValue>(&erofs_data).unwrap();
2087 let mut dump = Vec::new();
2088 composefs::dumpfile::write_dumpfile(&mut dump, &erofs_fs).unwrap();
2089 let dump = String::from_utf8(dump).unwrap();
2090 similar_asserts::assert_eq!(dump, EXPECTED_BASE_IMAGE_DUMPFILE);
2091 }
2092
2093 #[tokio::test]
2098 async fn test_pre_erofs_pull_upgrade_with_old_format() {
2099 use composefs::test::TestRepo;
2100
2101 let test_repo = TestRepo::<Sha256HashValue>::new();
2102 let repo = &test_repo.repo;
2103
2104 repo.set_write_old_splitstream_format(true);
2106 let img = test_util::create_base_image(repo, Some("upgrade:v1")).await;
2109 repo.set_write_old_splitstream_format(false);
2110
2111 let oci_before = oci_image::OciImage::open_ref(repo, "upgrade:v1").unwrap();
2113 assert!(
2114 oci_before.image_ref(repo.erofs_version()).is_none(),
2115 "pre-EROFS pull should have no image ref"
2116 );
2117
2118 let erofs_id = ensure_oci_composefs_erofs(
2121 repo,
2122 &img.manifest_digest,
2123 Some(&img.manifest_verity),
2124 Some("upgrade:v1"),
2125 )
2126 .unwrap()
2127 .expect("container image should produce EROFS");
2128
2129 let oci_after = oci_image::OciImage::open_ref(repo, "upgrade:v1").unwrap();
2131 assert_eq!(
2132 oci_after.image_ref(repo.erofs_version()),
2133 Some(&erofs_id),
2134 "config should reference the EROFS image after upgrade"
2135 );
2136
2137 assert!(
2139 repo.open_image(&erofs_id.to_hex()).is_ok(),
2140 "EROFS image should be accessible"
2141 );
2142
2143 let erofs_data = repo.read_object(&erofs_id).unwrap();
2145 let erofs_fs =
2146 composefs::erofs::reader::erofs_to_filesystem::<Sha256HashValue>(&erofs_data).unwrap();
2147 let mut dump = Vec::new();
2148 composefs::dumpfile::write_dumpfile(&mut dump, &erofs_fs).unwrap();
2149 let dump = String::from_utf8(dump).unwrap();
2150 similar_asserts::assert_eq!(dump, EXPECTED_BASE_IMAGE_DUMPFILE);
2151
2152 let gc1 = repo.gc(&[]).unwrap();
2154 assert_eq!(
2155 gc1.objects_removed, 2,
2156 "old config+manifest splitstream objects"
2157 );
2158 assert_eq!(gc1.streams_pruned, 0);
2159 assert_eq!(gc1.images_pruned, 0);
2160
2161 oci_image::untag_image(repo, "upgrade:v1").unwrap();
2163 let gc2 = repo.gc(&[]).unwrap();
2164 assert_eq!(gc2.objects_removed, 14, "all objects collected after untag");
2165 assert_eq!(gc2.streams_pruned, 7, "all stream symlinks pruned");
2166 assert_eq!(gc2.images_pruned, 1, "EROFS image symlink pruned");
2167 }
2168
2169 #[tokio::test]
2172 async fn test_upgrade_repo() {
2173 use composefs::test::TestRepo;
2174
2175 let test_repo = TestRepo::<Sha256HashValue>::new();
2176 let repo = &test_repo.repo;
2177
2178 repo.set_write_old_splitstream_format(true);
2180 let _img1 = test_util::create_base_image(repo, Some("app:v1")).await;
2181 let _img2 = test_util::create_bootable_image(repo, Some("os:v1"), 1).await;
2182 repo.set_write_old_splitstream_format(false);
2183
2184 let oci1 = oci_image::OciImage::open_ref(repo, "app:v1").unwrap();
2186 assert!(
2187 oci1.image_ref(repo.erofs_version()).is_none(),
2188 "app:v1 should have no EROFS ref before upgrade"
2189 );
2190 let oci2 = oci_image::OciImage::open_ref(repo, "os:v1").unwrap();
2191 assert!(
2192 oci2.image_ref(repo.erofs_version()).is_none(),
2193 "os:v1 should have no EROFS ref before upgrade"
2194 );
2195
2196 let result = upgrade_repo(repo).unwrap();
2198 assert_eq!(result.upgraded, 2, "both images should be upgraded");
2199 assert_eq!(result.already_current, 0, "none should be already current");
2200 assert_eq!(result.skipped_non_container, 0);
2201
2202 let oci1_after = oci_image::OciImage::open_ref(repo, "app:v1").unwrap();
2204 let erofs1 = oci1_after
2205 .image_ref(repo.erofs_version())
2206 .expect("app:v1 should have EROFS ref after upgrade");
2207 assert!(
2208 repo.open_image(&erofs1.to_hex()).is_ok(),
2209 "app:v1 EROFS image should be accessible"
2210 );
2211 let oci2_after = oci_image::OciImage::open_ref(repo, "os:v1").unwrap();
2212 let erofs2 = oci2_after
2213 .image_ref(repo.erofs_version())
2214 .expect("os:v1 should have EROFS ref after upgrade");
2215 assert!(
2216 repo.open_image(&erofs2.to_hex()).is_ok(),
2217 "os:v1 EROFS image should be accessible"
2218 );
2219
2220 let result2 = upgrade_repo(repo).unwrap();
2222 assert_eq!(result2.upgraded, 0, "no images should need upgrading");
2223 assert_eq!(result2.already_current, 2, "both should be already current");
2224 assert_eq!(result2.skipped_non_container, 0);
2225
2226 let gc = repo.gc(&[]).unwrap();
2229 assert_eq!(
2230 gc.objects_removed, 4,
2231 "old config+manifest splitstream objects from 2 images"
2232 );
2233
2234 assert!(
2236 repo.open_image(&erofs1.to_hex()).is_ok(),
2237 "app:v1 EROFS image should survive GC"
2238 );
2239 assert!(
2240 repo.open_image(&erofs2.to_hex()).is_ok(),
2241 "os:v1 EROFS image should survive GC"
2242 );
2243
2244 let erofs_data = repo.read_object(erofs1).unwrap();
2246 let fs =
2247 composefs::erofs::reader::erofs_to_filesystem::<Sha256HashValue>(&erofs_data).unwrap();
2248 let mut dump = Vec::new();
2249 composefs::dumpfile::write_dumpfile(&mut dump, &fs).unwrap();
2250 let dump = String::from_utf8(dump).unwrap();
2251 assert!(
2253 dump.contains("/usr/bin/busybox"),
2254 "EROFS should contain busybox"
2255 );
2256 assert!(
2257 dump.contains("/etc/hostname"),
2258 "EROFS should contain hostname"
2259 );
2260 }
2261
2262 fn make_test_oci_layout(parent: &std::path::Path) -> std::path::PathBuf {
2272 use cap_std_ext::cap_std;
2273 use containers_image_proxy::oci_spec::image::{
2274 Arch, ConfigBuilder, ImageConfigurationBuilder, Os, PlatformBuilder, RootFsBuilder,
2275 };
2276 use ocidir::OciDir;
2277
2278 let oci_dir = parent.join("oci-layout");
2279 std::fs::create_dir_all(&oci_dir).unwrap();
2280 let dir =
2281 cap_std::fs::Dir::open_ambient_dir(&oci_dir, cap_std::ambient_authority()).unwrap();
2282 let ocidir = OciDir::ensure(dir).unwrap();
2283
2284 let mut manifest = ocidir.new_empty_manifest().unwrap().build().unwrap();
2285 let mut config = ImageConfigurationBuilder::default()
2286 .architecture(Arch::default())
2287 .os(Os::default())
2288 .rootfs(
2289 RootFsBuilder::default()
2290 .typ("layers")
2291 .diff_ids(Vec::<String>::new())
2292 .build()
2293 .unwrap(),
2294 )
2295 .config(ConfigBuilder::default().build().unwrap())
2296 .build()
2297 .unwrap();
2298
2299 let layer = ocidir
2301 .create_layer(None)
2302 .unwrap()
2303 .into_inner()
2304 .unwrap()
2305 .complete()
2306 .unwrap();
2307 ocidir.push_layer(&mut manifest, &mut config, layer, "layer", None);
2308
2309 let platform = PlatformBuilder::default()
2310 .architecture(Arch::default())
2311 .os(Os::default())
2312 .build()
2313 .unwrap();
2314 ocidir
2315 .insert_manifest_and_config(manifest, config, None, platform)
2316 .unwrap();
2317
2318 oci_dir
2319 }
2320
2321 #[tokio::test]
2329 async fn test_oci_layout_pull_emits_started_and_done() {
2330 use crate::oci_layout::import_oci_layout;
2331 use crate::progress::ProgressEvent;
2332 use crate::progress::test_support::RecordingReporter;
2333 use composefs::fsverity::Sha256HashValue;
2334 use composefs::test::TestRepo;
2335
2336 let layout_dir = tempfile::tempdir().unwrap();
2337 let layout_path = make_test_oci_layout(layout_dir.path());
2338
2339 let test_repo = TestRepo::<Sha256HashValue>::new();
2340 let repo = &test_repo.repo;
2341 let recorder = std::sync::Arc::new(RecordingReporter::new());
2342 let reporter: crate::progress::SharedReporter =
2343 std::sync::Arc::clone(&recorder) as crate::progress::SharedReporter;
2344
2345 import_oci_layout(repo, &layout_path, None, reporter)
2346 .await
2347 .expect("import_oci_layout should succeed");
2348
2349 let events = recorder.events();
2350
2351 let started_count = events
2353 .iter()
2354 .filter(|e| matches!(e, ProgressEvent::Started { .. }))
2355 .count();
2356 assert!(
2357 started_count >= 1,
2358 "expected at least one Started event, got {started_count} (total events: {})",
2359 events.len()
2360 );
2361
2362 let started_ids: std::collections::HashSet<String> = events
2364 .iter()
2365 .filter_map(|e| {
2366 if let ProgressEvent::Started { id, .. } = e {
2367 Some(id.as_str().to_owned())
2368 } else {
2369 None
2370 }
2371 })
2372 .collect();
2373 for started_id in &started_ids {
2374 let has_terminal = events.iter().any(|e| match e {
2375 ProgressEvent::Done { id, .. } | ProgressEvent::Skipped { id } => {
2376 id.as_str() == started_id
2377 }
2378 _ => false,
2379 });
2380 assert!(
2381 has_terminal,
2382 "Started for '{started_id}' has no matching Done or Skipped"
2383 );
2384 }
2385 }
2386
2387 #[tokio::test]
2390 async fn test_oci_layout_reimport_emits_skipped() {
2391 use crate::oci_layout::import_oci_layout;
2392 use crate::progress::test_support::RecordingReporter;
2393 use crate::progress::{NullReporter, ProgressEvent};
2394 use composefs::fsverity::Sha256HashValue;
2395 use composefs::test::TestRepo;
2396
2397 let layout_dir = tempfile::tempdir().unwrap();
2398 let layout_path = make_test_oci_layout(layout_dir.path());
2399
2400 let test_repo = TestRepo::<Sha256HashValue>::new();
2401 let repo = &test_repo.repo;
2402
2403 let null: crate::progress::SharedReporter = std::sync::Arc::new(NullReporter);
2405 import_oci_layout(repo, &layout_path, None, null)
2406 .await
2407 .expect("first import should succeed");
2408
2409 let recorder = std::sync::Arc::new(RecordingReporter::new());
2411 let reporter: crate::progress::SharedReporter =
2412 std::sync::Arc::clone(&recorder) as crate::progress::SharedReporter;
2413 import_oci_layout(repo, &layout_path, None, reporter)
2414 .await
2415 .expect("second import should succeed");
2416
2417 let events = recorder.events();
2418
2419 let done_count = events
2421 .iter()
2422 .filter(|e| matches!(e, ProgressEvent::Done { .. }))
2423 .count();
2424 let skipped_count = events
2425 .iter()
2426 .filter(|e| matches!(e, ProgressEvent::Skipped { .. }))
2427 .count();
2428 assert_eq!(
2429 done_count, 0,
2430 "no Done events expected on reimport (layers cached), got {done_count}"
2431 );
2432 assert!(
2433 skipped_count >= 1,
2434 "expected at least one Skipped on reimport, got {skipped_count}"
2435 );
2436 }
2437
2438 #[tokio::test]
2443 async fn test_import_oci_layout_with_null_reporter_does_not_panic() {
2444 use crate::oci_layout::import_oci_layout;
2445 use crate::progress::NullReporter;
2446 use composefs::fsverity::Sha256HashValue;
2447 use composefs::test::TestRepo;
2448
2449 let layout_dir = tempfile::tempdir().unwrap();
2450 let layout_path = make_test_oci_layout(layout_dir.path());
2451
2452 let test_repo = TestRepo::<Sha256HashValue>::new();
2453 let repo = &test_repo.repo;
2454
2455 let reporter: crate::progress::SharedReporter = std::sync::Arc::new(NullReporter);
2457 import_oci_layout(repo, &layout_path, None, reporter)
2458 .await
2459 .expect("import_oci_layout with NullReporter should not panic");
2460 }
2461}