Skip to main content

composefs_ctl/
lib.rs

1//! Library for `cfsctl` command line utility
2//!
3//! This crate also re-exports all composefs-rs library crates, so downstream
4//! consumers can take a single dependency on `cfsctl` instead of listing each
5//! crate individually.
6//!
7//! ```
8//! use composefs_ctl::composefs::repository::Repository;
9//! use composefs_ctl::composefs::fsverity::Sha256HashValue;
10//!
11//! let repo = Repository::<Sha256HashValue>::open_path(
12//!     rustix::fs::CWD,
13//!     "/nonexistent",
14//! );
15//! assert!(repo.is_err());
16//! ```
17
18pub use composefs;
19pub use composefs_boot;
20#[cfg(feature = "http")]
21pub use composefs_http;
22#[cfg(feature = "oci")]
23pub use composefs_oci;
24
25pub mod composefs_info;
26pub mod mkcomposefs;
27/// Varlink RPC service exposing repository operations over a Unix socket.
28pub mod varlink;
29
30#[cfg(any(feature = "oci", feature = "http"))]
31use std::collections::HashMap;
32use std::io::Read;
33use std::path::Path;
34#[cfg(any(feature = "oci", feature = "http"))]
35use std::sync::Mutex;
36use std::{ffi::OsString, path::PathBuf};
37
38#[cfg(feature = "oci")]
39use std::{fs::create_dir_all, io::IsTerminal};
40
41use std::sync::Arc;
42
43use anyhow::{Context as _, Result};
44use clap::{Parser, Subcommand, ValueEnum};
45#[cfg(any(feature = "oci", feature = "ostree"))]
46use comfy_table::{Table, presets::UTF8_FULL};
47#[cfg(any(feature = "oci", feature = "http"))]
48use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
49use rustix::fs::{CWD, Mode, OFlags};
50
51#[cfg(any(feature = "oci", feature = "http"))]
52use composefs::progress::{
53    ComponentId, ProgressEvent, ProgressReporter, ProgressUnit, SharedReporter,
54};
55use composefs_boot::BootOps;
56use composefs_boot::cmdline::ComposefsCmdline;
57#[cfg(feature = "oci")]
58use composefs_boot::write_boot;
59
60use composefs::erofs::format::FormatVersion;
61#[cfg(feature = "oci")]
62use composefs::shared_internals::IO_BUF_CAPACITY;
63use composefs::{
64    dumpfile::{dump_single_dir, dump_single_file},
65    erofs::reader::erofs_to_filesystem,
66    fsverity::{Algorithm, FsVerityHashValue, Sha256HashValue, Sha512HashValue},
67    generic_tree::{FileSystem, Inode},
68    mount::MountOptions,
69    repository::{
70        REPO_METADATA_FILENAME, Repository, RepositoryConfig, read_repo_algorithm, system_path,
71        user_path,
72    },
73    tree::RegularFile,
74};
75
76/// An `indicatif`-backed [`ProgressReporter`] for use in the CLI.
77///
78/// Renders per-component progress bars via [`MultiProgress`].  When a component
79/// completes or is skipped the bar is removed; human-readable messages are
80/// printed above the bar group via [`MultiProgress::println`].
81#[cfg(any(feature = "oci", feature = "http"))]
82struct IndicatifReporter {
83    multi: MultiProgress,
84    bars: Mutex<HashMap<ComponentId, ProgressBar>>,
85}
86
87#[cfg(any(feature = "oci", feature = "http"))]
88impl IndicatifReporter {
89    fn new() -> Self {
90        IndicatifReporter {
91            multi: MultiProgress::new(),
92            bars: Mutex::new(HashMap::new()),
93        }
94    }
95
96    /// Build a shared reporter from this instance.
97    fn into_shared(self) -> SharedReporter {
98        Arc::new(self)
99    }
100}
101
102#[cfg(any(feature = "oci", feature = "http"))]
103impl std::fmt::Debug for IndicatifReporter {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.debug_struct("IndicatifReporter").finish_non_exhaustive()
106    }
107}
108
109#[cfg(any(feature = "oci", feature = "http"))]
110impl ProgressReporter for IndicatifReporter {
111    fn report(&self, event: ProgressEvent) {
112        match event {
113            ProgressEvent::Started { id, total, unit } => {
114                let bar = if let Some(total) = total {
115                    self.multi.add(ProgressBar::new(total))
116                } else {
117                    self.multi.add(ProgressBar::new_spinner())
118                };
119                let style = match unit {
120                    ProgressUnit::Bytes => ProgressStyle::with_template(
121                        "[eta {eta}] {bar:40.cyan/blue} {decimal_bytes:>7}/{decimal_total_bytes:7} {msg}",
122                    ),
123                    ProgressUnit::Items => ProgressStyle::with_template(
124                        "[eta {eta}] {bar:40.cyan/blue} {pos:>7}/{len:7} objects {msg}",
125                    ),
126                    // Future unit variants fall back to a generic spinner.
127                    _ => ProgressStyle::with_template(
128                        "[eta {eta}] {bar:40.cyan/blue} {pos}/{len} {msg}",
129                    ),
130                };
131                bar.set_style(
132                    style
133                        .unwrap_or_else(|_| ProgressStyle::default_bar())
134                        .progress_chars("##-"),
135                );
136                bar.set_message(id.to_string());
137                self.bars.lock().unwrap().insert(id, bar);
138            }
139            ProgressEvent::Progress { id, fetched, .. } => {
140                if let Some(bar) = self.bars.lock().unwrap().get(&id) {
141                    bar.set_position(fetched);
142                }
143            }
144            ProgressEvent::Done { id, .. } => {
145                if let Some(bar) = self.bars.lock().unwrap().remove(&id) {
146                    bar.finish_and_clear();
147                }
148            }
149            ProgressEvent::Skipped { id } => {
150                if let Some(bar) = self.bars.lock().unwrap().remove(&id) {
151                    bar.finish_with_message("skipped");
152                }
153            }
154            ProgressEvent::Message(msg) => {
155                let _ = self.multi.println(msg);
156            }
157            // `ProgressEvent` is #[non_exhaustive]: new variants added to the library
158            // will be silently ignored here until cfsctl is updated to handle them.
159            _ => {}
160        }
161    }
162}
163
164/// cfsctl
165#[derive(Debug, Parser)]
166#[clap(name = "cfsctl", version)]
167pub struct App {
168    /// Operate on repo at path
169    #[clap(long, group = "repopath")]
170    repo: Option<PathBuf>,
171    /// Operate on repo at standard user location $HOME/.var/lib/composefs
172    #[clap(long, group = "repopath")]
173    user: bool,
174    /// Operate on repo at standard system location /sysroot/composefs
175    #[clap(long, group = "repopath")]
176    system: bool,
177
178    /// What hash digest type to use for composefs repo.
179    /// If omitted, auto-detected from repository metadata (meta.json).
180    #[clap(long, value_enum)]
181    pub hash: Option<HashType>,
182
183    /// The EROFS format version to use when generating images.
184    /// If omitted, the library default (V2) is used.
185    #[clap(long, value_enum)]
186    pub erofs_version: Option<ErofsVersion>,
187
188    /// Deprecated: security mode is now auto-detected from meta.json.
189    /// Use `cfsctl init --insecure` to create a repo without verity.
190    /// Kept for backward compatibility.
191    #[clap(long, hide = true)]
192    insecure: bool,
193
194    /// Error if the repository does not have fs-verity enabled.
195    #[clap(long)]
196    require_verity: bool,
197
198    /// Don't automatically upgrade old-format repositories.
199    /// When set, commands will fail on repos without meta.json instead
200    /// of inferring metadata from existing objects.
201    #[clap(long)]
202    no_upgrade: bool,
203
204    /// Don't open a repository. Only valid for commands that don't need one
205    /// (compute-id, create-dumpfile).
206    #[clap(long)]
207    pub no_repo: bool,
208
209    #[clap(subcommand)]
210    cmd: Command,
211}
212
213/// The Hash algorithm used for FsVerity computation
214#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum)]
215pub enum HashType {
216    /// Sha256
217    Sha256,
218    /// Sha512
219    Sha512,
220}
221
222/// The EROFS format version used when generating images.
223#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum)]
224pub enum ErofsVersion {
225    /// Format V0: compact inodes, BFS, C-compatible (composefs_version auto-detects 0 or 1).
226    #[clap(name = "0")]
227    V0,
228    /// Format V1: same layout as V0, composefs_version always 1.
229    #[clap(name = "1")]
230    V1,
231    /// Format V2: extended inodes, DFS (composefs_version=2).
232    #[clap(name = "2")]
233    V2,
234}
235
236impl From<ErofsVersion> for composefs::erofs::format::FormatVersion {
237    fn from(v: ErofsVersion) -> Self {
238        match v {
239            ErofsVersion::V0 => Self::V0,
240            ErofsVersion::V1 => Self::V1,
241            ErofsVersion::V2 => Self::V2,
242        }
243    }
244}
245
246/// A reference to an OCI image: either a content digest or a named ref.
247///
248/// Digests are prefixed with `@` (e.g. `@sha256:abc123…`), while bare
249/// names are refs resolved through the repository's ref tree. The `@`
250/// prefix is necessary to disambiguate because ref names may contain `:`
251/// — OCI digest algorithms are intentionally extensible, so we cannot
252/// rely on parse heuristics to distinguish the two.
253///
254/// Note this differs from the podman/docker convention where `@` appears
255/// between the image name and the digest (e.g. `fedora@sha256:abc…`).
256/// Here, `@` is always a leading prefix on the entire argument.
257///
258/// At the repository level, ref names are freeform strings (the only
259/// restriction is that they must not start with `@`). In practice,
260/// `oci pull` defaults to tagging with the source transport reference
261/// (e.g. `docker://quay.io/fedora/fedora:latest`), so most refs in a
262/// repository will be container transport names — which naturally never
263/// start with `@`.
264#[cfg(feature = "oci")]
265#[derive(Debug, Clone)]
266pub(crate) enum OciReference {
267    /// A content-addressable digest such as `sha256:abcdef…`.
268    Digest(composefs_oci::OciDigest),
269    /// A named ref resolved through the repository's ref tree, typically
270    /// a container transport name (e.g. `docker://quay.io/foo:latest`).
271    Named(String),
272}
273
274#[cfg(feature = "oci")]
275impl std::str::FromStr for OciReference {
276    type Err = anyhow::Error;
277
278    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
279        if let Some(digest_str) = s.strip_prefix('@') {
280            let digest: composefs_oci::OciDigest =
281                digest_str.parse().context("Invalid OCI digest after '@'")?;
282            Ok(Self::Digest(digest))
283        } else {
284            Ok(Self::Named(s.to_owned()))
285        }
286    }
287}
288
289#[cfg(feature = "oci")]
290impl std::fmt::Display for OciReference {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        match self {
293            Self::Digest(d) => write!(f, "@{d}"),
294            Self::Named(n) => write!(f, "{n}"),
295        }
296    }
297}
298
299/// CLI representation of [`composefs_oci::LocalFetchOpt`].
300#[cfg(feature = "oci")]
301#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
302enum LocalFetchCli {
303    /// Do not use native containers-storage import; use skopeo.
304    #[default]
305    Disabled,
306    /// Use native import with reflink/hardlink/copy fallback.
307    Auto,
308    /// Use native import; error if zero-copy is not possible.
309    Zerocopy,
310}
311
312#[cfg(feature = "oci")]
313impl From<LocalFetchCli> for composefs_oci::LocalFetchOpt {
314    fn from(cli: LocalFetchCli) -> Self {
315        match cli {
316            LocalFetchCli::Disabled => Self::Disabled,
317            LocalFetchCli::Auto => Self::IfPossible,
318            LocalFetchCli::Zerocopy => Self::ZeroCopy,
319        }
320    }
321}
322
323/// Common options for operations using OCI config manifest streams that may transform the image rootfs
324#[cfg(feature = "oci")]
325#[derive(Debug, Parser)]
326struct OCIConfigFilesystemOptions {
327    #[clap(flatten)]
328    base_config: OCIConfigOptions,
329    /// Whether bootable transformation should be performed on the image rootfs
330    #[clap(long)]
331    bootable: bool,
332}
333
334/// Common options for operations using OCI config manifest streams
335#[cfg(feature = "oci")]
336#[derive(Debug, Parser)]
337struct OCIConfigOptions {
338    /// Ref name (e.g. myimage:latest) or @digest (e.g. @sha256:a1b2c3...)
339    config_name: OciReference,
340    /// verity digest for the manifest stream to be verified against
341    config_verity: Option<String>,
342}
343
344#[cfg(feature = "oci")]
345#[derive(Debug, Subcommand)]
346enum OciCommand {
347    /// Import a tar layer as a splitstream in the repository
348    ImportLayer {
349        /// Layer content digest, e.g. sha256:a1b2c3...
350        digest: composefs_oci::OciDigest,
351        /// Optional human-readable name for the layer
352        name: Option<String>,
353    },
354    /// Dump the rootfs of a stored OCI image as a composefs dumpfile to stdout
355    ///
356    /// The image can be specified by ref name or @digest:
357    ///   cfsctl oci dump myimage:latest
358    ///   cfsctl oci dump @sha256:a1b2c3...
359    Dump {
360        #[clap(flatten)]
361        config_opts: OCIConfigFilesystemOptions,
362    },
363    /// Pull an OCI image into the repository
364    ///
365    /// Prints the config stream digest and verity of the stored manifest.
366    Pull {
367        /// Source image reference, as accepted by skopeo
368        image: String,
369        /// Tag name to assign to the pulled image (defaults to the image reference)
370        name: Option<String>,
371        /// Also generate a bootable EROFS image from the pulled OCI image
372        #[arg(long)]
373        bootable: bool,
374        /// Controls whether containers-storage: references use the native
375        /// import path with zero-copy reflink/hardlink support.
376        #[arg(long, value_enum, default_value_t = LocalFetchCli::Disabled)]
377        local_fetch: LocalFetchCli,
378    },
379    /// List all tagged OCI images in the repository
380    #[clap(name = "images")]
381    ListImages {
382        /// Output as JSON array
383        #[clap(long)]
384        json: bool,
385    },
386    /// Show information about an OCI image
387    ///
388    /// The image can be specified by ref name or @digest:
389    ///   cfsctl oci inspect myimage:latest
390    ///   cfsctl oci inspect @sha256:a1b2c3...
391    ///
392    /// By default, outputs JSON with manifest, config, and referrers.
393    /// Use --manifest or --config to output just that raw JSON.
394    #[clap(name = "inspect")]
395    Inspect {
396        /// Ref name (e.g. myimage:latest) or @digest (e.g. @sha256:a1b2c3...)
397        image: OciReference,
398        /// Output only the raw manifest JSON (as originally stored)
399        #[clap(long, conflicts_with = "config")]
400        manifest: bool,
401        /// Output only the raw config JSON (as originally stored)
402        #[clap(long, conflicts_with = "manifest")]
403        config: bool,
404    },
405    /// Tag an image with a new name
406    ///
407    /// Example: cfsctl oci tag sha256:a1b2c3... myimage:latest
408    Tag {
409        /// Manifest digest, e.g. sha256:a1b2c3...
410        manifest_digest: composefs_oci::OciDigest,
411        /// Tag name to assign (must not contain '@')
412        name: String,
413    },
414    /// Remove a tag from an image
415    Untag {
416        /// Tag name to remove
417        name: String,
418    },
419    /// Inspect a stored layer
420    ///
421    /// By default, outputs the raw tar stream to stdout.
422    /// Use --dumpfile for composefs dumpfile format, or --json for metadata.
423    #[clap(name = "layer")]
424    LayerInspect {
425        /// Layer diff_id, e.g. sha256:a1b2c3...
426        layer: composefs_oci::OciDigest,
427        /// Output as composefs dumpfile format (one entry per line)
428        #[clap(long, conflicts_with = "json")]
429        dumpfile: bool,
430        /// Output layer metadata as JSON
431        #[clap(long, conflicts_with = "dumpfile")]
432        json: bool,
433    },
434    /// Mount an OCI image's composefs EROFS at the given mountpoint
435    Mount {
436        /// Image reference (tag name or manifest digest)
437        image: String,
438        /// Target mountpoint
439        mountpoint: String,
440        /// Mount the bootable variant instead of the regular EROFS image
441        #[arg(long)]
442        bootable: bool,
443        /// Writable upper layer directory for overlayfs
444        #[arg(long, requires = "workdir")]
445        upperdir: Option<PathBuf>,
446        /// Work directory for overlayfs (required with --upperdir)
447        #[arg(long, requires = "upperdir")]
448        workdir: Option<PathBuf>,
449        /// Mount read-write (requires --upperdir)
450        #[arg(long, requires = "upperdir")]
451        read_write: bool,
452    },
453    /// Compute the composefs image ID of a stored OCI image's rootfs
454    ///
455    /// The image can be specified by ref name or @digest:
456    ///   cfsctl oci compute-id myimage:latest
457    ///   cfsctl oci compute-id @sha256:a1b2c3...
458    ComputeId {
459        #[clap(flatten)]
460        config_opts: OCIConfigFilesystemOptions,
461    },
462
463    /// Create the composefs image of the rootfs of a stored OCI image, perform bootable transformation, commit it to the repo,
464    /// then configure boot for the image by writing new boot resources and bootloader entries to boot partition. Performs
465    /// state preparation for composefs-setup-root consumption as well. Note that state preparation here is not suitable for
466    /// consumption by bootc.
467    PrepareBoot {
468        #[clap(flatten)]
469        config_opts: OCIConfigOptions,
470        /// boot partition mount point
471        #[clap(long, default_value = "/boot")]
472        bootdir: PathBuf,
473        /// Boot entry identifier to use. By default uses ID provided by the image or kernel version
474        #[clap(long)]
475        entry_id: Option<String>,
476        /// additional kernel command line
477        #[clap(long)]
478        cmdline: Vec<String>,
479    },
480    /// Check integrity of OCI images in the repository
481    ///
482    /// Verifies manifest and config content digests, layer references, seal
483    /// consistency, and delegates to the underlying repository fsck for object
484    /// integrity and splitstream validation.
485    Fsck {
486        /// Check only the named image instead of all tagged images
487        image: Option<String>,
488        /// Output results as JSON (always exits 0 unless the check itself fails)
489        #[clap(long)]
490        json: bool,
491    },
492    /// Serve the varlink RPC API on a Unix socket or systemd socket.
493    ///
494    /// Equivalent to `cfsctl varlink`: a single service answers both the
495    /// `org.composefs.Repository` and `org.composefs.Oci` interfaces on one
496    /// socket. Kept for discoverability under the `oci` subcommand.
497    Varlink {
498        /// Unix socket path to listen on (omit when using systemd socket activation).
499        #[clap(long)]
500        address: Option<PathBuf>,
501    },
502}
503
504#[cfg(feature = "ostree")]
505#[derive(Debug, Subcommand)]
506enum OstreeCommand {
507    PullLocal {
508        ostree_repo_path: PathBuf,
509        /// Ostree ref name or commit ID (64-character hex)
510        ostree_ref: String,
511        #[clap(long)]
512        base_name: Option<String>,
513    },
514    Pull {
515        ostree_repo_url: String,
516        /// Ostree ref name or commit ID (64-character hex)
517        ostree_ref: String,
518        #[clap(long)]
519        base_name: Option<String>,
520    },
521    /// Mount an ostree commit's composefs EROFS at the given mountpoint
522    Mount {
523        /// Ostree commit ref or commit ID
524        commit: String,
525        /// Target mountpoint
526        mountpoint: String,
527        /// Writable upper layer directory for overlayfs
528        #[arg(long, requires = "workdir")]
529        upperdir: Option<PathBuf>,
530        /// Work directory for overlayfs (required with --upperdir)
531        #[arg(long, requires = "upperdir")]
532        workdir: Option<PathBuf>,
533        /// Mount read-write (requires --upperdir)
534        #[arg(long, requires = "upperdir")]
535        read_write: bool,
536    },
537    /// Dump the filesystem of an ostree commit as a composefs dumpfile to stdout
538    Dump {
539        /// Ostree commit ref name
540        commit_name: String,
541    },
542    /// Compute the composefs image ID of an ostree commit
543    ComputeId {
544        /// Ostree commit ref name
545        commit_name: String,
546    },
547    /// Show the contents of an ostree commit
548    Inspect {
549        /// Ostree ref name, commit ID, or commit ID prefix
550        source: String,
551        /// Print only the commit metadata key-value pairs
552        #[clap(long)]
553        metadata: bool,
554    },
555    /// Tag an ostree commit with a name
556    ///
557    /// The source can be an ostree commit checksum or an existing ref name.
558    Tag {
559        /// Ostree commit checksum (hex) or existing ref name
560        source: String,
561        /// Tag name to assign
562        name: String,
563    },
564    /// Remove a named ostree reference
565    Untag {
566        /// Tag name to remove
567        name: String,
568    },
569    /// List all ostree commits in the repository
570    #[clap(name = "images")]
571    ListCommits,
572}
573
574/// Common options for reading a filesystem from a path
575#[derive(Debug, Parser)]
576struct FsReadOptions {
577    /// The path to the filesystem
578    path: PathBuf,
579    /// Transform the filesystem for boot (SELinux labels, empty /boot and /sysroot)
580    #[clap(long)]
581    bootable: bool,
582    /// Don't copy /usr metadata to root directory (use if root already has well-defined metadata)
583    #[clap(long)]
584    no_propagate_usr_to_root: bool,
585}
586
587#[derive(Debug, Subcommand)]
588enum Command {
589    /// Initialize a new composefs repository with a metadata file.
590    ///
591    /// Creates the repository directory (if it doesn't exist) and writes
592    /// a `meta.json` recording the digest algorithm.  By default fs-verity
593    /// is enabled on `meta.json`, signaling that all objects require
594    /// verity.  Use `--insecure` to skip (e.g. on tmpfs).
595    Init {
596        /// The fs-verity algorithm identifier.
597        /// Format: fsverity-<hash>-<lg_blocksize>, e.g. fsverity-sha512-12
598        #[clap(long, value_parser = clap::value_parser!(Algorithm), default_value = "fsverity-sha512-12")]
599        algorithm: Algorithm,
600        /// Path to the repository directory (created if it doesn't exist).
601        /// If omitted, uses --repo/--user/--system location.
602        path: Option<PathBuf>,
603        /// Do not enable fs-verity on meta.json (insecure repository).
604        #[clap(long)]
605        insecure: bool,
606        /// Migrate an old-format repository: remove streams/ and images/
607        /// (which encode the algorithm) but keep objects/, then write
608        /// fresh meta.json.  Streams and images will need to be
609        /// re-imported after migration.
610        #[clap(long)]
611        reset_metadata: bool,
612        /// Default EROFS format version for images in this repository.
613        /// V1 is compatible with C `mkcomposefs` 1.0.8; V2 is the native format.
614        /// If omitted, falls back to the global `--erofs-version` flag, then defaults to V2.
615        #[clap(long)]
616        erofs_version: Option<ErofsVersion>,
617    },
618    /// Take a transaction lock on the repository.
619    /// This prevents garbage collection from occurring.
620    Transaction,
621    /// Reconstitutes a split stream and writes it to stdout
622    Cat {
623        /// the name of the stream to cat, either a content identifier or prefixed with 'ref/'
624        name: String,
625    },
626    /// Perform garbage collection
627    GC {
628        /// Additional roots to keep (image or stream names)
629        #[clap(long, short = 'r')]
630        root: Vec<String>,
631        /// Preview what would be deleted without actually deleting
632        #[clap(long, short = 'n')]
633        dry_run: bool,
634    },
635    /// Imports a composefs image (unsafe!)
636    ImportImage { reference: String },
637    /// Commands for dealing with OCI images and layers
638    #[cfg(feature = "oci")]
639    Oci {
640        #[clap(subcommand)]
641        cmd: OciCommand,
642    },
643    #[cfg(feature = "ostree")]
644    Ostree {
645        #[clap(subcommand)]
646        cmd: OstreeCommand,
647    },
648    /// Mounts a composefs image, possibly enforcing fsverity of the image
649    Mount {
650        /// the name of the image to mount, either an fs-verity hash or prefixed with 'ref/'
651        name: String,
652        /// the mountpoint
653        mountpoint: String,
654        /// Writable upper layer directory for overlayfs
655        #[arg(long, requires = "workdir")]
656        upperdir: Option<PathBuf>,
657        /// Work directory for overlayfs (required with --upperdir)
658        #[arg(long, requires = "upperdir")]
659        workdir: Option<PathBuf>,
660        /// Mount read-write (requires --upperdir)
661        #[arg(long, requires = "upperdir")]
662        read_write: bool,
663    },
664    /// Read rootfs located at a path, add all files to the repo, then create the composefs image of the rootfs,
665    /// commit it to the repo, and print its image object ID
666    CreateImage {
667        #[clap(flatten)]
668        fs_opts: FsReadOptions,
669        /// optional reference name for the image, use as 'ref/<name>' elsewhere
670        image_name: Option<String>,
671    },
672    /// Read rootfs located at a path and compute the composefs image object id of the rootfs.
673    /// Note that this does not create or commit the composefs image itself, and does not
674    /// store any file objects in the repository.
675    ComputeId {
676        #[clap(flatten)]
677        fs_opts: FsReadOptions,
678    },
679    /// Read rootfs located at a path and compute the composefs kernel argument string.
680    ///
681    /// Like compute-id but outputs the full kernel argument rather than the bare digest,
682    /// choosing the argument name based on the EROFS format version:
683    ///
684    ///   V1: composefs.digest=v1-sha256-12:<hex>
685    ///   V2: composefs=<hex>
686    ///
687    /// Use --erofs-version to select the format.
688    /// The boot transformation (SELinux relabeling, empty /boot and /sysroot) is
689    /// always applied — this command produces a karg for a sealed boot image.
690    ///
691    /// Example (in a Containerfile):
692    ///   cfsctl --erofs-version 1 compute-karg /mnt/base > /etc/kernel/cmdline
693    #[clap(name = "compute-karg")]
694    ComputeKarg {
695        /// The path to the filesystem
696        path: PathBuf,
697        /// Don't copy /usr metadata to root directory (use if root already has well-defined metadata)
698        #[clap(long)]
699        no_propagate_usr_to_root: bool,
700    },
701    /// Read rootfs located at a path and dump full content of the rootfs to a composefs dumpfile,
702    /// writing to stdout. Does not store any file objects in the repository.
703    CreateDumpfile {
704        #[clap(flatten)]
705        fs_opts: FsReadOptions,
706    },
707    /// Lists all object IDs referenced by an image
708    ImageObjects {
709        /// the name of the image to read, either an object ID digest or prefixed with 'ref/'
710        name: String,
711    },
712    /// Extract file information from a composefs image for specified files or directories
713    ///
714    /// By default, outputs information in composefs dumpfile format
715    DumpFiles {
716        /// The name of the composefs image to read from, either an object ID digest or prefixed with 'ref/'
717        image_name: String,
718        /// File or directory paths to process. If a path is a directory, its contents will be listed.
719        files: Vec<PathBuf>,
720        /// Show backing path information instead of dumpfile format
721        /// For each file, prints either "inline" for files stored within the image,
722        /// or a path relative to the object store for files stored extrenally
723        #[clap(long)]
724        backing_path_only: bool,
725    },
726    /// Check repository integrity
727    ///
728    /// Verifies fsverity digests of all objects, validates stream and image
729    /// symlinks, and checks splitstream internal consistency. Exits with
730    /// a non-zero status if corruption is found.
731    Fsck {
732        /// Output results as JSON (always exits 0 unless the check itself fails)
733        #[clap(long)]
734        json: bool,
735        /// Skip per-object fs-verity verification; check only metadata and
736        /// symlink structure (much faster on large repositories)
737        #[clap(long)]
738        metadata_only: bool,
739    },
740    #[cfg(feature = "http")]
741    Fetch { url: String, name: String },
742    /// Serve the varlink RPC API on a Unix socket or systemd socket.
743    ///
744    /// A single service answers both the `org.composefs.Repository` and (when
745    /// the `oci` feature is enabled) `org.composefs.Oci` interfaces on one
746    /// socket.
747    Varlink {
748        /// Unix socket path to listen on (omit when using systemd socket activation).
749        #[clap(long)]
750        address: Option<PathBuf>,
751    },
752
753    /// Run mkcomposefs (C-compatible image builder); hidden, also available via argv0 dispatch.
754    #[clap(hide = true, name = "mkcomposefs")]
755    Mkcomposefs {
756        /// Arguments forwarded verbatim to mkcomposefs
757        #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
758        args: Vec<std::ffi::OsString>,
759    },
760
761    /// Run composefs-info (C-compatible image inspector); hidden, also available via argv0 dispatch.
762    #[clap(hide = true, name = "composefs-info")]
763    ComposefsInfo {
764        /// Arguments forwarded verbatim to composefs-info
765        #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
766        args: Vec<std::ffi::OsString>,
767    },
768}
769
770/// Acts as a proxy for the `cfsctl` CLI by executing the CLI logic programmatically
771///
772/// This function behaves the same as invoking the `cfsctl` binary from the
773/// command line. It accepts an iterator of CLI-style arguments (excluding
774/// the binary name), parses them using `clap`
775pub async fn run_from_iter<I>(args: I) -> Result<()>
776where
777    I: IntoIterator,
778    I::Item: Into<OsString> + Clone,
779{
780    let args = App::parse_from(
781        std::iter::once(OsString::from("cfsctl")).chain(args.into_iter().map(Into::into)),
782    );
783
784    run_app(args).await
785}
786
787fn get_mount_options(
788    upperdir: Option<&Path>,
789    workdir: Option<&Path>,
790    read_write: bool,
791) -> Result<MountOptions> {
792    let mut options = MountOptions::default();
793    if let (Some(u), Some(w)) = (upperdir, workdir) {
794        let upper_fd = rustix::fs::open(
795            u,
796            OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
797            Mode::empty(),
798        )
799        .with_context(|| format!("Opening upperdir '{}'", u.display()))?;
800        let work_fd = rustix::fs::open(
801            w,
802            OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
803            Mode::empty(),
804        )
805        .with_context(|| format!("Opening workdir '{}'", w.display()))?;
806        options.set_overlay(upper_fd, work_fd);
807    }
808    options.set_read_write(read_write);
809    Ok(options)
810}
811
812#[cfg(feature = "oci")]
813pub(crate) fn verity_opt<ObjectID>(opt: &Option<String>) -> Result<Option<ObjectID>>
814where
815    ObjectID: FsVerityHashValue,
816{
817    Ok(match opt {
818        Some(value) => Some(FsVerityHashValue::from_hex(value)?),
819        None => None,
820    })
821}
822
823/// Resolve the default repository path based on the effective uid.
824///
825/// Root operates on the system repository; everyone else on their per-user
826/// repository. Used both when no `--repo`/`--user`/`--system` is given and by
827/// the socket-activated path (which has no CLI args to consult).
828pub(crate) fn default_repo_path() -> Result<PathBuf> {
829    if rustix::process::getuid().is_root() {
830        Ok(system_path())
831    } else {
832        user_path()
833    }
834}
835
836/// Resolve the repository path from CLI args without opening it.
837///
838/// Uses [`user_path`] and [`system_path`] to avoid duplicating
839/// path constants.
840pub(crate) fn resolve_repo_path(args: &App) -> Result<PathBuf> {
841    if let Some(path) = &args.repo {
842        Ok(path.clone())
843    } else if args.system {
844        Ok(system_path())
845    } else if args.user {
846        user_path()
847    } else {
848        default_repo_path()
849    }
850}
851
852/// Determine the effective hash type for a repository.
853///
854/// Resolution order:
855/// 1. If `meta.json` exists, use its algorithm. Error if `--hash` was
856///    explicitly passed and conflicts.
857/// 2. If no metadata and `upgrade` is true, infer from existing objects.
858/// 3. If no metadata and `upgrade` is false, error.
859///
860/// Note: we read the metadata file directly here (rather than via
861/// `Repository::metadata`) because this runs *before* we know which
862/// generic `ObjectID` type to use — that's exactly what we're deciding.
863pub(crate) fn resolve_hash_type(
864    repo_path: &Path,
865    cli_hash: Option<HashType>,
866    upgrade: bool,
867) -> Result<HashType> {
868    let repo_fd = rustix::fs::open(
869        repo_path,
870        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
871        Mode::empty(),
872    )
873    .with_context(|| format!("opening repository {}", repo_path.display()))?;
874
875    let algorithm = match read_repo_algorithm(&repo_fd)? {
876        Some(alg) => alg,
877        None if upgrade => {
878            // No meta.json — try to infer from objects (old-format repo).
879            // open_upgrade will write meta.json later when the repo is opened.
880            composefs::repository::infer_repo_algorithm(&repo_fd).with_context(|| {
881                format!(
882                    "no {REPO_METADATA_FILENAME} in {}; tried to infer algorithm from objects",
883                    repo_path.display(),
884                )
885            })?
886        }
887        None => {
888            anyhow::bail!(
889                "{REPO_METADATA_FILENAME} not found in {}; \
890                 this repository must be initialized with `cfsctl init`",
891                repo_path.display(),
892            );
893        }
894    };
895
896    let detected = match algorithm {
897        Algorithm::Sha256 { .. } => HashType::Sha256,
898        Algorithm::Sha512 { .. } => HashType::Sha512,
899    };
900
901    // If the user explicitly passed --hash and it doesn't match, error
902    if let Some(explicit) = cli_hash
903        && explicit != detected
904    {
905        anyhow::bail!(
906            "repository is configured for {algorithm} (from {REPO_METADATA_FILENAME}) \
907             but --hash {} was specified",
908            match explicit {
909                HashType::Sha256 => "sha256",
910                HashType::Sha512 => "sha512",
911            },
912        );
913    }
914
915    Ok(detected)
916}
917
918/// If the process was started *bare* via systemd socket activation, serve the
919/// varlink API on the activated socket and return `Ok(true)`. Otherwise return
920/// `Ok(false)` so the caller falls through to normal CLI parsing.
921///
922/// This runs *before* clap to support a truly argument-less invocation —
923/// notably `varlinkctl exec:cfsctl`, which hands us the connected socket on fd
924/// 3 but passes no subcommand for clap to parse. A client selects a repository
925/// at runtime via the `OpenRepository` method.
926///
927/// The shortcut is taken *only* when there are no command-line arguments
928/// (`argv` is just the program name). When any argument is present — e.g. a
929/// systemd unit running `cfsctl varlink` — we fall through to clap; the
930/// `varlink`/`oci varlink` subcommand's [`serve`](crate::varlink::serve)
931/// detects and serves on the activation fd itself. We must NOT call
932/// [`try_activated_listener`](crate::varlink::try_activated_listener) on that
933/// path: it consumes `LISTEN_FDS`/`LISTEN_PID` (via `receive_descriptors`),
934/// which would prevent `serve` from finding the fd later.
935pub async fn run_if_socket_activated() -> Result<bool> {
936    // Only take the pre-clap shortcut for a bare invocation (`argv[0]` only).
937    // Check argv before touching the activation env so the latter is consumed
938    // only when we actually intend to serve from this shortcut.
939    if std::env::args_os().len() != 1 {
940        return Ok(false);
941    }
942    let Some(listener) = crate::varlink::try_activated_listener()? else {
943        return Ok(false);
944    };
945    let service = crate::varlink::CfsctlService::activated();
946    crate::varlink::serve_activated(service, listener).await?;
947    Ok(true)
948}
949
950/// Top-level dispatch: handle init specially, otherwise open repo and run.
951pub async fn run_app(args: App) -> Result<()> {
952    // Hidden compat subcommands: forward all trailing args to the respective tool.
953    if let Command::Mkcomposefs { args: extra } = args.cmd {
954        return mkcomposefs::run_from_args(extra);
955    }
956    if let Command::ComposefsInfo { args: extra } = args.cmd {
957        return composefs_info::run_from_args(extra);
958    }
959
960    // Init is handled before opening a repo since it creates one
961    if let Command::Init {
962        ref algorithm,
963        ref path,
964        insecure,
965        reset_metadata,
966        erofs_version: ref init_erofs_version,
967    } = args.cmd
968    {
969        // Prefer the subcommand-level --erofs-version; fall back to global flag; default V2.
970        let erofs_version = init_erofs_version
971            .or(args.erofs_version)
972            .map(composefs::erofs::format::FormatVersion::from)
973            .unwrap_or(composefs::erofs::format::FormatVersion::V2);
974        return run_init(
975            algorithm,
976            path.as_deref(),
977            insecure || args.insecure,
978            reset_metadata,
979            erofs_version,
980            &args,
981        );
982    }
983
984    // The varlink service opens repositories on demand via `OpenRepository`
985    // (handling both hash types), so it bypasses the generic repo-open dispatch
986    // below. A single `CfsctlService` answers both the `org.composefs.Repository`
987    // and (when the `oci` feature is enabled) `org.composefs.Oci` interfaces, so
988    // `cfsctl varlink` and `cfsctl oci varlink` serve the same combined service.
989    if let Command::Varlink { ref address } = args.cmd {
990        let service = crate::varlink::CfsctlService::from_app(&args);
991        return crate::varlink::serve(service, address.as_deref()).await;
992    }
993
994    #[cfg(feature = "oci")]
995    if let Command::Oci {
996        cmd: OciCommand::Varlink { ref address },
997    } = args.cmd
998    {
999        let service = crate::varlink::CfsctlService::from_app(&args);
1000        return crate::varlink::serve(service, address.as_deref()).await;
1001    }
1002
1003    // Commands that only need verity digests (no object storage) can
1004    // run without opening a repository.
1005    if args.no_repo
1006        || matches!(
1007            args.cmd,
1008            Command::ComputeId { .. }
1009                | Command::ComputeKarg { .. }
1010                | Command::CreateDumpfile { .. }
1011        )
1012    {
1013        // If a repo path is available and --no-repo wasn't passed,
1014        // try to read the hash type from the repo's metadata so that
1015        // e.g. `cfsctl --repo <sha256-repo> compute-id` uses SHA-256
1016        // instead of the default SHA-512.
1017        let effective_hash = if !args.no_repo {
1018            if let Ok(repo_path) = resolve_repo_path(&args) {
1019                resolve_hash_type(&repo_path, args.hash, !args.no_upgrade)
1020                    .unwrap_or(args.hash.unwrap_or(HashType::Sha512))
1021            } else {
1022                args.hash.unwrap_or(HashType::Sha512)
1023            }
1024        } else {
1025            args.hash.unwrap_or(HashType::Sha512)
1026        };
1027        return match effective_hash {
1028            HashType::Sha256 => run_cmd_without_repo::<Sha256HashValue>(args).await,
1029            HashType::Sha512 => run_cmd_without_repo::<Sha512HashValue>(args).await,
1030        };
1031    }
1032
1033    let repo_path = resolve_repo_path(&args)?;
1034    let effective_hash = resolve_hash_type(&repo_path, args.hash, !args.no_upgrade)?;
1035
1036    match effective_hash {
1037        HashType::Sha256 => run_cmd_with_repo(open_repo::<Sha256HashValue>(&args)?, args).await,
1038        HashType::Sha512 => run_cmd_with_repo(open_repo::<Sha512HashValue>(&args)?, args).await,
1039    }
1040}
1041
1042/// Handle `cfsctl init`
1043fn run_init(
1044    algorithm: &Algorithm,
1045    path: Option<&Path>,
1046    insecure: bool,
1047    reset_metadata: bool,
1048    erofs_version: composefs::erofs::format::FormatVersion,
1049    args: &App,
1050) -> Result<()> {
1051    let repo_path = if let Some(p) = path {
1052        p.to_path_buf()
1053    } else {
1054        resolve_repo_path(args)?
1055    };
1056
1057    if reset_metadata {
1058        composefs::repository::reset_metadata(&repo_path)?;
1059    }
1060
1061    // Ensure parent directories exist (init_path only creates the final dir).
1062    if let Some(parent) = repo_path.parent() {
1063        std::fs::create_dir_all(parent)
1064            .with_context(|| format!("creating parent directories for {}", repo_path.display()))?;
1065    }
1066
1067    // init_path handles idempotency: same algorithm is a no-op,
1068    // different algorithm is an error.
1069    let config = {
1070        let mut c = RepositoryConfig::new(*algorithm);
1071        c.erofs_formats = composefs::erofs::format::FormatConfig::single(erofs_version);
1072        if insecure { c.set_insecure() } else { c }
1073    };
1074    let created = match algorithm {
1075        Algorithm::Sha256 { .. } => {
1076            Repository::<Sha256HashValue>::init_path(CWD, &repo_path, config)?.1
1077        }
1078        Algorithm::Sha512 { .. } => {
1079            Repository::<Sha512HashValue>::init_path(CWD, &repo_path, config)?.1
1080        }
1081    };
1082
1083    if created {
1084        println!(
1085            "Initialized composefs repository at {}",
1086            repo_path.display()
1087        );
1088        println!("  algorithm: {algorithm}");
1089        if insecure {
1090            println!("  verity:    not required (insecure)");
1091        } else {
1092            println!("  verity:    required");
1093        }
1094    } else {
1095        println!("Repository already initialized at {}", repo_path.display());
1096    }
1097
1098    Ok(())
1099}
1100
1101/// Open a repo at an explicit path, auto-upgrading old-format repos unless
1102/// `no_upgrade` is set.
1103///
1104/// This is the parameterized core shared by [`open_repo`] (which derives the
1105/// path and flags from [`App`]) and the varlink service (which holds these
1106/// values directly).
1107pub(crate) fn open_repo_at<ObjectID>(
1108    path: &Path,
1109    insecure: bool,
1110    require_verity: bool,
1111    no_upgrade: bool,
1112) -> Result<Repository<ObjectID>>
1113where
1114    ObjectID: FsVerityHashValue,
1115{
1116    let mut repo = if no_upgrade {
1117        Repository::open_path(CWD, path)?
1118    } else {
1119        let (repo, _upgraded) = Repository::open_upgrade(CWD, path)?;
1120        repo
1121    };
1122    // Hidden --insecure flag for backward compatibility; the default
1123    // now is to inherit the repo config, but if it's specified we
1124    // disable requiring verity even if the repo says to use it.
1125    if insecure {
1126        repo.set_insecure();
1127    }
1128    if require_verity {
1129        repo.require_verity()?;
1130    }
1131    Ok(repo)
1132}
1133
1134/// Open a repo, auto-upgrading old-format repos unless `--no-upgrade` was passed.
1135pub fn open_repo<ObjectID>(args: &App) -> Result<Repository<ObjectID>>
1136where
1137    ObjectID: FsVerityHashValue,
1138{
1139    let path = resolve_repo_path(args)?;
1140    let mut repo = open_repo_at(&path, args.insecure, args.require_verity, args.no_upgrade)?;
1141    // If the user explicitly passed --erofs-version, override the stored
1142    // repo setting for this invocation only (does not rewrite meta.json).
1143    if let Some(version) = args.erofs_version {
1144        repo.set_erofs_version(version.into());
1145    }
1146    Ok(repo)
1147}
1148
1149/// Resolve an [`OciReference`] to an [`OciImage`].
1150#[cfg(feature = "oci")]
1151pub(crate) fn resolve_oci_image<ObjectID: FsVerityHashValue>(
1152    repo: &Repository<ObjectID>,
1153    reference: &OciReference,
1154) -> Result<composefs_oci::oci_image::OciImage<ObjectID>> {
1155    match reference {
1156        OciReference::Digest(digest) => {
1157            composefs_oci::oci_image::OciImage::open(repo, digest, None)
1158        }
1159        OciReference::Named(name) => composefs_oci::oci_image::OciImage::open_ref(repo, name),
1160    }
1161}
1162
1163/// Resolve an [`OciReference`] to a config digest and optional verity.
1164///
1165/// When resolving via a named ref, the verity override is ignored since
1166/// the image metadata provides the correct verity.
1167#[cfg(feature = "oci")]
1168pub(crate) fn resolve_oci_config<ObjectID: FsVerityHashValue>(
1169    repo: &Repository<ObjectID>,
1170    reference: &OciReference,
1171    verity_override: Option<ObjectID>,
1172) -> Result<(composefs_oci::OciDigest, Option<ObjectID>)> {
1173    match reference {
1174        OciReference::Digest(digest) => Ok((digest.clone(), verity_override)),
1175        OciReference::Named(_) => {
1176            let img = resolve_oci_image(repo, reference)?;
1177            Ok((
1178                img.config_digest().clone(),
1179                Some(img.config_verity().clone()),
1180            ))
1181        }
1182    }
1183}
1184
1185#[cfg(feature = "oci")]
1186fn load_filesystem_from_oci_image<ObjectID: FsVerityHashValue>(
1187    repo: &Repository<ObjectID>,
1188    opts: OCIConfigFilesystemOptions,
1189) -> Result<FileSystem<RegularFile<ObjectID>>> {
1190    let verity = verity_opt(&opts.base_config.config_verity)?;
1191    let (config_digest, config_verity) =
1192        resolve_oci_config(repo, &opts.base_config.config_name, verity)?;
1193    let mut fs =
1194        composefs_oci::image::create_filesystem(repo, &config_digest, config_verity.as_ref())?;
1195    if opts.bootable {
1196        fs.transform_for_boot(repo)?;
1197    }
1198    Ok(fs)
1199}
1200
1201async fn load_filesystem_from_ondisk_fs<ObjectID: FsVerityHashValue>(
1202    fs_opts: &FsReadOptions,
1203    repo: Option<Arc<Repository<ObjectID>>>,
1204) -> Result<FileSystem<RegularFile<ObjectID>>> {
1205    // The async API needs an OwnedFd; fs_opts.path is typically absolute
1206    // so the dirfd is unused for path resolution, but required by the API.
1207    let dirfd = rustix::fs::openat(
1208        CWD,
1209        ".",
1210        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1211        Mode::empty(),
1212    )?;
1213    let mut fs = if fs_opts.no_propagate_usr_to_root {
1214        composefs::fs::read_filesystem(dirfd, fs_opts.path.clone(), repo.clone()).await?
1215    } else {
1216        composefs::fs::read_container_root(dirfd, fs_opts.path.clone(), repo.clone()).await?
1217    };
1218    if fs_opts.bootable {
1219        if let Some(repo) = &repo {
1220            fs.transform_for_boot(repo)?;
1221        } else {
1222            let rootfd = rustix::fs::openat(
1223                CWD,
1224                &fs_opts.path,
1225                OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1226                Mode::empty(),
1227            )?;
1228            fs.transform_for_boot_from_dir(rootfd)?;
1229        }
1230    }
1231    Ok(fs)
1232}
1233
1234fn dump_file_impl(
1235    fs: FileSystem<RegularFile<impl FsVerityHashValue>>,
1236    files: &Vec<PathBuf>,
1237    backing_path_only: bool,
1238) -> Result<()> {
1239    let mut out = Vec::new();
1240    let nlink_map = fs.nlinks();
1241
1242    for file_path in files {
1243        let (dir, file) = fs.root.split(file_path.as_os_str())?;
1244
1245        let (_, file) = dir
1246            .entries()
1247            .find(|ent| ent.0 == file)
1248            .ok_or_else(|| anyhow::anyhow!("{} not found", file_path.display()))?;
1249
1250        match &file {
1251            Inode::Directory(directory) => {
1252                if backing_path_only {
1253                    anyhow::bail!("{} is a directory", file_path.display());
1254                }
1255
1256                dump_single_dir(&mut out, directory, &fs, &nlink_map, file_path.clone())?
1257            }
1258
1259            Inode::Leaf(leaf_id, _) => {
1260                use composefs::generic_tree::LeafContent::*;
1261                use composefs::tree::RegularFile::*;
1262
1263                if backing_path_only {
1264                    let leaf = fs.leaf(*leaf_id);
1265                    match &leaf.content {
1266                        Regular(f) => match f {
1267                            Inline(..) => println!("{} inline", file_path.display()),
1268                            External(id, _) => {
1269                                println!("{} {}", file_path.display(), id.to_object_pathname());
1270                            }
1271                        },
1272                        _ => {
1273                            println!("{} inline", file_path.display())
1274                        }
1275                    }
1276
1277                    continue;
1278                }
1279
1280                dump_single_file(&mut out, *leaf_id, &fs, &nlink_map, file_path.clone())?
1281            }
1282        };
1283    }
1284
1285    if !out.is_empty() {
1286        let out_str = std::str::from_utf8(&out).unwrap();
1287        println!("{}", out_str);
1288    }
1289
1290    Ok(())
1291}
1292
1293/// Run commands that don't require a repository.
1294pub async fn run_cmd_without_repo<ObjectID: FsVerityHashValue>(args: App) -> Result<()> {
1295    let erofs_version = args
1296        .erofs_version
1297        .map(composefs::erofs::format::FormatVersion::from);
1298    match args.cmd {
1299        Command::ComputeId { fs_opts } => {
1300            let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
1301            let version = erofs_version.unwrap_or_default();
1302            let id = composefs::fsverity::compute_verity::<ObjectID>(
1303                &composefs::erofs::writer::mkfs_erofs_versioned(
1304                    &composefs::erofs::writer::ValidatedFileSystem::new(fs)?,
1305                    version,
1306                ),
1307            );
1308            println!("{}", id.to_hex());
1309        }
1310        Command::ComputeKarg {
1311            path,
1312            no_propagate_usr_to_root,
1313        } => {
1314            let fs_opts = FsReadOptions {
1315                path,
1316                bootable: true,
1317                no_propagate_usr_to_root,
1318            };
1319            let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
1320            let version = erofs_version.unwrap_or_default();
1321            let id = composefs::fsverity::compute_verity::<ObjectID>(
1322                &composefs::erofs::writer::mkfs_erofs_versioned(
1323                    &composefs::erofs::writer::ValidatedFileSystem::new(fs)?,
1324                    version,
1325                ),
1326            );
1327            let karg = match version {
1328                FormatVersion::V0 | FormatVersion::V1 => {
1329                    ComposefsCmdline::new_v1(id, args.insecure)
1330                }
1331                FormatVersion::V2 => ComposefsCmdline::new_v2(id, args.insecure),
1332            };
1333            println!("{}", karg.to_cmdline_arg());
1334        }
1335        Command::CreateDumpfile { fs_opts } => {
1336            let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
1337            fs.print_dumpfile()?;
1338        }
1339        _ => {
1340            anyhow::bail!("--no-repo is only supported for compute-id and create-dumpfile");
1341        }
1342    }
1343    Ok(())
1344}
1345
1346/// Run with cmd
1347pub async fn run_cmd_with_repo<ObjectID>(repo: Repository<ObjectID>, args: App) -> Result<()>
1348where
1349    ObjectID: FsVerityHashValue,
1350{
1351    let repo = Arc::new(repo);
1352    match args.cmd {
1353        Command::Init { .. } => {
1354            // Handled in run_app before we get here
1355            unreachable!("init is handled before opening a repository");
1356        }
1357        Command::Transaction => {
1358            // just wait for ^C
1359            loop {
1360                std::thread::park();
1361            }
1362        }
1363        Command::Cat { name } => {
1364            repo.merge_splitstream(&name, None, None, &mut std::io::stdout())?;
1365        }
1366        Command::ImportImage { reference } => {
1367            let image_id = repo.import_image(&reference, &mut std::io::stdin())?;
1368            println!("{}", image_id.to_id());
1369        }
1370        #[cfg(feature = "oci")]
1371        Command::Oci { cmd: oci_cmd } => match oci_cmd {
1372            OciCommand::ImportLayer { name, ref digest } => {
1373                let (object_id, _stats) = composefs_oci::import_layer(
1374                    &repo,
1375                    digest,
1376                    name.as_deref(),
1377                    tokio::io::BufReader::with_capacity(IO_BUF_CAPACITY, tokio::io::stdin()),
1378                )
1379                .await?;
1380                println!("{}", object_id.to_id());
1381            }
1382            OciCommand::Dump { config_opts } => {
1383                let fs = load_filesystem_from_oci_image(&repo, config_opts)?;
1384                fs.print_dumpfile()?;
1385            }
1386            OciCommand::Mount {
1387                ref image,
1388                ref mountpoint,
1389                bootable,
1390                ref upperdir,
1391                ref workdir,
1392                read_write,
1393            } => {
1394                let mount_options =
1395                    get_mount_options(upperdir.as_deref(), workdir.as_deref(), read_write)?;
1396                let img = if image.starts_with("sha256:") {
1397                    let digest: composefs_oci::OciDigest =
1398                        image.parse().context("Parsing manifest digest")?;
1399                    composefs_oci::oci_image::OciImage::open(&repo, &digest, None)?
1400                } else {
1401                    composefs_oci::oci_image::OciImage::open_ref(&repo, image)?
1402                };
1403                let erofs_id = if bootable {
1404                    match img.boot_image_ref(repo.erofs_version()) {
1405                        Some(id) => id,
1406                        None => anyhow::bail!(
1407                            "No boot EROFS image linked — try pulling with --bootable"
1408                        ),
1409                    }
1410                } else {
1411                    match img.image_ref(repo.erofs_version()) {
1412                        Some(id) => id,
1413                        None => anyhow::bail!(
1414                            "No composefs EROFS image linked — try re-pulling the image"
1415                        ),
1416                    }
1417                };
1418                repo.mount_at(&erofs_id.to_hex(), mountpoint.as_str(), &mount_options)?;
1419            }
1420            OciCommand::ComputeId { config_opts } => {
1421                let fs = load_filesystem_from_oci_image(&repo, config_opts)?;
1422                let id = fs.compute_image_id(repo.erofs_version());
1423                println!("{}", id.to_hex());
1424            }
1425            OciCommand::Pull {
1426                ref image,
1427                name,
1428                bootable,
1429                local_fetch,
1430            } => {
1431                // If no explicit name provided, use the image reference as the tag
1432                let tag_name = name.as_deref().unwrap_or(image);
1433
1434                let reporter: SharedReporter = IndicatifReporter::new().into_shared();
1435                let opts = composefs_oci::PullOptions {
1436                    local_fetch: local_fetch.into(),
1437                    progress: Some(reporter),
1438                    ..Default::default()
1439                };
1440
1441                let result = composefs_oci::pull(&repo, image, Some(tag_name), opts).await?;
1442
1443                println!("manifest {}", result.manifest_digest);
1444                println!("config   {}", result.config_digest);
1445                println!("verity   {}", result.manifest_verity.to_hex());
1446                println!("tagged   {tag_name}");
1447                println!("objects  {}", result.stats);
1448
1449                if bootable {
1450                    let image_verity =
1451                        composefs_oci::generate_boot_image(&repo, &result.manifest_digest)?;
1452                    println!("Boot image: {}", image_verity.to_hex());
1453                }
1454            }
1455            OciCommand::ListImages { json } => {
1456                let images = composefs_oci::oci_image::list_images(&repo)?;
1457
1458                if json {
1459                    let reply = crate::varlink::ListImagesReply {
1460                        images: images
1461                            .iter()
1462                            .map(crate::varlink::ImageEntry::from)
1463                            .collect(),
1464                    };
1465                    serde_json::to_writer_pretty(std::io::stdout().lock(), &reply)?;
1466                    println!();
1467                } else if images.is_empty() {
1468                    println!("No images found");
1469                } else {
1470                    let mut table = Table::new();
1471                    table.load_preset(UTF8_FULL);
1472                    table.set_header(["NAME", "DIGEST", "ARCH", "LAYERS", "REFS"]);
1473
1474                    for img in images {
1475                        let digest_str: &str = img.manifest_digest.as_ref();
1476                        let digest_short = digest_str.strip_prefix("sha256:").unwrap_or(digest_str);
1477                        let digest_display = if digest_short.len() > 12 {
1478                            &digest_short[..12]
1479                        } else {
1480                            digest_short
1481                        };
1482                        let arch = if img.architecture.is_empty() {
1483                            "artifact"
1484                        } else {
1485                            &img.architecture
1486                        };
1487                        table.add_row([
1488                            img.name.as_str(),
1489                            digest_display,
1490                            arch,
1491                            &img.layer_count.to_string(),
1492                            &img.referrer_count.to_string(),
1493                        ]);
1494                    }
1495                    println!("{table}");
1496                }
1497            }
1498            OciCommand::Inspect {
1499                ref image,
1500                manifest,
1501                config,
1502            } => {
1503                let img = resolve_oci_image(&repo, image)?;
1504
1505                if manifest {
1506                    // Output raw manifest JSON exactly as stored
1507                    let manifest_json = img.read_manifest_json(&repo)?;
1508                    std::io::Write::write_all(&mut std::io::stdout(), &manifest_json)?;
1509                    println!();
1510                } else if config {
1511                    // Output raw config JSON exactly as stored
1512                    let config_json = img.read_config_json(&repo)?;
1513                    std::io::Write::write_all(&mut std::io::stdout(), &config_json)?;
1514                    println!();
1515                } else {
1516                    // Default: output combined JSON with manifest, config, and referrers
1517                    let output = crate::varlink::OciInspectReply::from_image(&repo, &img)?;
1518                    serde_json::to_writer_pretty(std::io::stdout().lock(), &output)?;
1519                    println!();
1520                }
1521            }
1522            OciCommand::Tag {
1523                ref manifest_digest,
1524                ref name,
1525            } => {
1526                composefs_oci::oci_image::tag_image(&repo, manifest_digest, name)?;
1527                println!("Tagged {manifest_digest} as {name}");
1528            }
1529            OciCommand::Untag { ref name } => {
1530                composefs_oci::oci_image::untag_image(&repo, name)?;
1531                println!("Removed tag {name}");
1532            }
1533            OciCommand::LayerInspect {
1534                ref layer,
1535                dumpfile,
1536                json,
1537            } => {
1538                if json {
1539                    let info = composefs_oci::layer_info(&repo, layer)?;
1540                    serde_json::to_writer_pretty(std::io::stdout().lock(), &info)?;
1541                    println!();
1542                } else if dumpfile {
1543                    composefs_oci::layer_dumpfile(&repo, layer, &mut std::io::stdout())?;
1544                } else {
1545                    // Default: output raw tar, but not to a tty
1546                    let mut out = std::io::stdout().lock();
1547                    if out.is_terminal() {
1548                        anyhow::bail!(
1549                            "Refusing to write tar data to terminal. \
1550                            Redirect to a file, pipe to tar, or use --json for metadata."
1551                        );
1552                    }
1553                    composefs_oci::layer_tar(&repo, layer, &mut out)?;
1554                }
1555            }
1556
1557            OciCommand::PrepareBoot {
1558                config_opts:
1559                    OCIConfigOptions {
1560                        ref config_name,
1561                        ref config_verity,
1562                    },
1563                ref bootdir,
1564                ref entry_id,
1565                ref cmdline,
1566            } => {
1567                let verity = verity_opt(config_verity)?;
1568                let (config_digest, config_verity) =
1569                    resolve_oci_config(&repo, config_name, verity)?;
1570                let mut fs = composefs_oci::image::create_filesystem(
1571                    &repo,
1572                    &config_digest,
1573                    config_verity.as_ref(),
1574                )?;
1575                let entries = fs.transform_for_boot(&repo)?;
1576                let ids = fs.commit_images(&repo, None)?;
1577                let fmt_config = repo.default_format_config();
1578                // Prefer V1 digest; fall back to V2.
1579                let id = ids
1580                    .get(&FormatVersion::V1)
1581                    .or_else(|| ids.get(&FormatVersion::V2))
1582                    .ok_or_else(|| anyhow::anyhow!("commit_images produced no images"))?
1583                    .clone();
1584
1585                let insecure = repo.is_insecure();
1586                let karg = if fmt_config.default == FormatVersion::V1
1587                    && !fmt_config.extra.contains(&FormatVersion::V2)
1588                {
1589                    // V1-only repo → composefs.digest=v1-...: (with optional ? for insecure)
1590                    ComposefsCmdline::new_v1(id, insecure)
1591                } else {
1592                    // BOTH or V2-only repo → composefs= (with optional ? for insecure)
1593                    ComposefsCmdline::new_v2(id, insecure)
1594                };
1595
1596                let Some(entry) = entries.into_iter().next() else {
1597                    anyhow::bail!("No boot entries!");
1598                };
1599
1600                let cmdline_refs: Vec<&str> = cmdline.iter().map(String::as_str).collect();
1601                write_boot::write_boot_simple(
1602                    &repo,
1603                    entry,
1604                    &karg,
1605                    bootdir,
1606                    None,
1607                    entry_id.as_deref(),
1608                    &cmdline_refs,
1609                )?;
1610
1611                let state = args
1612                    .repo
1613                    .as_ref()
1614                    .map(|p: &PathBuf| p.parent().unwrap())
1615                    .unwrap_or(Path::new("/sysroot"))
1616                    .join("state/deploy")
1617                    .join(karg.digest().to_hex());
1618
1619                create_dir_all(state.join("var"))?;
1620                create_dir_all(state.join("etc/upper"))?;
1621                create_dir_all(state.join("etc/work"))?;
1622            }
1623            OciCommand::Fsck { image, json } => {
1624                let result = if let Some(ref name) = image {
1625                    composefs_oci::oci_fsck_image(&repo, name).await?
1626                } else {
1627                    composefs_oci::oci_fsck(&repo).await?
1628                };
1629                if json {
1630                    let output = crate::varlink::OciFsckReply::from(&result);
1631                    serde_json::to_writer_pretty(std::io::stdout().lock(), &output)?;
1632                    println!();
1633                } else {
1634                    print!("{result}");
1635                    if !result.is_ok() {
1636                        anyhow::bail!("OCI integrity check failed");
1637                    }
1638                }
1639            }
1640            OciCommand::Varlink { .. } => {
1641                unreachable!("oci varlink is handled before opening a repository");
1642            }
1643        },
1644        #[cfg(feature = "ostree")]
1645        Command::Ostree { cmd: ostree_cmd } => match ostree_cmd {
1646            OstreeCommand::PullLocal {
1647                ref ostree_repo_path,
1648                ref ostree_ref,
1649                base_name,
1650            } => {
1651                eprintln!("Fetching {ostree_ref}");
1652                let (verity, stats) = composefs_ostree::pull_local(
1653                    &repo,
1654                    ostree_repo_path,
1655                    ostree_ref,
1656                    base_name.as_deref(),
1657                )
1658                .await?;
1659
1660                let image_id = composefs_ostree::get_image_ref(&repo, &stats.commit_id)?;
1661                println!("commit  {}", stats.commit_id);
1662                println!("verity  {}", verity.to_hex());
1663                println!("image   {}", image_id.to_hex());
1664                if !composefs_ostree::is_commit_id(ostree_ref) {
1665                    println!("tagged  {ostree_ref}");
1666                }
1667                println!(
1668                    "objects {} metadata + {} files fetched",
1669                    stats.metadata_fetched, stats.files_fetched
1670                );
1671            }
1672            OstreeCommand::Pull {
1673                ref ostree_repo_url,
1674                ref ostree_ref,
1675                base_name,
1676            } => {
1677                eprintln!("Fetching {ostree_ref}");
1678                let (verity, stats) = composefs_ostree::pull(
1679                    &repo,
1680                    ostree_repo_url,
1681                    ostree_ref,
1682                    base_name.as_deref(),
1683                )
1684                .await?;
1685
1686                let image_id = composefs_ostree::get_image_ref(&repo, &stats.commit_id)?;
1687                println!("commit  {}", stats.commit_id);
1688                println!("verity  {}", verity.to_hex());
1689                println!("image   {}", image_id.to_hex());
1690                if !composefs_ostree::is_commit_id(ostree_ref) {
1691                    println!("tagged  {ostree_ref}");
1692                }
1693                println!(
1694                    "objects {} metadata + {} files fetched",
1695                    stats.metadata_fetched, stats.files_fetched
1696                );
1697            }
1698            OstreeCommand::Mount {
1699                ref commit,
1700                ref mountpoint,
1701                ref upperdir,
1702                ref workdir,
1703                read_write,
1704            } => {
1705                let mount_options =
1706                    get_mount_options(upperdir.as_deref(), workdir.as_deref(), read_write)?;
1707                let image_id = composefs_ostree::get_image_ref(&repo, commit)?;
1708                repo.mount_at(&image_id.to_hex(), mountpoint.as_str(), &mount_options)?;
1709            }
1710            OstreeCommand::Dump { ref commit_name } => {
1711                let fs = composefs_ostree::create_filesystem(&repo, commit_name)?;
1712                fs.print_dumpfile()?;
1713            }
1714            OstreeCommand::ComputeId { ref commit_name } => {
1715                let image_id = composefs_ostree::ensure_ostree_erofs(&repo, commit_name)?;
1716                println!("{}", image_id.to_hex());
1717            }
1718            OstreeCommand::Inspect {
1719                ref source,
1720                metadata,
1721            } => {
1722                composefs_ostree::inspect(&repo, source, metadata)?;
1723            }
1724            OstreeCommand::Tag {
1725                ref source,
1726                ref name,
1727            } => {
1728                composefs_ostree::tag(&repo, source, name)?;
1729                println!("Tagged {source} as {name}");
1730            }
1731            OstreeCommand::Untag { ref name } => {
1732                composefs_ostree::untag(&repo, name)?;
1733            }
1734            OstreeCommand::ListCommits => {
1735                let commits = composefs_ostree::list_commits(&repo)?;
1736                if commits.is_empty() {
1737                    println!("No ostree commits found");
1738                } else {
1739                    let mut table = Table::new();
1740                    table.load_preset(UTF8_FULL);
1741                    table.set_header(["NAME", "COMMIT"]);
1742                    for c in commits {
1743                        table.add_row([c.name.as_str(), &c.commit_id]);
1744                    }
1745                    println!("{table}");
1746                }
1747            }
1748        },
1749        Command::CreateImage {
1750            fs_opts,
1751            ref image_name,
1752        } => {
1753            let fs = load_filesystem_from_ondisk_fs(&fs_opts, Some(Arc::clone(&repo))).await?;
1754            let id = fs.commit_image(&repo, image_name.as_deref())?;
1755            println!("{}", id.to_id());
1756        }
1757        Command::ComputeId { .. }
1758        | Command::ComputeKarg { .. }
1759        | Command::CreateDumpfile { .. } => {
1760            // Handled in run_app before opening the repo
1761            unreachable!(
1762                "compute-id, compute-karg, and create-dumpfile are dispatched without a repo"
1763            );
1764        }
1765        Command::Mount {
1766            name,
1767            mountpoint,
1768            ref upperdir,
1769            ref workdir,
1770            read_write,
1771        } => {
1772            let mount_options =
1773                get_mount_options(upperdir.as_deref(), workdir.as_deref(), read_write)?;
1774            repo.mount_at(&name, &mountpoint, &mount_options)?;
1775        }
1776        Command::ImageObjects { name } => {
1777            let objects = repo.objects_for_image(&name)?;
1778            for object in objects {
1779                println!("{}", object.to_id());
1780            }
1781        }
1782        Command::GC { root, dry_run } => {
1783            let roots: Vec<&str> = root.iter().map(|s| s.as_str()).collect();
1784            let result = if dry_run {
1785                repo.gc_dry_run(&roots)?
1786            } else {
1787                repo.gc(&roots)?
1788            };
1789            if dry_run {
1790                println!("Dry run (no files deleted):");
1791            }
1792            println!(
1793                "Objects: {} removed ({} bytes)",
1794                result.objects_removed, result.objects_bytes
1795            );
1796            if result.images_pruned > 0 || result.streams_pruned > 0 {
1797                println!(
1798                    "Pruned symlinks: {} images, {} streams",
1799                    result.images_pruned, result.streams_pruned
1800                );
1801            }
1802        }
1803        Command::DumpFiles {
1804            image_name,
1805            files,
1806            backing_path_only,
1807        } => {
1808            let (img_fd, _) = repo.open_image(&image_name)?;
1809
1810            let mut img_buf = Vec::new();
1811            std::fs::File::from(img_fd).read_to_end(&mut img_buf)?;
1812
1813            dump_file_impl(
1814                erofs_to_filesystem::<ObjectID>(&img_buf)?,
1815                &files,
1816                backing_path_only,
1817            )?;
1818        }
1819        Command::Fsck {
1820            json,
1821            metadata_only,
1822        } => {
1823            let result = if metadata_only {
1824                repo.fsck_metadata_only().await?
1825            } else {
1826                repo.fsck().await?
1827            };
1828            if json {
1829                let output = crate::varlink::FsckReply::from(&result);
1830                serde_json::to_writer_pretty(std::io::stdout().lock(), &output)?;
1831                println!();
1832            } else {
1833                print!("{result}");
1834                if !result.is_ok() {
1835                    anyhow::bail!("repository integrity check failed");
1836                }
1837            }
1838        }
1839        Command::Varlink { .. } => {
1840            // Handled in run_app before opening the repo.
1841            unreachable!("varlink is handled before opening a repository");
1842        }
1843        #[cfg(feature = "http")]
1844        Command::Fetch { url, name } => {
1845            let reporter: SharedReporter = IndicatifReporter::new().into_shared();
1846            let (digest, verity) = composefs_http::download(
1847                &url,
1848                &name,
1849                Arc::clone(&repo),
1850                composefs_http::DownloadOptions {
1851                    progress: Some(reporter),
1852                },
1853            )
1854            .await?;
1855            println!("content {digest}");
1856            println!("verity {}", verity.to_hex());
1857        }
1858        Command::Mkcomposefs { .. } | Command::ComposefsInfo { .. } => {
1859            // Dispatched in run_app before a repository is opened
1860            unreachable!("mkcomposefs/composefs-info are dispatched before opening a repository");
1861        }
1862    }
1863    Ok(())
1864}
1865
1866#[cfg(test)]
1867#[cfg(any(feature = "oci", feature = "http"))]
1868mod tests {
1869    use super::*;
1870    use composefs::progress::{ProgressEvent, ProgressUnit};
1871
1872    // ── IndicatifReporter ────────────────────────────────────────────────────
1873
1874    /// A complete valid lifecycle (Started → Progress → Done) must not panic,
1875    /// even without a real terminal (indicatif handles headless gracefully).
1876    #[test]
1877    fn test_indicatif_reporter_valid_lifecycle() {
1878        let reporter = IndicatifReporter::new();
1879        // Message before any component
1880        reporter.report(ProgressEvent::Message("starting pull".into()));
1881        // Byte-tracked component
1882        reporter.report(ProgressEvent::Started {
1883            id: "sha256:abc".into(),
1884            total: Some(1_000_000),
1885            unit: ProgressUnit::Bytes,
1886        });
1887        reporter.report(ProgressEvent::Progress {
1888            id: "sha256:abc".into(),
1889            fetched: 500_000,
1890            total: Some(1_000_000),
1891        });
1892        reporter.report(ProgressEvent::Done {
1893            id: "sha256:abc".into(),
1894            transferred: 1_000_000,
1895        });
1896        // Item-counted component (HTTP objects)
1897        reporter.report(ProgressEvent::Started {
1898            id: "objects:stream".into(),
1899            total: Some(200),
1900            unit: ProgressUnit::Items,
1901        });
1902        reporter.report(ProgressEvent::Progress {
1903            id: "objects:stream".into(),
1904            fetched: 100,
1905            total: Some(200),
1906        });
1907        reporter.report(ProgressEvent::Done {
1908            id: "objects:stream".into(),
1909            transferred: 200,
1910        });
1911        // Skipped component
1912        reporter.report(ProgressEvent::Started {
1913            id: "sha256:cached".into(),
1914            total: None,
1915            unit: ProgressUnit::Bytes,
1916        });
1917        reporter.report(ProgressEvent::Skipped {
1918            id: "sha256:cached".into(),
1919        });
1920    }
1921
1922    /// Progress/Done events for an ID that was never `Started` must not panic.
1923    ///
1924    /// This guards against error-recovery paths where a `Started` event may
1925    /// have been suppressed or the reporter was attached after the operation
1926    /// began.
1927    #[test]
1928    fn test_indicatif_reporter_unknown_id_no_panic() {
1929        let reporter = IndicatifReporter::new();
1930        // Progress for unknown ID — should silently ignore
1931        reporter.report(ProgressEvent::Progress {
1932            id: "ghost".into(),
1933            fetched: 42,
1934            total: None,
1935        });
1936        // Done for unknown ID — should silently ignore
1937        reporter.report(ProgressEvent::Done {
1938            id: "ghost".into(),
1939            transferred: 42,
1940        });
1941        // Skipped for unknown ID — should silently ignore
1942        reporter.report(ProgressEvent::Skipped { id: "ghost".into() });
1943    }
1944
1945    /// A spinner-style bar (unknown total) must not panic.
1946    #[test]
1947    fn test_indicatif_reporter_spinner_lifecycle() {
1948        let reporter = IndicatifReporter::new();
1949        // Started with unknown total → spinner
1950        reporter.report(ProgressEvent::Started {
1951            id: "layer:unknown-size".into(),
1952            total: None,
1953            unit: ProgressUnit::Bytes,
1954        });
1955        reporter.report(ProgressEvent::Progress {
1956            id: "layer:unknown-size".into(),
1957            fetched: 1024,
1958            total: None,
1959        });
1960        reporter.report(ProgressEvent::Done {
1961            id: "layer:unknown-size".into(),
1962            transferred: 2048,
1963        });
1964    }
1965
1966    /// Multiple concurrent components must not interfere with each other.
1967    #[test]
1968    fn test_indicatif_reporter_multiple_concurrent_components() {
1969        let reporter = IndicatifReporter::new();
1970        // Start two layers in parallel
1971        reporter.report(ProgressEvent::Started {
1972            id: "layer:a".into(),
1973            total: Some(100),
1974            unit: ProgressUnit::Bytes,
1975        });
1976        reporter.report(ProgressEvent::Started {
1977            id: "layer:b".into(),
1978            total: Some(200),
1979            unit: ProgressUnit::Bytes,
1980        });
1981        // Interleaved progress
1982        reporter.report(ProgressEvent::Progress {
1983            id: "layer:a".into(),
1984            fetched: 50,
1985            total: Some(100),
1986        });
1987        reporter.report(ProgressEvent::Progress {
1988            id: "layer:b".into(),
1989            fetched: 100,
1990            total: Some(200),
1991        });
1992        // Layer B finishes first
1993        reporter.report(ProgressEvent::Done {
1994            id: "layer:b".into(),
1995            transferred: 200,
1996        });
1997        // Layer A finishes
1998        reporter.report(ProgressEvent::Done {
1999            id: "layer:a".into(),
2000            transferred: 100,
2001        });
2002    }
2003}