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 layer_sync;
26pub mod layer_transport;
27pub mod oci_image;
28pub mod oci_layout;
29pub mod progress;
31pub mod skopeo;
32pub mod tar;
33#[cfg(feature = "varlink")]
37pub mod varlink_types;
38
39#[cfg(any(test, feature = "test"))]
41#[allow(missing_docs, missing_debug_implementations)]
42#[doc(hidden)]
43pub mod test_util;
44
45#[cfg(doc)]
46pub mod design;
47#[cfg(doc)]
48pub mod sealing_spec;
49
50pub use composefs;
52
53use std::io::Read;
54use std::{borrow::Cow, collections::HashMap, sync::Arc};
55
56use anyhow::{Context, Result, ensure};
57pub use containers_image_proxy::oci_spec::image::Digest as OciDigest;
61
62use composefs::digest::{Digest, Sha256};
63use containers_image_proxy::ImageProxyConfig;
64use containers_image_proxy::oci_spec::image::ImageConfiguration;
65use containers_image_proxy::oci_spec::image::{Descriptor, ImageManifest, MediaType};
66
67use composefs::{
68 erofs::format::{FormatEpoch, FormatVersion},
69 fsverity::FsVerityHashValue,
70 repository::{ObjectStoreMethod, Repository},
71 splitstream::SplitStreamStats,
72};
73
74use crate::skopeo::{OCI_BLOB_CONTENT_TYPE, OCI_CONFIG_CONTENT_TYPE, TAR_LAYER_CONTENT_TYPE};
75
76pub const LAYER_CONTENT_TYPE: u64 = TAR_LAYER_CONTENT_TYPE;
83
84pub const BLOB_CONTENT_TYPE: u64 = OCI_BLOB_CONTENT_TYPE;
90
91pub const IMAGE_REF_KEY: &str = "composefs.image";
93
94pub const IMAGE_REF_KEY_V1: &str = "composefs.image.v1";
96
97pub const BOOT_IMAGE_REF_KEY: &str = "composefs.image.boot";
100
101pub const BOOT_IMAGE_REF_KEY_V1: &str = "composefs.image.boot.v1";
104
105pub(crate) fn boot_image_ref_key(
115 version: FormatVersion,
116 mode: XattrFiltering,
117) -> Cow<'static, str> {
118 let base = match version.epoch() {
119 FormatEpoch::Epoch1 => BOOT_IMAGE_REF_KEY_V1,
120 FormatEpoch::Epoch2 => BOOT_IMAGE_REF_KEY,
121 };
122 match mode {
123 XattrFiltering::AllowlistOnly => Cow::Borrowed(base),
124 mode => Cow::Owned(format!("{base}.xattrs={mode}")),
125 }
126}
127
128pub(crate) fn take_boot_image_refs<ObjectID>(
133 refs: HashMap<Box<str>, ObjectID>,
134) -> (HashMap<Box<str>, ObjectID>, HashMap<Box<str>, ObjectID>) {
135 refs.into_iter()
136 .partition(|(k, _)| k.starts_with(BOOT_IMAGE_REF_KEY))
137}
138
139#[cfg(feature = "boot")]
141pub use boot::{
142 BootImageMatch, find_matching_boot_image, generate_boot_image, generate_boot_image_get_fs,
143};
144pub use boot::{boot_image, remove_boot_image};
145pub use composefs::generic_tree::{OciTransformOptions, XattrFiltering};
146pub use oci_image::{
147 ImageInfo, LayerInfo, OCI_REF_PREFIX, OciFsckError, OciFsckResult, OciImage, OciImageNotFound,
148 OciRefNotFound, SplitstreamInfo, add_referrer, layer_dumpfile, layer_info, layer_tar,
149 list_images, list_referrers, list_refs, oci_fsck, oci_fsck_image, remove_referrer,
150 remove_referrers_for_subject, resolve_ref, tag_image, untag_image,
151};
152pub use progress::{ComponentId, NullReporter, ProgressEvent, ProgressReporter, SharedReporter};
153pub use skopeo::pull_image;
154
155#[derive(Debug, Clone, Default)]
157pub struct ImportStats {
158 pub layers: u64,
160 pub layers_already_present: u64,
162 pub objects_copied: u64,
164 pub objects_reflinked: u64,
166 pub objects_hardlinked: u64,
168 pub objects_already_present: u64,
170 pub bytes_copied: u64,
172 pub bytes_reflinked: u64,
174 pub bytes_hardlinked: u64,
176 pub bytes_inlined: u64,
178}
179
180impl ImportStats {
181 pub fn new_objects(&self) -> u64 {
183 self.objects_copied + self.objects_reflinked + self.objects_hardlinked
184 }
185
186 pub fn total_objects(&self) -> u64 {
188 self.new_objects() + self.objects_already_present
189 }
190
191 pub fn new_bytes(&self) -> u64 {
193 self.bytes_copied + self.bytes_reflinked + self.bytes_hardlinked
194 }
195
196 pub fn merge(&mut self, other: &ImportStats) {
198 self.layers += other.layers;
199 self.layers_already_present += other.layers_already_present;
200 self.objects_copied += other.objects_copied;
201 self.objects_reflinked += other.objects_reflinked;
202 self.objects_hardlinked += other.objects_hardlinked;
203 self.objects_already_present += other.objects_already_present;
204 self.bytes_copied += other.bytes_copied;
205 self.bytes_reflinked += other.bytes_reflinked;
206 self.bytes_hardlinked += other.bytes_hardlinked;
207 self.bytes_inlined += other.bytes_inlined;
208 }
209
210 pub(crate) fn from_split_stream_stats(ss: &SplitStreamStats) -> Self {
212 let mut stats = ImportStats {
213 bytes_inlined: ss.inline_bytes,
214 ..Default::default()
215 };
216 for &(size, method) in &ss.external_objects {
217 match method {
218 ObjectStoreMethod::Copied => {
219 stats.objects_copied += 1;
220 stats.bytes_copied += size;
221 }
222 ObjectStoreMethod::Reflinked => {
223 stats.objects_reflinked += 1;
224 stats.bytes_reflinked += size;
225 }
226 ObjectStoreMethod::Hardlinked => {
227 stats.objects_hardlinked += 1;
228 stats.bytes_hardlinked += size;
229 }
230 ObjectStoreMethod::AlreadyPresent => {
231 stats.objects_already_present += 1;
232 }
233 }
234 }
235 stats
236 }
237}
238
239impl std::fmt::Display for ImportStats {
240 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241 let has_zerocopy = self.objects_reflinked > 0 || self.objects_hardlinked > 0;
242 if has_zerocopy {
243 let mut parts = Vec::new();
245 if self.objects_reflinked > 0 {
246 parts.push(format!("{} reflinked", self.objects_reflinked));
247 }
248 if self.objects_hardlinked > 0 {
249 parts.push(format!("{} hardlinked", self.objects_hardlinked));
250 }
251 parts.push(format!("{} copied", self.objects_copied));
252 parts.push(format!("{} already present", self.objects_already_present));
253 write!(f, "{} objects; ", parts.join(" + "))?;
254
255 let mut byte_parts = Vec::new();
256 if self.objects_reflinked > 0 {
257 byte_parts.push(format!(
258 "{} reflinked",
259 indicatif::HumanBytes(self.bytes_reflinked)
260 ));
261 }
262 if self.objects_hardlinked > 0 {
263 byte_parts.push(format!(
264 "{} hardlinked",
265 indicatif::HumanBytes(self.bytes_hardlinked)
266 ));
267 }
268 byte_parts.push(format!(
269 "{} copied",
270 indicatif::HumanBytes(self.bytes_copied)
271 ));
272 byte_parts.push(format!(
273 "{} inlined",
274 indicatif::HumanBytes(self.bytes_inlined)
275 ));
276 write!(f, "{}", byte_parts.join(", "))
277 } else {
278 write!(
279 f,
280 "{} new + {} already present objects; {} stored, {} inlined",
281 self.objects_copied,
282 self.objects_already_present,
283 indicatif::HumanBytes(self.bytes_copied),
284 indicatif::HumanBytes(self.bytes_inlined),
285 )
286 }
287 }
288}
289
290#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
293pub enum LocalFetchOpt {
294 #[default]
297 Disabled,
298 IfPossible,
301 ZeroCopy,
304}
305
306#[derive(Default)]
311pub struct PullOptions<'a> {
312 pub img_proxy_config: Option<ImageProxyConfig>,
316
317 pub local_fetch: LocalFetchOpt,
320
321 pub storage_root: Option<&'a std::path::Path>,
325
326 pub additional_image_stores: &'a [&'a std::path::Path],
331
332 pub progress: Option<SharedReporter>,
338}
339
340impl<'a> std::fmt::Debug for PullOptions<'a> {
341 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342 f.debug_struct("PullOptions")
343 .field("img_proxy_config", &self.img_proxy_config)
344 .field("local_fetch", &self.local_fetch)
345 .field("storage_root", &self.storage_root)
346 .field("additional_image_stores", &self.additional_image_stores)
347 .field(
348 "progress",
349 if self.progress.is_some() {
350 &"Some(<ProgressReporter>)"
351 } else {
352 &"None"
353 },
354 )
355 .finish()
356 }
357}
358
359#[derive(Debug)]
361pub struct PullResult<ObjectID> {
362 pub manifest_digest: OciDigest,
364 pub manifest_verity: ObjectID,
366 pub config_digest: OciDigest,
368 pub config_verity: ObjectID,
370 pub stats: ImportStats,
372}
373
374pub type ContentAndVerity<ObjectID> = (OciDigest, ObjectID);
376
377pub struct OpenConfig<ObjectID> {
379 pub config: ImageConfiguration,
381 pub layer_refs: HashMap<Box<str>, ObjectID>,
383 pub image_ref: Option<ObjectID>,
385 pub image_ref_v1: Option<ObjectID>,
387 pub boot_image_refs: HashMap<Box<str>, ObjectID>,
391}
392
393impl<ObjectID: std::fmt::Debug> std::fmt::Debug for OpenConfig<ObjectID> {
394 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395 f.debug_struct("OpenConfig")
396 .field("layer_refs", &self.layer_refs)
397 .field("image_ref", &self.image_ref)
398 .field("image_ref_v1", &self.image_ref_v1)
399 .field("boot_image_refs", &self.boot_image_refs)
400 .finish_non_exhaustive()
401 }
402}
403
404pub(crate) fn layer_identifier(diff_id: &OciDigest) -> String {
405 format!("oci-layer-{diff_id}")
406}
407
408pub fn layer_content_id(diff_id: &OciDigest) -> String {
416 layer_identifier(diff_id)
417}
418
419pub(crate) fn config_identifier(config: &OciDigest) -> String {
420 format!("oci-config-{config}")
421}
422
423pub async fn import_layer<ObjectID: FsVerityHashValue>(
430 repo: &Arc<Repository<ObjectID>>,
431 diff_id: &OciDigest,
432 name: Option<&str>,
433 tar_stream: impl tokio::io::AsyncRead + Unpin,
434) -> Result<(ObjectID, ImportStats)> {
435 let content_identifier = layer_identifier(diff_id);
436
437 if let Some(id) = repo.has_stream(&content_identifier)? {
439 if let Some(name) = name {
440 repo.name_stream(&content_identifier, name)?;
441 }
442 return Ok((id, ImportStats::default()));
443 }
444
445 let (object_id, stats) =
446 tar::split_async(tar_stream, repo.clone(), TAR_LAYER_CONTENT_TYPE).await?;
447
448 repo.register_stream(&object_id, &content_identifier, name)
450 .await?;
451
452 Ok((object_id, stats))
453}
454
455pub async fn pull<ObjectID: FsVerityHashValue>(
466 repo: &Arc<Repository<ObjectID>>,
467 imgref: &str,
468 reference: Option<&str>,
469 opts: PullOptions<'_>,
470) -> Result<PullResult<ObjectID>> {
471 let reporter: SharedReporter = opts
472 .progress
473 .unwrap_or_else(|| std::sync::Arc::new(NullReporter));
474
475 #[cfg(feature = "containers-storage")]
476 if opts.local_fetch != LocalFetchOpt::Disabled
477 && let Some(image_id) = cstor::parse_containers_storage_ref(imgref)
478 {
479 let zerocopy = opts.local_fetch == LocalFetchOpt::ZeroCopy;
480 let (((manifest_digest, manifest_verity), (config_digest, config_verity)), stats) =
481 cstor::import_from_containers_storage(
482 repo,
483 image_id,
484 reference,
485 zerocopy,
486 opts.storage_root,
487 opts.additional_image_stores,
488 reporter,
489 )
490 .await?;
491 return Ok(PullResult {
492 manifest_digest,
493 manifest_verity,
494 config_digest,
495 config_verity,
496 stats,
497 });
498 }
499
500 let (result, stats) =
501 skopeo::pull_image(repo, imgref, reference, opts.img_proxy_config, reporter).await?;
502 Ok(crate::PullResult {
503 manifest_digest: result.manifest_digest,
504 manifest_verity: result.manifest_verity,
505 config_digest: result.config_digest,
506 config_verity: result.config_verity,
507 stats,
508 })
509}
510
511pub(crate) fn sha256_output_to_digest(output: composefs::digest::Output<Sha256>) -> OciDigest {
513 let hex = hex::encode(output);
514 format!("sha256:{hex}")
515 .try_into()
516 .expect("sha256 hex should always produce a valid OCI digest")
517}
518
519pub fn sha256_content_digest(bytes: &[u8]) -> OciDigest {
526 let mut context = Sha256::new();
527 context.update(bytes);
528 sha256_output_to_digest(context.finalize())
529}
530
531fn hash_sha256(bytes: &[u8]) -> OciDigest {
532 sha256_content_digest(bytes)
533}
534
535pub fn extract_layer_ids(manifest_json: &str, config_json: &str) -> Result<Vec<String>> {
544 let manifest: ImageManifest =
545 serde_json::from_str(manifest_json).context("parsing manifest JSON")?;
546 extract_diff_ids(
547 manifest.config().media_type(),
548 config_json.as_bytes(),
549 manifest.layers(),
550 )
551 .map(|ids| ids.iter().map(|d| d.to_string()).collect())
552}
553
554pub fn extract_diff_ids(
564 media_type: &MediaType,
565 config_reader: impl Read,
566 manifest_layers: &[Descriptor],
567) -> Result<Vec<OciDigest>> {
568 if *media_type == MediaType::ImageConfig {
569 let config = ImageConfiguration::from_reader(config_reader)?;
570 config
571 .rootfs()
572 .diff_ids()
573 .iter()
574 .map(|s| s.parse().context("parsing diff_id from image config"))
575 .collect()
576 } else {
577 Ok(manifest_layers
578 .iter()
579 .map(|d: &Descriptor| d.digest().clone())
580 .collect())
581 }
582}
583
584pub fn open_config<ObjectID: FsVerityHashValue>(
601 repo: &Repository<ObjectID>,
602 config_digest: &OciDigest,
603 verity: Option<&ObjectID>,
604) -> Result<OpenConfig<ObjectID>> {
605 let (data, mut named_refs) = oci_image::read_external_splitstream(
606 repo,
607 &config_identifier(config_digest),
608 verity,
609 Some(OCI_CONFIG_CONTENT_TYPE),
610 )?;
611
612 if verity.is_none() {
613 let computed = hash_sha256(&data);
614 ensure!(
615 *config_digest == computed,
616 "Config integrity check failed: expected {config_digest}, got {computed}"
617 );
618 }
619
620 let image_ref = named_refs.remove(IMAGE_REF_KEY);
621 let image_ref_v1 = named_refs.remove(IMAGE_REF_KEY_V1);
622 let (boot_image_refs, layer_refs) = take_boot_image_refs(named_refs);
623 let config = ImageConfiguration::from_reader(&data[..])?;
624 Ok(OpenConfig {
625 config,
626 layer_refs,
627 image_ref,
628 image_ref_v1,
629 boot_image_refs,
630 })
631}
632
633pub fn composefs_erofs_for_config<ObjectID: FsVerityHashValue>(
635 repo: &Repository<ObjectID>,
636 config_digest: &OciDigest,
637 verity: Option<&ObjectID>,
638 version: FormatVersion,
639) -> Result<Option<ObjectID>> {
640 let oc = open_config(repo, config_digest, verity)?;
641 Ok(match version.epoch() {
642 FormatEpoch::Epoch1 => oc.image_ref_v1,
643 FormatEpoch::Epoch2 => oc.image_ref,
644 })
645}
646
647pub fn composefs_erofs_for_manifest<ObjectID: FsVerityHashValue>(
652 repo: &Repository<ObjectID>,
653 manifest_digest: &OciDigest,
654 manifest_verity: Option<&ObjectID>,
655 version: FormatVersion,
656) -> Result<Option<ObjectID>> {
657 let img = oci_image::OciImage::open(repo, manifest_digest, manifest_verity)?;
658 Ok(img.image_ref(version).cloned())
659}
660
661pub fn composefs_boot_erofs_for_config<ObjectID: FsVerityHashValue>(
664 repo: &Repository<ObjectID>,
665 config_digest: &OciDigest,
666 verity: Option<&ObjectID>,
667 version: FormatVersion,
668 mode: XattrFiltering,
669) -> Result<Option<ObjectID>> {
670 let oc = open_config(repo, config_digest, verity)?;
671 Ok(oc
672 .boot_image_refs
673 .get(&*boot_image_ref_key(version, mode))
674 .cloned())
675}
676
677pub fn composefs_boot_erofs_for_manifest<ObjectID: FsVerityHashValue>(
680 repo: &Repository<ObjectID>,
681 manifest_digest: &OciDigest,
682 manifest_verity: Option<&ObjectID>,
683 version: FormatVersion,
684 mode: XattrFiltering,
685) -> Result<Option<ObjectID>> {
686 let img = oci_image::OciImage::open(repo, manifest_digest, manifest_verity)?;
687 Ok(img.boot_image_ref_for_mode(version, mode).cloned())
688}
689
690#[derive(Debug, Clone, Default)]
692pub struct UpgradeResult {
693 pub already_current: u64,
695 pub upgraded: u64,
697 pub skipped_non_container: u64,
699}
700
701pub fn upgrade_repo<ObjectID: FsVerityHashValue>(
715 repo: &Arc<Repository<ObjectID>>,
716) -> Result<UpgradeResult> {
717 let mut result = UpgradeResult::default();
718
719 for (tag, manifest_digest) in oci_image::list_refs(repo)? {
720 let img = oci_image::OciImage::open(repo, &manifest_digest, None)
721 .with_context(|| format!("opening image {tag}"))?;
722
723 if !img.is_container_image() {
724 tracing::debug!("skipping non-container image {tag}");
725 result.skipped_non_container += 1;
726 continue;
727 }
728
729 if img.image_ref(repo.erofs_version()).is_some() {
730 tracing::debug!("image {tag} already has EROFS ref, skipping");
731 result.already_current += 1;
732 continue;
733 }
734
735 let erofs_id = ensure_oci_composefs_erofs(
736 repo,
737 &manifest_digest,
738 Some(img.manifest_verity()),
739 Some(&tag),
740 )
741 .with_context(|| format!("generating EROFS for image {tag}"))?;
742
743 if erofs_id.is_some() {
744 tracing::info!("upgraded image {tag}");
745 result.upgraded += 1;
746 } else {
747 tracing::debug!("image {tag} produced no EROFS (not a container image?)");
748 result.skipped_non_container += 1;
749 }
750 }
751
752 Ok(result)
753}
754
755pub fn write_config<ObjectID: FsVerityHashValue>(
774 repo: &Arc<Repository<ObjectID>>,
775 config: &ImageConfiguration,
776 refs: HashMap<Box<str>, ObjectID>,
777 image: Option<&ObjectID>,
778 image_v1: Option<&ObjectID>,
779 boot_images: &HashMap<Box<str>, ObjectID>,
780) -> Result<ContentAndVerity<ObjectID>> {
781 let json = config.to_string()?;
782 write_config_raw(repo, json.as_bytes(), refs, image, image_v1, boot_images)
783}
784
785pub fn write_config_raw<ObjectID: FsVerityHashValue>(
792 repo: &Arc<Repository<ObjectID>>,
793 config_json: &[u8],
794 refs: HashMap<Box<str>, ObjectID>,
795 image: Option<&ObjectID>,
796 image_v1: Option<&ObjectID>,
797 boot_images: &HashMap<Box<str>, ObjectID>,
798) -> Result<ContentAndVerity<ObjectID>> {
799 let config_digest = hash_sha256(config_json);
800 let mut stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE)?;
801 let config = ImageConfiguration::from_reader(config_json)?;
804 for diff_id_str in config.rootfs().diff_ids() {
805 let value = refs.get(diff_id_str.as_str()).with_context(|| {
806 let keys: Vec<_> = refs.keys().collect();
807 format!(
808 "missing layer verity for diff_id {diff_id_str}. Available keys in refs: {keys:?}"
809 )
810 })?;
811 stream.add_named_stream_ref(diff_id_str, value);
812 }
813 if let Some(image_id) = image {
814 stream.add_named_stream_ref(IMAGE_REF_KEY, image_id);
815 }
816 if let Some(image_id_v1) = image_v1 {
817 stream.add_named_stream_ref(IMAGE_REF_KEY_V1, image_id_v1);
818 }
819 for (key, boot_id) in boot_images {
820 stream.add_named_stream_ref(key, boot_id);
821 }
822 stream.write_external(config_json)?;
823 let id = repo.write_stream(stream, &config_identifier(&config_digest), None)?;
824 Ok((config_digest, id))
825}
826
827pub(crate) fn ensure_oci_composefs_erofs<ObjectID: FsVerityHashValue>(
846 repo: &Arc<Repository<ObjectID>>,
847 manifest_digest: &OciDigest,
848 manifest_verity: Option<&ObjectID>,
849 tag: Option<&str>,
850) -> Result<Option<ObjectID>> {
851 let img = oci_image::OciImage::open(repo, manifest_digest, manifest_verity)?;
852 if !img.is_container_image() {
853 return Ok(None);
854 }
855
856 let fs = image::create_filesystem(
858 repo,
859 img.config_digest(),
860 Some(img.config_verity()),
861 &composefs::generic_tree::OciTransformOptions::default(),
862 )?;
863
864 let mut erofs_map = fs.commit_images(repo, None)?;
867 let erofs_id_v2 = erofs_map.remove(&FormatVersion::V2);
868 let erofs_id_v1 = erofs_map.remove(&FormatVersion::V1);
869
870 let erofs_id = match repo.erofs_version().epoch() {
871 FormatEpoch::Epoch1 => erofs_id_v1.clone(),
872 FormatEpoch::Epoch2 => erofs_id_v2.clone(),
873 }
874 .ok_or_else(|| {
875 anyhow::anyhow!("commit_images did not produce the repository's default EROFS format")
876 })?;
877
878 let config_json = img.read_config_json(repo)?;
881
882 let (_config_digest, new_config_verity) = write_config_raw(
887 repo,
888 &config_json,
889 img.layer_refs().clone(),
890 erofs_id_v2.as_ref(),
891 erofs_id_v1.as_ref(),
892 img.boot_image_refs(),
893 )?;
894
895 let manifest_json = img.read_manifest_json(repo)?;
897
898 let layer_verities: Vec<_> = img
902 .layer_refs()
903 .iter()
904 .map(|(k, v)| (k.clone(), v.clone()))
905 .collect();
906
907 let (_new_manifest_digest, _new_manifest_verity) = oci_image::rewrite_manifest(
908 repo,
909 &manifest_json,
910 manifest_digest,
911 &new_config_verity,
912 &layer_verities,
913 tag,
914 )?;
915
916 Ok(Some(erofs_id))
917}
918
919#[cfg(feature = "boot")]
922fn ensure_oci_composefs_erofs_boot<ObjectID: FsVerityHashValue>(
923 repo: &Arc<Repository<ObjectID>>,
924 manifest_digest: &OciDigest,
925 manifest_verity: Option<&ObjectID>,
926 tag: Option<&str>,
927 options: &composefs::generic_tree::OciTransformOptions,
928 get_untransformed_filesystem: bool,
929) -> Result<Option<(ObjectID, Option<composefs::tree::FileSystem<ObjectID>>)>> {
930 use composefs_boot::BootOps;
931
932 let img = oci_image::OciImage::open(repo, manifest_digest, manifest_verity)?;
933 if !img.is_container_image() {
934 return Ok(None);
935 }
936
937 let mut fs = image::create_filesystem(
939 repo,
940 img.config_digest(),
941 Some(img.config_verity()),
942 options,
943 )?;
944
945 let untransformed_fs = if get_untransformed_filesystem {
948 Some(fs.clone())
949 } else {
950 None
951 };
952
953 fs.transform_for_boot(repo)?;
954
955 let mut boot_erofs_map = fs.commit_images(repo, None)?;
957 let boot_erofs_id_v2 = boot_erofs_map.remove(&FormatVersion::V2);
958 let boot_erofs_id_v1 = boot_erofs_map.remove(&FormatVersion::V1);
959
960 let boot_erofs_id = match repo.erofs_version().epoch() {
961 FormatEpoch::Epoch1 => boot_erofs_id_v1.clone(),
962 FormatEpoch::Epoch2 => boot_erofs_id_v2.clone(),
963 }
964 .ok_or_else(|| {
965 anyhow::anyhow!("commit_images did not produce the repository's default boot EROFS format")
966 })?;
967
968 let config_json = img.read_config_json(repo)?;
970
971 let mut boot_images = img.boot_image_refs().clone();
976 if let Some(id) = &boot_erofs_id_v2 {
977 boot_images.insert(
978 boot_image_ref_key(FormatVersion::V2, options.xattrs)
979 .into_owned()
980 .into_boxed_str(),
981 id.clone(),
982 );
983 }
984 if let Some(id) = &boot_erofs_id_v1 {
985 boot_images.insert(
986 boot_image_ref_key(FormatVersion::V1, options.xattrs)
987 .into_owned()
988 .into_boxed_str(),
989 id.clone(),
990 );
991 }
992 let (_config_digest, new_config_verity) = write_config_raw(
993 repo,
994 &config_json,
995 img.layer_refs().clone(),
996 img.image_ref_v2(),
997 img.image_ref_v1(),
998 &boot_images,
999 )?;
1000
1001 let manifest_json = img.read_manifest_json(repo)?;
1003
1004 let layer_verities: Vec<_> = img
1005 .layer_refs()
1006 .iter()
1007 .map(|(k, v)| (k.clone(), v.clone()))
1008 .collect();
1009
1010 let (_new_manifest_digest, _new_manifest_verity) = oci_image::rewrite_manifest(
1011 repo,
1012 &manifest_json,
1013 manifest_digest,
1014 &new_config_verity,
1015 &layer_verities,
1016 tag,
1017 )?;
1018
1019 Ok(Some((boot_erofs_id, untransformed_fs)))
1020}
1021
1022#[cfg(test)]
1023mod test {
1024 use std::{fmt::Write, io::Read};
1025
1026 use rustix::fs::CWD;
1027
1028 use composefs::{
1029 fsverity::Sha256HashValue,
1030 repository::{Repository, RepositoryConfig},
1031 test::tempdir,
1032 };
1033
1034 use super::*;
1035
1036 const EXPECTED_BASE_IMAGE_DUMPFILE: &str = "\
1040/ 0 40755 6 0 0 0 0.0 - - -
1041/etc 0 40755 2 0 0 0 0.0 - - -
1042/etc/hostname 9 100644 1 0 0 0 0.0 - test-host -
1043/etc/os-release 23 100644 1 0 0 0 0.0 - ID=test\\nVERSION_ID=1.0\\n -
1044/etc/passwd 100 100644 1 0 0 0 0.0 f2/c4fd5735bd46db3b18d402ae87c5086c97c0e1321901cfd30f320b73ef25aa - f2c4fd5735bd46db3b18d402ae87c5086c97c0e1321901cfd30f320b73ef25aa
1045/tmp 0 40755 2 0 0 0 0.0 - - -
1046/usr 0 40755 5 0 0 0 0.0 - - -
1047/usr/bin 0 40755 2 0 0 0 0.0 - - -
1048/usr/bin/busybox 4096 100755 1 0 0 0 0.0 f0/f7e1e58fdd31f5792222087377a4a976760c416ecdf5f426193e608681b7a1 - f0f7e1e58fdd31f5792222087377a4a976760c416ecdf5f426193e608681b7a1
1049/usr/bin/cat 7 120777 1 0 0 0 0.0 busybox - -
1050/usr/bin/cp 7 120777 1 0 0 0 0.0 busybox - -
1051/usr/bin/ls 7 120777 1 0 0 0 0.0 busybox - -
1052/usr/bin/mv 7 120777 1 0 0 0 0.0 busybox - -
1053/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
1054/usr/bin/rm 7 120777 1 0 0 0 0.0 busybox - -
1055/usr/bin/sh 7 120777 1 0 0 0 0.0 busybox - -
1056/usr/lib 0 40755 2 0 0 0 0.0 - - -
1057/usr/share 0 40755 3 0 0 0 0.0 - - -
1058/usr/share/doc 0 40755 2 0 0 0 0.0 - - -
1059/usr/share/doc/README 512 100644 1 0 0 0 0.0 51/44b8f80be57c3518f410d930e18c4e405387c82e4993c18265a1ba4a80263b - 5144b8f80be57c3518f410d930e18c4e405387c82e4993c18265a1ba4a80263b
1060/var 0 40755 3 0 0 0 0.0 - - -
1061/var/data 0 40755 2 0 0 0 0.0 - - -
1062/var/data/app.json 256 100644 1 0 0 0 0.0 c9/21965b74ac1780bc437cec640b27186d85317b9afdb3dbb68626aed5ecd2b6 - c921965b74ac1780bc437cec640b27186d85317b9afdb3dbb68626aed5ecd2b6
1063";
1064
1065 fn create_test_repo() -> (tempfile::TempDir, Arc<Repository<Sha256HashValue>>) {
1067 let dir = tempdir();
1068 let repo_path = dir.path().join("repo");
1069 let (repo, _) =
1070 Repository::init_path(CWD, &repo_path, RepositoryConfig::default().set_insecure())
1071 .expect("initializing test repo");
1072 (dir, Arc::new(repo))
1073 }
1074
1075 fn append_data(builder: &mut ::tar::Builder<Vec<u8>>, name: &str, size: usize) {
1076 let mut header = ::tar::Header::new_ustar();
1077 header.set_uid(0);
1078 header.set_gid(0);
1079 header.set_mode(0o700);
1080 header.set_entry_type(::tar::EntryType::Regular);
1081 header.set_size(size as u64);
1082 builder
1083 .append_data(&mut header, name, std::io::repeat(0u8).take(size as u64))
1084 .unwrap();
1085 }
1086
1087 fn example_layer() -> Vec<u8> {
1088 let mut builder = ::tar::Builder::new(vec![]);
1089 append_data(&mut builder, "file0", 0);
1090 append_data(&mut builder, "file4095", 4095);
1091 append_data(&mut builder, "file4096", 4096);
1092 append_data(&mut builder, "file4097", 4097);
1093 builder.into_inner().unwrap()
1094 }
1095
1096 #[tokio::test]
1097 async fn test_layer() {
1098 let layer = example_layer();
1099 let layer_id = hash_sha256(&layer);
1100
1101 let (_repo_dir, repo) = create_test_repo();
1102 let (id, _stats) = import_layer(&repo, &layer_id, Some("name"), &layer[..])
1103 .await
1104 .unwrap();
1105
1106 let mut dump = String::new();
1107 let mut split_stream = repo.open_stream("refs/name", Some(&id), None).unwrap();
1108 while let Some(entry) = tar::get_entry(&mut split_stream).unwrap() {
1109 writeln!(dump, "{entry}").unwrap();
1110 }
1111 similar_asserts::assert_eq!(dump, "\
1112/file0 0 100700 1 0 0 0 0.0 - - -
1113/file4095 4095 100700 1 0 0 0 0.0 53/72beb83c78537c8970c8361e3254119fafdf1763854ecd57d3f0fe2da7c719 - 5372beb83c78537c8970c8361e3254119fafdf1763854ecd57d3f0fe2da7c719
1114/file4096 4096 100700 1 0 0 0 0.0 ba/bc284ee4ffe7f449377fbf6692715b43aec7bc39c094a95878904d34bac97e - babc284ee4ffe7f449377fbf6692715b43aec7bc39c094a95878904d34bac97e
1115/file4097 4097 100700 1 0 0 0 0.0 09/3756e4ea9683329106d4a16982682ed182c14bf076463a9e7f97305cbac743 - 093756e4ea9683329106d4a16982682ed182c14bf076463a9e7f97305cbac743
1116");
1117 }
1118
1119 #[tokio::test]
1120 async fn test_layer_import_stats() {
1121 let layer = example_layer();
1122 let layer_id = hash_sha256(&layer);
1123
1124 let (_repo_dir, repo) = create_test_repo();
1125 let (_id, stats) = import_layer(&repo, &layer_id, Some("name"), &layer[..])
1126 .await
1127 .unwrap();
1128
1129 assert_eq!(
1133 stats.objects_copied, 3,
1134 "three files above inline threshold should be external objects"
1135 );
1136 assert_eq!(stats.objects_already_present, 0);
1137 assert!(
1138 stats.bytes_copied > 0,
1139 "bytes_copied should be nonzero for external objects"
1140 );
1141 assert!(
1142 stats.bytes_inlined > 0,
1143 "bytes_inlined should be nonzero (tar headers + small file)"
1144 );
1145 }
1146
1147 #[tokio::test]
1148 async fn test_layer_import_deduplication_stats() {
1149 let layer = example_layer();
1150 let layer_id = hash_sha256(&layer);
1151
1152 let (_repo_dir, repo) = create_test_repo();
1153
1154 let (_id, stats1) = import_layer(&repo, &layer_id, None, &layer[..])
1156 .await
1157 .unwrap();
1158 assert_eq!(stats1.objects_copied, 3);
1159 assert_eq!(stats1.objects_already_present, 0);
1160
1161 let (_id, stats2) = import_layer(&repo, &layer_id, None, &layer[..])
1164 .await
1165 .unwrap();
1166 assert_eq!(stats2.objects_copied, 0);
1167 assert_eq!(stats2.objects_already_present, 0);
1168 assert_eq!(stats2.bytes_copied, 0);
1169 }
1170
1171 #[test]
1172 fn test_write_and_open_config() {
1173 use containers_image_proxy::oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
1174
1175 let (_repo_dir, repo) = create_test_repo();
1176
1177 let rootfs = RootFsBuilder::default()
1178 .typ("layers")
1179 .diff_ids(vec!["sha256:abc123def456".to_string()])
1180 .build()
1181 .unwrap();
1182
1183 let config = ImageConfigurationBuilder::default()
1184 .architecture("amd64")
1185 .os("linux")
1186 .rootfs(rootfs)
1187 .build()
1188 .unwrap();
1189
1190 let mut refs = HashMap::new();
1191 refs.insert("sha256:abc123def456".into(), Sha256HashValue::EMPTY);
1192
1193 let (config_digest, config_verity) =
1194 write_config(&repo, &config, refs.clone(), None, None, &HashMap::new()).unwrap();
1195
1196 assert!(config_digest.as_ref().starts_with("sha256:"));
1197
1198 let oc = open_config(&repo, &config_digest, Some(&config_verity)).unwrap();
1199 assert_eq!(oc.config.architecture().to_string(), "amd64");
1200 assert_eq!(oc.config.os().to_string(), "linux");
1201 assert_eq!(oc.layer_refs.len(), 1);
1202 assert!(oc.layer_refs.contains_key("sha256:abc123def456"));
1203 assert!(oc.image_ref.is_none());
1204 assert!(oc.boot_image_refs.is_empty());
1205
1206 let oc2 = open_config(&repo, &config_digest, None).unwrap();
1207 assert_eq!(oc2.config.architecture().to_string(), "amd64");
1208 }
1209
1210 #[test]
1211 fn test_config_stored_as_external_object() {
1212 use containers_image_proxy::oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
1213
1214 let (_repo_dir, repo) = create_test_repo();
1215
1216 let rootfs = RootFsBuilder::default()
1217 .typ("layers")
1218 .diff_ids(vec![])
1219 .build()
1220 .unwrap();
1221
1222 let config = ImageConfigurationBuilder::default()
1223 .architecture("amd64")
1224 .os("linux")
1225 .rootfs(rootfs)
1226 .build()
1227 .unwrap();
1228
1229 let (config_digest, config_verity) =
1230 write_config(&repo, &config, HashMap::new(), None, None, &HashMap::new()).unwrap();
1231
1232 let mut stream = repo
1238 .open_stream(
1239 &config_identifier(&config_digest),
1240 Some(&config_verity),
1241 Some(crate::skopeo::OCI_CONFIG_CONTENT_TYPE),
1242 )
1243 .unwrap();
1244
1245 let mut object_refs = Vec::new();
1246 stream
1247 .get_object_refs(|id| object_refs.push(id.clone()))
1248 .unwrap();
1249
1250 assert_eq!(
1252 object_refs.len(),
1253 1,
1254 "Config should be stored as one external object, got {} refs",
1255 object_refs.len()
1256 );
1257
1258 let json_bytes = config.to_string().unwrap();
1261 let expected_verity: Sha256HashValue =
1262 composefs::fsverity::compute_verity(json_bytes.as_bytes());
1263 assert_eq!(
1264 object_refs[0], expected_verity,
1265 "External object verity should match independently computed verity of config JSON"
1266 );
1267 }
1268
1269 #[tokio::test]
1270 async fn test_config_verity_deterministic() -> Result<()> {
1271 use containers_image_proxy::oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
1272
1273 let (_repo_dir, repo) = create_test_repo();
1274
1275 let mut layers = Vec::new();
1277 for (name, size) in [("alpha", 1000), ("beta", 2000), ("gamma", 3000)] {
1278 let mut builder = ::tar::Builder::new(vec![]);
1279 append_data(&mut builder, name, size);
1280 let layer = builder.into_inner().unwrap();
1281
1282 let diff_id = hash_sha256(&layer);
1283
1284 let (verity, _stats) = import_layer(&repo, &diff_id, None, &mut layer.as_slice())
1285 .await
1286 .unwrap();
1287 layers.push((diff_id.to_string(), verity));
1288 }
1289
1290 let diff_ids: Vec<String> = layers.iter().map(|(d, _)| d.clone()).collect();
1291 let config = ImageConfigurationBuilder::default()
1292 .architecture("amd64")
1293 .os("linux")
1294 .rootfs(
1295 RootFsBuilder::default()
1296 .typ("layers")
1297 .diff_ids(diff_ids.clone())
1298 .build()
1299 .unwrap(),
1300 )
1301 .build()
1302 .unwrap();
1303
1304 let refs1: HashMap<Box<str>, Sha256HashValue> = layers
1307 .iter()
1308 .map(|(d, v)| (d.as_str().into(), v.clone()))
1309 .collect();
1310 let refs2: HashMap<Box<str>, Sha256HashValue> = layers
1311 .iter()
1312 .rev()
1313 .map(|(d, v)| (d.as_str().into(), v.clone()))
1314 .collect();
1315
1316 let (_digest1, verity1) = write_config(&repo, &config, refs1, None, None, &HashMap::new())?;
1317 let (_digest2, verity2) = write_config(&repo, &config, refs2, None, None, &HashMap::new())?;
1318
1319 assert_eq!(
1321 verity1, verity2,
1322 "config verity must be deterministic across calls"
1323 );
1324
1325 assert_eq!(
1327 verity1.to_hex(),
1328 "4839518dea22749f8ff233e7f7baec65f23dd5336462f46ad6884769af84bf95",
1329 "config verity changed unexpectedly"
1330 );
1331
1332 Ok(())
1333 }
1334
1335 #[test]
1336 fn test_open_config_bad_hash() {
1337 use containers_image_proxy::oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
1338
1339 let (_repo_dir, repo) = create_test_repo();
1340
1341 let rootfs = RootFsBuilder::default()
1342 .typ("layers")
1343 .diff_ids(vec![])
1344 .build()
1345 .unwrap();
1346
1347 let config = ImageConfigurationBuilder::default()
1348 .architecture("amd64")
1349 .os("linux")
1350 .rootfs(rootfs)
1351 .build()
1352 .unwrap();
1353
1354 let (config_digest, _config_verity) =
1355 write_config(&repo, &config, HashMap::new(), None, None, &HashMap::new()).unwrap();
1356
1357 let bad_digest: OciDigest =
1358 "sha256:0000000000000000000000000000000000000000000000000000000000000000"
1359 .parse()
1360 .unwrap();
1361 let result = open_config::<Sha256HashValue>(&repo, &bad_digest, None);
1362 assert!(result.is_err());
1363
1364 let result = open_config::<Sha256HashValue>(&repo, &config_digest, None);
1365 assert!(result.is_ok());
1366 }
1367
1368 #[test]
1369 fn test_config_with_image_ref() {
1370 use containers_image_proxy::oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
1371
1372 let (_repo_dir, repo) = create_test_repo();
1373
1374 let rootfs = RootFsBuilder::default()
1375 .typ("layers")
1376 .diff_ids(vec!["sha256:abc123def456".to_string()])
1377 .build()
1378 .unwrap();
1379
1380 let config = ImageConfigurationBuilder::default()
1381 .architecture("amd64")
1382 .os("linux")
1383 .rootfs(rootfs)
1384 .build()
1385 .unwrap();
1386
1387 let mut refs = HashMap::new();
1388 let layer_id = Sha256HashValue::EMPTY;
1389 refs.insert("sha256:abc123def456".into(), layer_id);
1390
1391 let fake_erofs_id: Sha256HashValue =
1393 composefs::fsverity::compute_verity(b"fake-erofs-image");
1394
1395 let (config_digest, config_verity) = write_config(
1396 &repo,
1397 &config,
1398 refs.clone(),
1399 Some(&fake_erofs_id),
1400 None,
1401 &HashMap::new(),
1402 )
1403 .unwrap();
1404
1405 let oc = open_config(&repo, &config_digest, Some(&config_verity)).unwrap();
1407 assert_eq!(
1408 oc.layer_refs.len(),
1409 1,
1410 "layer refs should not include image ref"
1411 );
1412 assert!(oc.layer_refs.contains_key("sha256:abc123def456"));
1413 assert_eq!(
1414 oc.image_ref,
1415 Some(fake_erofs_id.clone()),
1416 "image ref should be returned"
1417 );
1418 assert!(
1419 oc.image_ref_v1.is_none(),
1420 "expected no V1 image ref for a V2-only config"
1421 );
1422
1423 let img_ref = composefs_erofs_for_config(
1426 &repo,
1427 &config_digest,
1428 Some(&config_verity),
1429 FormatVersion::V2,
1430 )
1431 .unwrap();
1432 assert_eq!(img_ref, Some(fake_erofs_id));
1433 }
1434
1435 #[test]
1436 fn test_config_without_image_ref() {
1437 use containers_image_proxy::oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
1438
1439 let (_repo_dir, repo) = create_test_repo();
1440
1441 let rootfs = RootFsBuilder::default()
1442 .typ("layers")
1443 .diff_ids(vec!["sha256:abc123def456".to_string()])
1444 .build()
1445 .unwrap();
1446
1447 let config = ImageConfigurationBuilder::default()
1448 .architecture("amd64")
1449 .os("linux")
1450 .rootfs(rootfs)
1451 .build()
1452 .unwrap();
1453
1454 let mut refs = HashMap::new();
1455 refs.insert("sha256:abc123def456".into(), Sha256HashValue::EMPTY);
1456
1457 let (config_digest, config_verity) =
1458 write_config(&repo, &config, refs.clone(), None, None, &HashMap::new()).unwrap();
1459
1460 let oc = open_config(&repo, &config_digest, Some(&config_verity)).unwrap();
1461 assert_eq!(oc.layer_refs.len(), 1);
1462 assert!(oc.layer_refs.contains_key("sha256:abc123def456"));
1463 assert!(oc.image_ref.is_none(), "no image ref should be present");
1464
1465 let img_ref = composefs_erofs_for_config(
1466 &repo,
1467 &config_digest,
1468 Some(&config_verity),
1469 repo.erofs_version(),
1470 )
1471 .unwrap();
1472 assert!(img_ref.is_none());
1473 }
1474
1475 #[tokio::test]
1476 async fn test_ensure_oci_composefs_erofs() {
1477 use composefs::test::TestRepo;
1478
1479 let test_repo = TestRepo::<Sha256HashValue>::new();
1480 let repo = &test_repo.repo;
1481
1482 let img = test_util::create_base_image(repo, Some("test:v1")).await;
1483
1484 let erofs_id = ensure_oci_composefs_erofs(
1486 repo,
1487 &img.manifest_digest,
1488 Some(&img.manifest_verity),
1489 Some("test:v1"),
1490 )
1491 .unwrap()
1492 .expect("container image should produce EROFS");
1493
1494 assert!(
1496 repo.open_image(&erofs_id.to_hex()).is_ok(),
1497 "EROFS image should be accessible"
1498 );
1499
1500 let oci = oci_image::OciImage::open_ref(repo, "test:v1").unwrap();
1502 assert_ne!(
1503 oci.manifest_verity(),
1504 &img.manifest_verity,
1505 "manifest should have been rewritten with new config verity"
1506 );
1507 assert_eq!(
1508 oci.image_ref(repo.erofs_version()),
1509 Some(&erofs_id),
1510 "config should reference the EROFS image"
1511 );
1512 let erofs_ref = composefs_erofs_for_config(
1514 repo,
1515 oci.config_digest(),
1516 Some(oci.config_verity()),
1517 repo.erofs_version(),
1518 )
1519 .unwrap();
1520 assert_eq!(erofs_ref, Some(erofs_id.clone()));
1521
1522 let erofs_ref2 = composefs_erofs_for_manifest(
1523 repo,
1524 &img.manifest_digest,
1525 Some(oci.manifest_verity()),
1526 repo.erofs_version(),
1527 )
1528 .unwrap();
1529 assert_eq!(erofs_ref2, Some(erofs_id.clone()));
1530
1531 let erofs_data = repo.read_object(&erofs_id).unwrap();
1533 let fs =
1534 composefs::erofs::reader::erofs_to_filesystem::<Sha256HashValue>(&erofs_data).unwrap();
1535 let mut dump = Vec::new();
1536 composefs::dumpfile::write_dumpfile(&mut dump, &fs).unwrap();
1537 let dump = String::from_utf8(dump).unwrap();
1538 similar_asserts::assert_eq!(dump, EXPECTED_BASE_IMAGE_DUMPFILE);
1539 }
1540
1541 #[tokio::test]
1544 async fn test_dual_format_both_image_refs() {
1545 use composefs::erofs::format::{FormatConfig, FormatVersion};
1546
1547 let dir = tempdir();
1549 let repo_path = dir.path().join("repo");
1550 let mut both_config = RepositoryConfig::default().set_insecure();
1551 both_config.erofs_formats = FormatConfig {
1552 default: FormatVersion::V1,
1553 extra: [FormatVersion::V2].into(),
1554 };
1555 let (repo_inner, _) = Repository::init_path(CWD, &repo_path, both_config)
1556 .expect("initializing dual-format test repo");
1557 let repo = std::sync::Arc::new(repo_inner);
1558
1559 assert_eq!(
1560 repo.default_format_config(),
1561 FormatConfig {
1562 default: FormatVersion::V1,
1563 extra: [FormatVersion::V2].into(),
1564 }
1565 );
1566
1567 let img = test_util::create_base_image(&repo, Some("dual:v1")).await;
1569 let primary_id = ensure_oci_composefs_erofs(
1570 &repo,
1571 &img.manifest_digest,
1572 Some(&img.manifest_verity),
1573 Some("dual:v1"),
1574 )
1575 .unwrap()
1576 .expect("container image should produce EROFS");
1577
1578 let oci = oci_image::OciImage::open_ref(&repo, "dual:v1").unwrap();
1580 let oc = open_config(&repo, oci.config_digest(), Some(oci.config_verity())).unwrap();
1581
1582 let id_v1 = oc
1584 .image_ref_v1
1585 .as_ref()
1586 .expect("V1 image ref should be set for dual-format repo");
1587 let id_v2 = oc
1588 .image_ref
1589 .as_ref()
1590 .expect("V2 image ref should be set for dual-format repo");
1591
1592 assert_ne!(
1594 id_v1, id_v2,
1595 "V1 and V2 EROFS images must have different digests"
1596 );
1597
1598 assert_eq!(&primary_id, id_v1, "primary ID should be the V1 digest");
1600
1601 let via_fn = composefs_erofs_for_config(
1603 &repo,
1604 oci.config_digest(),
1605 Some(oci.config_verity()),
1606 repo.erofs_version(),
1607 )
1608 .unwrap();
1609 assert_eq!(
1610 via_fn.as_ref(),
1611 Some(id_v1),
1612 "composefs_erofs_for_config should return repo default (V1)"
1613 );
1614
1615 assert_eq!(oci.image_ref(repo.erofs_version()), Some(id_v1));
1617 assert_eq!(oci.image_ref_v2(), Some(id_v2));
1618
1619 assert!(
1621 repo.open_image(&id_v1.to_hex()).is_ok(),
1622 "V1 EROFS image should exist in repo"
1623 );
1624 assert!(
1625 repo.open_image(&id_v2.to_hex()).is_ok(),
1626 "V2 EROFS image should exist in repo"
1627 );
1628
1629 let fs = image::create_filesystem(
1631 &repo,
1632 oci.config_digest(),
1633 Some(oci.config_verity()),
1634 &composefs::generic_tree::OciTransformOptions::default(),
1635 )
1636 .unwrap();
1637 let map = fs
1638 .commit_images(&repo, None)
1639 .expect("commit_images with dual-format config should succeed");
1640 assert!(map.contains_key(&FormatVersion::V1), "map must contain V1");
1641 assert!(map.contains_key(&FormatVersion::V2), "map must contain V2");
1642 assert_eq!(map[&FormatVersion::V1], *id_v1);
1643 assert_eq!(map[&FormatVersion::V2], *id_v2);
1644 }
1645
1646 #[tokio::test]
1647 async fn test_ensure_oci_composefs_erofs_gc() {
1648 use composefs::test::TestRepo;
1649
1650 let test_repo = TestRepo::<Sha256HashValue>::new();
1651 let repo = &test_repo.repo;
1652
1653 let img = test_util::create_base_image(repo, Some("gctest:v1")).await;
1654
1655 let dry = repo.gc_dry_run(&[]).unwrap();
1657 assert_eq!(dry.objects_removed, 0);
1658 assert_eq!(dry.streams_pruned, 0);
1659 assert_eq!(dry.images_pruned, 0);
1660
1661 let erofs_id = ensure_oci_composefs_erofs(
1662 repo,
1663 &img.manifest_digest,
1664 Some(&img.manifest_verity),
1665 Some("gctest:v1"),
1666 )
1667 .unwrap()
1668 .expect("container image should produce EROFS");
1669
1670 let gc1 = repo.gc(&[]).unwrap();
1673 assert_eq!(
1674 gc1.objects_removed, 2,
1675 "old config+manifest splitstream objects"
1676 );
1677 assert_eq!(gc1.streams_pruned, 0);
1678 assert_eq!(gc1.images_pruned, 0);
1679
1680 let dry = repo.gc_dry_run(&[]).unwrap();
1682 assert_eq!(dry.objects_removed, 0);
1683 assert!(
1684 repo.open_image(&erofs_id.to_hex()).is_ok(),
1685 "EROFS image should survive GC while tagged"
1686 );
1687
1688 oci_image::untag_image(repo, "gctest:v1").unwrap();
1690 let gc2 = repo.gc(&[]).unwrap();
1691 assert_eq!(gc2.objects_removed, 14, "all objects collected after untag");
1695 assert_eq!(gc2.streams_pruned, 7, "all stream symlinks pruned");
1697 assert_eq!(gc2.images_pruned, 1, "EROFS image symlink pruned");
1699
1700 assert!(
1701 repo.open_image(&erofs_id.to_hex()).is_err(),
1702 "EROFS image should be collected after untag + GC"
1703 );
1704
1705 let dry = repo.gc_dry_run(&[]).unwrap();
1707 assert_eq!(dry.objects_removed, 0);
1708 assert_eq!(dry.streams_pruned, 0);
1709 assert_eq!(dry.images_pruned, 0);
1710 }
1711
1712 #[tokio::test]
1721 async fn test_config_rewrite_preserves_noncanonical_json() {
1722 use composefs::test::TestRepo;
1723 use serde_json::ser::{PrettyFormatter, Serializer};
1724
1725 let test_repo = TestRepo::<Sha256HashValue>::new();
1726 let repo = &test_repo.repo;
1727
1728 let _img = test_util::create_base_image(repo, Some("nc:v1")).await;
1730
1731 let oci_before = oci_image::OciImage::open_ref(repo, "nc:v1").unwrap();
1733 let canonical_json = oci_before.read_config_json(repo).unwrap();
1734
1735 let value: serde_json::Value = serde_json::from_slice(&canonical_json).unwrap();
1739 let mut buf = Vec::new();
1740 let formatter = PrettyFormatter::with_indent(b"\t");
1741 let mut ser = Serializer::with_formatter(&mut buf, formatter);
1742 serde::Serialize::serialize(&value, &mut ser).unwrap();
1743 let noncanonical_json = buf;
1744
1745 assert_ne!(
1748 canonical_json.as_slice(),
1749 noncanonical_json.as_slice(),
1750 "pretty-printed JSON should differ from canonical"
1751 );
1752 let reparsed: serde_json::Value = serde_json::from_slice(&noncanonical_json).unwrap();
1753 assert_eq!(value, reparsed, "non-canonical JSON must parse identically");
1754
1755 let (_new_config_digest, new_config_verity) = write_config_raw(
1757 repo,
1758 &noncanonical_json,
1759 oci_before.layer_refs().clone(),
1760 None,
1761 None,
1762 &HashMap::new(),
1763 )
1764 .unwrap();
1765 let new_config_digest = hash_sha256(&noncanonical_json);
1766
1767 use containers_image_proxy::oci_spec::image::{
1769 DescriptorBuilder, ImageManifestBuilder, MediaType,
1770 };
1771
1772 let old_manifest = oci_before.manifest();
1773 let config_descriptor = DescriptorBuilder::default()
1774 .media_type(MediaType::ImageConfig)
1775 .digest(new_config_digest.clone())
1776 .size(noncanonical_json.len() as u64)
1777 .build()
1778 .unwrap();
1779 let new_manifest = ImageManifestBuilder::default()
1780 .schema_version(2u32)
1781 .media_type(MediaType::ImageManifest)
1782 .config(config_descriptor)
1783 .layers(old_manifest.layers().clone())
1784 .build()
1785 .unwrap();
1786
1787 let new_manifest_json = new_manifest.to_string().unwrap();
1788 let new_manifest_digest = hash_sha256(new_manifest_json.as_bytes());
1789
1790 oci_image::untag_image(repo, "nc:v1").unwrap();
1791 let layer_verities: Vec<_> = oci_before
1792 .layer_refs()
1793 .iter()
1794 .map(|(k, v)| (k.clone(), v.clone()))
1795 .collect();
1796 let (_md, new_manifest_verity) = oci_image::write_manifest(
1797 repo,
1798 &new_manifest,
1799 &new_manifest_digest,
1800 &new_config_verity,
1801 &layer_verities,
1802 Some("nc:v1"),
1803 )
1804 .unwrap();
1805
1806 let erofs_id = ensure_oci_composefs_erofs(
1809 repo,
1810 &new_manifest_digest,
1811 Some(&new_manifest_verity),
1812 Some("nc:v1"),
1813 )
1814 .unwrap()
1815 .expect("should produce EROFS");
1816
1817 let oci_after = oci_image::OciImage::open_ref(repo, "nc:v1").unwrap();
1818 assert_eq!(
1819 oci_after.config_digest(),
1820 &new_config_digest,
1821 "config digest must be preserved after EROFS rewrite"
1822 );
1823 assert_eq!(oci_after.image_ref(repo.erofs_version()), Some(&erofs_id));
1824
1825 let stored_json = oci_after.read_config_json(repo).unwrap();
1826 assert_eq!(
1827 stored_json, noncanonical_json,
1828 "raw config JSON bytes must survive round-trip through EROFS rewrite"
1829 );
1830 }
1831
1832 #[test]
1833 fn test_import_stats_display() {
1834 let stats = ImportStats {
1836 objects_copied: 42,
1837 objects_already_present: 100,
1838 bytes_copied: 1_500_000,
1839 bytes_inlined: 800,
1840 ..Default::default()
1841 };
1842 assert_eq!(
1843 stats.to_string(),
1844 "42 new + 100 already present objects; 1.43 MiB stored, 800 B inlined"
1845 );
1846 assert_eq!(stats.total_objects(), 142);
1847 assert_eq!(stats.new_objects(), 42);
1848 assert_eq!(stats.new_bytes(), 1_500_000);
1849
1850 let reflink_stats = ImportStats {
1852 objects_reflinked: 30,
1853 objects_copied: 12,
1854 objects_already_present: 100,
1855 bytes_reflinked: 1_000_000,
1856 bytes_copied: 500_000,
1857 bytes_inlined: 800,
1858 ..Default::default()
1859 };
1860 assert_eq!(
1861 reflink_stats.to_string(),
1862 "30 reflinked + 12 copied + 100 already present objects; 976.56 KiB reflinked, 488.28 KiB copied, 800 B inlined"
1863 );
1864 assert_eq!(reflink_stats.total_objects(), 142);
1865 assert_eq!(reflink_stats.new_objects(), 42);
1866 assert_eq!(reflink_stats.new_bytes(), 1_500_000);
1867
1868 let hardlink_stats = ImportStats {
1870 objects_hardlinked: 20,
1871 objects_copied: 5,
1872 objects_already_present: 50,
1873 bytes_hardlinked: 800_000,
1874 bytes_copied: 200_000,
1875 bytes_inlined: 400,
1876 ..Default::default()
1877 };
1878 assert_eq!(
1879 hardlink_stats.to_string(),
1880 "20 hardlinked + 5 copied + 50 already present objects; 781.25 KiB hardlinked, 195.31 KiB copied, 400 B inlined"
1881 );
1882 assert_eq!(hardlink_stats.total_objects(), 75);
1883 assert_eq!(hardlink_stats.new_objects(), 25);
1884 assert_eq!(hardlink_stats.new_bytes(), 1_000_000);
1885
1886 let mixed_stats = ImportStats {
1888 objects_reflinked: 10,
1889 objects_hardlinked: 15,
1890 objects_copied: 5,
1891 objects_already_present: 70,
1892 bytes_reflinked: 500_000,
1893 bytes_hardlinked: 750_000,
1894 bytes_copied: 250_000,
1895 bytes_inlined: 600,
1896 ..Default::default()
1897 };
1898 assert_eq!(
1899 mixed_stats.to_string(),
1900 "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"
1901 );
1902 assert_eq!(mixed_stats.total_objects(), 100);
1903 assert_eq!(mixed_stats.new_objects(), 30);
1904 assert_eq!(mixed_stats.new_bytes(), 1_500_000);
1905
1906 let empty = ImportStats::default();
1907 assert_eq!(
1908 empty.to_string(),
1909 "0 new + 0 already present objects; 0 B stored, 0 B inlined"
1910 );
1911 assert_eq!(empty.total_objects(), 0);
1912 }
1913
1914 #[tokio::test]
1921 async fn test_whiteout_multi_layer_import() {
1922 use composefs::test::TestRepo;
1923 use containers_image_proxy::oci_spec::image::{
1924 ConfigBuilder, DescriptorBuilder, ImageConfigurationBuilder, ImageManifestBuilder,
1925 MediaType, RootFsBuilder,
1926 };
1927
1928 fn tar_dir(builder: &mut ::tar::Builder<Vec<u8>>, name: &str) {
1931 let mut header = ::tar::Header::new_ustar();
1932 header.set_uid(0);
1933 header.set_gid(0);
1934 header.set_mode(0o755);
1935 header.set_entry_type(::tar::EntryType::Directory);
1936 header.set_size(0);
1937 builder
1938 .append_data(&mut header, name, std::io::empty())
1939 .unwrap();
1940 }
1941
1942 fn tar_file(builder: &mut ::tar::Builder<Vec<u8>>, name: &str, content: &[u8]) {
1943 let mut header = ::tar::Header::new_ustar();
1944 header.set_uid(0);
1945 header.set_gid(0);
1946 header.set_mode(0o644);
1947 header.set_entry_type(::tar::EntryType::Regular);
1948 header.set_size(content.len() as u64);
1949 builder.append_data(&mut header, name, content).unwrap();
1950 }
1951
1952 fn tar_whiteout(builder: &mut ::tar::Builder<Vec<u8>>, name: &str) {
1954 tar_file(builder, name, &[]);
1955 }
1956
1957 let layer1 = {
1961 let mut b = ::tar::Builder::new(vec![]);
1962 tar_dir(&mut b, "etc");
1963 tar_file(&mut b, "etc/config.toml", b"[server]\nport = 8080\n");
1964 tar_file(&mut b, "etc/hosts", b"127.0.0.1 localhost\n");
1965 tar_dir(&mut b, "usr");
1966 tar_dir(&mut b, "usr/bin");
1967 tar_file(&mut b, "usr/bin/app", b"#!/bin/sh\necho hello\n");
1968 tar_dir(&mut b, "usr/lib");
1969 tar_file(&mut b, "usr/lib/old-lib.so", b"fake-old-lib-content");
1970 tar_file(&mut b, "usr/lib/shared.so", b"fake-shared-lib-content");
1971 tar_dir(&mut b, "tmp");
1972 tar_dir(&mut b, "tmp/cache");
1973 tar_file(&mut b, "tmp/cache/data.bin", b"cached-data-payload");
1974 tar_file(&mut b, "tmp/cache/index.db", b"cached-index-payload");
1975 b.into_inner().unwrap()
1976 };
1977
1978 let layer2 = {
1985 let mut b = ::tar::Builder::new(vec![]);
1986 tar_dir(&mut b, "etc");
1987 tar_whiteout(&mut b, "etc/.wh.hosts");
1988 tar_file(&mut b, "etc/hosts.new", b"127.0.0.1 localhost.new\n");
1989 tar_dir(&mut b, "usr");
1990 tar_dir(&mut b, "usr/lib");
1991 tar_whiteout(&mut b, "usr/lib/.wh.old-lib.so");
1992 tar_dir(&mut b, "tmp");
1993 tar_dir(&mut b, "tmp/cache");
1994 tar_whiteout(&mut b, "tmp/cache/.wh..wh..opq");
1995 tar_file(&mut b, "tmp/cache/fresh.bin", b"fresh-cache-content");
1996 b.into_inner().unwrap()
1997 };
1998
1999 let layer3 = {
2003 let mut b = ::tar::Builder::new(vec![]);
2004 tar_dir(&mut b, "usr");
2005 tar_dir(&mut b, "usr/bin");
2006 tar_whiteout(&mut b, "usr/bin/.wh.app");
2007 tar_file(&mut b, "usr/bin/app-v2", b"#!/bin/sh\necho hello v2\n");
2008 b.into_inner().unwrap()
2009 };
2010
2011 let test_repo = TestRepo::<Sha256HashValue>::new();
2014 let repo = &test_repo.repo;
2015
2016 let layers_data = [&layer1[..], &layer2[..], &layer3[..]];
2017 let mut layer_digests = Vec::new();
2018 let mut layer_verities_map: HashMap<Box<str>, composefs::fsverity::Sha256HashValue> =
2019 HashMap::new();
2020 let mut layer_descriptors = Vec::new();
2021
2022 for tar_data in &layers_data {
2023 let digest = hash_sha256(tar_data);
2024 let (verity, _stats) = import_layer(repo, &digest, None, *tar_data).await.unwrap();
2025
2026 let descriptor = DescriptorBuilder::default()
2027 .media_type(MediaType::ImageLayerGzip)
2028 .digest(digest.clone())
2029 .size(tar_data.len() as u64)
2030 .build()
2031 .unwrap();
2032
2033 layer_verities_map.insert(digest.to_string().into_boxed_str(), verity);
2034 layer_digests.push(digest.to_string());
2035 layer_descriptors.push(descriptor);
2036 }
2037
2038 let rootfs = RootFsBuilder::default()
2040 .typ("layers")
2041 .diff_ids(layer_digests.clone())
2042 .build()
2043 .unwrap();
2044
2045 let cfg = ConfigBuilder::default().build().unwrap();
2046
2047 let config = ImageConfigurationBuilder::default()
2048 .architecture("amd64")
2049 .os("linux")
2050 .rootfs(rootfs)
2051 .config(cfg)
2052 .build()
2053 .unwrap();
2054
2055 let config_json = config.to_string().unwrap();
2056 let config_digest = hash_sha256(config_json.as_bytes());
2057
2058 let mut config_stream = repo.create_stream(skopeo::OCI_CONFIG_CONTENT_TYPE).unwrap();
2059 for (digest, verity) in &layer_verities_map {
2060 config_stream.add_named_stream_ref(digest, verity);
2061 }
2062 config_stream
2063 .write_external(config_json.as_bytes())
2064 .unwrap();
2065 let config_verity = repo
2066 .write_stream(config_stream, &config_identifier(&config_digest), None)
2067 .unwrap();
2068
2069 let config_descriptor = DescriptorBuilder::default()
2071 .media_type(MediaType::ImageConfig)
2072 .digest(config_digest.clone())
2073 .size(config_json.len() as u64)
2074 .build()
2075 .unwrap();
2076
2077 let manifest = ImageManifestBuilder::default()
2078 .schema_version(2u32)
2079 .media_type(MediaType::ImageManifest)
2080 .config(config_descriptor)
2081 .layers(layer_descriptors)
2082 .build()
2083 .unwrap();
2084
2085 let manifest_json = manifest.to_string().unwrap();
2086 let manifest_digest = hash_sha256(manifest_json.as_bytes());
2087
2088 let layer_verities_vec: Vec<_> = layer_verities_map
2089 .iter()
2090 .map(|(k, v)| (k.clone(), v.clone()))
2091 .collect();
2092 let (_stored_digest, manifest_verity) = oci_image::write_manifest(
2093 repo,
2094 &manifest,
2095 &manifest_digest,
2096 &config_verity,
2097 &layer_verities_vec,
2098 Some("whiteout-test:v1"),
2099 )
2100 .unwrap();
2101
2102 let erofs_id = ensure_oci_composefs_erofs(
2105 repo,
2106 &manifest_digest,
2107 Some(&manifest_verity),
2108 Some("whiteout-test:v1"),
2109 )
2110 .unwrap()
2111 .expect("container image should produce EROFS");
2112
2113 let erofs_data = repo.read_object(&erofs_id).unwrap();
2116 let fs =
2117 composefs::erofs::reader::erofs_to_filesystem::<Sha256HashValue>(&erofs_data).unwrap();
2118 let mut dump = Vec::new();
2119 composefs::dumpfile::write_dumpfile(&mut dump, &fs).unwrap();
2120 let dump = String::from_utf8(dump).unwrap();
2121
2122 let paths: Vec<&str> = dump.lines().map(|l| l.split_once(' ').unwrap().0).collect();
2124
2125 let expected_present = [
2127 "/",
2128 "/etc",
2129 "/etc/config.toml", "/etc/hosts.new", "/tmp",
2132 "/tmp/cache",
2133 "/tmp/cache/fresh.bin", "/usr",
2135 "/usr/bin",
2136 "/usr/bin/app-v2", "/usr/lib",
2138 "/usr/lib/shared.so", ];
2140
2141 let must_not_exist = [
2143 "/etc/hosts", "/usr/lib/old-lib.so", "/usr/bin/app", "/tmp/cache/data.bin", "/tmp/cache/index.db", ];
2149
2150 similar_asserts::assert_eq!(paths, expected_present);
2151
2152 for path in &must_not_exist {
2153 assert!(
2154 !paths.contains(path),
2155 "{path} should have been removed by whiteout but is still present"
2156 );
2157 }
2158 }
2159
2160 #[tokio::test]
2167 async fn test_old_format_splitstream_oci_roundtrip() {
2168 use composefs::test::TestRepo;
2169
2170 let test_repo = TestRepo::<Sha256HashValue>::new();
2171 let repo = &test_repo.repo;
2172
2173 repo.set_write_old_splitstream_format(true);
2177 let img = test_util::create_base_image(repo, Some("old:v1")).await;
2178
2179 let oci = oci_image::OciImage::open_ref(repo, "old:v1").unwrap();
2181 let oc = open_config(repo, oci.config_digest(), Some(oci.config_verity())).unwrap();
2182 assert_eq!(oc.config.architecture().to_string(), "amd64");
2183 assert!(
2184 oc.image_ref.is_none(),
2185 "pre-EROFS image should have no image ref"
2186 );
2187
2188 let fs = image::create_filesystem(
2190 repo,
2191 oci.config_digest(),
2192 Some(oci.config_verity()),
2193 &composefs::generic_tree::OciTransformOptions::default(),
2194 )
2195 .unwrap();
2196 let mut fs_dump = Vec::new();
2197 composefs::dumpfile::write_dumpfile(&mut fs_dump, &fs).unwrap();
2198 assert!(
2199 !fs_dump.is_empty(),
2200 "filesystem should contain entries from old-format layers"
2201 );
2202
2203 let erofs_id = ensure_oci_composefs_erofs(
2206 repo,
2207 &img.manifest_digest,
2208 Some(&img.manifest_verity),
2209 Some("old:v1"),
2210 )
2211 .unwrap()
2212 .expect("container image should produce EROFS");
2213
2214 let oci_after = oci_image::OciImage::open_ref(repo, "old:v1").unwrap();
2217 assert_eq!(
2218 oci_after.image_ref(repo.erofs_version()),
2219 Some(&erofs_id),
2220 "old-format rewritten config should reference the EROFS image"
2221 );
2222
2223 let erofs_data = repo.read_object(&erofs_id).unwrap();
2224 let erofs_fs =
2225 composefs::erofs::reader::erofs_to_filesystem::<Sha256HashValue>(&erofs_data).unwrap();
2226 let mut dump = Vec::new();
2227 composefs::dumpfile::write_dumpfile(&mut dump, &erofs_fs).unwrap();
2228 let dump = String::from_utf8(dump).unwrap();
2229 similar_asserts::assert_eq!(dump, EXPECTED_BASE_IMAGE_DUMPFILE);
2230 }
2231
2232 #[tokio::test]
2237 async fn test_pre_erofs_pull_upgrade_with_old_format() {
2238 use composefs::test::TestRepo;
2239
2240 let test_repo = TestRepo::<Sha256HashValue>::new();
2241 let repo = &test_repo.repo;
2242
2243 repo.set_write_old_splitstream_format(true);
2245 let img = test_util::create_base_image(repo, Some("upgrade:v1")).await;
2248 repo.set_write_old_splitstream_format(false);
2249
2250 let oci_before = oci_image::OciImage::open_ref(repo, "upgrade:v1").unwrap();
2252 assert!(
2253 oci_before.image_ref(repo.erofs_version()).is_none(),
2254 "pre-EROFS pull should have no image ref"
2255 );
2256
2257 let erofs_id = ensure_oci_composefs_erofs(
2260 repo,
2261 &img.manifest_digest,
2262 Some(&img.manifest_verity),
2263 Some("upgrade:v1"),
2264 )
2265 .unwrap()
2266 .expect("container image should produce EROFS");
2267
2268 let oci_after = oci_image::OciImage::open_ref(repo, "upgrade:v1").unwrap();
2270 assert_eq!(
2271 oci_after.image_ref(repo.erofs_version()),
2272 Some(&erofs_id),
2273 "config should reference the EROFS image after upgrade"
2274 );
2275
2276 assert!(
2278 repo.open_image(&erofs_id.to_hex()).is_ok(),
2279 "EROFS image should be accessible"
2280 );
2281
2282 let erofs_data = repo.read_object(&erofs_id).unwrap();
2284 let erofs_fs =
2285 composefs::erofs::reader::erofs_to_filesystem::<Sha256HashValue>(&erofs_data).unwrap();
2286 let mut dump = Vec::new();
2287 composefs::dumpfile::write_dumpfile(&mut dump, &erofs_fs).unwrap();
2288 let dump = String::from_utf8(dump).unwrap();
2289 similar_asserts::assert_eq!(dump, EXPECTED_BASE_IMAGE_DUMPFILE);
2290
2291 let gc1 = repo.gc(&[]).unwrap();
2293 assert_eq!(
2294 gc1.objects_removed, 2,
2295 "old config+manifest splitstream objects"
2296 );
2297 assert_eq!(gc1.streams_pruned, 0);
2298 assert_eq!(gc1.images_pruned, 0);
2299
2300 oci_image::untag_image(repo, "upgrade:v1").unwrap();
2302 let gc2 = repo.gc(&[]).unwrap();
2303 assert_eq!(gc2.objects_removed, 14, "all objects collected after untag");
2304 assert_eq!(gc2.streams_pruned, 7, "all stream symlinks pruned");
2305 assert_eq!(gc2.images_pruned, 1, "EROFS image symlink pruned");
2306 }
2307
2308 #[tokio::test]
2311 async fn test_upgrade_repo() {
2312 use composefs::test::TestRepo;
2313
2314 let test_repo = TestRepo::<Sha256HashValue>::new();
2315 let repo = &test_repo.repo;
2316
2317 repo.set_write_old_splitstream_format(true);
2319 let _img1 = test_util::create_base_image(repo, Some("app:v1")).await;
2320 let _img2 = test_util::create_bootable_image(repo, Some("os:v1"), 1).await;
2321 repo.set_write_old_splitstream_format(false);
2322
2323 let oci1 = oci_image::OciImage::open_ref(repo, "app:v1").unwrap();
2325 assert!(
2326 oci1.image_ref(repo.erofs_version()).is_none(),
2327 "app:v1 should have no EROFS ref before upgrade"
2328 );
2329 let oci2 = oci_image::OciImage::open_ref(repo, "os:v1").unwrap();
2330 assert!(
2331 oci2.image_ref(repo.erofs_version()).is_none(),
2332 "os:v1 should have no EROFS ref before upgrade"
2333 );
2334
2335 let result = upgrade_repo(repo).unwrap();
2337 assert_eq!(result.upgraded, 2, "both images should be upgraded");
2338 assert_eq!(result.already_current, 0, "none should be already current");
2339 assert_eq!(result.skipped_non_container, 0);
2340
2341 let oci1_after = oci_image::OciImage::open_ref(repo, "app:v1").unwrap();
2343 let erofs1 = oci1_after
2344 .image_ref(repo.erofs_version())
2345 .expect("app:v1 should have EROFS ref after upgrade");
2346 assert!(
2347 repo.open_image(&erofs1.to_hex()).is_ok(),
2348 "app:v1 EROFS image should be accessible"
2349 );
2350 let oci2_after = oci_image::OciImage::open_ref(repo, "os:v1").unwrap();
2351 let erofs2 = oci2_after
2352 .image_ref(repo.erofs_version())
2353 .expect("os:v1 should have EROFS ref after upgrade");
2354 assert!(
2355 repo.open_image(&erofs2.to_hex()).is_ok(),
2356 "os:v1 EROFS image should be accessible"
2357 );
2358
2359 let result2 = upgrade_repo(repo).unwrap();
2361 assert_eq!(result2.upgraded, 0, "no images should need upgrading");
2362 assert_eq!(result2.already_current, 2, "both should be already current");
2363 assert_eq!(result2.skipped_non_container, 0);
2364
2365 let gc = repo.gc(&[]).unwrap();
2368 assert_eq!(
2369 gc.objects_removed, 4,
2370 "old config+manifest splitstream objects from 2 images"
2371 );
2372
2373 assert!(
2375 repo.open_image(&erofs1.to_hex()).is_ok(),
2376 "app:v1 EROFS image should survive GC"
2377 );
2378 assert!(
2379 repo.open_image(&erofs2.to_hex()).is_ok(),
2380 "os:v1 EROFS image should survive GC"
2381 );
2382
2383 let erofs_data = repo.read_object(erofs1).unwrap();
2385 let fs =
2386 composefs::erofs::reader::erofs_to_filesystem::<Sha256HashValue>(&erofs_data).unwrap();
2387 let mut dump = Vec::new();
2388 composefs::dumpfile::write_dumpfile(&mut dump, &fs).unwrap();
2389 let dump = String::from_utf8(dump).unwrap();
2390 assert!(
2392 dump.contains("/usr/bin/busybox"),
2393 "EROFS should contain busybox"
2394 );
2395 assert!(
2396 dump.contains("/etc/hostname"),
2397 "EROFS should contain hostname"
2398 );
2399 }
2400
2401 fn make_test_oci_layout(parent: &std::path::Path) -> std::path::PathBuf {
2411 use cap_std_ext::cap_std;
2412 use containers_image_proxy::oci_spec::image::{
2413 Arch, ConfigBuilder, ImageConfigurationBuilder, Os, PlatformBuilder, RootFsBuilder,
2414 };
2415 use ocidir::OciDir;
2416
2417 let oci_dir = parent.join("oci-layout");
2418 std::fs::create_dir_all(&oci_dir).unwrap();
2419 let dir =
2420 cap_std::fs::Dir::open_ambient_dir(&oci_dir, cap_std::ambient_authority()).unwrap();
2421 let ocidir = OciDir::ensure(dir).unwrap();
2422
2423 let mut manifest = ocidir.new_empty_manifest().unwrap().build().unwrap();
2424 let mut config = ImageConfigurationBuilder::default()
2425 .architecture(Arch::default())
2426 .os(Os::default())
2427 .rootfs(
2428 RootFsBuilder::default()
2429 .typ("layers")
2430 .diff_ids(Vec::<String>::new())
2431 .build()
2432 .unwrap(),
2433 )
2434 .config(ConfigBuilder::default().build().unwrap())
2435 .build()
2436 .unwrap();
2437
2438 let layer = ocidir
2440 .create_layer(None)
2441 .unwrap()
2442 .into_inner()
2443 .unwrap()
2444 .complete()
2445 .unwrap();
2446 ocidir.push_layer(&mut manifest, &mut config, layer, "layer", None);
2447
2448 let platform = PlatformBuilder::default()
2449 .architecture(Arch::default())
2450 .os(Os::default())
2451 .build()
2452 .unwrap();
2453 ocidir
2454 .insert_manifest_and_config(manifest, config, None, platform)
2455 .unwrap();
2456
2457 oci_dir
2458 }
2459
2460 #[tokio::test]
2468 async fn test_oci_layout_pull_emits_started_and_done() {
2469 use crate::oci_layout::import_oci_layout;
2470 use crate::progress::ProgressEvent;
2471 use crate::progress::test_support::RecordingReporter;
2472 use composefs::fsverity::Sha256HashValue;
2473 use composefs::test::TestRepo;
2474
2475 let layout_dir = tempfile::tempdir().unwrap();
2476 let layout_path = make_test_oci_layout(layout_dir.path());
2477
2478 let test_repo = TestRepo::<Sha256HashValue>::new();
2479 let repo = &test_repo.repo;
2480 let recorder = std::sync::Arc::new(RecordingReporter::new());
2481 let reporter: crate::progress::SharedReporter =
2482 std::sync::Arc::clone(&recorder) as crate::progress::SharedReporter;
2483
2484 import_oci_layout(repo, &layout_path, None, reporter)
2485 .await
2486 .expect("import_oci_layout should succeed");
2487
2488 let events = recorder.events();
2489
2490 let started_count = events
2492 .iter()
2493 .filter(|e| matches!(e, ProgressEvent::Started { .. }))
2494 .count();
2495 assert!(
2496 started_count >= 1,
2497 "expected at least one Started event, got {started_count} (total events: {})",
2498 events.len()
2499 );
2500
2501 let started_ids: std::collections::HashSet<String> = events
2503 .iter()
2504 .filter_map(|e| {
2505 if let ProgressEvent::Started { id, .. } = e {
2506 Some(id.as_str().to_owned())
2507 } else {
2508 None
2509 }
2510 })
2511 .collect();
2512 for started_id in &started_ids {
2513 let has_terminal = events.iter().any(|e| match e {
2514 ProgressEvent::Done { id, .. } | ProgressEvent::Skipped { id } => {
2515 id.as_str() == started_id
2516 }
2517 _ => false,
2518 });
2519 assert!(
2520 has_terminal,
2521 "Started for '{started_id}' has no matching Done or Skipped"
2522 );
2523 }
2524 }
2525
2526 #[tokio::test]
2529 async fn test_oci_layout_reimport_emits_skipped() {
2530 use crate::oci_layout::import_oci_layout;
2531 use crate::progress::test_support::RecordingReporter;
2532 use crate::progress::{NullReporter, ProgressEvent};
2533 use composefs::fsverity::Sha256HashValue;
2534 use composefs::test::TestRepo;
2535
2536 let layout_dir = tempfile::tempdir().unwrap();
2537 let layout_path = make_test_oci_layout(layout_dir.path());
2538
2539 let test_repo = TestRepo::<Sha256HashValue>::new();
2540 let repo = &test_repo.repo;
2541
2542 let null: crate::progress::SharedReporter = std::sync::Arc::new(NullReporter);
2544 import_oci_layout(repo, &layout_path, None, null)
2545 .await
2546 .expect("first import should succeed");
2547
2548 let recorder = std::sync::Arc::new(RecordingReporter::new());
2550 let reporter: crate::progress::SharedReporter =
2551 std::sync::Arc::clone(&recorder) as crate::progress::SharedReporter;
2552 import_oci_layout(repo, &layout_path, None, reporter)
2553 .await
2554 .expect("second import should succeed");
2555
2556 let events = recorder.events();
2557
2558 let done_count = events
2560 .iter()
2561 .filter(|e| matches!(e, ProgressEvent::Done { .. }))
2562 .count();
2563 let skipped_count = events
2564 .iter()
2565 .filter(|e| matches!(e, ProgressEvent::Skipped { .. }))
2566 .count();
2567 assert_eq!(
2568 done_count, 0,
2569 "no Done events expected on reimport (layers cached), got {done_count}"
2570 );
2571 assert!(
2572 skipped_count >= 1,
2573 "expected at least one Skipped on reimport, got {skipped_count}"
2574 );
2575 }
2576
2577 #[tokio::test]
2582 async fn test_import_oci_layout_with_null_reporter_does_not_panic() {
2583 use crate::oci_layout::import_oci_layout;
2584 use crate::progress::NullReporter;
2585 use composefs::fsverity::Sha256HashValue;
2586 use composefs::test::TestRepo;
2587
2588 let layout_dir = tempfile::tempdir().unwrap();
2589 let layout_path = make_test_oci_layout(layout_dir.path());
2590
2591 let test_repo = TestRepo::<Sha256HashValue>::new();
2592 let repo = &test_repo.repo;
2593
2594 let reporter: crate::progress::SharedReporter = std::sync::Arc::new(NullReporter);
2596 import_oci_layout(repo, &layout_path, None, reporter)
2597 .await
2598 .expect("import_oci_layout with NullReporter should not panic");
2599 }
2600}