Skip to main content

composefs_oci/
lib.rs

1//! OCI container image support for composefs.
2//!
3//! This crate provides functionality for working with OCI (Open Container Initiative) container images
4//! in the context of composefs. It enables importing, extracting, and mounting container images as
5//! composefs filesystems with fs-verity integrity protection.
6//!
7//! Key functionality includes:
8//! - Pulling container images from registries using skopeo
9//! - Converting OCI image layers from tar format to composefs split streams
10//! - Creating mountable filesystems from OCI image configurations
11//! - Importing from containers-storage with zero-copy reflinks (optional feature)
12
13#![forbid(unsafe_code)]
14// This is a library: emit diagnostics via the `log` crate (or return them),
15// never by writing to the process's stdout/stderr. Genuinely-intentional
16// exceptions carry a local `#[allow]` with justification. Test code is exempt.
17#![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;
27/// Re-exported from [`composefs::progress`]; use that path directly in new code.
28pub mod progress;
29pub mod skopeo;
30pub mod tar;
31
32/// Test utilities for building OCI images from dumpfile strings.
33#[cfg(any(test, feature = "test"))]
34#[allow(missing_docs, missing_debug_implementations)]
35#[doc(hidden)]
36pub mod test_util;
37
38// Re-export the composefs crate for consumers who only need composefs-oci
39pub use composefs;
40
41use std::io::Read;
42use std::{collections::HashMap, sync::Arc};
43
44use anyhow::{Context, Result, ensure};
45/// OCI content-addressable digest type (e.g. `sha256:abcd...`).
46///
47/// Re-exported from `oci-spec` for convenience.
48pub 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
64/// Named ref key for the V2 EROFS image derived from this OCI config.
65pub const IMAGE_REF_KEY: &str = "composefs.image";
66
67/// Named ref key for the V1 EROFS image derived from this OCI config.
68pub const IMAGE_REF_KEY_V1: &str = "composefs.image.v1";
69
70/// Named ref key for the V2 boot EROFS image derived from this OCI config.
71pub const BOOT_IMAGE_REF_KEY: &str = "composefs.image.boot";
72
73/// Named ref key for the V1 boot EROFS image derived from this OCI config.
74pub const BOOT_IMAGE_REF_KEY_V1: &str = "composefs.image.boot.v1";
75
76// Re-export key types for convenience
77#[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/// Statistics from an image import operation.
90#[derive(Debug, Clone, Default)]
91pub struct ImportStats {
92    /// Number of layers in the image.
93    pub layers: u64,
94    /// Number of layers that were already present (skipped).
95    pub layers_already_present: u64,
96    /// Number of objects stored via regular copy.
97    pub objects_copied: u64,
98    /// Number of objects stored via reflink (zero-copy).
99    pub objects_reflinked: u64,
100    /// Number of objects stored via hardlink (zero-copy).
101    pub objects_hardlinked: u64,
102    /// Number of objects that already existed (deduplicated).
103    pub objects_already_present: u64,
104    /// Total bytes stored via regular copy.
105    pub bytes_copied: u64,
106    /// Total bytes stored via reflink.
107    pub bytes_reflinked: u64,
108    /// Total bytes stored via hardlink.
109    pub bytes_hardlinked: u64,
110    /// Total bytes inlined in splitstreams (small files + headers).
111    pub bytes_inlined: u64,
112}
113
114impl ImportStats {
115    /// Total number of new objects stored (copied + reflinked + hardlinked).
116    pub fn new_objects(&self) -> u64 {
117        self.objects_copied + self.objects_reflinked + self.objects_hardlinked
118    }
119
120    /// Total number of objects processed (new + already present).
121    pub fn total_objects(&self) -> u64 {
122        self.new_objects() + self.objects_already_present
123    }
124
125    /// Total bytes stored as new objects (copied + reflinked + hardlinked).
126    pub fn new_bytes(&self) -> u64 {
127        self.bytes_copied + self.bytes_reflinked + self.bytes_hardlinked
128    }
129
130    /// Merge another `ImportStats` into this one.
131    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    /// Build import stats from [`SplitStreamStats`].
145    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            // Show detailed breakdown when zero-copy methods were used
178            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/// Controls whether and how the `containers-storage:` native import path
225/// is used when pulling images.
226#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
227pub enum LocalFetchOpt {
228    /// Do not use the native containers-storage import path; fall through
229    /// to skopeo.
230    #[default]
231    Disabled,
232    /// Use native containers-storage import with reflink → hardlink → copy
233    /// fallback chain.
234    IfPossible,
235    /// Use native containers-storage import but error if zero-copy
236    /// (reflink or hardlink) is not possible.
237    ZeroCopy,
238}
239
240/// Options for a [`pull`] operation.
241///
242/// Use `Default::default()` for the common case (skopeo transport, no
243/// containers-storage import).
244#[derive(Default)]
245pub struct PullOptions<'a> {
246    /// Image proxy configuration passed to skopeo (ignored for
247    /// `containers-storage:` references when `local_fetch` is not
248    /// [`Disabled`](LocalFetchOpt::Disabled)).
249    pub img_proxy_config: Option<ImageProxyConfig>,
250
251    /// Controls whether the native containers-storage import path is used.
252    /// See [`LocalFetchOpt`] for details.
253    pub local_fetch: LocalFetchOpt,
254
255    /// Explicit containers-storage root.  When set, auto-discovery is skipped
256    /// and only this path (plus any `additional_image_stores`) is searched.
257    /// Only relevant when `local_fetch` is not [`Disabled`](LocalFetchOpt::Disabled).
258    pub storage_root: Option<&'a std::path::Path>,
259
260    /// Additional read-only image stores to search beyond the primary
261    /// (auto-discovered or explicit) store.  Equivalent to the
262    /// `additionalimagestore=` option in containers/storage.
263    /// Only relevant when `local_fetch` is not [`Disabled`](LocalFetchOpt::Disabled).
264    pub additional_image_stores: &'a [&'a std::path::Path],
265
266    /// Progress reporter for this pull operation.
267    ///
268    /// When `None`, all progress events are silently discarded.  Supply a
269    /// [`SharedReporter`] implementation (e.g. an `indicatif`-backed renderer)
270    /// to receive [`ProgressEvent`]s as the pull proceeds.
271    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/// Result of a pull operation.
294#[derive(Debug)]
295pub struct PullResult<ObjectID> {
296    /// The manifest digest (sha256:...).
297    pub manifest_digest: OciDigest,
298    /// The fs-verity hash of the manifest splitstream.
299    pub manifest_verity: ObjectID,
300    /// The config digest (sha256:...).
301    pub config_digest: OciDigest,
302    /// The fs-verity hash of the config splitstream.
303    pub config_verity: ObjectID,
304    /// Import statistics.
305    pub stats: ImportStats,
306}
307
308/// A tuple of (content digest, fs-verity ObjectID).
309pub type ContentAndVerity<ObjectID> = (OciDigest, ObjectID);
310
311/// Parsed OCI config and its associated references.
312pub struct OpenConfig<ObjectID> {
313    /// The parsed OCI image configuration.
314    pub config: ImageConfiguration,
315    /// Map from layer diff_id to its fs-verity object ID.
316    pub layer_refs: HashMap<Box<str>, ObjectID>,
317    /// The V2 EROFS image ObjectID linked to this config, if any.
318    pub image_ref: Option<ObjectID>,
319    /// The V1 EROFS image ObjectID linked to this config, if any.
320    pub image_ref_v1: Option<ObjectID>,
321    /// The V2 boot EROFS image ObjectID linked to this config, if any.
322    pub boot_image_ref: Option<ObjectID>,
323    /// The V1 boot EROFS image ObjectID linked to this config, if any.
324    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
347/// Imports a container layer from a tar stream into the repository.
348///
349/// Converts the tar stream into a composefs split stream format and stores it in the repository.
350/// If a name is provided, creates a reference to the imported layer for easier access.
351///
352/// Returns the fs-verity hash value and import statistics for the stored split stream.
353pub 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    // Idempotency: if the stream already exists, just ensure the reference symlink
362    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    // Sync and register the stream with its content identifier
373    repo.register_stream(&object_id, &content_identifier, name)
374        .await?;
375
376    Ok((object_id, stats))
377}
378
379/// Pull the target image, and add the provided tag. If this is a mountable
380/// image (i.e. not an artifact), it is *not* unpacked by default.
381///
382/// When the `containers-storage` feature is enabled, the image reference
383/// starts with `containers-storage:`, **and** [`PullOptions::local_fetch`]
384/// is not [`LocalFetchOpt::Disabled`], this uses the native cstor import path
385/// which supports zero-copy reflinks/hardlinks.  Otherwise, it uses skopeo.
386///
387/// See [`PullOptions`] for tunable knobs (local-copy mode, extra storage
388/// roots, image proxy configuration).
389pub 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
435/// Convert a SHA-256 hash output to an OCI content digest.
436pub(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
443/// Compute the SHA-256 content digest of `bytes`, returning an OCI digest
444/// (e.g. `sha256:abcd...`).
445pub(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
455/// Extract ordered diff_ids from a config descriptor.
456///
457/// For standard container images (ImageConfig media type), parses the
458/// config JSON and returns `rootfs.diff_ids`. For artifacts with
459/// non-standard config types, falls back to using manifest layer
460/// digests as identifiers.
461/// Note: oci-spec models diff_ids as `Vec<String>` but they are actually
462/// OCI content digests.  We parse them here so the rest of the codebase
463/// can work with the strongly-typed `Digest`.
464pub(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
485/// Opens and parses a container configuration.
486///
487/// Reads the OCI image configuration from the repository and returns an [`OpenConfig`]
488/// containing the parsed configuration, a digest map of layer fs-verity hashes, and an
489/// optional EROFS image ObjectID if one has been linked to this config.
490///
491/// If verity is provided, it's used directly. Otherwise, the name must be a sha256 digest
492/// and the corresponding verity hash will be looked up (which is more expensive) and the content
493/// will be hashed and compared to the provided digest.
494///
495/// The returned layer refs map does not contain the [`IMAGE_REF_KEY`] — that is
496/// returned separately in [`OpenConfig::image_ref`].
497///
498/// Note: if the verity value is known and trusted then the layer fs-verity values can also be
499/// trusted.  If not, then you can use the layer map to find objects that are ostensibly the layers
500/// in question, but you'll have to verity their content hashes yourself.
501pub 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
536/// Returns the composefs EROFS ObjectID for `version` referenced by the given OCI config, if any.
537pub 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
550/// Returns the composefs EROFS ObjectID for an OCI image identified by manifest, if any.
551///
552/// This opens the manifest to find the config, then reads the config's
553/// [`IMAGE_REF_KEY`] named ref.
554pub 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
564/// Returns the boot EROFS ObjectID for `version` from the given OCI config, if any.
565pub 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
578/// Returns the boot EROFS ObjectID for an OCI image identified by manifest, if any.
579pub 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/// Result of a repository upgrade operation.
590#[derive(Debug, Clone, Default)]
591pub struct UpgradeResult {
592    /// Number of images that already had EROFS (skipped).
593    pub already_current: u64,
594    /// Number of images that were upgraded (EROFS generated).
595    pub upgraded: u64,
596    /// Number of non-container images skipped (artifacts, etc.).
597    pub skipped_non_container: u64,
598}
599
600/// Upgrades all tagged OCI images in the repository to the current format.
601///
602/// For each tagged container image, this ensures a composefs EROFS image
603/// exists and is linked to the config splitstream. Images that already have
604/// an EROFS ref are skipped. Non-container images (artifacts) are also skipped.
605///
606/// This is the migration path for repositories created by older versions of
607/// composefs-rs (e.g. bootc ≤ 1.15.x) that did not generate EROFS at pull
608/// time. Old-format splitstream headers (pre-`repr(C)`) are read transparently;
609/// the rewritten config and manifest splitstreams use the current format.
610///
611/// After upgrading, callers should run [`Repository::gc`] to clean up
612/// unreferenced old config and manifest splitstream objects.
613pub 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
654/// Writes a container configuration to the repository.
655///
656/// Serializes the image configuration to JSON and stores it as a split stream with the
657/// provided layer reference map. The configuration is stored as an external object so
658/// fsverity can be independently enabled on it.
659///
660/// If `image` is provided, a named ref with key [`IMAGE_REF_KEY`] is added to the
661/// splitstream pointing to the V2 EROFS image's ObjectID. If `image_v1` is provided,
662/// a named ref with key [`IMAGE_REF_KEY_V1`] is added pointing to the V1 image.
663/// These named refs ensure the GC walk keeps images alive as long as the config is reachable.
664///
665/// If `boot_image` / `boot_image_v1` are provided, named refs with keys
666/// [`BOOT_IMAGE_REF_KEY`] / [`BOOT_IMAGE_REF_KEY_V1`] are added.
667///
668/// Returns a tuple of (sha256 content hash, fs-verity hash value).
669pub 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
690/// Rewrites a container configuration in the repository from raw JSON bytes.
691///
692/// Like [`write_config`], but takes pre-serialized JSON bytes instead of an
693/// `ImageConfiguration`. This must be used when rewriting an existing config
694/// (e.g. to add EROFS image refs) to preserve the original JSON bytes and
695/// avoid changing the sha256 content digest.
696pub 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    // Add refs in config-defined diff_id order for deterministic output.
708    // Parse the config to get the canonical ordering of diff_ids.
709    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
736/// Ensures a composefs EROFS image exists for the given OCI container image,
737/// linking it to the config splitstream so GC keeps it alive through the tag chain.
738///
739/// This performs the following steps:
740/// 1. Opens the manifest and config to get the image configuration
741/// 2. Creates a composefs `FileSystem` from the OCI layers
742/// 3. Commits the filesystem as an EROFS image to the repository
743/// 4. Rewrites the config splitstream with an [`IMAGE_REF_KEY`] named ref
744///    pointing to the EROFS image's ObjectID
745/// 5. Rewrites the manifest splitstream with the updated config verity
746/// 6. If `tag` is provided, updates the tag to point to the new manifest
747///
748/// Calling this multiple times is safe — a new EROFS image is generated each
749/// time (though usually identical via object dedup) and the config+manifest
750/// splitstreams are rewritten. The old splitstream objects become unreferenced
751/// and are collected by the next GC.
752///
753/// Returns the EROFS image's ObjectID (fs-verity digest).
754fn 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    // Build the composefs filesystem from all layers
766    let fs = image::create_filesystem(repo, img.config_digest(), Some(img.config_verity()))?;
767
768    // Commit as EROFS image(s) for all formats in the repository's default set.
769    // No named ref — the GC link comes from the config splitstream ref.
770    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    // Read original config JSON to preserve its exact bytes (and thus its
783    // sha256 digest) when rewriting the splitstream with the new EROFS ref.
784    let config_json = img.read_config_json(repo)?;
785
786    // Rewrite config with the EROFS image ref(s), using layer refs from the
787    // OciImage (which already stripped the old image ref if any).
788    // Preserve any existing boot image refs (using explicit V2/V1 accessors).
789    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    // Read original manifest JSON for rewriting
800    let manifest_json = img.read_manifest_json(repo)?;
801
802    // Rewrite manifest with updated config verity, preserving layer verities.
803    // The layer_refs from OciImage are the same as the manifest's layer refs
804    // (both ultimately come from the config's diff_id → verity map).
805    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/// Boot-variant counterpart to [`ensure_oci_composefs_erofs`]; applies
824/// `transform_for_boot` before committing.
825#[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    // Build the composefs filesystem from all layers, then transform for boot
840    let mut fs = image::create_filesystem(repo, img.config_digest(), Some(img.config_verity()))?;
841    fs.transform_for_boot(repo)?;
842
843    // Commit as EROFS image(s) for all formats in the repository's default set.
844    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    // Read original config JSON to preserve its exact bytes
857    let config_json = img.read_config_json(repo)?;
858
859    // Rewrite config with the boot EROFS image ref(s), preserving the existing image refs
860    // (using explicit V2/V1 accessors to avoid the V1-preferred fallback).
861    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    // Read original manifest JSON for rewriting
872    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    /// Expected composefs dumpfile output for the base test image created by
907    /// [`test_util::create_base_image`]. Used across multiple tests to verify
908    /// EROFS round-trip correctness.
909    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    /// Create a test repository with meta.json in insecure mode.
936    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        // The example layer has files of sizes 0, 4095, 4096, 4097.
1000        // Files > INLINE_CONTENT_MAX (64 bytes) are stored as external objects.
1001        // So 4095, 4096, and 4097 are all external → 3 objects copied.
1002        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        // First import
1025        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        // Re-import the same layer — the stream already exists so we get
1032        // an early return with zero stats (idempotent).
1033        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        // Re-open the splitstream and check that the config JSON is stored
1103        // as an external object reference (not inline). This is important
1104        // because external objects get their own file in objects/, which
1105        // allows fsverity to be independently enabled on the raw content —
1106        // a prerequisite for signing the config by its fsverity digest.
1107        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        // The config JSON should appear as exactly one external object
1121        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        // The external object's fsverity digest should match what we'd
1129        // compute independently from the raw JSON bytes
1130        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        // Create 3 distinct layers with different content
1146        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        // Build refs HashMaps with different insertion orders to exercise
1175        // that write_config uses config-defined diff_id order, not HashMap order.
1176        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        // The verity must be identical regardless of HashMap iteration order
1190        assert_eq!(
1191            verity1, verity2,
1192            "config verity must be deterministic across calls"
1193        );
1194
1195        // Hardcoded expected value to catch any accidental changes
1196        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        // Use a fake EROFS image ID
1262        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        // Reopen and verify
1277        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        // Also verify via the convenience function
1295        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        // Create the EROFS image and link it to the config
1355        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        // The EROFS image should exist in the repository
1365        assert!(
1366            repo.open_image(&erofs_id.to_hex()).is_ok(),
1367            "EROFS image should be accessible"
1368        );
1369
1370        // The manifest+config were rewritten with the EROFS ref
1371        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        // Also verify via the convenience functions
1383        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        // Verify the EROFS content by round-tripping through erofs_to_filesystem
1402        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    /// Verify that a dual-format (V1+V2) repository populates both V1 and V2
1412    /// named refs in the config splitstream and that both image objects exist.
1413    #[tokio::test]
1414    async fn test_dual_format_both_image_refs() {
1415        use composefs::erofs::format::{FormatConfig, FormatVersion};
1416
1417        // Create a dual-format repo (insecure, SHA-256): V1 primary + V2 extra.
1418        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        // Pull a base image and generate EROFS.
1438        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        // Re-open the rewritten config.
1449        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        // Both V1 and V2 refs must be populated.
1453        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        // The two digests must differ (V1 and V2 produce different wire formats).
1463        assert_ne!(
1464            id_v1, id_v2,
1465            "V1 and V2 EROFS images must have different digests"
1466        );
1467
1468        // primary returned by ensure_oci_composefs_erofs is V1 (formats.iter() yields V1 first).
1469        assert_eq!(&primary_id, id_v1, "primary ID should be the V1 digest");
1470
1471        // composefs_erofs_for_config returns repo default (V1 for dual-format repos).
1472        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        // OciImage::image_ref() returns repo default (V1 for dual-format repos).
1486        assert_eq!(oci.image_ref(repo.erofs_version()), Some(id_v1));
1487        assert_eq!(oci.image_ref_v2(), Some(id_v2));
1488
1489        // Both image objects must actually exist in the repository.
1490        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        // Verify that commit_images with the dual-format repo wrote V1 and V2 in the map.
1500        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        // After pull, nothing is garbage
1521        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        // ensure_oci_composefs_erofs rewrites config+manifest, leaving 2 old splitstream
1536        // objects unreferenced (the original config and manifest splitstreams)
1537        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        // After GC, everything is clean — EROFS survives via config ref
1546        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        // Untag and GC — everything gets collected
1554        oci_image::untag_image(repo, "gctest:v1").unwrap();
1555        let gc2 = repo.gc(&[]).unwrap();
1556        // 14 objects: 5 layer splitstreams + 4 external file objects
1557        //   + config JSON + manifest JSON + EROFS image
1558        //   + new config splitstream + new manifest splitstream
1559        assert_eq!(gc2.objects_removed, 14, "all objects collected after untag");
1560        // 7 streams: 5 layers + 1 config + 1 manifest (tag ref removed by untag)
1561        assert_eq!(gc2.streams_pruned, 7, "all stream symlinks pruned");
1562        // 1 image: the EROFS symlink under images/
1563        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        // Repo is completely empty now
1571        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    /// Verify that rewriting a config splitstream (to add an EROFS image ref)
1578    /// preserves the original config JSON bytes — even when those bytes use
1579    /// non-canonical formatting that differs from `ImageConfiguration::to_string()`.
1580    ///
1581    /// Regression test: `ensure_oci_composefs_erofs` previously re-serialized
1582    /// the config through `config.to_string()`, producing different bytes (and
1583    /// a different sha256 digest), which caused `oci fsck` to report a
1584    /// `config-digest-mismatch`.
1585    #[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        // Create a normal image with well-formed layers
1594        let _img = test_util::create_base_image(repo, Some("nc:v1")).await;
1595
1596        // Read back the original config JSON
1597        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        // Re-serialize through serde_json::Value with PrettyFormatter to
1601        // get different bytes (tab indentation) while remaining
1602        // semantically identical JSON.
1603        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        // Sanity: the two serializations must differ in bytes but parse
1611        // identically.
1612        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        // Now overwrite the config splitstream with the non-canonical bytes.
1621        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        // Rewrite the manifest to reference the non-canonical config.
1634        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        // Now the real test: ensure_oci_composefs_erofs rewrites the config
1673        // to add an EROFS image ref.  The config digest MUST be preserved.
1674        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        // Copy-only stats (no reflinks)
1701        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        // Stats with reflinks
1717        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        // Stats with hardlinks only
1735        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        // Stats with both reflinks and hardlinks
1753        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    /// End-to-end test: multi-layer OCI image with nontrivial whiteout usage.
1781    ///
1782    /// Builds three tar layers exercising individual file whiteouts (`.wh.<name>`)
1783    /// and opaque directory whiteouts (`.wh..wh..opq`), imports them through the
1784    /// full OCI pipeline (tar → splitstream → OCI config/manifest → EROFS), and
1785    /// verifies the resulting filesystem contains exactly the expected files.
1786    #[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        // --- Tar builder helpers (local to this test) ---
1795
1796        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        /// Zero-length regular file — used for `.wh.<name>` and `.wh..wh..opq` entries.
1819        fn tar_whiteout(builder: &mut ::tar::Builder<Vec<u8>>, name: &str) {
1820            tar_file(builder, name, &[]);
1821        }
1822
1823        // --- Build the three layers ---
1824
1825        // Layer 1 (base): create initial filesystem
1826        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        // Layer 2 (whiteout + modify):
1845        //  - delete /etc/hosts (file whiteout)
1846        //  - delete /usr/lib/old-lib.so (file whiteout)
1847        //  - add /etc/hosts.new (replacement)
1848        //  - opaque whiteout on /tmp/cache (clears data.bin + index.db)
1849        //  - add /tmp/cache/fresh.bin (re-populate after opaque)
1850        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        // Layer 3 (more whiteouts):
1866        //  - delete /usr/bin/app (file whiteout)
1867        //  - add /usr/bin/app-v2 (replacement)
1868        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        // --- Import layers and build OCI image ---
1878
1879        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        // Build OCI config
1905        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        // Build OCI manifest
1936        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        // --- Create the EROFS image ---
1969
1970        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        // --- Verify the flattened filesystem ---
1980
1981        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        // Extract just the paths from the dumpfile for structural verification
1989        let paths: Vec<&str> = dump.lines().map(|l| l.split_once(' ').unwrap().0).collect();
1990
1991        // Files that SHOULD exist after all three layers
1992        let expected_present = [
1993            "/",
1994            "/etc",
1995            "/etc/config.toml", // layer 1, survived
1996            "/etc/hosts.new",   // layer 2 addition
1997            "/tmp",
1998            "/tmp/cache",
1999            "/tmp/cache/fresh.bin", // layer 2, after opaque whiteout
2000            "/usr",
2001            "/usr/bin",
2002            "/usr/bin/app-v2", // layer 3 replacement
2003            "/usr/lib",
2004            "/usr/lib/shared.so", // layer 1, survived
2005        ];
2006
2007        // Files that MUST NOT exist (removed by whiteouts)
2008        let must_not_exist = [
2009            "/etc/hosts",          // deleted by layer 2 file whiteout
2010            "/usr/lib/old-lib.so", // deleted by layer 2 file whiteout
2011            "/usr/bin/app",        // deleted by layer 3 file whiteout
2012            "/tmp/cache/data.bin", // cleared by layer 2 opaque whiteout
2013            "/tmp/cache/index.db", // cleared by layer 2 opaque whiteout
2014        ];
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    /// Verify that the full OCI pipeline works when all splitstreams use the
2027    /// old (pre-repr(C)) header layout — the format that bootc <= 1.15.x wrote.
2028    ///
2029    /// Old-format writing stays on throughout, including EROFS generation, so
2030    /// the rewritten config+manifest splitstreams are also old-format. This
2031    /// exercises the complete read-old → write-old → read-old-again chain.
2032    #[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        // Enable old-format writing for the entire test — layers, config,
2040        // manifest, and the rewritten config+manifest after EROFS generation
2041        // all get old-format headers.
2042        repo.set_write_old_splitstream_format(true);
2043        let img = test_util::create_base_image(repo, Some("old:v1")).await;
2044
2045        // Verify open_config still works with old-format splitstreams
2046        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        // Verify create_filesystem works (reads old-format layer splitstreams)
2055        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        // Generate EROFS with old-format still enabled — the rewritten
2065        // config+manifest splitstreams also get old-format headers.
2066        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        // The rewritten config+manifest are old-format; verify we can
2076        // still open the image and read back the EROFS ref through them.
2077        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    /// Simulate upgrading from the pre-EROFS-at-pull-time layout with old-format
2094    /// splitstreams. This covers the case of a system that was running
2095    /// bootc <= 1.15.x (old splitstream format, no EROFS generated at pull)
2096    /// and then upgrades to current code.
2097    #[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        // Write everything in old format — simulates bootc <= 1.15.x
2105        repo.set_write_old_splitstream_format(true);
2106        // create_base_image does NOT call ensure_oci_composefs_erofs,
2107        // so this represents the "pre-EROFS-at-pull-time" layout.
2108        let img = test_util::create_base_image(repo, Some("upgrade:v1")).await;
2109        repo.set_write_old_splitstream_format(false);
2110
2111        // Verify image_ref is None (no EROFS yet)
2112        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        // Upgrade: ensure_oci_composefs_erofs reads old-format splitstreams,
2119        // generates EROFS, and rewrites config+manifest in new format.
2120        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        // Verify the OciImage now has image_ref
2130        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        // Verify the EROFS image is accessible
2138        assert!(
2139            repo.open_image(&erofs_id.to_hex()).is_ok(),
2140            "EROFS image should be accessible"
2141        );
2142
2143        // Verify the EROFS content matches expected dumpfile
2144        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        // GC: old config+manifest splitstreams (2 objects) are now unreferenced
2153        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        // Untag and GC — everything gets collected
2162        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    /// Verify that `upgrade_repo` walks all tagged images, generates EROFS for
2170    /// those missing it, and is idempotent on subsequent runs.
2171    #[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        // Simulate old-format pulls (no EROFS generated at pull time)
2179        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        // Verify neither image has an EROFS ref yet
2185        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        // First upgrade: both images should be upgraded
2197        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        // Verify both images now have EROFS refs
2203        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        // Second upgrade: idempotent — both should be skipped
2221        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        // GC should collect old config+manifest splitstream objects
2227        // (2 per image = 4 total)
2228        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        // EROFS images should survive GC
2235        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        // Verify EROFS content is correct for the base image
2245        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        // The base image EROFS should match what other tests produce
2252        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    // ── Progress API integration tests ───────────────────────────────────────
2263
2264    /// Create a minimal OCI layout directory with one (empty) tar layer.
2265    ///
2266    /// Returns the path to the OCI layout directory. The image is pinned to
2267    /// the current host platform so `import_oci_layout` can resolve it.
2268    ///
2269    /// The layer is an empty tar archive (valid tar, zero entries), which is
2270    /// sufficient to exercise the `import_layer_from_file` progress path.
2271    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        // Create an empty tar layer (finish the builder immediately without adding any entries)
2300        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    /// Pulling a fresh OCI layout image (no prior cache) must emit at least one
2322    /// `Started` event per layer and a matching `Done` event, via the
2323    /// `import_oci_layout` fast path.
2324    ///
2325    /// This is the primary integration test for the progress API: it verifies
2326    /// that the oci_layout fast path actually emits events (previously it
2327    /// emitted none).
2328    #[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        // There must be at least one Started event
2352        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        // Every Started must have a matching Done or Skipped
2363        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    /// Re-importing the same OCI layout (layers already cached) must emit
2388    /// `Skipped` events rather than `Started`/`Done`.
2389    #[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        // First import (populates cache)
2404        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        // Second import (everything already cached)
2410        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        // On reimport, layers are cached: expect Skipped, not Done
2420        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    /// The `import_oci_layout` function with `NullReporter` (via `SharedReporter`
2439    /// wrapping `NullReporter`) must not panic now that it uses the reporter internally.
2440    ///
2441    /// This verifies the zero-overhead default path still works correctly.
2442    #[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        // NullReporter: zero overhead, no events collected
2456        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}