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 layer_sync;
26pub mod layer_transport;
27pub mod oci_image;
28pub mod oci_layout;
29/// Re-exported from [`composefs::progress`]; use that path directly in new code.
30pub mod progress;
31pub mod skopeo;
32pub mod tar;
33/// Shared wire types and client proxy for the `org.composefs.Oci` interface.
34///
35/// Available when the `varlink` feature is enabled.
36#[cfg(feature = "varlink")]
37pub mod varlink_types;
38
39/// Test utilities for building OCI images from dumpfile strings.
40#[cfg(any(test, feature = "test"))]
41#[allow(missing_docs, missing_debug_implementations)]
42#[doc(hidden)]
43pub mod test_util;
44
45#[cfg(doc)]
46pub mod design;
47#[cfg(doc)]
48pub mod sealing_spec;
49
50// Re-export the composefs crate for consumers who only need composefs-oci
51pub use composefs;
52
53use std::io::Read;
54use std::{borrow::Cow, collections::HashMap, sync::Arc};
55
56use anyhow::{Context, Result, ensure};
57/// OCI content-addressable digest type (e.g. `sha256:abcd...`).
58///
59/// Re-exported from `oci-spec` for convenience.
60pub use containers_image_proxy::oci_spec::image::Digest as OciDigest;
61
62use composefs::digest::{Digest, Sha256};
63use containers_image_proxy::ImageProxyConfig;
64use containers_image_proxy::oci_spec::image::ImageConfiguration;
65use containers_image_proxy::oci_spec::image::{Descriptor, ImageManifest, MediaType};
66
67use composefs::{
68    erofs::format::{FormatEpoch, FormatVersion},
69    fsverity::FsVerityHashValue,
70    repository::{ObjectStoreMethod, Repository},
71    splitstream::SplitStreamStats,
72};
73
74use crate::skopeo::{OCI_BLOB_CONTENT_TYPE, OCI_CONFIG_CONTENT_TYPE, TAR_LAYER_CONTENT_TYPE};
75
76/// The content-type tag used to identify OCI layer (tar) splitstreams in the
77/// repository.
78///
79/// Pass this to [`composefs::repository::Repository::open_stream`] when
80/// reading back a stored layer splitstream by its fs-verity hash.  The value
81/// is the 8-byte ASCII string `"ocilayer"` encoded as a little-endian `u64`.
82pub const LAYER_CONTENT_TYPE: u64 = TAR_LAYER_CONTENT_TYPE;
83
84/// The content-type tag used to identify non-tar OCI artifact blob splitstreams
85/// in the repository.
86///
87/// Used for OCI artifact layers whose media type is not a tar variant.
88/// The value is the 8-byte ASCII string `"oci_blob"` encoded as a little-endian `u64`.
89pub const BLOB_CONTENT_TYPE: u64 = OCI_BLOB_CONTENT_TYPE;
90
91/// Named ref key for the V2 EROFS image derived from this OCI config.
92pub const IMAGE_REF_KEY: &str = "composefs.image";
93
94/// Named ref key for the V1 EROFS image derived from this OCI config.
95pub const IMAGE_REF_KEY_V1: &str = "composefs.image.v1";
96
97/// Named ref key for the V2 boot EROFS image derived from this OCI config,
98/// built with the default [`XattrFiltering::AllowlistOnly`] mode.
99pub const BOOT_IMAGE_REF_KEY: &str = "composefs.image.boot";
100
101/// Named ref key for the V1 boot EROFS image derived from this OCI config,
102/// built with the default [`XattrFiltering::AllowlistOnly`] mode.
103pub const BOOT_IMAGE_REF_KEY_V1: &str = "composefs.image.boot.v1";
104
105/// Returns the named ref key for the boot EROFS image of the given format
106/// version built with the given xattr filtering `mode`.
107///
108/// The default mode ([`XattrFiltering::AllowlistOnly`]) uses the plain
109/// [`BOOT_IMAGE_REF_KEY`] / [`BOOT_IMAGE_REF_KEY_V1`] keys, unchanged from
110/// before per-mode caching existed, and is returned without allocating.
111/// Any other mode gets its own key, suffixed with `.xattrs=<mode>`, so that
112/// boot images built with different xattr filtering modes are cached side
113/// by side without evicting each other.
114pub(crate) fn boot_image_ref_key(
115    version: FormatVersion,
116    mode: XattrFiltering,
117) -> Cow<'static, str> {
118    let base = match version.epoch() {
119        FormatEpoch::Epoch1 => BOOT_IMAGE_REF_KEY_V1,
120        FormatEpoch::Epoch2 => BOOT_IMAGE_REF_KEY,
121    };
122    match mode {
123        XattrFiltering::AllowlistOnly => Cow::Borrowed(base),
124        mode => Cow::Owned(format!("{base}.xattrs={mode}")),
125    }
126}
127
128/// Splits a map of named refs into (boot image refs, everything else),
129/// based on the [`BOOT_IMAGE_REF_KEY`] prefix shared by all boot image ref
130/// keys (both format versions, and all xattr filtering modes — see
131/// [`boot_image_ref_key`]).
132pub(crate) fn take_boot_image_refs<ObjectID>(
133    refs: HashMap<Box<str>, ObjectID>,
134) -> (HashMap<Box<str>, ObjectID>, HashMap<Box<str>, ObjectID>) {
135    refs.into_iter()
136        .partition(|(k, _)| k.starts_with(BOOT_IMAGE_REF_KEY))
137}
138
139// Re-export key types for convenience
140#[cfg(feature = "boot")]
141pub use boot::{
142    BootImageMatch, find_matching_boot_image, generate_boot_image, generate_boot_image_get_fs,
143};
144pub use boot::{boot_image, remove_boot_image};
145pub use composefs::generic_tree::{OciTransformOptions, XattrFiltering};
146pub use oci_image::{
147    ImageInfo, LayerInfo, OCI_REF_PREFIX, OciFsckError, OciFsckResult, OciImage, OciImageNotFound,
148    OciRefNotFound, SplitstreamInfo, add_referrer, layer_dumpfile, layer_info, layer_tar,
149    list_images, list_referrers, list_refs, oci_fsck, oci_fsck_image, remove_referrer,
150    remove_referrers_for_subject, resolve_ref, tag_image, untag_image,
151};
152pub use progress::{ComponentId, NullReporter, ProgressEvent, ProgressReporter, SharedReporter};
153pub use skopeo::pull_image;
154
155/// Statistics from an image import operation.
156#[derive(Debug, Clone, Default)]
157pub struct ImportStats {
158    /// Number of layers in the image.
159    pub layers: u64,
160    /// Number of layers that were already present (skipped).
161    pub layers_already_present: u64,
162    /// Number of objects stored via regular copy.
163    pub objects_copied: u64,
164    /// Number of objects stored via reflink (zero-copy).
165    pub objects_reflinked: u64,
166    /// Number of objects stored via hardlink (zero-copy).
167    pub objects_hardlinked: u64,
168    /// Number of objects that already existed (deduplicated).
169    pub objects_already_present: u64,
170    /// Total bytes stored via regular copy.
171    pub bytes_copied: u64,
172    /// Total bytes stored via reflink.
173    pub bytes_reflinked: u64,
174    /// Total bytes stored via hardlink.
175    pub bytes_hardlinked: u64,
176    /// Total bytes inlined in splitstreams (small files + headers).
177    pub bytes_inlined: u64,
178}
179
180impl ImportStats {
181    /// Total number of new objects stored (copied + reflinked + hardlinked).
182    pub fn new_objects(&self) -> u64 {
183        self.objects_copied + self.objects_reflinked + self.objects_hardlinked
184    }
185
186    /// Total number of objects processed (new + already present).
187    pub fn total_objects(&self) -> u64 {
188        self.new_objects() + self.objects_already_present
189    }
190
191    /// Total bytes stored as new objects (copied + reflinked + hardlinked).
192    pub fn new_bytes(&self) -> u64 {
193        self.bytes_copied + self.bytes_reflinked + self.bytes_hardlinked
194    }
195
196    /// Merge another `ImportStats` into this one.
197    pub fn merge(&mut self, other: &ImportStats) {
198        self.layers += other.layers;
199        self.layers_already_present += other.layers_already_present;
200        self.objects_copied += other.objects_copied;
201        self.objects_reflinked += other.objects_reflinked;
202        self.objects_hardlinked += other.objects_hardlinked;
203        self.objects_already_present += other.objects_already_present;
204        self.bytes_copied += other.bytes_copied;
205        self.bytes_reflinked += other.bytes_reflinked;
206        self.bytes_hardlinked += other.bytes_hardlinked;
207        self.bytes_inlined += other.bytes_inlined;
208    }
209
210    /// Build import stats from [`SplitStreamStats`].
211    pub(crate) fn from_split_stream_stats(ss: &SplitStreamStats) -> Self {
212        let mut stats = ImportStats {
213            bytes_inlined: ss.inline_bytes,
214            ..Default::default()
215        };
216        for &(size, method) in &ss.external_objects {
217            match method {
218                ObjectStoreMethod::Copied => {
219                    stats.objects_copied += 1;
220                    stats.bytes_copied += size;
221                }
222                ObjectStoreMethod::Reflinked => {
223                    stats.objects_reflinked += 1;
224                    stats.bytes_reflinked += size;
225                }
226                ObjectStoreMethod::Hardlinked => {
227                    stats.objects_hardlinked += 1;
228                    stats.bytes_hardlinked += size;
229                }
230                ObjectStoreMethod::AlreadyPresent => {
231                    stats.objects_already_present += 1;
232                }
233            }
234        }
235        stats
236    }
237}
238
239impl std::fmt::Display for ImportStats {
240    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241        let has_zerocopy = self.objects_reflinked > 0 || self.objects_hardlinked > 0;
242        if has_zerocopy {
243            // Show detailed breakdown when zero-copy methods were used
244            let mut parts = Vec::new();
245            if self.objects_reflinked > 0 {
246                parts.push(format!("{} reflinked", self.objects_reflinked));
247            }
248            if self.objects_hardlinked > 0 {
249                parts.push(format!("{} hardlinked", self.objects_hardlinked));
250            }
251            parts.push(format!("{} copied", self.objects_copied));
252            parts.push(format!("{} already present", self.objects_already_present));
253            write!(f, "{} objects; ", parts.join(" + "))?;
254
255            let mut byte_parts = Vec::new();
256            if self.objects_reflinked > 0 {
257                byte_parts.push(format!(
258                    "{} reflinked",
259                    indicatif::HumanBytes(self.bytes_reflinked)
260                ));
261            }
262            if self.objects_hardlinked > 0 {
263                byte_parts.push(format!(
264                    "{} hardlinked",
265                    indicatif::HumanBytes(self.bytes_hardlinked)
266                ));
267            }
268            byte_parts.push(format!(
269                "{} copied",
270                indicatif::HumanBytes(self.bytes_copied)
271            ));
272            byte_parts.push(format!(
273                "{} inlined",
274                indicatif::HumanBytes(self.bytes_inlined)
275            ));
276            write!(f, "{}", byte_parts.join(", "))
277        } else {
278            write!(
279                f,
280                "{} new + {} already present objects; {} stored, {} inlined",
281                self.objects_copied,
282                self.objects_already_present,
283                indicatif::HumanBytes(self.bytes_copied),
284                indicatif::HumanBytes(self.bytes_inlined),
285            )
286        }
287    }
288}
289
290/// Controls whether and how the `containers-storage:` native import path
291/// is used when pulling images.
292#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
293pub enum LocalFetchOpt {
294    /// Do not use the native containers-storage import path; fall through
295    /// to skopeo.
296    #[default]
297    Disabled,
298    /// Use native containers-storage import with reflink → hardlink → copy
299    /// fallback chain.
300    IfPossible,
301    /// Use native containers-storage import but error if zero-copy
302    /// (reflink or hardlink) is not possible.
303    ZeroCopy,
304}
305
306/// Options for a [`pull`] operation.
307///
308/// Use `Default::default()` for the common case (skopeo transport, no
309/// containers-storage import).
310#[derive(Default)]
311pub struct PullOptions<'a> {
312    /// Image proxy configuration passed to skopeo (ignored for
313    /// `containers-storage:` references when `local_fetch` is not
314    /// [`Disabled`](LocalFetchOpt::Disabled)).
315    pub img_proxy_config: Option<ImageProxyConfig>,
316
317    /// Controls whether the native containers-storage import path is used.
318    /// See [`LocalFetchOpt`] for details.
319    pub local_fetch: LocalFetchOpt,
320
321    /// Explicit containers-storage root.  When set, auto-discovery is skipped
322    /// and only this path (plus any `additional_image_stores`) is searched.
323    /// Only relevant when `local_fetch` is not [`Disabled`](LocalFetchOpt::Disabled).
324    pub storage_root: Option<&'a std::path::Path>,
325
326    /// Additional read-only image stores to search beyond the primary
327    /// (auto-discovered or explicit) store.  Equivalent to the
328    /// `additionalimagestore=` option in containers/storage.
329    /// Only relevant when `local_fetch` is not [`Disabled`](LocalFetchOpt::Disabled).
330    pub additional_image_stores: &'a [&'a std::path::Path],
331
332    /// Progress reporter for this pull operation.
333    ///
334    /// When `None`, all progress events are silently discarded.  Supply a
335    /// [`SharedReporter`] implementation (e.g. an `indicatif`-backed renderer)
336    /// to receive [`ProgressEvent`]s as the pull proceeds.
337    pub progress: Option<SharedReporter>,
338}
339
340impl<'a> std::fmt::Debug for PullOptions<'a> {
341    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342        f.debug_struct("PullOptions")
343            .field("img_proxy_config", &self.img_proxy_config)
344            .field("local_fetch", &self.local_fetch)
345            .field("storage_root", &self.storage_root)
346            .field("additional_image_stores", &self.additional_image_stores)
347            .field(
348                "progress",
349                if self.progress.is_some() {
350                    &"Some(<ProgressReporter>)"
351                } else {
352                    &"None"
353                },
354            )
355            .finish()
356    }
357}
358
359/// Result of a pull operation.
360#[derive(Debug)]
361pub struct PullResult<ObjectID> {
362    /// The manifest digest (sha256:...).
363    pub manifest_digest: OciDigest,
364    /// The fs-verity hash of the manifest splitstream.
365    pub manifest_verity: ObjectID,
366    /// The config digest (sha256:...).
367    pub config_digest: OciDigest,
368    /// The fs-verity hash of the config splitstream.
369    pub config_verity: ObjectID,
370    /// Import statistics.
371    pub stats: ImportStats,
372}
373
374/// A tuple of (content digest, fs-verity ObjectID).
375pub type ContentAndVerity<ObjectID> = (OciDigest, ObjectID);
376
377/// Parsed OCI config and its associated references.
378pub struct OpenConfig<ObjectID> {
379    /// The parsed OCI image configuration.
380    pub config: ImageConfiguration,
381    /// Map from layer diff_id to its fs-verity object ID.
382    pub layer_refs: HashMap<Box<str>, ObjectID>,
383    /// The V2 EROFS image ObjectID linked to this config, if any.
384    pub image_ref: Option<ObjectID>,
385    /// The V1 EROFS image ObjectID linked to this config, if any.
386    pub image_ref_v1: Option<ObjectID>,
387    /// Boot EROFS image refs linked to this config, keyed by their named-ref
388    /// key (which encodes both the format version and the xattr filtering
389    /// mode used to build it).
390    pub boot_image_refs: HashMap<Box<str>, ObjectID>,
391}
392
393impl<ObjectID: std::fmt::Debug> std::fmt::Debug for OpenConfig<ObjectID> {
394    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395        f.debug_struct("OpenConfig")
396            .field("layer_refs", &self.layer_refs)
397            .field("image_ref", &self.image_ref)
398            .field("image_ref_v1", &self.image_ref_v1)
399            .field("boot_image_refs", &self.boot_image_refs)
400            .finish_non_exhaustive()
401    }
402}
403
404pub(crate) fn layer_identifier(diff_id: &OciDigest) -> String {
405    format!("oci-layer-{diff_id}")
406}
407
408/// Return the content-identifier string used to register a layer splitstream.
409///
410/// This is the key passed to [`composefs::repository::Repository::has_stream`]
411/// and [`composefs::repository::Repository::write_stream`] for a given OCI
412/// diff-id.  Callers outside this crate (e.g. the varlink layer-sync service)
413/// use this to look up whether a layer has been imported and to obtain its
414/// fs-verity hash.
415pub fn layer_content_id(diff_id: &OciDigest) -> String {
416    layer_identifier(diff_id)
417}
418
419pub(crate) fn config_identifier(config: &OciDigest) -> String {
420    format!("oci-config-{config}")
421}
422
423/// Imports a container layer from a tar stream into the repository.
424///
425/// Converts the tar stream into a composefs split stream format and stores it in the repository.
426/// If a name is provided, creates a reference to the imported layer for easier access.
427///
428/// Returns the fs-verity hash value and import statistics for the stored split stream.
429pub async fn import_layer<ObjectID: FsVerityHashValue>(
430    repo: &Arc<Repository<ObjectID>>,
431    diff_id: &OciDigest,
432    name: Option<&str>,
433    tar_stream: impl tokio::io::AsyncRead + Unpin,
434) -> Result<(ObjectID, ImportStats)> {
435    let content_identifier = layer_identifier(diff_id);
436
437    // Idempotency: if the stream already exists, just ensure the reference symlink
438    if let Some(id) = repo.has_stream(&content_identifier)? {
439        if let Some(name) = name {
440            repo.name_stream(&content_identifier, name)?;
441        }
442        return Ok((id, ImportStats::default()));
443    }
444
445    let (object_id, stats) =
446        tar::split_async(tar_stream, repo.clone(), TAR_LAYER_CONTENT_TYPE).await?;
447
448    // Sync and register the stream with its content identifier
449    repo.register_stream(&object_id, &content_identifier, name)
450        .await?;
451
452    Ok((object_id, stats))
453}
454
455/// Pull the target image, and add the provided tag. If this is a mountable
456/// image (i.e. not an artifact), it is *not* unpacked by default.
457///
458/// When the `containers-storage` feature is enabled, the image reference
459/// starts with `containers-storage:`, **and** [`PullOptions::local_fetch`]
460/// is not [`LocalFetchOpt::Disabled`], this uses the native cstor import path
461/// which supports zero-copy reflinks/hardlinks.  Otherwise, it uses skopeo.
462///
463/// See [`PullOptions`] for tunable knobs (local-copy mode, extra storage
464/// roots, image proxy configuration).
465pub async fn pull<ObjectID: FsVerityHashValue>(
466    repo: &Arc<Repository<ObjectID>>,
467    imgref: &str,
468    reference: Option<&str>,
469    opts: PullOptions<'_>,
470) -> Result<PullResult<ObjectID>> {
471    let reporter: SharedReporter = opts
472        .progress
473        .unwrap_or_else(|| std::sync::Arc::new(NullReporter));
474
475    #[cfg(feature = "containers-storage")]
476    if opts.local_fetch != LocalFetchOpt::Disabled
477        && let Some(image_id) = cstor::parse_containers_storage_ref(imgref)
478    {
479        let zerocopy = opts.local_fetch == LocalFetchOpt::ZeroCopy;
480        let (((manifest_digest, manifest_verity), (config_digest, config_verity)), stats) =
481            cstor::import_from_containers_storage(
482                repo,
483                image_id,
484                reference,
485                zerocopy,
486                opts.storage_root,
487                opts.additional_image_stores,
488                reporter,
489            )
490            .await?;
491        return Ok(PullResult {
492            manifest_digest,
493            manifest_verity,
494            config_digest,
495            config_verity,
496            stats,
497        });
498    }
499
500    let (result, stats) =
501        skopeo::pull_image(repo, imgref, reference, opts.img_proxy_config, reporter).await?;
502    Ok(crate::PullResult {
503        manifest_digest: result.manifest_digest,
504        manifest_verity: result.manifest_verity,
505        config_digest: result.config_digest,
506        config_verity: result.config_verity,
507        stats,
508    })
509}
510
511/// Convert a SHA-256 hash output to an OCI content digest.
512pub(crate) fn sha256_output_to_digest(output: composefs::digest::Output<Sha256>) -> OciDigest {
513    let hex = hex::encode(output);
514    format!("sha256:{hex}")
515        .try_into()
516        .expect("sha256 hex should always produce a valid OCI digest")
517}
518
519/// Compute the SHA-256 content digest of `bytes`, returning an OCI digest
520/// (e.g. `sha256:abcd...`).
521///
522/// This is primarily used to derive an OCI diff-id from a tar layer's raw
523/// bytes (before any compression).  It is also used by tests that construct
524/// synthetic layers without going through the full OCI pull path.
525pub fn sha256_content_digest(bytes: &[u8]) -> OciDigest {
526    let mut context = Sha256::new();
527    context.update(bytes);
528    sha256_output_to_digest(context.finalize())
529}
530
531fn hash_sha256(bytes: &[u8]) -> OciDigest {
532    sha256_content_digest(bytes)
533}
534
535/// Extract ordered layer identifiers from raw manifest and config JSON.
536///
537/// For standard container images (`ImageConfig` media type), parses the
538/// config and returns `rootfs.diff_ids`.  For OCI artifacts with
539/// non-standard config types, falls back to the manifest's layer digests.
540///
541/// This is the high-level entry point for callers that have raw JSON
542/// strings (e.g. from a varlink `Inspect` reply).
543pub fn extract_layer_ids(manifest_json: &str, config_json: &str) -> Result<Vec<String>> {
544    let manifest: ImageManifest =
545        serde_json::from_str(manifest_json).context("parsing manifest JSON")?;
546    extract_diff_ids(
547        manifest.config().media_type(),
548        config_json.as_bytes(),
549        manifest.layers(),
550    )
551    .map(|ids| ids.iter().map(|d| d.to_string()).collect())
552}
553
554/// Extract ordered diff_ids from a config descriptor.
555///
556/// For standard container images (ImageConfig media type), parses the
557/// config JSON and returns `rootfs.diff_ids`. For artifacts with
558/// non-standard config types, falls back to using manifest layer
559/// digests as identifiers.
560/// Note: oci-spec models diff_ids as `Vec<String>` but they are actually
561/// OCI content digests.  We parse them here so the rest of the codebase
562/// can work with the strongly-typed `Digest`.
563pub fn extract_diff_ids(
564    media_type: &MediaType,
565    config_reader: impl Read,
566    manifest_layers: &[Descriptor],
567) -> Result<Vec<OciDigest>> {
568    if *media_type == MediaType::ImageConfig {
569        let config = ImageConfiguration::from_reader(config_reader)?;
570        config
571            .rootfs()
572            .diff_ids()
573            .iter()
574            .map(|s| s.parse().context("parsing diff_id from image config"))
575            .collect()
576    } else {
577        Ok(manifest_layers
578            .iter()
579            .map(|d: &Descriptor| d.digest().clone())
580            .collect())
581    }
582}
583
584/// Opens and parses a container configuration.
585///
586/// Reads the OCI image configuration from the repository and returns an [`OpenConfig`]
587/// containing the parsed configuration, a digest map of layer fs-verity hashes, and an
588/// optional EROFS image ObjectID if one has been linked to this config.
589///
590/// If verity is provided, it's used directly. Otherwise, the name must be a sha256 digest
591/// and the corresponding verity hash will be looked up (which is more expensive) and the content
592/// will be hashed and compared to the provided digest.
593///
594/// The returned layer refs map does not contain the [`IMAGE_REF_KEY`] — that is
595/// returned separately in [`OpenConfig::image_ref`].
596///
597/// Note: if the verity value is known and trusted then the layer fs-verity values can also be
598/// trusted.  If not, then you can use the layer map to find objects that are ostensibly the layers
599/// in question, but you'll have to verity their content hashes yourself.
600pub fn open_config<ObjectID: FsVerityHashValue>(
601    repo: &Repository<ObjectID>,
602    config_digest: &OciDigest,
603    verity: Option<&ObjectID>,
604) -> Result<OpenConfig<ObjectID>> {
605    let (data, mut named_refs) = oci_image::read_external_splitstream(
606        repo,
607        &config_identifier(config_digest),
608        verity,
609        Some(OCI_CONFIG_CONTENT_TYPE),
610    )?;
611
612    if verity.is_none() {
613        let computed = hash_sha256(&data);
614        ensure!(
615            *config_digest == computed,
616            "Config integrity check failed: expected {config_digest}, got {computed}"
617        );
618    }
619
620    let image_ref = named_refs.remove(IMAGE_REF_KEY);
621    let image_ref_v1 = named_refs.remove(IMAGE_REF_KEY_V1);
622    let (boot_image_refs, layer_refs) = take_boot_image_refs(named_refs);
623    let config = ImageConfiguration::from_reader(&data[..])?;
624    Ok(OpenConfig {
625        config,
626        layer_refs,
627        image_ref,
628        image_ref_v1,
629        boot_image_refs,
630    })
631}
632
633/// Returns the composefs EROFS ObjectID for `version` referenced by the given OCI config, if any.
634pub fn composefs_erofs_for_config<ObjectID: FsVerityHashValue>(
635    repo: &Repository<ObjectID>,
636    config_digest: &OciDigest,
637    verity: Option<&ObjectID>,
638    version: FormatVersion,
639) -> Result<Option<ObjectID>> {
640    let oc = open_config(repo, config_digest, verity)?;
641    Ok(match version.epoch() {
642        FormatEpoch::Epoch1 => oc.image_ref_v1,
643        FormatEpoch::Epoch2 => oc.image_ref,
644    })
645}
646
647/// Returns the composefs EROFS ObjectID for an OCI image identified by manifest, if any.
648///
649/// This opens the manifest to find the config, then reads the config's
650/// [`IMAGE_REF_KEY`] named ref.
651pub fn composefs_erofs_for_manifest<ObjectID: FsVerityHashValue>(
652    repo: &Repository<ObjectID>,
653    manifest_digest: &OciDigest,
654    manifest_verity: Option<&ObjectID>,
655    version: FormatVersion,
656) -> Result<Option<ObjectID>> {
657    let img = oci_image::OciImage::open(repo, manifest_digest, manifest_verity)?;
658    Ok(img.image_ref(version).cloned())
659}
660
661/// Returns the boot EROFS ObjectID for `version` built with the given xattr
662/// filtering `mode`, from the given OCI config, if any.
663pub fn composefs_boot_erofs_for_config<ObjectID: FsVerityHashValue>(
664    repo: &Repository<ObjectID>,
665    config_digest: &OciDigest,
666    verity: Option<&ObjectID>,
667    version: FormatVersion,
668    mode: XattrFiltering,
669) -> Result<Option<ObjectID>> {
670    let oc = open_config(repo, config_digest, verity)?;
671    Ok(oc
672        .boot_image_refs
673        .get(&*boot_image_ref_key(version, mode))
674        .cloned())
675}
676
677/// Returns the boot EROFS ObjectID for `version` built with the given xattr
678/// filtering `mode`, for an OCI image identified by manifest, if any.
679pub fn composefs_boot_erofs_for_manifest<ObjectID: FsVerityHashValue>(
680    repo: &Repository<ObjectID>,
681    manifest_digest: &OciDigest,
682    manifest_verity: Option<&ObjectID>,
683    version: FormatVersion,
684    mode: XattrFiltering,
685) -> Result<Option<ObjectID>> {
686    let img = oci_image::OciImage::open(repo, manifest_digest, manifest_verity)?;
687    Ok(img.boot_image_ref_for_mode(version, mode).cloned())
688}
689
690/// Result of a repository upgrade operation.
691#[derive(Debug, Clone, Default)]
692pub struct UpgradeResult {
693    /// Number of images that already had EROFS (skipped).
694    pub already_current: u64,
695    /// Number of images that were upgraded (EROFS generated).
696    pub upgraded: u64,
697    /// Number of non-container images skipped (artifacts, etc.).
698    pub skipped_non_container: u64,
699}
700
701/// Upgrades all tagged OCI images in the repository to the current format.
702///
703/// For each tagged container image, this ensures a composefs EROFS image
704/// exists and is linked to the config splitstream. Images that already have
705/// an EROFS ref are skipped. Non-container images (artifacts) are also skipped.
706///
707/// This is the migration path for repositories created by older versions of
708/// composefs-rs (e.g. bootc ≤ 1.15.x) that did not generate EROFS at pull
709/// time. Old-format splitstream headers (pre-`repr(C)`) are read transparently;
710/// the rewritten config and manifest splitstreams use the current format.
711///
712/// After upgrading, callers should run [`Repository::gc`] to clean up
713/// unreferenced old config and manifest splitstream objects.
714pub fn upgrade_repo<ObjectID: FsVerityHashValue>(
715    repo: &Arc<Repository<ObjectID>>,
716) -> Result<UpgradeResult> {
717    let mut result = UpgradeResult::default();
718
719    for (tag, manifest_digest) in oci_image::list_refs(repo)? {
720        let img = oci_image::OciImage::open(repo, &manifest_digest, None)
721            .with_context(|| format!("opening image {tag}"))?;
722
723        if !img.is_container_image() {
724            tracing::debug!("skipping non-container image {tag}");
725            result.skipped_non_container += 1;
726            continue;
727        }
728
729        if img.image_ref(repo.erofs_version()).is_some() {
730            tracing::debug!("image {tag} already has EROFS ref, skipping");
731            result.already_current += 1;
732            continue;
733        }
734
735        let erofs_id = ensure_oci_composefs_erofs(
736            repo,
737            &manifest_digest,
738            Some(img.manifest_verity()),
739            Some(&tag),
740        )
741        .with_context(|| format!("generating EROFS for image {tag}"))?;
742
743        if erofs_id.is_some() {
744            tracing::info!("upgraded image {tag}");
745            result.upgraded += 1;
746        } else {
747            tracing::debug!("image {tag} produced no EROFS (not a container image?)");
748            result.skipped_non_container += 1;
749        }
750    }
751
752    Ok(result)
753}
754
755/// Writes a container configuration to the repository.
756///
757/// Serializes the image configuration to JSON and stores it as a split stream with the
758/// provided layer reference map. The configuration is stored as an external object so
759/// fsverity can be independently enabled on it.
760///
761/// If `image` is provided, a named ref with key [`IMAGE_REF_KEY`] is added to the
762/// splitstream pointing to the V2 EROFS image's ObjectID. If `image_v1` is provided,
763/// a named ref with key [`IMAGE_REF_KEY_V1`] is added pointing to the V1 image.
764/// These named refs ensure the GC walk keeps images alive as long as the config is reachable.
765///
766/// `boot_images` supplies the complete set of boot EROFS named refs to write
767/// (already keyed by their final named-ref key), covering every format
768/// version and xattr filtering mode that should remain cached. Callers that
769/// don't intend to touch boot image refs should pass through the existing
770/// set unchanged; passing an empty map removes all cached boot images.
771///
772/// Returns a tuple of (sha256 content hash, fs-verity hash value).
773pub fn write_config<ObjectID: FsVerityHashValue>(
774    repo: &Arc<Repository<ObjectID>>,
775    config: &ImageConfiguration,
776    refs: HashMap<Box<str>, ObjectID>,
777    image: Option<&ObjectID>,
778    image_v1: Option<&ObjectID>,
779    boot_images: &HashMap<Box<str>, ObjectID>,
780) -> Result<ContentAndVerity<ObjectID>> {
781    let json = config.to_string()?;
782    write_config_raw(repo, json.as_bytes(), refs, image, image_v1, boot_images)
783}
784
785/// Rewrites a container configuration in the repository from raw JSON bytes.
786///
787/// Like [`write_config`], but takes pre-serialized JSON bytes instead of an
788/// `ImageConfiguration`. This must be used when rewriting an existing config
789/// (e.g. to add EROFS image refs) to preserve the original JSON bytes and
790/// avoid changing the sha256 content digest.
791pub fn write_config_raw<ObjectID: FsVerityHashValue>(
792    repo: &Arc<Repository<ObjectID>>,
793    config_json: &[u8],
794    refs: HashMap<Box<str>, ObjectID>,
795    image: Option<&ObjectID>,
796    image_v1: Option<&ObjectID>,
797    boot_images: &HashMap<Box<str>, ObjectID>,
798) -> Result<ContentAndVerity<ObjectID>> {
799    let config_digest = hash_sha256(config_json);
800    let mut stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE)?;
801    // Add refs in config-defined diff_id order for deterministic output.
802    // Parse the config to get the canonical ordering of diff_ids.
803    let config = ImageConfiguration::from_reader(config_json)?;
804    for diff_id_str in config.rootfs().diff_ids() {
805        let value = refs.get(diff_id_str.as_str()).with_context(|| {
806            let keys: Vec<_> = refs.keys().collect();
807            format!(
808                "missing layer verity for diff_id {diff_id_str}. Available keys in refs: {keys:?}"
809            )
810        })?;
811        stream.add_named_stream_ref(diff_id_str, value);
812    }
813    if let Some(image_id) = image {
814        stream.add_named_stream_ref(IMAGE_REF_KEY, image_id);
815    }
816    if let Some(image_id_v1) = image_v1 {
817        stream.add_named_stream_ref(IMAGE_REF_KEY_V1, image_id_v1);
818    }
819    for (key, boot_id) in boot_images {
820        stream.add_named_stream_ref(key, boot_id);
821    }
822    stream.write_external(config_json)?;
823    let id = repo.write_stream(stream, &config_identifier(&config_digest), None)?;
824    Ok((config_digest, id))
825}
826
827/// Ensures a composefs EROFS image exists for the given OCI container image,
828/// linking it to the config splitstream so GC keeps it alive through the tag chain.
829///
830/// This performs the following steps:
831/// 1. Opens the manifest and config to get the image configuration
832/// 2. Creates a composefs `FileSystem` from the OCI layers
833/// 3. Commits the filesystem as an EROFS image to the repository
834/// 4. Rewrites the config splitstream with an [`IMAGE_REF_KEY`] named ref
835///    pointing to the EROFS image's ObjectID
836/// 5. Rewrites the manifest splitstream with the updated config verity
837/// 6. If `tag` is provided, updates the tag to point to the new manifest
838///
839/// Calling this multiple times is safe — a new EROFS image is generated each
840/// time (though usually identical via object dedup) and the config+manifest
841/// splitstreams are rewritten. The old splitstream objects become unreferenced
842/// and are collected by the next GC.
843///
844/// Returns the EROFS image's ObjectID (fs-verity digest).
845pub(crate) fn ensure_oci_composefs_erofs<ObjectID: FsVerityHashValue>(
846    repo: &Arc<Repository<ObjectID>>,
847    manifest_digest: &OciDigest,
848    manifest_verity: Option<&ObjectID>,
849    tag: Option<&str>,
850) -> Result<Option<ObjectID>> {
851    let img = oci_image::OciImage::open(repo, manifest_digest, manifest_verity)?;
852    if !img.is_container_image() {
853        return Ok(None);
854    }
855
856    // Build the composefs filesystem from all layers
857    let fs = image::create_filesystem(
858        repo,
859        img.config_digest(),
860        Some(img.config_verity()),
861        &composefs::generic_tree::OciTransformOptions::default(),
862    )?;
863
864    // Commit as EROFS image(s) for all formats in the repository's default set.
865    // No named ref — the GC link comes from the config splitstream ref.
866    let mut erofs_map = fs.commit_images(repo, None)?;
867    let erofs_id_v2 = erofs_map.remove(&FormatVersion::V2);
868    let erofs_id_v1 = erofs_map.remove(&FormatVersion::V1);
869
870    let erofs_id = match repo.erofs_version().epoch() {
871        FormatEpoch::Epoch1 => erofs_id_v1.clone(),
872        FormatEpoch::Epoch2 => erofs_id_v2.clone(),
873    }
874    .ok_or_else(|| {
875        anyhow::anyhow!("commit_images did not produce the repository's default EROFS format")
876    })?;
877
878    // Read original config JSON to preserve its exact bytes (and thus its
879    // sha256 digest) when rewriting the splitstream with the new EROFS ref.
880    let config_json = img.read_config_json(repo)?;
881
882    // Rewrite config with the EROFS image ref(s), using layer refs from the
883    // OciImage (which already stripped the old image ref if any).
884    // Preserve all existing boot image refs unchanged — this path never
885    // touches boot images.
886    let (_config_digest, new_config_verity) = write_config_raw(
887        repo,
888        &config_json,
889        img.layer_refs().clone(),
890        erofs_id_v2.as_ref(),
891        erofs_id_v1.as_ref(),
892        img.boot_image_refs(),
893    )?;
894
895    // Read original manifest JSON for rewriting
896    let manifest_json = img.read_manifest_json(repo)?;
897
898    // Rewrite manifest with updated config verity, preserving layer verities.
899    // The layer_refs from OciImage are the same as the manifest's layer refs
900    // (both ultimately come from the config's diff_id → verity map).
901    let layer_verities: Vec<_> = img
902        .layer_refs()
903        .iter()
904        .map(|(k, v)| (k.clone(), v.clone()))
905        .collect();
906
907    let (_new_manifest_digest, _new_manifest_verity) = oci_image::rewrite_manifest(
908        repo,
909        &manifest_json,
910        manifest_digest,
911        &new_config_verity,
912        &layer_verities,
913        tag,
914    )?;
915
916    Ok(Some(erofs_id))
917}
918
919/// Boot-variant counterpart to [`ensure_oci_composefs_erofs`]; applies
920/// `transform_for_boot` before committing.
921#[cfg(feature = "boot")]
922fn ensure_oci_composefs_erofs_boot<ObjectID: FsVerityHashValue>(
923    repo: &Arc<Repository<ObjectID>>,
924    manifest_digest: &OciDigest,
925    manifest_verity: Option<&ObjectID>,
926    tag: Option<&str>,
927    options: &composefs::generic_tree::OciTransformOptions,
928    get_untransformed_filesystem: bool,
929) -> Result<Option<(ObjectID, Option<composefs::tree::FileSystem<ObjectID>>)>> {
930    use composefs_boot::BootOps;
931
932    let img = oci_image::OciImage::open(repo, manifest_digest, manifest_verity)?;
933    if !img.is_container_image() {
934        return Ok(None);
935    }
936
937    // Build the composefs filesystem from all layers, then transform for boot
938    let mut fs = image::create_filesystem(
939        repo,
940        img.config_digest(),
941        Some(img.config_verity()),
942        options,
943    )?;
944
945    // We want the full filesystem to get boot entries
946    // [`transform_for_boot`] masks /boot which we don't want
947    let untransformed_fs = if get_untransformed_filesystem {
948        Some(fs.clone())
949    } else {
950        None
951    };
952
953    fs.transform_for_boot(repo)?;
954
955    // Commit as EROFS image(s) for all formats in the repository's default set.
956    let mut boot_erofs_map = fs.commit_images(repo, None)?;
957    let boot_erofs_id_v2 = boot_erofs_map.remove(&FormatVersion::V2);
958    let boot_erofs_id_v1 = boot_erofs_map.remove(&FormatVersion::V1);
959
960    let boot_erofs_id = match repo.erofs_version().epoch() {
961        FormatEpoch::Epoch1 => boot_erofs_id_v1.clone(),
962        FormatEpoch::Epoch2 => boot_erofs_id_v2.clone(),
963    }
964    .ok_or_else(|| {
965        anyhow::anyhow!("commit_images did not produce the repository's default boot EROFS format")
966    })?;
967
968    // Read original config JSON to preserve its exact bytes
969    let config_json = img.read_config_json(repo)?;
970
971    // Rewrite config with the boot EROFS image ref(s), preserving the existing
972    // image refs (using explicit V2/V1 accessors to avoid the V1-preferred
973    // fallback) as well as any boot image refs cached under other xattr
974    // filtering modes — only the entries for `options.xattrs` are updated.
975    let mut boot_images = img.boot_image_refs().clone();
976    if let Some(id) = &boot_erofs_id_v2 {
977        boot_images.insert(
978            boot_image_ref_key(FormatVersion::V2, options.xattrs)
979                .into_owned()
980                .into_boxed_str(),
981            id.clone(),
982        );
983    }
984    if let Some(id) = &boot_erofs_id_v1 {
985        boot_images.insert(
986            boot_image_ref_key(FormatVersion::V1, options.xattrs)
987                .into_owned()
988                .into_boxed_str(),
989            id.clone(),
990        );
991    }
992    let (_config_digest, new_config_verity) = write_config_raw(
993        repo,
994        &config_json,
995        img.layer_refs().clone(),
996        img.image_ref_v2(),
997        img.image_ref_v1(),
998        &boot_images,
999    )?;
1000
1001    // Read original manifest JSON for rewriting
1002    let manifest_json = img.read_manifest_json(repo)?;
1003
1004    let layer_verities: Vec<_> = img
1005        .layer_refs()
1006        .iter()
1007        .map(|(k, v)| (k.clone(), v.clone()))
1008        .collect();
1009
1010    let (_new_manifest_digest, _new_manifest_verity) = oci_image::rewrite_manifest(
1011        repo,
1012        &manifest_json,
1013        manifest_digest,
1014        &new_config_verity,
1015        &layer_verities,
1016        tag,
1017    )?;
1018
1019    Ok(Some((boot_erofs_id, untransformed_fs)))
1020}
1021
1022#[cfg(test)]
1023mod test {
1024    use std::{fmt::Write, io::Read};
1025
1026    use rustix::fs::CWD;
1027
1028    use composefs::{
1029        fsverity::Sha256HashValue,
1030        repository::{Repository, RepositoryConfig},
1031        test::tempdir,
1032    };
1033
1034    use super::*;
1035
1036    /// Expected composefs dumpfile output for the base test image created by
1037    /// [`test_util::create_base_image`]. Used across multiple tests to verify
1038    /// EROFS round-trip correctness.
1039    const EXPECTED_BASE_IMAGE_DUMPFILE: &str = "\
1040/ 0 40755 6 0 0 0 0.0 - - -
1041/etc 0 40755 2 0 0 0 0.0 - - -
1042/etc/hostname 9 100644 1 0 0 0 0.0 - test-host -
1043/etc/os-release 23 100644 1 0 0 0 0.0 - ID=test\\nVERSION_ID=1.0\\n -
1044/etc/passwd 100 100644 1 0 0 0 0.0 f2/c4fd5735bd46db3b18d402ae87c5086c97c0e1321901cfd30f320b73ef25aa - f2c4fd5735bd46db3b18d402ae87c5086c97c0e1321901cfd30f320b73ef25aa
1045/tmp 0 40755 2 0 0 0 0.0 - - -
1046/usr 0 40755 5 0 0 0 0.0 - - -
1047/usr/bin 0 40755 2 0 0 0 0.0 - - -
1048/usr/bin/busybox 4096 100755 1 0 0 0 0.0 f0/f7e1e58fdd31f5792222087377a4a976760c416ecdf5f426193e608681b7a1 - f0f7e1e58fdd31f5792222087377a4a976760c416ecdf5f426193e608681b7a1
1049/usr/bin/cat 7 120777 1 0 0 0 0.0 busybox - -
1050/usr/bin/cp 7 120777 1 0 0 0 0.0 busybox - -
1051/usr/bin/ls 7 120777 1 0 0 0 0.0 busybox - -
1052/usr/bin/mv 7 120777 1 0 0 0 0.0 busybox - -
1053/usr/bin/ping 7 120777 1 0 0 0 0.0 busybox - - security.capability=\\x02\\x00\\x00\\x02\\x00\\x20\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00
1054/usr/bin/rm 7 120777 1 0 0 0 0.0 busybox - -
1055/usr/bin/sh 7 120777 1 0 0 0 0.0 busybox - -
1056/usr/lib 0 40755 2 0 0 0 0.0 - - -
1057/usr/share 0 40755 3 0 0 0 0.0 - - -
1058/usr/share/doc 0 40755 2 0 0 0 0.0 - - -
1059/usr/share/doc/README 512 100644 1 0 0 0 0.0 51/44b8f80be57c3518f410d930e18c4e405387c82e4993c18265a1ba4a80263b - 5144b8f80be57c3518f410d930e18c4e405387c82e4993c18265a1ba4a80263b
1060/var 0 40755 3 0 0 0 0.0 - - -
1061/var/data 0 40755 2 0 0 0 0.0 - - -
1062/var/data/app.json 256 100644 1 0 0 0 0.0 c9/21965b74ac1780bc437cec640b27186d85317b9afdb3dbb68626aed5ecd2b6 - c921965b74ac1780bc437cec640b27186d85317b9afdb3dbb68626aed5ecd2b6
1063";
1064
1065    /// Create a test repository with meta.json in insecure mode.
1066    fn create_test_repo() -> (tempfile::TempDir, Arc<Repository<Sha256HashValue>>) {
1067        let dir = tempdir();
1068        let repo_path = dir.path().join("repo");
1069        let (repo, _) =
1070            Repository::init_path(CWD, &repo_path, RepositoryConfig::default().set_insecure())
1071                .expect("initializing test repo");
1072        (dir, Arc::new(repo))
1073    }
1074
1075    fn append_data(builder: &mut ::tar::Builder<Vec<u8>>, name: &str, size: usize) {
1076        let mut header = ::tar::Header::new_ustar();
1077        header.set_uid(0);
1078        header.set_gid(0);
1079        header.set_mode(0o700);
1080        header.set_entry_type(::tar::EntryType::Regular);
1081        header.set_size(size as u64);
1082        builder
1083            .append_data(&mut header, name, std::io::repeat(0u8).take(size as u64))
1084            .unwrap();
1085    }
1086
1087    fn example_layer() -> Vec<u8> {
1088        let mut builder = ::tar::Builder::new(vec![]);
1089        append_data(&mut builder, "file0", 0);
1090        append_data(&mut builder, "file4095", 4095);
1091        append_data(&mut builder, "file4096", 4096);
1092        append_data(&mut builder, "file4097", 4097);
1093        builder.into_inner().unwrap()
1094    }
1095
1096    #[tokio::test]
1097    async fn test_layer() {
1098        let layer = example_layer();
1099        let layer_id = hash_sha256(&layer);
1100
1101        let (_repo_dir, repo) = create_test_repo();
1102        let (id, _stats) = import_layer(&repo, &layer_id, Some("name"), &layer[..])
1103            .await
1104            .unwrap();
1105
1106        let mut dump = String::new();
1107        let mut split_stream = repo.open_stream("refs/name", Some(&id), None).unwrap();
1108        while let Some(entry) = tar::get_entry(&mut split_stream).unwrap() {
1109            writeln!(dump, "{entry}").unwrap();
1110        }
1111        similar_asserts::assert_eq!(dump, "\
1112/file0 0 100700 1 0 0 0 0.0 - - -
1113/file4095 4095 100700 1 0 0 0 0.0 53/72beb83c78537c8970c8361e3254119fafdf1763854ecd57d3f0fe2da7c719 - 5372beb83c78537c8970c8361e3254119fafdf1763854ecd57d3f0fe2da7c719
1114/file4096 4096 100700 1 0 0 0 0.0 ba/bc284ee4ffe7f449377fbf6692715b43aec7bc39c094a95878904d34bac97e - babc284ee4ffe7f449377fbf6692715b43aec7bc39c094a95878904d34bac97e
1115/file4097 4097 100700 1 0 0 0 0.0 09/3756e4ea9683329106d4a16982682ed182c14bf076463a9e7f97305cbac743 - 093756e4ea9683329106d4a16982682ed182c14bf076463a9e7f97305cbac743
1116");
1117    }
1118
1119    #[tokio::test]
1120    async fn test_layer_import_stats() {
1121        let layer = example_layer();
1122        let layer_id = hash_sha256(&layer);
1123
1124        let (_repo_dir, repo) = create_test_repo();
1125        let (_id, stats) = import_layer(&repo, &layer_id, Some("name"), &layer[..])
1126            .await
1127            .unwrap();
1128
1129        // The example layer has files of sizes 0, 4095, 4096, 4097.
1130        // Files > INLINE_CONTENT_MAX (64 bytes) are stored as external objects.
1131        // So 4095, 4096, and 4097 are all external → 3 objects copied.
1132        assert_eq!(
1133            stats.objects_copied, 3,
1134            "three files above inline threshold should be external objects"
1135        );
1136        assert_eq!(stats.objects_already_present, 0);
1137        assert!(
1138            stats.bytes_copied > 0,
1139            "bytes_copied should be nonzero for external objects"
1140        );
1141        assert!(
1142            stats.bytes_inlined > 0,
1143            "bytes_inlined should be nonzero (tar headers + small file)"
1144        );
1145    }
1146
1147    #[tokio::test]
1148    async fn test_layer_import_deduplication_stats() {
1149        let layer = example_layer();
1150        let layer_id = hash_sha256(&layer);
1151
1152        let (_repo_dir, repo) = create_test_repo();
1153
1154        // First import
1155        let (_id, stats1) = import_layer(&repo, &layer_id, None, &layer[..])
1156            .await
1157            .unwrap();
1158        assert_eq!(stats1.objects_copied, 3);
1159        assert_eq!(stats1.objects_already_present, 0);
1160
1161        // Re-import the same layer — the stream already exists so we get
1162        // an early return with zero stats (idempotent).
1163        let (_id, stats2) = import_layer(&repo, &layer_id, None, &layer[..])
1164            .await
1165            .unwrap();
1166        assert_eq!(stats2.objects_copied, 0);
1167        assert_eq!(stats2.objects_already_present, 0);
1168        assert_eq!(stats2.bytes_copied, 0);
1169    }
1170
1171    #[test]
1172    fn test_write_and_open_config() {
1173        use containers_image_proxy::oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
1174
1175        let (_repo_dir, repo) = create_test_repo();
1176
1177        let rootfs = RootFsBuilder::default()
1178            .typ("layers")
1179            .diff_ids(vec!["sha256:abc123def456".to_string()])
1180            .build()
1181            .unwrap();
1182
1183        let config = ImageConfigurationBuilder::default()
1184            .architecture("amd64")
1185            .os("linux")
1186            .rootfs(rootfs)
1187            .build()
1188            .unwrap();
1189
1190        let mut refs = HashMap::new();
1191        refs.insert("sha256:abc123def456".into(), Sha256HashValue::EMPTY);
1192
1193        let (config_digest, config_verity) =
1194            write_config(&repo, &config, refs.clone(), None, None, &HashMap::new()).unwrap();
1195
1196        assert!(config_digest.as_ref().starts_with("sha256:"));
1197
1198        let oc = open_config(&repo, &config_digest, Some(&config_verity)).unwrap();
1199        assert_eq!(oc.config.architecture().to_string(), "amd64");
1200        assert_eq!(oc.config.os().to_string(), "linux");
1201        assert_eq!(oc.layer_refs.len(), 1);
1202        assert!(oc.layer_refs.contains_key("sha256:abc123def456"));
1203        assert!(oc.image_ref.is_none());
1204        assert!(oc.boot_image_refs.is_empty());
1205
1206        let oc2 = open_config(&repo, &config_digest, None).unwrap();
1207        assert_eq!(oc2.config.architecture().to_string(), "amd64");
1208    }
1209
1210    #[test]
1211    fn test_config_stored_as_external_object() {
1212        use containers_image_proxy::oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
1213
1214        let (_repo_dir, repo) = create_test_repo();
1215
1216        let rootfs = RootFsBuilder::default()
1217            .typ("layers")
1218            .diff_ids(vec![])
1219            .build()
1220            .unwrap();
1221
1222        let config = ImageConfigurationBuilder::default()
1223            .architecture("amd64")
1224            .os("linux")
1225            .rootfs(rootfs)
1226            .build()
1227            .unwrap();
1228
1229        let (config_digest, config_verity) =
1230            write_config(&repo, &config, HashMap::new(), None, None, &HashMap::new()).unwrap();
1231
1232        // Re-open the splitstream and check that the config JSON is stored
1233        // as an external object reference (not inline). This is important
1234        // because external objects get their own file in objects/, which
1235        // allows fsverity to be independently enabled on the raw content —
1236        // a prerequisite for signing the config by its fsverity digest.
1237        let mut stream = repo
1238            .open_stream(
1239                &config_identifier(&config_digest),
1240                Some(&config_verity),
1241                Some(crate::skopeo::OCI_CONFIG_CONTENT_TYPE),
1242            )
1243            .unwrap();
1244
1245        let mut object_refs = Vec::new();
1246        stream
1247            .get_object_refs(|id| object_refs.push(id.clone()))
1248            .unwrap();
1249
1250        // The config JSON should appear as exactly one external object
1251        assert_eq!(
1252            object_refs.len(),
1253            1,
1254            "Config should be stored as one external object, got {} refs",
1255            object_refs.len()
1256        );
1257
1258        // The external object's fsverity digest should match what we'd
1259        // compute independently from the raw JSON bytes
1260        let json_bytes = config.to_string().unwrap();
1261        let expected_verity: Sha256HashValue =
1262            composefs::fsverity::compute_verity(json_bytes.as_bytes());
1263        assert_eq!(
1264            object_refs[0], expected_verity,
1265            "External object verity should match independently computed verity of config JSON"
1266        );
1267    }
1268
1269    #[tokio::test]
1270    async fn test_config_verity_deterministic() -> Result<()> {
1271        use containers_image_proxy::oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
1272
1273        let (_repo_dir, repo) = create_test_repo();
1274
1275        // Create 3 distinct layers with different content
1276        let mut layers = Vec::new();
1277        for (name, size) in [("alpha", 1000), ("beta", 2000), ("gamma", 3000)] {
1278            let mut builder = ::tar::Builder::new(vec![]);
1279            append_data(&mut builder, name, size);
1280            let layer = builder.into_inner().unwrap();
1281
1282            let diff_id = hash_sha256(&layer);
1283
1284            let (verity, _stats) = import_layer(&repo, &diff_id, None, &mut layer.as_slice())
1285                .await
1286                .unwrap();
1287            layers.push((diff_id.to_string(), verity));
1288        }
1289
1290        let diff_ids: Vec<String> = layers.iter().map(|(d, _)| d.clone()).collect();
1291        let config = ImageConfigurationBuilder::default()
1292            .architecture("amd64")
1293            .os("linux")
1294            .rootfs(
1295                RootFsBuilder::default()
1296                    .typ("layers")
1297                    .diff_ids(diff_ids.clone())
1298                    .build()
1299                    .unwrap(),
1300            )
1301            .build()
1302            .unwrap();
1303
1304        // Build refs HashMaps with different insertion orders to exercise
1305        // that write_config uses config-defined diff_id order, not HashMap order.
1306        let refs1: HashMap<Box<str>, Sha256HashValue> = layers
1307            .iter()
1308            .map(|(d, v)| (d.as_str().into(), v.clone()))
1309            .collect();
1310        let refs2: HashMap<Box<str>, Sha256HashValue> = layers
1311            .iter()
1312            .rev()
1313            .map(|(d, v)| (d.as_str().into(), v.clone()))
1314            .collect();
1315
1316        let (_digest1, verity1) = write_config(&repo, &config, refs1, None, None, &HashMap::new())?;
1317        let (_digest2, verity2) = write_config(&repo, &config, refs2, None, None, &HashMap::new())?;
1318
1319        // The verity must be identical regardless of HashMap iteration order
1320        assert_eq!(
1321            verity1, verity2,
1322            "config verity must be deterministic across calls"
1323        );
1324
1325        // Hardcoded expected value to catch any accidental changes
1326        assert_eq!(
1327            verity1.to_hex(),
1328            "4839518dea22749f8ff233e7f7baec65f23dd5336462f46ad6884769af84bf95",
1329            "config verity changed unexpectedly"
1330        );
1331
1332        Ok(())
1333    }
1334
1335    #[test]
1336    fn test_open_config_bad_hash() {
1337        use containers_image_proxy::oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
1338
1339        let (_repo_dir, repo) = create_test_repo();
1340
1341        let rootfs = RootFsBuilder::default()
1342            .typ("layers")
1343            .diff_ids(vec![])
1344            .build()
1345            .unwrap();
1346
1347        let config = ImageConfigurationBuilder::default()
1348            .architecture("amd64")
1349            .os("linux")
1350            .rootfs(rootfs)
1351            .build()
1352            .unwrap();
1353
1354        let (config_digest, _config_verity) =
1355            write_config(&repo, &config, HashMap::new(), None, None, &HashMap::new()).unwrap();
1356
1357        let bad_digest: OciDigest =
1358            "sha256:0000000000000000000000000000000000000000000000000000000000000000"
1359                .parse()
1360                .unwrap();
1361        let result = open_config::<Sha256HashValue>(&repo, &bad_digest, None);
1362        assert!(result.is_err());
1363
1364        let result = open_config::<Sha256HashValue>(&repo, &config_digest, None);
1365        assert!(result.is_ok());
1366    }
1367
1368    #[test]
1369    fn test_config_with_image_ref() {
1370        use containers_image_proxy::oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
1371
1372        let (_repo_dir, repo) = create_test_repo();
1373
1374        let rootfs = RootFsBuilder::default()
1375            .typ("layers")
1376            .diff_ids(vec!["sha256:abc123def456".to_string()])
1377            .build()
1378            .unwrap();
1379
1380        let config = ImageConfigurationBuilder::default()
1381            .architecture("amd64")
1382            .os("linux")
1383            .rootfs(rootfs)
1384            .build()
1385            .unwrap();
1386
1387        let mut refs = HashMap::new();
1388        let layer_id = Sha256HashValue::EMPTY;
1389        refs.insert("sha256:abc123def456".into(), layer_id);
1390
1391        // Use a fake EROFS image ID
1392        let fake_erofs_id: Sha256HashValue =
1393            composefs::fsverity::compute_verity(b"fake-erofs-image");
1394
1395        let (config_digest, config_verity) = write_config(
1396            &repo,
1397            &config,
1398            refs.clone(),
1399            Some(&fake_erofs_id),
1400            None,
1401            &HashMap::new(),
1402        )
1403        .unwrap();
1404
1405        // Reopen and verify
1406        let oc = open_config(&repo, &config_digest, Some(&config_verity)).unwrap();
1407        assert_eq!(
1408            oc.layer_refs.len(),
1409            1,
1410            "layer refs should not include image ref"
1411        );
1412        assert!(oc.layer_refs.contains_key("sha256:abc123def456"));
1413        assert_eq!(
1414            oc.image_ref,
1415            Some(fake_erofs_id.clone()),
1416            "image ref should be returned"
1417        );
1418        assert!(
1419            oc.image_ref_v1.is_none(),
1420            "expected no V1 image ref for a V2-only config"
1421        );
1422
1423        // Also verify via the convenience function (V2 lookup, matching the
1424        // V2-only ref stored above)
1425        let img_ref = composefs_erofs_for_config(
1426            &repo,
1427            &config_digest,
1428            Some(&config_verity),
1429            FormatVersion::V2,
1430        )
1431        .unwrap();
1432        assert_eq!(img_ref, Some(fake_erofs_id));
1433    }
1434
1435    #[test]
1436    fn test_config_without_image_ref() {
1437        use containers_image_proxy::oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
1438
1439        let (_repo_dir, repo) = create_test_repo();
1440
1441        let rootfs = RootFsBuilder::default()
1442            .typ("layers")
1443            .diff_ids(vec!["sha256:abc123def456".to_string()])
1444            .build()
1445            .unwrap();
1446
1447        let config = ImageConfigurationBuilder::default()
1448            .architecture("amd64")
1449            .os("linux")
1450            .rootfs(rootfs)
1451            .build()
1452            .unwrap();
1453
1454        let mut refs = HashMap::new();
1455        refs.insert("sha256:abc123def456".into(), Sha256HashValue::EMPTY);
1456
1457        let (config_digest, config_verity) =
1458            write_config(&repo, &config, refs.clone(), None, None, &HashMap::new()).unwrap();
1459
1460        let oc = open_config(&repo, &config_digest, Some(&config_verity)).unwrap();
1461        assert_eq!(oc.layer_refs.len(), 1);
1462        assert!(oc.layer_refs.contains_key("sha256:abc123def456"));
1463        assert!(oc.image_ref.is_none(), "no image ref should be present");
1464
1465        let img_ref = composefs_erofs_for_config(
1466            &repo,
1467            &config_digest,
1468            Some(&config_verity),
1469            repo.erofs_version(),
1470        )
1471        .unwrap();
1472        assert!(img_ref.is_none());
1473    }
1474
1475    #[tokio::test]
1476    async fn test_ensure_oci_composefs_erofs() {
1477        use composefs::test::TestRepo;
1478
1479        let test_repo = TestRepo::<Sha256HashValue>::new();
1480        let repo = &test_repo.repo;
1481
1482        let img = test_util::create_base_image(repo, Some("test:v1")).await;
1483
1484        // Create the EROFS image and link it to the config
1485        let erofs_id = ensure_oci_composefs_erofs(
1486            repo,
1487            &img.manifest_digest,
1488            Some(&img.manifest_verity),
1489            Some("test:v1"),
1490        )
1491        .unwrap()
1492        .expect("container image should produce EROFS");
1493
1494        // The EROFS image should exist in the repository
1495        assert!(
1496            repo.open_image(&erofs_id.to_hex()).is_ok(),
1497            "EROFS image should be accessible"
1498        );
1499
1500        // The manifest+config were rewritten with the EROFS ref
1501        let oci = oci_image::OciImage::open_ref(repo, "test:v1").unwrap();
1502        assert_ne!(
1503            oci.manifest_verity(),
1504            &img.manifest_verity,
1505            "manifest should have been rewritten with new config verity"
1506        );
1507        assert_eq!(
1508            oci.image_ref(repo.erofs_version()),
1509            Some(&erofs_id),
1510            "config should reference the EROFS image"
1511        );
1512        // Also verify via the convenience functions
1513        let erofs_ref = composefs_erofs_for_config(
1514            repo,
1515            oci.config_digest(),
1516            Some(oci.config_verity()),
1517            repo.erofs_version(),
1518        )
1519        .unwrap();
1520        assert_eq!(erofs_ref, Some(erofs_id.clone()));
1521
1522        let erofs_ref2 = composefs_erofs_for_manifest(
1523            repo,
1524            &img.manifest_digest,
1525            Some(oci.manifest_verity()),
1526            repo.erofs_version(),
1527        )
1528        .unwrap();
1529        assert_eq!(erofs_ref2, Some(erofs_id.clone()));
1530
1531        // Verify the EROFS content by round-tripping through erofs_to_filesystem
1532        let erofs_data = repo.read_object(&erofs_id).unwrap();
1533        let fs =
1534            composefs::erofs::reader::erofs_to_filesystem::<Sha256HashValue>(&erofs_data).unwrap();
1535        let mut dump = Vec::new();
1536        composefs::dumpfile::write_dumpfile(&mut dump, &fs).unwrap();
1537        let dump = String::from_utf8(dump).unwrap();
1538        similar_asserts::assert_eq!(dump, EXPECTED_BASE_IMAGE_DUMPFILE);
1539    }
1540
1541    /// Verify that a dual-format (V1+V2) repository populates both V1 and V2
1542    /// named refs in the config splitstream and that both image objects exist.
1543    #[tokio::test]
1544    async fn test_dual_format_both_image_refs() {
1545        use composefs::erofs::format::{FormatConfig, FormatVersion};
1546
1547        // Create a dual-format repo (insecure, SHA-256): V1 primary + V2 extra.
1548        let dir = tempdir();
1549        let repo_path = dir.path().join("repo");
1550        let mut both_config = RepositoryConfig::default().set_insecure();
1551        both_config.erofs_formats = FormatConfig {
1552            default: FormatVersion::V1,
1553            extra: [FormatVersion::V2].into(),
1554        };
1555        let (repo_inner, _) = Repository::init_path(CWD, &repo_path, both_config)
1556            .expect("initializing dual-format test repo");
1557        let repo = std::sync::Arc::new(repo_inner);
1558
1559        assert_eq!(
1560            repo.default_format_config(),
1561            FormatConfig {
1562                default: FormatVersion::V1,
1563                extra: [FormatVersion::V2].into(),
1564            }
1565        );
1566
1567        // Pull a base image and generate EROFS.
1568        let img = test_util::create_base_image(&repo, Some("dual:v1")).await;
1569        let primary_id = ensure_oci_composefs_erofs(
1570            &repo,
1571            &img.manifest_digest,
1572            Some(&img.manifest_verity),
1573            Some("dual:v1"),
1574        )
1575        .unwrap()
1576        .expect("container image should produce EROFS");
1577
1578        // Re-open the rewritten config.
1579        let oci = oci_image::OciImage::open_ref(&repo, "dual:v1").unwrap();
1580        let oc = open_config(&repo, oci.config_digest(), Some(oci.config_verity())).unwrap();
1581
1582        // Both V1 and V2 refs must be populated.
1583        let id_v1 = oc
1584            .image_ref_v1
1585            .as_ref()
1586            .expect("V1 image ref should be set for dual-format repo");
1587        let id_v2 = oc
1588            .image_ref
1589            .as_ref()
1590            .expect("V2 image ref should be set for dual-format repo");
1591
1592        // The two digests must differ (V1 and V2 produce different wire formats).
1593        assert_ne!(
1594            id_v1, id_v2,
1595            "V1 and V2 EROFS images must have different digests"
1596        );
1597
1598        // primary returned by ensure_oci_composefs_erofs is V1 (formats.iter() yields V1 first).
1599        assert_eq!(&primary_id, id_v1, "primary ID should be the V1 digest");
1600
1601        // composefs_erofs_for_config returns repo default (V1 for dual-format repos).
1602        let via_fn = composefs_erofs_for_config(
1603            &repo,
1604            oci.config_digest(),
1605            Some(oci.config_verity()),
1606            repo.erofs_version(),
1607        )
1608        .unwrap();
1609        assert_eq!(
1610            via_fn.as_ref(),
1611            Some(id_v1),
1612            "composefs_erofs_for_config should return repo default (V1)"
1613        );
1614
1615        // OciImage::image_ref() returns repo default (V1 for dual-format repos).
1616        assert_eq!(oci.image_ref(repo.erofs_version()), Some(id_v1));
1617        assert_eq!(oci.image_ref_v2(), Some(id_v2));
1618
1619        // Both image objects must actually exist in the repository.
1620        assert!(
1621            repo.open_image(&id_v1.to_hex()).is_ok(),
1622            "V1 EROFS image should exist in repo"
1623        );
1624        assert!(
1625            repo.open_image(&id_v2.to_hex()).is_ok(),
1626            "V2 EROFS image should exist in repo"
1627        );
1628
1629        // Verify that commit_images with the dual-format repo wrote V1 and V2 in the map.
1630        let fs = image::create_filesystem(
1631            &repo,
1632            oci.config_digest(),
1633            Some(oci.config_verity()),
1634            &composefs::generic_tree::OciTransformOptions::default(),
1635        )
1636        .unwrap();
1637        let map = fs
1638            .commit_images(&repo, None)
1639            .expect("commit_images with dual-format config should succeed");
1640        assert!(map.contains_key(&FormatVersion::V1), "map must contain V1");
1641        assert!(map.contains_key(&FormatVersion::V2), "map must contain V2");
1642        assert_eq!(map[&FormatVersion::V1], *id_v1);
1643        assert_eq!(map[&FormatVersion::V2], *id_v2);
1644    }
1645
1646    #[tokio::test]
1647    async fn test_ensure_oci_composefs_erofs_gc() {
1648        use composefs::test::TestRepo;
1649
1650        let test_repo = TestRepo::<Sha256HashValue>::new();
1651        let repo = &test_repo.repo;
1652
1653        let img = test_util::create_base_image(repo, Some("gctest:v1")).await;
1654
1655        // After pull, nothing is garbage
1656        let dry = repo.gc_dry_run(&[]).unwrap();
1657        assert_eq!(dry.objects_removed, 0);
1658        assert_eq!(dry.streams_pruned, 0);
1659        assert_eq!(dry.images_pruned, 0);
1660
1661        let erofs_id = ensure_oci_composefs_erofs(
1662            repo,
1663            &img.manifest_digest,
1664            Some(&img.manifest_verity),
1665            Some("gctest:v1"),
1666        )
1667        .unwrap()
1668        .expect("container image should produce EROFS");
1669
1670        // ensure_oci_composefs_erofs rewrites config+manifest, leaving 2 old splitstream
1671        // objects unreferenced (the original config and manifest splitstreams)
1672        let gc1 = repo.gc(&[]).unwrap();
1673        assert_eq!(
1674            gc1.objects_removed, 2,
1675            "old config+manifest splitstream objects"
1676        );
1677        assert_eq!(gc1.streams_pruned, 0);
1678        assert_eq!(gc1.images_pruned, 0);
1679
1680        // After GC, everything is clean — EROFS survives via config ref
1681        let dry = repo.gc_dry_run(&[]).unwrap();
1682        assert_eq!(dry.objects_removed, 0);
1683        assert!(
1684            repo.open_image(&erofs_id.to_hex()).is_ok(),
1685            "EROFS image should survive GC while tagged"
1686        );
1687
1688        // Untag and GC — everything gets collected
1689        oci_image::untag_image(repo, "gctest:v1").unwrap();
1690        let gc2 = repo.gc(&[]).unwrap();
1691        // 14 objects: 5 layer splitstreams + 4 external file objects
1692        //   + config JSON + manifest JSON + EROFS image
1693        //   + new config splitstream + new manifest splitstream
1694        assert_eq!(gc2.objects_removed, 14, "all objects collected after untag");
1695        // 7 streams: 5 layers + 1 config + 1 manifest (tag ref removed by untag)
1696        assert_eq!(gc2.streams_pruned, 7, "all stream symlinks pruned");
1697        // 1 image: the EROFS symlink under images/
1698        assert_eq!(gc2.images_pruned, 1, "EROFS image symlink pruned");
1699
1700        assert!(
1701            repo.open_image(&erofs_id.to_hex()).is_err(),
1702            "EROFS image should be collected after untag + GC"
1703        );
1704
1705        // Repo is completely empty now
1706        let dry = repo.gc_dry_run(&[]).unwrap();
1707        assert_eq!(dry.objects_removed, 0);
1708        assert_eq!(dry.streams_pruned, 0);
1709        assert_eq!(dry.images_pruned, 0);
1710    }
1711
1712    /// Verify that rewriting a config splitstream (to add an EROFS image ref)
1713    /// preserves the original config JSON bytes — even when those bytes use
1714    /// non-canonical formatting that differs from `ImageConfiguration::to_string()`.
1715    ///
1716    /// Regression test: `ensure_oci_composefs_erofs` previously re-serialized
1717    /// the config through `config.to_string()`, producing different bytes (and
1718    /// a different sha256 digest), which caused `oci fsck` to report a
1719    /// `config-digest-mismatch`.
1720    #[tokio::test]
1721    async fn test_config_rewrite_preserves_noncanonical_json() {
1722        use composefs::test::TestRepo;
1723        use serde_json::ser::{PrettyFormatter, Serializer};
1724
1725        let test_repo = TestRepo::<Sha256HashValue>::new();
1726        let repo = &test_repo.repo;
1727
1728        // Create a normal image with well-formed layers
1729        let _img = test_util::create_base_image(repo, Some("nc:v1")).await;
1730
1731        // Read back the original config JSON
1732        let oci_before = oci_image::OciImage::open_ref(repo, "nc:v1").unwrap();
1733        let canonical_json = oci_before.read_config_json(repo).unwrap();
1734
1735        // Re-serialize through serde_json::Value with PrettyFormatter to
1736        // get different bytes (tab indentation) while remaining
1737        // semantically identical JSON.
1738        let value: serde_json::Value = serde_json::from_slice(&canonical_json).unwrap();
1739        let mut buf = Vec::new();
1740        let formatter = PrettyFormatter::with_indent(b"\t");
1741        let mut ser = Serializer::with_formatter(&mut buf, formatter);
1742        serde::Serialize::serialize(&value, &mut ser).unwrap();
1743        let noncanonical_json = buf;
1744
1745        // Sanity: the two serializations must differ in bytes but parse
1746        // identically.
1747        assert_ne!(
1748            canonical_json.as_slice(),
1749            noncanonical_json.as_slice(),
1750            "pretty-printed JSON should differ from canonical"
1751        );
1752        let reparsed: serde_json::Value = serde_json::from_slice(&noncanonical_json).unwrap();
1753        assert_eq!(value, reparsed, "non-canonical JSON must parse identically");
1754
1755        // Now overwrite the config splitstream with the non-canonical bytes.
1756        let (_new_config_digest, new_config_verity) = write_config_raw(
1757            repo,
1758            &noncanonical_json,
1759            oci_before.layer_refs().clone(),
1760            None,
1761            None,
1762            &HashMap::new(),
1763        )
1764        .unwrap();
1765        let new_config_digest = hash_sha256(&noncanonical_json);
1766
1767        // Rewrite the manifest to reference the non-canonical config.
1768        use containers_image_proxy::oci_spec::image::{
1769            DescriptorBuilder, ImageManifestBuilder, MediaType,
1770        };
1771
1772        let old_manifest = oci_before.manifest();
1773        let config_descriptor = DescriptorBuilder::default()
1774            .media_type(MediaType::ImageConfig)
1775            .digest(new_config_digest.clone())
1776            .size(noncanonical_json.len() as u64)
1777            .build()
1778            .unwrap();
1779        let new_manifest = ImageManifestBuilder::default()
1780            .schema_version(2u32)
1781            .media_type(MediaType::ImageManifest)
1782            .config(config_descriptor)
1783            .layers(old_manifest.layers().clone())
1784            .build()
1785            .unwrap();
1786
1787        let new_manifest_json = new_manifest.to_string().unwrap();
1788        let new_manifest_digest = hash_sha256(new_manifest_json.as_bytes());
1789
1790        oci_image::untag_image(repo, "nc:v1").unwrap();
1791        let layer_verities: Vec<_> = oci_before
1792            .layer_refs()
1793            .iter()
1794            .map(|(k, v)| (k.clone(), v.clone()))
1795            .collect();
1796        let (_md, new_manifest_verity) = oci_image::write_manifest(
1797            repo,
1798            &new_manifest,
1799            &new_manifest_digest,
1800            &new_config_verity,
1801            &layer_verities,
1802            Some("nc:v1"),
1803        )
1804        .unwrap();
1805
1806        // Now the real test: ensure_oci_composefs_erofs rewrites the config
1807        // to add an EROFS image ref.  The config digest MUST be preserved.
1808        let erofs_id = ensure_oci_composefs_erofs(
1809            repo,
1810            &new_manifest_digest,
1811            Some(&new_manifest_verity),
1812            Some("nc:v1"),
1813        )
1814        .unwrap()
1815        .expect("should produce EROFS");
1816
1817        let oci_after = oci_image::OciImage::open_ref(repo, "nc:v1").unwrap();
1818        assert_eq!(
1819            oci_after.config_digest(),
1820            &new_config_digest,
1821            "config digest must be preserved after EROFS rewrite"
1822        );
1823        assert_eq!(oci_after.image_ref(repo.erofs_version()), Some(&erofs_id));
1824
1825        let stored_json = oci_after.read_config_json(repo).unwrap();
1826        assert_eq!(
1827            stored_json, noncanonical_json,
1828            "raw config JSON bytes must survive round-trip through EROFS rewrite"
1829        );
1830    }
1831
1832    #[test]
1833    fn test_import_stats_display() {
1834        // Copy-only stats (no reflinks)
1835        let stats = ImportStats {
1836            objects_copied: 42,
1837            objects_already_present: 100,
1838            bytes_copied: 1_500_000,
1839            bytes_inlined: 800,
1840            ..Default::default()
1841        };
1842        assert_eq!(
1843            stats.to_string(),
1844            "42 new + 100 already present objects; 1.43 MiB stored, 800 B inlined"
1845        );
1846        assert_eq!(stats.total_objects(), 142);
1847        assert_eq!(stats.new_objects(), 42);
1848        assert_eq!(stats.new_bytes(), 1_500_000);
1849
1850        // Stats with reflinks
1851        let reflink_stats = ImportStats {
1852            objects_reflinked: 30,
1853            objects_copied: 12,
1854            objects_already_present: 100,
1855            bytes_reflinked: 1_000_000,
1856            bytes_copied: 500_000,
1857            bytes_inlined: 800,
1858            ..Default::default()
1859        };
1860        assert_eq!(
1861            reflink_stats.to_string(),
1862            "30 reflinked + 12 copied + 100 already present objects; 976.56 KiB reflinked, 488.28 KiB copied, 800 B inlined"
1863        );
1864        assert_eq!(reflink_stats.total_objects(), 142);
1865        assert_eq!(reflink_stats.new_objects(), 42);
1866        assert_eq!(reflink_stats.new_bytes(), 1_500_000);
1867
1868        // Stats with hardlinks only
1869        let hardlink_stats = ImportStats {
1870            objects_hardlinked: 20,
1871            objects_copied: 5,
1872            objects_already_present: 50,
1873            bytes_hardlinked: 800_000,
1874            bytes_copied: 200_000,
1875            bytes_inlined: 400,
1876            ..Default::default()
1877        };
1878        assert_eq!(
1879            hardlink_stats.to_string(),
1880            "20 hardlinked + 5 copied + 50 already present objects; 781.25 KiB hardlinked, 195.31 KiB copied, 400 B inlined"
1881        );
1882        assert_eq!(hardlink_stats.total_objects(), 75);
1883        assert_eq!(hardlink_stats.new_objects(), 25);
1884        assert_eq!(hardlink_stats.new_bytes(), 1_000_000);
1885
1886        // Stats with both reflinks and hardlinks
1887        let mixed_stats = ImportStats {
1888            objects_reflinked: 10,
1889            objects_hardlinked: 15,
1890            objects_copied: 5,
1891            objects_already_present: 70,
1892            bytes_reflinked: 500_000,
1893            bytes_hardlinked: 750_000,
1894            bytes_copied: 250_000,
1895            bytes_inlined: 600,
1896            ..Default::default()
1897        };
1898        assert_eq!(
1899            mixed_stats.to_string(),
1900            "10 reflinked + 15 hardlinked + 5 copied + 70 already present objects; 488.28 KiB reflinked, 732.42 KiB hardlinked, 244.14 KiB copied, 600 B inlined"
1901        );
1902        assert_eq!(mixed_stats.total_objects(), 100);
1903        assert_eq!(mixed_stats.new_objects(), 30);
1904        assert_eq!(mixed_stats.new_bytes(), 1_500_000);
1905
1906        let empty = ImportStats::default();
1907        assert_eq!(
1908            empty.to_string(),
1909            "0 new + 0 already present objects; 0 B stored, 0 B inlined"
1910        );
1911        assert_eq!(empty.total_objects(), 0);
1912    }
1913
1914    /// End-to-end test: multi-layer OCI image with nontrivial whiteout usage.
1915    ///
1916    /// Builds three tar layers exercising individual file whiteouts (`.wh.<name>`)
1917    /// and opaque directory whiteouts (`.wh..wh..opq`), imports them through the
1918    /// full OCI pipeline (tar → splitstream → OCI config/manifest → EROFS), and
1919    /// verifies the resulting filesystem contains exactly the expected files.
1920    #[tokio::test]
1921    async fn test_whiteout_multi_layer_import() {
1922        use composefs::test::TestRepo;
1923        use containers_image_proxy::oci_spec::image::{
1924            ConfigBuilder, DescriptorBuilder, ImageConfigurationBuilder, ImageManifestBuilder,
1925            MediaType, RootFsBuilder,
1926        };
1927
1928        // --- Tar builder helpers (local to this test) ---
1929
1930        fn tar_dir(builder: &mut ::tar::Builder<Vec<u8>>, name: &str) {
1931            let mut header = ::tar::Header::new_ustar();
1932            header.set_uid(0);
1933            header.set_gid(0);
1934            header.set_mode(0o755);
1935            header.set_entry_type(::tar::EntryType::Directory);
1936            header.set_size(0);
1937            builder
1938                .append_data(&mut header, name, std::io::empty())
1939                .unwrap();
1940        }
1941
1942        fn tar_file(builder: &mut ::tar::Builder<Vec<u8>>, name: &str, content: &[u8]) {
1943            let mut header = ::tar::Header::new_ustar();
1944            header.set_uid(0);
1945            header.set_gid(0);
1946            header.set_mode(0o644);
1947            header.set_entry_type(::tar::EntryType::Regular);
1948            header.set_size(content.len() as u64);
1949            builder.append_data(&mut header, name, content).unwrap();
1950        }
1951
1952        /// Zero-length regular file — used for `.wh.<name>` and `.wh..wh..opq` entries.
1953        fn tar_whiteout(builder: &mut ::tar::Builder<Vec<u8>>, name: &str) {
1954            tar_file(builder, name, &[]);
1955        }
1956
1957        // --- Build the three layers ---
1958
1959        // Layer 1 (base): create initial filesystem
1960        let layer1 = {
1961            let mut b = ::tar::Builder::new(vec![]);
1962            tar_dir(&mut b, "etc");
1963            tar_file(&mut b, "etc/config.toml", b"[server]\nport = 8080\n");
1964            tar_file(&mut b, "etc/hosts", b"127.0.0.1 localhost\n");
1965            tar_dir(&mut b, "usr");
1966            tar_dir(&mut b, "usr/bin");
1967            tar_file(&mut b, "usr/bin/app", b"#!/bin/sh\necho hello\n");
1968            tar_dir(&mut b, "usr/lib");
1969            tar_file(&mut b, "usr/lib/old-lib.so", b"fake-old-lib-content");
1970            tar_file(&mut b, "usr/lib/shared.so", b"fake-shared-lib-content");
1971            tar_dir(&mut b, "tmp");
1972            tar_dir(&mut b, "tmp/cache");
1973            tar_file(&mut b, "tmp/cache/data.bin", b"cached-data-payload");
1974            tar_file(&mut b, "tmp/cache/index.db", b"cached-index-payload");
1975            b.into_inner().unwrap()
1976        };
1977
1978        // Layer 2 (whiteout + modify):
1979        //  - delete /etc/hosts (file whiteout)
1980        //  - delete /usr/lib/old-lib.so (file whiteout)
1981        //  - add /etc/hosts.new (replacement)
1982        //  - opaque whiteout on /tmp/cache (clears data.bin + index.db)
1983        //  - add /tmp/cache/fresh.bin (re-populate after opaque)
1984        let layer2 = {
1985            let mut b = ::tar::Builder::new(vec![]);
1986            tar_dir(&mut b, "etc");
1987            tar_whiteout(&mut b, "etc/.wh.hosts");
1988            tar_file(&mut b, "etc/hosts.new", b"127.0.0.1 localhost.new\n");
1989            tar_dir(&mut b, "usr");
1990            tar_dir(&mut b, "usr/lib");
1991            tar_whiteout(&mut b, "usr/lib/.wh.old-lib.so");
1992            tar_dir(&mut b, "tmp");
1993            tar_dir(&mut b, "tmp/cache");
1994            tar_whiteout(&mut b, "tmp/cache/.wh..wh..opq");
1995            tar_file(&mut b, "tmp/cache/fresh.bin", b"fresh-cache-content");
1996            b.into_inner().unwrap()
1997        };
1998
1999        // Layer 3 (more whiteouts):
2000        //  - delete /usr/bin/app (file whiteout)
2001        //  - add /usr/bin/app-v2 (replacement)
2002        let layer3 = {
2003            let mut b = ::tar::Builder::new(vec![]);
2004            tar_dir(&mut b, "usr");
2005            tar_dir(&mut b, "usr/bin");
2006            tar_whiteout(&mut b, "usr/bin/.wh.app");
2007            tar_file(&mut b, "usr/bin/app-v2", b"#!/bin/sh\necho hello v2\n");
2008            b.into_inner().unwrap()
2009        };
2010
2011        // --- Import layers and build OCI image ---
2012
2013        let test_repo = TestRepo::<Sha256HashValue>::new();
2014        let repo = &test_repo.repo;
2015
2016        let layers_data = [&layer1[..], &layer2[..], &layer3[..]];
2017        let mut layer_digests = Vec::new();
2018        let mut layer_verities_map: HashMap<Box<str>, composefs::fsverity::Sha256HashValue> =
2019            HashMap::new();
2020        let mut layer_descriptors = Vec::new();
2021
2022        for tar_data in &layers_data {
2023            let digest = hash_sha256(tar_data);
2024            let (verity, _stats) = import_layer(repo, &digest, None, *tar_data).await.unwrap();
2025
2026            let descriptor = DescriptorBuilder::default()
2027                .media_type(MediaType::ImageLayerGzip)
2028                .digest(digest.clone())
2029                .size(tar_data.len() as u64)
2030                .build()
2031                .unwrap();
2032
2033            layer_verities_map.insert(digest.to_string().into_boxed_str(), verity);
2034            layer_digests.push(digest.to_string());
2035            layer_descriptors.push(descriptor);
2036        }
2037
2038        // Build OCI config
2039        let rootfs = RootFsBuilder::default()
2040            .typ("layers")
2041            .diff_ids(layer_digests.clone())
2042            .build()
2043            .unwrap();
2044
2045        let cfg = ConfigBuilder::default().build().unwrap();
2046
2047        let config = ImageConfigurationBuilder::default()
2048            .architecture("amd64")
2049            .os("linux")
2050            .rootfs(rootfs)
2051            .config(cfg)
2052            .build()
2053            .unwrap();
2054
2055        let config_json = config.to_string().unwrap();
2056        let config_digest = hash_sha256(config_json.as_bytes());
2057
2058        let mut config_stream = repo.create_stream(skopeo::OCI_CONFIG_CONTENT_TYPE).unwrap();
2059        for (digest, verity) in &layer_verities_map {
2060            config_stream.add_named_stream_ref(digest, verity);
2061        }
2062        config_stream
2063            .write_external(config_json.as_bytes())
2064            .unwrap();
2065        let config_verity = repo
2066            .write_stream(config_stream, &config_identifier(&config_digest), None)
2067            .unwrap();
2068
2069        // Build OCI manifest
2070        let config_descriptor = DescriptorBuilder::default()
2071            .media_type(MediaType::ImageConfig)
2072            .digest(config_digest.clone())
2073            .size(config_json.len() as u64)
2074            .build()
2075            .unwrap();
2076
2077        let manifest = ImageManifestBuilder::default()
2078            .schema_version(2u32)
2079            .media_type(MediaType::ImageManifest)
2080            .config(config_descriptor)
2081            .layers(layer_descriptors)
2082            .build()
2083            .unwrap();
2084
2085        let manifest_json = manifest.to_string().unwrap();
2086        let manifest_digest = hash_sha256(manifest_json.as_bytes());
2087
2088        let layer_verities_vec: Vec<_> = layer_verities_map
2089            .iter()
2090            .map(|(k, v)| (k.clone(), v.clone()))
2091            .collect();
2092        let (_stored_digest, manifest_verity) = oci_image::write_manifest(
2093            repo,
2094            &manifest,
2095            &manifest_digest,
2096            &config_verity,
2097            &layer_verities_vec,
2098            Some("whiteout-test:v1"),
2099        )
2100        .unwrap();
2101
2102        // --- Create the EROFS image ---
2103
2104        let erofs_id = ensure_oci_composefs_erofs(
2105            repo,
2106            &manifest_digest,
2107            Some(&manifest_verity),
2108            Some("whiteout-test:v1"),
2109        )
2110        .unwrap()
2111        .expect("container image should produce EROFS");
2112
2113        // --- Verify the flattened filesystem ---
2114
2115        let erofs_data = repo.read_object(&erofs_id).unwrap();
2116        let fs =
2117            composefs::erofs::reader::erofs_to_filesystem::<Sha256HashValue>(&erofs_data).unwrap();
2118        let mut dump = Vec::new();
2119        composefs::dumpfile::write_dumpfile(&mut dump, &fs).unwrap();
2120        let dump = String::from_utf8(dump).unwrap();
2121
2122        // Extract just the paths from the dumpfile for structural verification
2123        let paths: Vec<&str> = dump.lines().map(|l| l.split_once(' ').unwrap().0).collect();
2124
2125        // Files that SHOULD exist after all three layers
2126        let expected_present = [
2127            "/",
2128            "/etc",
2129            "/etc/config.toml", // layer 1, survived
2130            "/etc/hosts.new",   // layer 2 addition
2131            "/tmp",
2132            "/tmp/cache",
2133            "/tmp/cache/fresh.bin", // layer 2, after opaque whiteout
2134            "/usr",
2135            "/usr/bin",
2136            "/usr/bin/app-v2", // layer 3 replacement
2137            "/usr/lib",
2138            "/usr/lib/shared.so", // layer 1, survived
2139        ];
2140
2141        // Files that MUST NOT exist (removed by whiteouts)
2142        let must_not_exist = [
2143            "/etc/hosts",          // deleted by layer 2 file whiteout
2144            "/usr/lib/old-lib.so", // deleted by layer 2 file whiteout
2145            "/usr/bin/app",        // deleted by layer 3 file whiteout
2146            "/tmp/cache/data.bin", // cleared by layer 2 opaque whiteout
2147            "/tmp/cache/index.db", // cleared by layer 2 opaque whiteout
2148        ];
2149
2150        similar_asserts::assert_eq!(paths, expected_present);
2151
2152        for path in &must_not_exist {
2153            assert!(
2154                !paths.contains(path),
2155                "{path} should have been removed by whiteout but is still present"
2156            );
2157        }
2158    }
2159
2160    /// Verify that the full OCI pipeline works when all splitstreams use the
2161    /// old (pre-repr(C)) header layout — the format that bootc <= 1.15.x wrote.
2162    ///
2163    /// Old-format writing stays on throughout, including EROFS generation, so
2164    /// the rewritten config+manifest splitstreams are also old-format. This
2165    /// exercises the complete read-old → write-old → read-old-again chain.
2166    #[tokio::test]
2167    async fn test_old_format_splitstream_oci_roundtrip() {
2168        use composefs::test::TestRepo;
2169
2170        let test_repo = TestRepo::<Sha256HashValue>::new();
2171        let repo = &test_repo.repo;
2172
2173        // Enable old-format writing for the entire test — layers, config,
2174        // manifest, and the rewritten config+manifest after EROFS generation
2175        // all get old-format headers.
2176        repo.set_write_old_splitstream_format(true);
2177        let img = test_util::create_base_image(repo, Some("old:v1")).await;
2178
2179        // Verify open_config still works with old-format splitstreams
2180        let oci = oci_image::OciImage::open_ref(repo, "old:v1").unwrap();
2181        let oc = open_config(repo, oci.config_digest(), Some(oci.config_verity())).unwrap();
2182        assert_eq!(oc.config.architecture().to_string(), "amd64");
2183        assert!(
2184            oc.image_ref.is_none(),
2185            "pre-EROFS image should have no image ref"
2186        );
2187
2188        // Verify create_filesystem works (reads old-format layer splitstreams)
2189        let fs = image::create_filesystem(
2190            repo,
2191            oci.config_digest(),
2192            Some(oci.config_verity()),
2193            &composefs::generic_tree::OciTransformOptions::default(),
2194        )
2195        .unwrap();
2196        let mut fs_dump = Vec::new();
2197        composefs::dumpfile::write_dumpfile(&mut fs_dump, &fs).unwrap();
2198        assert!(
2199            !fs_dump.is_empty(),
2200            "filesystem should contain entries from old-format layers"
2201        );
2202
2203        // Generate EROFS with old-format still enabled — the rewritten
2204        // config+manifest splitstreams also get old-format headers.
2205        let erofs_id = ensure_oci_composefs_erofs(
2206            repo,
2207            &img.manifest_digest,
2208            Some(&img.manifest_verity),
2209            Some("old:v1"),
2210        )
2211        .unwrap()
2212        .expect("container image should produce EROFS");
2213
2214        // The rewritten config+manifest are old-format; verify we can
2215        // still open the image and read back the EROFS ref through them.
2216        let oci_after = oci_image::OciImage::open_ref(repo, "old:v1").unwrap();
2217        assert_eq!(
2218            oci_after.image_ref(repo.erofs_version()),
2219            Some(&erofs_id),
2220            "old-format rewritten config should reference the EROFS image"
2221        );
2222
2223        let erofs_data = repo.read_object(&erofs_id).unwrap();
2224        let erofs_fs =
2225            composefs::erofs::reader::erofs_to_filesystem::<Sha256HashValue>(&erofs_data).unwrap();
2226        let mut dump = Vec::new();
2227        composefs::dumpfile::write_dumpfile(&mut dump, &erofs_fs).unwrap();
2228        let dump = String::from_utf8(dump).unwrap();
2229        similar_asserts::assert_eq!(dump, EXPECTED_BASE_IMAGE_DUMPFILE);
2230    }
2231
2232    /// Simulate upgrading from the pre-EROFS-at-pull-time layout with old-format
2233    /// splitstreams. This covers the case of a system that was running
2234    /// bootc <= 1.15.x (old splitstream format, no EROFS generated at pull)
2235    /// and then upgrades to current code.
2236    #[tokio::test]
2237    async fn test_pre_erofs_pull_upgrade_with_old_format() {
2238        use composefs::test::TestRepo;
2239
2240        let test_repo = TestRepo::<Sha256HashValue>::new();
2241        let repo = &test_repo.repo;
2242
2243        // Write everything in old format — simulates bootc <= 1.15.x
2244        repo.set_write_old_splitstream_format(true);
2245        // create_base_image does NOT call ensure_oci_composefs_erofs,
2246        // so this represents the "pre-EROFS-at-pull-time" layout.
2247        let img = test_util::create_base_image(repo, Some("upgrade:v1")).await;
2248        repo.set_write_old_splitstream_format(false);
2249
2250        // Verify image_ref is None (no EROFS yet)
2251        let oci_before = oci_image::OciImage::open_ref(repo, "upgrade:v1").unwrap();
2252        assert!(
2253            oci_before.image_ref(repo.erofs_version()).is_none(),
2254            "pre-EROFS pull should have no image ref"
2255        );
2256
2257        // Upgrade: ensure_oci_composefs_erofs reads old-format splitstreams,
2258        // generates EROFS, and rewrites config+manifest in new format.
2259        let erofs_id = ensure_oci_composefs_erofs(
2260            repo,
2261            &img.manifest_digest,
2262            Some(&img.manifest_verity),
2263            Some("upgrade:v1"),
2264        )
2265        .unwrap()
2266        .expect("container image should produce EROFS");
2267
2268        // Verify the OciImage now has image_ref
2269        let oci_after = oci_image::OciImage::open_ref(repo, "upgrade:v1").unwrap();
2270        assert_eq!(
2271            oci_after.image_ref(repo.erofs_version()),
2272            Some(&erofs_id),
2273            "config should reference the EROFS image after upgrade"
2274        );
2275
2276        // Verify the EROFS image is accessible
2277        assert!(
2278            repo.open_image(&erofs_id.to_hex()).is_ok(),
2279            "EROFS image should be accessible"
2280        );
2281
2282        // Verify the EROFS content matches expected dumpfile
2283        let erofs_data = repo.read_object(&erofs_id).unwrap();
2284        let erofs_fs =
2285            composefs::erofs::reader::erofs_to_filesystem::<Sha256HashValue>(&erofs_data).unwrap();
2286        let mut dump = Vec::new();
2287        composefs::dumpfile::write_dumpfile(&mut dump, &erofs_fs).unwrap();
2288        let dump = String::from_utf8(dump).unwrap();
2289        similar_asserts::assert_eq!(dump, EXPECTED_BASE_IMAGE_DUMPFILE);
2290
2291        // GC: old config+manifest splitstreams (2 objects) are now unreferenced
2292        let gc1 = repo.gc(&[]).unwrap();
2293        assert_eq!(
2294            gc1.objects_removed, 2,
2295            "old config+manifest splitstream objects"
2296        );
2297        assert_eq!(gc1.streams_pruned, 0);
2298        assert_eq!(gc1.images_pruned, 0);
2299
2300        // Untag and GC — everything gets collected
2301        oci_image::untag_image(repo, "upgrade:v1").unwrap();
2302        let gc2 = repo.gc(&[]).unwrap();
2303        assert_eq!(gc2.objects_removed, 14, "all objects collected after untag");
2304        assert_eq!(gc2.streams_pruned, 7, "all stream symlinks pruned");
2305        assert_eq!(gc2.images_pruned, 1, "EROFS image symlink pruned");
2306    }
2307
2308    /// Verify that `upgrade_repo` walks all tagged images, generates EROFS for
2309    /// those missing it, and is idempotent on subsequent runs.
2310    #[tokio::test]
2311    async fn test_upgrade_repo() {
2312        use composefs::test::TestRepo;
2313
2314        let test_repo = TestRepo::<Sha256HashValue>::new();
2315        let repo = &test_repo.repo;
2316
2317        // Simulate old-format pulls (no EROFS generated at pull time)
2318        repo.set_write_old_splitstream_format(true);
2319        let _img1 = test_util::create_base_image(repo, Some("app:v1")).await;
2320        let _img2 = test_util::create_bootable_image(repo, Some("os:v1"), 1).await;
2321        repo.set_write_old_splitstream_format(false);
2322
2323        // Verify neither image has an EROFS ref yet
2324        let oci1 = oci_image::OciImage::open_ref(repo, "app:v1").unwrap();
2325        assert!(
2326            oci1.image_ref(repo.erofs_version()).is_none(),
2327            "app:v1 should have no EROFS ref before upgrade"
2328        );
2329        let oci2 = oci_image::OciImage::open_ref(repo, "os:v1").unwrap();
2330        assert!(
2331            oci2.image_ref(repo.erofs_version()).is_none(),
2332            "os:v1 should have no EROFS ref before upgrade"
2333        );
2334
2335        // First upgrade: both images should be upgraded
2336        let result = upgrade_repo(repo).unwrap();
2337        assert_eq!(result.upgraded, 2, "both images should be upgraded");
2338        assert_eq!(result.already_current, 0, "none should be already current");
2339        assert_eq!(result.skipped_non_container, 0);
2340
2341        // Verify both images now have EROFS refs
2342        let oci1_after = oci_image::OciImage::open_ref(repo, "app:v1").unwrap();
2343        let erofs1 = oci1_after
2344            .image_ref(repo.erofs_version())
2345            .expect("app:v1 should have EROFS ref after upgrade");
2346        assert!(
2347            repo.open_image(&erofs1.to_hex()).is_ok(),
2348            "app:v1 EROFS image should be accessible"
2349        );
2350        let oci2_after = oci_image::OciImage::open_ref(repo, "os:v1").unwrap();
2351        let erofs2 = oci2_after
2352            .image_ref(repo.erofs_version())
2353            .expect("os:v1 should have EROFS ref after upgrade");
2354        assert!(
2355            repo.open_image(&erofs2.to_hex()).is_ok(),
2356            "os:v1 EROFS image should be accessible"
2357        );
2358
2359        // Second upgrade: idempotent — both should be skipped
2360        let result2 = upgrade_repo(repo).unwrap();
2361        assert_eq!(result2.upgraded, 0, "no images should need upgrading");
2362        assert_eq!(result2.already_current, 2, "both should be already current");
2363        assert_eq!(result2.skipped_non_container, 0);
2364
2365        // GC should collect old config+manifest splitstream objects
2366        // (2 per image = 4 total)
2367        let gc = repo.gc(&[]).unwrap();
2368        assert_eq!(
2369            gc.objects_removed, 4,
2370            "old config+manifest splitstream objects from 2 images"
2371        );
2372
2373        // EROFS images should survive GC
2374        assert!(
2375            repo.open_image(&erofs1.to_hex()).is_ok(),
2376            "app:v1 EROFS image should survive GC"
2377        );
2378        assert!(
2379            repo.open_image(&erofs2.to_hex()).is_ok(),
2380            "os:v1 EROFS image should survive GC"
2381        );
2382
2383        // Verify EROFS content is correct for the base image
2384        let erofs_data = repo.read_object(erofs1).unwrap();
2385        let fs =
2386            composefs::erofs::reader::erofs_to_filesystem::<Sha256HashValue>(&erofs_data).unwrap();
2387        let mut dump = Vec::new();
2388        composefs::dumpfile::write_dumpfile(&mut dump, &fs).unwrap();
2389        let dump = String::from_utf8(dump).unwrap();
2390        // The base image EROFS should match what other tests produce
2391        assert!(
2392            dump.contains("/usr/bin/busybox"),
2393            "EROFS should contain busybox"
2394        );
2395        assert!(
2396            dump.contains("/etc/hostname"),
2397            "EROFS should contain hostname"
2398        );
2399    }
2400
2401    // ── Progress API integration tests ───────────────────────────────────────
2402
2403    /// Create a minimal OCI layout directory with one (empty) tar layer.
2404    ///
2405    /// Returns the path to the OCI layout directory. The image is pinned to
2406    /// the current host platform so `import_oci_layout` can resolve it.
2407    ///
2408    /// The layer is an empty tar archive (valid tar, zero entries), which is
2409    /// sufficient to exercise the `import_layer_from_file` progress path.
2410    fn make_test_oci_layout(parent: &std::path::Path) -> std::path::PathBuf {
2411        use cap_std_ext::cap_std;
2412        use containers_image_proxy::oci_spec::image::{
2413            Arch, ConfigBuilder, ImageConfigurationBuilder, Os, PlatformBuilder, RootFsBuilder,
2414        };
2415        use ocidir::OciDir;
2416
2417        let oci_dir = parent.join("oci-layout");
2418        std::fs::create_dir_all(&oci_dir).unwrap();
2419        let dir =
2420            cap_std::fs::Dir::open_ambient_dir(&oci_dir, cap_std::ambient_authority()).unwrap();
2421        let ocidir = OciDir::ensure(dir).unwrap();
2422
2423        let mut manifest = ocidir.new_empty_manifest().unwrap().build().unwrap();
2424        let mut config = ImageConfigurationBuilder::default()
2425            .architecture(Arch::default())
2426            .os(Os::default())
2427            .rootfs(
2428                RootFsBuilder::default()
2429                    .typ("layers")
2430                    .diff_ids(Vec::<String>::new())
2431                    .build()
2432                    .unwrap(),
2433            )
2434            .config(ConfigBuilder::default().build().unwrap())
2435            .build()
2436            .unwrap();
2437
2438        // Create an empty tar layer (finish the builder immediately without adding any entries)
2439        let layer = ocidir
2440            .create_layer(None)
2441            .unwrap()
2442            .into_inner()
2443            .unwrap()
2444            .complete()
2445            .unwrap();
2446        ocidir.push_layer(&mut manifest, &mut config, layer, "layer", None);
2447
2448        let platform = PlatformBuilder::default()
2449            .architecture(Arch::default())
2450            .os(Os::default())
2451            .build()
2452            .unwrap();
2453        ocidir
2454            .insert_manifest_and_config(manifest, config, None, platform)
2455            .unwrap();
2456
2457        oci_dir
2458    }
2459
2460    /// Pulling a fresh OCI layout image (no prior cache) must emit at least one
2461    /// `Started` event per layer and a matching `Done` event, via the
2462    /// `import_oci_layout` fast path.
2463    ///
2464    /// This is the primary integration test for the progress API: it verifies
2465    /// that the oci_layout fast path actually emits events (previously it
2466    /// emitted none).
2467    #[tokio::test]
2468    async fn test_oci_layout_pull_emits_started_and_done() {
2469        use crate::oci_layout::import_oci_layout;
2470        use crate::progress::ProgressEvent;
2471        use crate::progress::test_support::RecordingReporter;
2472        use composefs::fsverity::Sha256HashValue;
2473        use composefs::test::TestRepo;
2474
2475        let layout_dir = tempfile::tempdir().unwrap();
2476        let layout_path = make_test_oci_layout(layout_dir.path());
2477
2478        let test_repo = TestRepo::<Sha256HashValue>::new();
2479        let repo = &test_repo.repo;
2480        let recorder = std::sync::Arc::new(RecordingReporter::new());
2481        let reporter: crate::progress::SharedReporter =
2482            std::sync::Arc::clone(&recorder) as crate::progress::SharedReporter;
2483
2484        import_oci_layout(repo, &layout_path, None, reporter)
2485            .await
2486            .expect("import_oci_layout should succeed");
2487
2488        let events = recorder.events();
2489
2490        // There must be at least one Started event
2491        let started_count = events
2492            .iter()
2493            .filter(|e| matches!(e, ProgressEvent::Started { .. }))
2494            .count();
2495        assert!(
2496            started_count >= 1,
2497            "expected at least one Started event, got {started_count} (total events: {})",
2498            events.len()
2499        );
2500
2501        // Every Started must have a matching Done or Skipped
2502        let started_ids: std::collections::HashSet<String> = events
2503            .iter()
2504            .filter_map(|e| {
2505                if let ProgressEvent::Started { id, .. } = e {
2506                    Some(id.as_str().to_owned())
2507                } else {
2508                    None
2509                }
2510            })
2511            .collect();
2512        for started_id in &started_ids {
2513            let has_terminal = events.iter().any(|e| match e {
2514                ProgressEvent::Done { id, .. } | ProgressEvent::Skipped { id } => {
2515                    id.as_str() == started_id
2516                }
2517                _ => false,
2518            });
2519            assert!(
2520                has_terminal,
2521                "Started for '{started_id}' has no matching Done or Skipped"
2522            );
2523        }
2524    }
2525
2526    /// Re-importing the same OCI layout (layers already cached) must emit
2527    /// `Skipped` events rather than `Started`/`Done`.
2528    #[tokio::test]
2529    async fn test_oci_layout_reimport_emits_skipped() {
2530        use crate::oci_layout::import_oci_layout;
2531        use crate::progress::test_support::RecordingReporter;
2532        use crate::progress::{NullReporter, ProgressEvent};
2533        use composefs::fsverity::Sha256HashValue;
2534        use composefs::test::TestRepo;
2535
2536        let layout_dir = tempfile::tempdir().unwrap();
2537        let layout_path = make_test_oci_layout(layout_dir.path());
2538
2539        let test_repo = TestRepo::<Sha256HashValue>::new();
2540        let repo = &test_repo.repo;
2541
2542        // First import (populates cache)
2543        let null: crate::progress::SharedReporter = std::sync::Arc::new(NullReporter);
2544        import_oci_layout(repo, &layout_path, None, null)
2545            .await
2546            .expect("first import should succeed");
2547
2548        // Second import (everything already cached)
2549        let recorder = std::sync::Arc::new(RecordingReporter::new());
2550        let reporter: crate::progress::SharedReporter =
2551            std::sync::Arc::clone(&recorder) as crate::progress::SharedReporter;
2552        import_oci_layout(repo, &layout_path, None, reporter)
2553            .await
2554            .expect("second import should succeed");
2555
2556        let events = recorder.events();
2557
2558        // On reimport, layers are cached: expect Skipped, not Done
2559        let done_count = events
2560            .iter()
2561            .filter(|e| matches!(e, ProgressEvent::Done { .. }))
2562            .count();
2563        let skipped_count = events
2564            .iter()
2565            .filter(|e| matches!(e, ProgressEvent::Skipped { .. }))
2566            .count();
2567        assert_eq!(
2568            done_count, 0,
2569            "no Done events expected on reimport (layers cached), got {done_count}"
2570        );
2571        assert!(
2572            skipped_count >= 1,
2573            "expected at least one Skipped on reimport, got {skipped_count}"
2574        );
2575    }
2576
2577    /// The `import_oci_layout` function with `NullReporter` (via `SharedReporter`
2578    /// wrapping `NullReporter`) must not panic now that it uses the reporter internally.
2579    ///
2580    /// This verifies the zero-overhead default path still works correctly.
2581    #[tokio::test]
2582    async fn test_import_oci_layout_with_null_reporter_does_not_panic() {
2583        use crate::oci_layout::import_oci_layout;
2584        use crate::progress::NullReporter;
2585        use composefs::fsverity::Sha256HashValue;
2586        use composefs::test::TestRepo;
2587
2588        let layout_dir = tempfile::tempdir().unwrap();
2589        let layout_path = make_test_oci_layout(layout_dir.path());
2590
2591        let test_repo = TestRepo::<Sha256HashValue>::new();
2592        let repo = &test_repo.repo;
2593
2594        // NullReporter: zero overhead, no events collected
2595        let reporter: crate::progress::SharedReporter = std::sync::Arc::new(NullReporter);
2596        import_oci_layout(repo, &layout_path, None, reporter)
2597            .await
2598            .expect("import_oci_layout with NullReporter should not panic");
2599    }
2600}