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
25/// Shell completion helpers for dynamic value completion via [`clap_complete`].
26pub mod complete;
27pub mod composefs_info;
28#[cfg(feature = "fuse")]
29pub mod fuse;
30pub mod mkcomposefs;
31pub mod mountcomposefs;
32/// Varlink RPC service exposing repository operations over a Unix socket.
33pub mod varlink;
34
35#[cfg(any(feature = "oci", feature = "http"))]
36use std::collections::HashMap;
37use std::io::{Read, Write};
38use std::path::Path;
39#[cfg(any(feature = "oci", feature = "http"))]
40use std::sync::Mutex;
41use std::{ffi::OsString, path::PathBuf};
42
43#[cfg(feature = "oci")]
44use std::{fs::create_dir_all, io::IsTerminal};
45
46use std::sync::Arc;
47
48use anyhow::{Context as _, Result};
49use clap::{Parser, Subcommand, ValueEnum};
50use clap_complete::engine::ArgValueCompleter;
51use comfy_table::{Table, presets::UTF8_FULL};
52#[cfg(feature = "ostree")]
53use complete::complete_ostree_refs;
54use complete::{complete_image_refs, complete_stream_refs};
55#[cfg(feature = "oci")]
56use complete::{complete_oci_digests, complete_oci_tags, complete_oci_tags_and_digests};
57#[cfg(any(feature = "oci", feature = "http", feature = "ostree"))]
58use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
59use rustix::fs::{CWD, Mode, OFlags};
60
61#[cfg(any(feature = "oci", feature = "http", feature = "ostree"))]
62use composefs::progress::{
63    ComponentId, ProgressEvent, ProgressReporter, ProgressUnit, SharedReporter,
64};
65use composefs_boot::BootOps;
66use composefs_boot::cmdline::ComposefsCmdline;
67#[cfg(feature = "oci")]
68use composefs_boot::write_boot;
69
70use composefs::erofs::format::FormatVersion;
71#[cfg(feature = "oci")]
72use composefs::shared_internals::IO_BUF_CAPACITY;
73use composefs::{
74    dumpfile::{dump_single_dir, dump_single_file},
75    erofs::reader::erofs_to_filesystem,
76    fsverity::{Algorithm, FsVerityHashValue, Sha256HashValue, Sha512HashValue},
77    generic_tree::{FileSystem, Inode},
78    mount::MountOptions,
79    repository::{
80        REPO_METADATA_FILENAME, Repository, RepositoryConfig, read_repo_algorithm, system_path,
81        user_path,
82    },
83    tree::RegularFile,
84};
85
86/// An `indicatif`-backed [`ProgressReporter`] for use in the CLI.
87///
88/// Renders per-component progress bars via [`MultiProgress`].  When a component
89/// completes or is skipped the bar is removed; human-readable messages are
90/// printed above the bar group via [`MultiProgress::println`].
91#[cfg(any(feature = "oci", feature = "http", feature = "ostree"))]
92struct IndicatifReporter {
93    multi: MultiProgress,
94    bars: Mutex<HashMap<ComponentId, ProgressBar>>,
95}
96
97#[cfg(any(feature = "oci", feature = "http", feature = "ostree"))]
98impl IndicatifReporter {
99    fn new() -> Self {
100        IndicatifReporter {
101            multi: MultiProgress::new(),
102            bars: Mutex::new(HashMap::new()),
103        }
104    }
105
106    /// Build a shared reporter from this instance.
107    fn into_shared(self) -> SharedReporter {
108        Arc::new(self)
109    }
110}
111
112#[cfg(any(feature = "oci", feature = "http", feature = "ostree"))]
113impl std::fmt::Debug for IndicatifReporter {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.debug_struct("IndicatifReporter").finish_non_exhaustive()
116    }
117}
118
119#[cfg(any(feature = "oci", feature = "http", feature = "ostree"))]
120impl ProgressReporter for IndicatifReporter {
121    fn report(&self, event: ProgressEvent) {
122        match event {
123            ProgressEvent::Started { id, total, unit } => {
124                let bar = if let Some(total) = total {
125                    self.multi.add(ProgressBar::new(total))
126                } else {
127                    self.multi.add(ProgressBar::new_spinner())
128                };
129                let style = match unit {
130                    ProgressUnit::Bytes => ProgressStyle::with_template(
131                        "[eta {eta}] {bar:40.cyan/blue} {decimal_bytes:>7}/{decimal_total_bytes:7} {msg}",
132                    ),
133                    ProgressUnit::Items => ProgressStyle::with_template(
134                        "[eta {eta}] {bar:40.cyan/blue} {pos:>7}/{len:7} objects {msg}",
135                    ),
136                    // Future unit variants fall back to a generic spinner.
137                    _ => ProgressStyle::with_template(
138                        "[eta {eta}] {bar:40.cyan/blue} {pos}/{len} {msg}",
139                    ),
140                };
141                bar.set_style(
142                    style
143                        .unwrap_or_else(|_| ProgressStyle::default_bar())
144                        .progress_chars("##-"),
145                );
146                bar.set_message(id.to_string());
147                self.bars.lock().unwrap().insert(id, bar);
148            }
149            ProgressEvent::Progress { id, fetched, .. } => {
150                if let Some(bar) = self.bars.lock().unwrap().get(&id) {
151                    bar.set_position(fetched);
152                }
153            }
154            ProgressEvent::Done { id, .. } => {
155                if let Some(bar) = self.bars.lock().unwrap().remove(&id) {
156                    bar.finish_and_clear();
157                }
158            }
159            ProgressEvent::Skipped { id } => {
160                if let Some(bar) = self.bars.lock().unwrap().remove(&id) {
161                    bar.finish_with_message("skipped");
162                }
163            }
164            ProgressEvent::Message(msg) => {
165                let _ = self.multi.println(msg);
166            }
167            // `ProgressEvent` is #[non_exhaustive]: new variants added to the library
168            // will be silently ignored here until cfsctl is updated to handle them.
169            _ => {}
170        }
171    }
172}
173
174/// cfsctl
175#[derive(Debug, Parser)]
176#[clap(name = "cfsctl", version)]
177pub struct App {
178    /// Operate on repo at path
179    #[clap(long, group = "repopath", value_hint = clap::ValueHint::DirPath)]
180    repo: Option<PathBuf>,
181    /// Operate on repo at standard user location $HOME/.var/lib/composefs
182    #[clap(long, group = "repopath")]
183    user: bool,
184    /// Operate on repo at standard system location /sysroot/composefs
185    #[clap(long, group = "repopath")]
186    system: bool,
187
188    /// What hash digest type to use for composefs repo.
189    /// If omitted, auto-detected from repository metadata (meta.json).
190    #[clap(long, value_enum)]
191    pub hash: Option<HashType>,
192
193    /// The EROFS format version to use when generating images.
194    /// If omitted, the library default (V1) is used.
195    #[clap(long, value_enum)]
196    pub erofs_version: Option<ErofsVersion>,
197
198    /// Deprecated: security mode is now auto-detected from meta.json.
199    /// Use `cfsctl init --insecure` to create a repo without verity.
200    /// Kept for backward compatibility.
201    #[clap(long, hide = true)]
202    insecure: bool,
203
204    /// Error if the repository does not have fs-verity enabled.
205    #[clap(long)]
206    require_verity: bool,
207
208    /// Don't automatically upgrade old-format repositories.
209    /// When set, commands will fail on repos without meta.json instead
210    /// of inferring metadata from existing objects.
211    #[clap(long)]
212    no_upgrade: bool,
213
214    /// Don't open a repository. Only valid for commands that don't need one
215    /// (compute-id, create-dumpfile).
216    #[clap(long)]
217    pub no_repo: bool,
218
219    #[clap(subcommand)]
220    cmd: Command,
221}
222
223/// The Hash algorithm used for FsVerity computation
224#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum)]
225pub enum HashType {
226    /// Sha256
227    Sha256,
228    /// Sha512
229    Sha512,
230}
231
232/// The EROFS format version used when generating images.
233#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum)]
234pub enum ErofsVersion {
235    /// Format V0: compact inodes, BFS, C-compatible (composefs_version auto-detects 0 or 1).
236    #[clap(name = "0")]
237    V0,
238    /// Format V1: same layout as V0, composefs_version always 1.
239    #[clap(name = "1")]
240    V1,
241    /// Format V2: extended inodes, DFS (composefs_version=2).
242    #[clap(name = "2")]
243    V2,
244}
245
246impl From<ErofsVersion> for composefs::erofs::format::FormatVersion {
247    fn from(v: ErofsVersion) -> Self {
248        match v {
249            ErofsVersion::V0 => Self::V0,
250            ErofsVersion::V1 => Self::V1,
251            ErofsVersion::V2 => Self::V2,
252        }
253    }
254}
255
256/// A reference to an OCI image: either a content digest or a named ref.
257///
258/// Digests are prefixed with `@` (e.g. `@sha256:abc123…`), while bare
259/// names are refs resolved through the repository's ref tree. The `@`
260/// prefix is necessary to disambiguate because ref names may contain `:`
261/// — OCI digest algorithms are intentionally extensible, so we cannot
262/// rely on parse heuristics to distinguish the two.
263///
264/// Note this differs from the podman/docker convention where `@` appears
265/// between the image name and the digest (e.g. `fedora@sha256:abc…`).
266/// Here, `@` is always a leading prefix on the entire argument.
267///
268/// At the repository level, ref names are freeform strings (the only
269/// restriction is that they must not start with `@`). In practice,
270/// `oci pull` defaults to tagging with the source transport reference
271/// (e.g. `docker://quay.io/fedora/fedora:latest`), so most refs in a
272/// repository will be container transport names — which naturally never
273/// start with `@`.
274#[cfg(feature = "oci")]
275#[derive(Debug, Clone)]
276pub enum OciReference {
277    /// A content-addressable digest such as `sha256:abcdef…`.
278    Digest(composefs_oci::OciDigest),
279    /// A named ref resolved through the repository's ref tree, typically
280    /// a container transport name (e.g. `docker://quay.io/foo:latest`).
281    Named(String),
282}
283
284#[cfg(feature = "oci")]
285impl std::str::FromStr for OciReference {
286    type Err = anyhow::Error;
287
288    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
289        if let Some(digest_str) = s.strip_prefix('@') {
290            let digest: composefs_oci::OciDigest =
291                digest_str.parse().context("Invalid OCI digest after '@'")?;
292            Ok(Self::Digest(digest))
293        } else {
294            Ok(Self::Named(s.to_owned()))
295        }
296    }
297}
298
299#[cfg(feature = "oci")]
300impl std::fmt::Display for OciReference {
301    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302        match self {
303            Self::Digest(d) => write!(f, "@{d}"),
304            Self::Named(n) => write!(f, "{n}"),
305        }
306    }
307}
308
309/// CLI representation of [`composefs_oci::LocalFetchOpt`].
310#[cfg(feature = "oci")]
311#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
312enum LocalFetchCli {
313    /// Do not use native containers-storage import; use skopeo.
314    #[default]
315    Disabled,
316    /// Use native import with reflink/hardlink/copy fallback.
317    Auto,
318    /// Use native import; error if zero-copy is not possible.
319    Zerocopy,
320}
321
322#[cfg(feature = "oci")]
323impl From<LocalFetchCli> for composefs_oci::LocalFetchOpt {
324    fn from(cli: LocalFetchCli) -> Self {
325        match cli {
326            LocalFetchCli::Disabled => Self::Disabled,
327            LocalFetchCli::Auto => Self::IfPossible,
328            LocalFetchCli::Zerocopy => Self::ZeroCopy,
329        }
330    }
331}
332
333/// Common options for operations using OCI config manifest streams that may transform the image rootfs
334#[cfg(feature = "oci")]
335#[derive(Debug, Parser)]
336struct OCIConfigFilesystemOptions {
337    #[clap(flatten)]
338    base_config: OCIConfigOptions,
339    /// Whether bootable transformation should be performed on the image rootfs
340    #[clap(long)]
341    bootable: bool,
342    /// Which extended attributes to keep; see
343    /// [`composefs::generic_tree::XattrFiltering`]
344    #[clap(
345        long,
346        value_parser = clap::value_parser!(composefs::generic_tree::XattrFiltering),
347        default_value_t = composefs::generic_tree::XattrFiltering::AllowlistOnly
348    )]
349    xattrs: composefs::generic_tree::XattrFiltering,
350}
351
352/// Common options for operations using OCI config manifest streams
353#[cfg(feature = "oci")]
354#[derive(Debug, Parser)]
355struct OCIConfigOptions {
356    /// Ref name (e.g. myimage:latest) or @digest (e.g. @sha256:a1b2c3...)
357    #[arg(add = ArgValueCompleter::new(complete_oci_tags_and_digests))]
358    config_name: OciReference,
359    /// verity digest for the manifest stream to be verified against
360    config_verity: Option<String>,
361}
362
363#[cfg(feature = "oci")]
364#[derive(Debug, Subcommand)]
365enum OciCommand {
366    /// Import a tar layer as a splitstream in the repository
367    ImportLayer {
368        /// Layer content digest, e.g. sha256:a1b2c3...
369        digest: composefs_oci::OciDigest,
370        /// Optional human-readable name for the layer
371        name: Option<String>,
372    },
373    /// Dump the rootfs of a stored OCI image as a composefs dumpfile to stdout
374    ///
375    /// The image can be specified by ref name or @digest:
376    ///   cfsctl oci dump myimage:latest
377    ///   cfsctl oci dump @sha256:a1b2c3...
378    Dump {
379        #[clap(flatten)]
380        config_opts: OCIConfigFilesystemOptions,
381    },
382    /// Pull an OCI image into the repository
383    ///
384    /// Prints the config stream digest and verity of the stored manifest.
385    Pull {
386        /// Source image reference, as accepted by skopeo
387        image: String,
388        /// Tag name to assign to the pulled image (defaults to the image reference)
389        name: Option<String>,
390        /// Also generate a bootable EROFS image from the pulled OCI image
391        #[arg(long)]
392        bootable: bool,
393        /// Recover a boot image built with an unknown xattr filtering mode
394        /// and EROFS format version, by searching every combination until
395        /// one matches this hex digest, instead of generating the boot
396        /// image with the default mode and the repository's format
397        /// version. Requires `--bootable`.
398        ///
399        /// This is for recovering from a UKI embedding a boot image digest
400        /// produced by an older composefs-rs release with different
401        /// defaults, against a repository whose format version is fixed
402        /// (see [`composefs_oci::find_matching_boot_image`]).
403        #[arg(long, requires = "bootable")]
404        expected_digest: Option<String>,
405        /// Controls whether containers-storage: references use the native
406        /// import path with zero-copy reflink/hardlink support.
407        #[arg(long, value_enum, default_value_t = LocalFetchCli::Disabled)]
408        local_fetch: LocalFetchCli,
409    },
410    /// Copy an OCI image (and its layers) from another composefs repository
411    /// into this repository.
412    ///
413    /// The destination repository is selected by the global `--repo`/`--user`/
414    /// `--system` flags. The source is `--from`.
415    ///
416    /// Pass `--zerocopy` to attempt reflink (then hardlink) instead of copying
417    /// object data.  This requires both repositories to be on the same
418    /// filesystem, to use the same hash algorithm, and the caller to have
419    /// `CAP_DAC_READ_SEARCH` (i.e. root).
420    /// Without `--zerocopy`, objects are always copied, which is safe on any
421    /// filesystem and across repositories using different hash algorithms.
422    Copy {
423        /// Image to copy (tag name or `@digest`).
424        image: OciReference,
425        /// Path to the source composefs repository.
426        #[clap(long)]
427        from: PathBuf,
428        /// Tag to assign to the image in the destination repository.
429        #[clap(long)]
430        name: Option<String>,
431        /// Use reflink/hardlink zero-copy transfer (requires same filesystem, same hash algorithm, and root).
432        #[clap(long)]
433        zerocopy: bool,
434    },
435    /// List all tagged OCI images in the repository
436    #[clap(name = "images")]
437    ListImages {
438        /// Output as JSON array
439        #[clap(long)]
440        json: bool,
441    },
442    /// Show information about an OCI image
443    ///
444    /// The image can be specified by ref name or @digest:
445    ///   cfsctl oci inspect myimage:latest
446    ///   cfsctl oci inspect @sha256:a1b2c3...
447    ///
448    /// By default, outputs JSON with manifest, config, and referrers.
449    /// Use --manifest or --config to output just that raw JSON.
450    #[clap(name = "inspect")]
451    Inspect {
452        /// Ref name (e.g. myimage:latest) or @digest (e.g. @sha256:a1b2c3...)
453        #[arg(add = ArgValueCompleter::new(complete_oci_tags_and_digests))]
454        image: OciReference,
455        /// Output only the raw manifest JSON (as originally stored)
456        #[clap(long, conflicts_with = "config")]
457        manifest: bool,
458        /// Output only the raw config JSON (as originally stored)
459        #[clap(long, conflicts_with = "manifest")]
460        config: bool,
461    },
462    /// Tag an image with a new name
463    ///
464    /// Example: cfsctl oci tag sha256:a1b2c3... myimage:latest
465    Tag {
466        /// Manifest digest, e.g. sha256:a1b2c3...
467        #[arg(add = ArgValueCompleter::new(complete_oci_digests))]
468        manifest_digest: composefs_oci::OciDigest,
469        /// Tag name to assign (must not contain '@')
470        name: String,
471    },
472    /// Remove a tag from an image
473    Untag {
474        /// Tag name to remove
475        #[arg(add = ArgValueCompleter::new(complete_oci_tags))]
476        name: String,
477    },
478    /// Inspect a stored layer
479    ///
480    /// By default, outputs the raw tar stream to stdout.
481    /// Use --dumpfile for composefs dumpfile format, or --json for metadata.
482    #[clap(name = "layer")]
483    LayerInspect {
484        /// Layer diff_id, e.g. sha256:a1b2c3...
485        layer: composefs_oci::OciDigest,
486        /// Output as composefs dumpfile format (one entry per line)
487        #[clap(long, conflicts_with = "json")]
488        dumpfile: bool,
489        /// Output layer metadata as JSON
490        #[clap(long, conflicts_with = "dumpfile")]
491        json: bool,
492    },
493    /// Mount an OCI image's composefs EROFS at the given mountpoint
494    Mount {
495        /// Image reference (tag name or manifest digest)
496        #[arg(add = ArgValueCompleter::new(complete_oci_tags_and_digests))]
497        image: String,
498        /// Target mountpoint
499        #[arg(value_hint = clap::ValueHint::AnyPath)]
500        mountpoint: String,
501        /// Mount the bootable variant instead of the regular EROFS image
502        #[arg(long)]
503        bootable: bool,
504        #[clap(flatten)]
505        mount_opts: MountOpts,
506    },
507    /// Compute the composefs image ID of a stored OCI image's rootfs
508    ///
509    /// The image can be specified by ref name or @digest:
510    ///   cfsctl oci compute-id myimage:latest
511    ///   cfsctl oci compute-id @sha256:a1b2c3...
512    ComputeId {
513        #[clap(flatten)]
514        config_opts: OCIConfigFilesystemOptions,
515    },
516
517    /// Create the composefs image of the rootfs of a stored OCI image, perform bootable transformation, commit it to the repo,
518    /// then configure boot for the image by writing new boot resources and bootloader entries to boot partition. Performs
519    /// state preparation for composefs-setup-root consumption as well. Note that state preparation here is not suitable for
520    /// consumption by bootc.
521    PrepareBoot {
522        #[clap(flatten)]
523        config_opts: OCIConfigOptions,
524        /// boot partition mount point
525        #[clap(long, default_value = "/boot", value_hint = clap::ValueHint::DirPath)]
526        bootdir: PathBuf,
527        /// Boot entry identifier to use. By default uses ID provided by the image or kernel version
528        #[clap(long)]
529        entry_id: Option<String>,
530        /// additional kernel command line
531        #[clap(long)]
532        cmdline: Vec<String>,
533    },
534    /// Check integrity of OCI images in the repository
535    ///
536    /// Verifies manifest and config content digests, layer references, seal
537    /// consistency, and delegates to the underlying repository fsck for object
538    /// integrity and splitstream validation.
539    Fsck {
540        /// Check only the named image instead of all tagged images
541        #[arg(add = ArgValueCompleter::new(complete_oci_tags))]
542        image: Option<String>,
543        /// Output results as JSON (always exits 0 unless the check itself fails)
544        #[clap(long)]
545        json: bool,
546    },
547    /// Serve the varlink RPC API on a Unix socket or systemd socket.
548    ///
549    /// Equivalent to `cfsctl varlink`: a single service answers both the
550    /// `org.composefs.Repository` and `org.composefs.Oci` interfaces on one
551    /// socket. Kept for discoverability under the `oci` subcommand.
552    Varlink {
553        /// Unix socket path to listen on (omit when using systemd socket activation).
554        #[clap(long, value_hint = clap::ValueHint::AnyPath)]
555        address: Option<PathBuf>,
556    },
557}
558
559#[cfg(feature = "ostree")]
560#[derive(Debug, Subcommand)]
561enum OstreeCommand {
562    PullLocal {
563        #[arg(value_hint = clap::ValueHint::DirPath)]
564        ostree_repo_path: PathBuf,
565        /// Ostree ref name or commit ID (64-character hex)
566        ostree_ref: String,
567        #[clap(long)]
568        base_name: Option<String>,
569    },
570    Pull {
571        #[arg(value_hint = clap::ValueHint::Url)]
572        ostree_repo_url: String,
573        /// Ostree ref name or commit ID (64-character hex)
574        ostree_ref: String,
575        #[clap(long)]
576        base_name: Option<String>,
577        /// Disable static delta usage, forcing object-by-object fetching
578        #[clap(long)]
579        no_delta: bool,
580    },
581    /// Mount an ostree commit's composefs EROFS at the given mountpoint
582    Mount {
583        /// Ostree commit ref or commit ID
584        #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
585        commit: String,
586        /// Target mountpoint
587        #[arg(value_hint = clap::ValueHint::AnyPath)]
588        mountpoint: String,
589        #[clap(flatten)]
590        mount_opts: MountOpts,
591    },
592    /// Dump the filesystem of an ostree commit as a composefs dumpfile to stdout
593    Dump {
594        /// Ostree commit ref name
595        #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
596        commit_name: String,
597    },
598    /// Compute the composefs image ID of an ostree commit
599    ComputeId {
600        /// Ostree commit ref name
601        #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
602        commit_name: String,
603    },
604    /// Show the contents of an ostree commit
605    Inspect {
606        /// Ostree ref name, commit ID, or commit ID prefix
607        #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
608        source: String,
609        /// Print only the commit metadata key-value pairs
610        #[clap(long)]
611        metadata: bool,
612    },
613    /// Tag an ostree commit with a name
614    ///
615    /// The source can be an ostree commit checksum or an existing ref name.
616    Tag {
617        /// Ostree commit checksum (hex) or existing ref name
618        #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
619        source: String,
620        /// Tag name to assign
621        name: String,
622    },
623    /// Remove a named ostree reference
624    Untag {
625        /// Tag name to remove
626        #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
627        name: String,
628    },
629    /// Create an ostree commit from a composefs image in the repository
630    ///
631    /// The image is specified by its object ID or refs/ name (the same
632    /// format used by `cfsctl mount` and `cfsctl image-objects`).
633    Commit {
634        /// Composefs image ID or refs/ name
635        #[arg(add = ArgValueCompleter::new(complete_image_refs))]
636        image: String,
637        /// Ostree ref name to tag the commit with
638        #[clap(long)]
639        reference: Option<String>,
640        /// One-line commit subject
641        #[clap(long, default_value = "")]
642        subject: String,
643    },
644    /// Export an ostree commit to a local ostree repository
645    ///
646    /// Writes all objects (files, dirtrees, dirmetas, commit) to the
647    /// destination repo. File content is reflinked when possible.
648    /// Only bare, bare-user, and bare-user-only repos are supported.
649    Export {
650        /// Ostree ref name or commit ID to export
651        #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
652        source: String,
653        /// Path to the destination ostree repository
654        #[arg(value_hint = clap::ValueHint::DirPath)]
655        ostree_repo_path: PathBuf,
656        /// Ref name to set in the destination repo
657        #[clap(long)]
658        reference: Option<String>,
659    },
660    /// List all ostree commits in the repository
661    #[clap(name = "images")]
662    ListCommits,
663    /// Apply a static delta to the repository
664    ApplyDelta {
665        /// Path to the delta file (single-file) or superblock
666        #[arg(value_hint = clap::ValueHint::FilePath)]
667        delta_path: PathBuf,
668    },
669    /// List refs available in a remote ostree repository
670    ListRefs {
671        /// URL of the remote ostree repository
672        #[arg(value_hint = clap::ValueHint::Url)]
673        ostree_repo_url: String,
674        /// Summary index subset key (defaults to system architecture)
675        #[clap(long)]
676        subset: Option<String>,
677    },
678}
679
680/// Common options for reading a filesystem from a path
681#[derive(Debug, Parser)]
682struct FsReadOptions {
683    /// The path to the filesystem
684    #[arg(value_hint = clap::ValueHint::DirPath)]
685    path: PathBuf,
686    /// Transform the filesystem for boot (SELinux labels, empty /boot and /sysroot)
687    #[clap(long)]
688    bootable: bool,
689    /// Don't copy /usr metadata to root directory (use if root already has well-defined metadata)
690    #[clap(long)]
691    no_propagate_usr_to_root: bool,
692    /// Which extended attributes to keep; see
693    /// [`composefs::generic_tree::XattrFiltering`]. Has no effect together
694    /// with --no-propagate-usr-to-root, since that skips the OCI transform
695    /// entirely.
696    #[clap(
697        long,
698        value_parser = clap::value_parser!(composefs::generic_tree::XattrFiltering),
699        default_value_t = composefs::generic_tree::XattrFiltering::AllowlistOnly
700    )]
701    xattrs: composefs::generic_tree::XattrFiltering,
702}
703
704/// Common options for mount commands (shared across regular, OCI, and ostree mount).
705#[derive(Debug, Parser)]
706struct MountOpts {
707    /// Mount mode: auto, yes (force FUSE), or no (force kernel)
708    #[cfg(feature = "fuse")]
709    #[arg(long, value_enum, default_value_t)]
710    fuse: FuseMode,
711    /// Run FUSE server in the foreground (don't daemonize)
712    #[cfg(feature = "fuse")]
713    #[arg(long)]
714    foreground: bool,
715    /// Writable upper layer directory for overlayfs
716    #[arg(long, requires = "workdir", value_hint = clap::ValueHint::DirPath)]
717    upperdir: Option<PathBuf>,
718    /// Work directory for overlayfs (required with --upperdir)
719    #[arg(long, requires = "upperdir", value_hint = clap::ValueHint::DirPath)]
720    workdir: Option<PathBuf>,
721    /// Mount read-write (requires --upperdir)
722    #[arg(long, requires = "upperdir")]
723    read_write: bool,
724}
725
726impl MountOpts {
727    fn to_mount_options(&self) -> Result<composefs::mount::MountOptions> {
728        get_mount_options(
729            self.upperdir.as_deref(),
730            self.workdir.as_deref(),
731            self.read_write,
732        )
733    }
734
735    fn mount_image<ObjectID: FsVerityHashValue>(
736        &self,
737        repo: &Arc<Repository<ObjectID>>,
738        image_name: &str,
739        mountpoint: &str,
740    ) -> Result<()> {
741        let mount_options = self.to_mount_options()?;
742
743        #[cfg(feature = "fuse")]
744        if let mode @ (MountMode::Fuse | MountMode::FuseOverlay) =
745            detect_mount_mode(self.fuse, self.upperdir.is_some())
746        {
747            return run_fuse_mount(
748                repo,
749                image_name,
750                mountpoint,
751                mode,
752                mount_options,
753                self.foreground,
754            );
755        }
756
757        repo.mount_at(image_name, mountpoint, &mount_options)?;
758        Ok(())
759    }
760}
761
762#[derive(Debug, Subcommand)]
763enum Command {
764    /// Initialize a new composefs repository with a metadata file.
765    ///
766    /// Creates the repository directory (if it doesn't exist) and writes
767    /// a `meta.json` recording the digest algorithm.  By default fs-verity
768    /// is enabled on `meta.json`, signaling that all objects require
769    /// verity.  Use `--insecure` to skip (e.g. on tmpfs).
770    Init {
771        /// The fs-verity algorithm identifier.
772        /// Format: fsverity-<hash>-<lg_blocksize>, e.g. fsverity-sha512-12
773        #[clap(long, value_parser = clap::value_parser!(Algorithm), default_value_t = Algorithm::SHA512)]
774        algorithm: Algorithm,
775        /// Path to the repository directory (created if it doesn't exist).
776        /// If omitted, uses --repo/--user/--system location.
777        #[arg(value_hint = clap::ValueHint::DirPath)]
778        path: Option<PathBuf>,
779        /// Do not enable fs-verity on meta.json (insecure repository).
780        #[clap(long)]
781        insecure: bool,
782        /// Migrate an old-format repository: remove streams/ and images/
783        /// (which encode the algorithm) but keep objects/, then write
784        /// fresh meta.json.  Streams and images will need to be
785        /// re-imported after migration.
786        #[clap(long)]
787        reset_metadata: bool,
788        /// Ensure the repository exists, opening it as-is if one is already
789        /// present instead of failing when its on-disk configuration (e.g.
790        /// EROFS format version) differs from the requested one. Useful for
791        /// idempotent invocations from unit files or automation. Has no effect
792        /// on the first initialization of a repository.
793        #[clap(long)]
794        ensure: bool,
795        /// Default EROFS format version for images in this repository.
796        /// V1 is compatible with C `mkcomposefs` 1.0.8; V2 is the legacy composefs-rs format.
797        /// If omitted, falls back to the global `--erofs-version` flag, then defaults to V1.
798        #[clap(long)]
799        erofs_version: Option<ErofsVersion>,
800    },
801    /// Take a transaction lock on the repository.
802    /// This prevents garbage collection from occurring.
803    Transaction,
804    /// Reconstitutes a split stream and writes it to stdout
805    Cat {
806        /// the name of the stream to cat, either a content identifier or prefixed with 'ref/'
807        #[arg(add = ArgValueCompleter::new(complete_stream_refs))]
808        name: String,
809    },
810    /// Perform garbage collection
811    GC {
812        /// Additional roots to keep (image or stream names)
813        #[clap(long, short = 'r')]
814        root: Vec<String>,
815        /// Preview what would be deleted without actually deleting
816        #[clap(long, short = 'n')]
817        dry_run: bool,
818    },
819    /// Imports a composefs image (unsafe!)
820    ImportImage { reference: String },
821    /// List all named image references in the repository
822    #[clap(name = "images", alias = "list-images")]
823    Images {
824        /// Output as JSON array
825        #[clap(long)]
826        json: bool,
827        /// Show full digest instead of truncated form
828        #[clap(long)]
829        no_trunc: bool,
830    },
831    /// Commands for dealing with OCI images and layers
832    #[cfg(feature = "oci")]
833    Oci {
834        #[clap(subcommand)]
835        cmd: OciCommand,
836    },
837    #[cfg(feature = "ostree")]
838    Ostree {
839        #[clap(subcommand)]
840        cmd: OstreeCommand,
841    },
842    /// Mounts a composefs image, possibly enforcing fsverity of the image
843    Mount {
844        /// the name of the image to mount, either an fs-verity hash or prefixed with 'ref/'
845        #[arg(add = ArgValueCompleter::new(complete_image_refs))]
846        name: String,
847        /// the mountpoint
848        #[arg(value_hint = clap::ValueHint::AnyPath)]
849        mountpoint: String,
850        #[clap(flatten)]
851        mount_opts: MountOpts,
852    },
853    /// Read rootfs located at a path, add all files to the repo, then create the composefs image of the rootfs,
854    /// commit it to the repo, and print its image object ID
855    CreateImage {
856        #[clap(flatten)]
857        fs_opts: FsReadOptions,
858        /// optional reference name for the image, use as 'ref/<name>' elsewhere
859        image_name: Option<String>,
860    },
861    /// Read rootfs located at a path and compute the composefs image object id of the rootfs.
862    /// Note that this does not create or commit the composefs image itself, and does not
863    /// store any file objects in the repository.
864    ComputeId {
865        #[clap(flatten)]
866        fs_opts: FsReadOptions,
867    },
868    /// Read rootfs located at a path and compute the composefs kernel argument string.
869    ///
870    /// Like compute-id but outputs the full kernel argument rather than the bare digest,
871    /// choosing the argument name based on the EROFS format version:
872    ///
873    ///   V1: composefs.digest=v1-sha256-12:<hex>
874    ///   V2: composefs=<hex>
875    ///
876    /// Use --erofs-version to select the format.
877    /// The boot transformation (SELinux relabeling, empty /boot and /sysroot) is
878    /// always applied — this command produces a karg for a sealed boot image.
879    ///
880    /// Example (in a Containerfile):
881    ///   cfsctl --erofs-version 1 compute-karg /mnt/base > /etc/kernel/cmdline
882    #[clap(name = "compute-karg")]
883    ComputeKarg {
884        /// The path to the filesystem
885        #[arg(value_hint = clap::ValueHint::DirPath)]
886        path: PathBuf,
887        /// Don't copy /usr metadata to root directory (use if root already has well-defined metadata)
888        #[clap(long)]
889        no_propagate_usr_to_root: bool,
890    },
891    /// Read rootfs located at a path and dump full content of the rootfs to a composefs dumpfile,
892    /// writing to stdout. Does not store any file objects in the repository.
893    CreateDumpfile {
894        #[clap(flatten)]
895        fs_opts: FsReadOptions,
896    },
897    /// Lists all object IDs referenced by an image
898    ImageObjects {
899        /// the name of the image to read, either an object ID digest or prefixed with 'ref/'
900        #[arg(add = ArgValueCompleter::new(complete_image_refs))]
901        name: String,
902    },
903    /// Extract file information from a composefs image for specified files or directories
904    ///
905    /// By default, outputs information in composefs dumpfile format
906    DumpFiles {
907        /// The name of the composefs image to read from, either an object ID digest or prefixed with 'ref/'
908        #[arg(add = ArgValueCompleter::new(complete_image_refs))]
909        image_name: String,
910        /// File or directory paths to process. If a path is a directory, its contents will be listed.
911        #[arg(value_hint = clap::ValueHint::AnyPath)]
912        files: Vec<PathBuf>,
913        /// Show backing path information instead of dumpfile format
914        /// For each file, prints either "inline" for files stored within the image,
915        /// or a path relative to the object store for files stored extrenally
916        #[clap(long)]
917        backing_path_only: bool,
918    },
919    /// Check repository integrity
920    ///
921    /// Verifies fsverity digests of all objects, validates stream and image
922    /// symlinks, and checks splitstream internal consistency. Exits with
923    /// a non-zero status if corruption is found.
924    Fsck {
925        /// Output results as JSON (always exits 0 unless the check itself fails)
926        #[clap(long)]
927        json: bool,
928        /// Skip per-object fs-verity verification; check only metadata and
929        /// symlink structure (much faster on large repositories)
930        #[clap(long)]
931        metadata_only: bool,
932    },
933    #[cfg(feature = "http")]
934    Fetch {
935        #[arg(value_hint = clap::ValueHint::Url)]
936        url: String,
937        name: String,
938    },
939    /// Serve the varlink RPC API on a Unix socket or systemd socket.
940    ///
941    /// A single service answers both the `org.composefs.Repository` and (when
942    /// the `oci` feature is enabled) `org.composefs.Oci` interfaces on one
943    /// socket.
944    Varlink {
945        /// Unix socket path to listen on (omit when using systemd socket activation).
946        #[clap(long, value_hint = clap::ValueHint::AnyPath)]
947        address: Option<PathBuf>,
948    },
949
950    /// Run mkcomposefs (C-compatible image builder); hidden, also available via argv0 dispatch.
951    #[clap(hide = true, name = "mkcomposefs")]
952    Mkcomposefs {
953        /// Arguments forwarded verbatim to mkcomposefs
954        #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
955        args: Vec<std::ffi::OsString>,
956    },
957
958    /// Run composefs-info (C-compatible image inspector); hidden, also available via argv0 dispatch.
959    #[clap(hide = true, name = "composefs-info")]
960    ComposefsInfo {
961        /// Arguments forwarded verbatim to composefs-info
962        #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
963        args: Vec<std::ffi::OsString>,
964    },
965}
966
967/// Acts as a proxy for the `cfsctl` CLI by executing the CLI logic programmatically
968///
969/// This function behaves the same as invoking the `cfsctl` binary from the
970/// command line. It accepts an iterator of CLI-style arguments (excluding
971/// the binary name), parses them using `clap`
972pub async fn run_from_iter<I>(args: I) -> Result<()>
973where
974    I: IntoIterator,
975    I::Item: Into<OsString> + Clone,
976{
977    let args = App::parse_from(
978        std::iter::once(OsString::from("cfsctl")).chain(args.into_iter().map(Into::into)),
979    );
980
981    run_app(args).await
982}
983
984#[cfg(feature = "ostree")]
985fn print_pull_stats(stats: &composefs_ostree::PullStats) {
986    if stats.delta_parts_applied > 0 {
987        println!(
988            "objects {} metadata + {} files via {} delta parts",
989            stats.metadata_fetched, stats.files_fetched, stats.delta_parts_applied
990        );
991    } else {
992        println!(
993            "objects {} metadata + {} files fetched",
994            stats.metadata_fetched, stats.files_fetched
995        );
996    }
997}
998
999fn get_mount_options(
1000    upperdir: Option<&Path>,
1001    workdir: Option<&Path>,
1002    read_write: bool,
1003) -> Result<MountOptions> {
1004    let mut options = MountOptions::default();
1005    if let (Some(u), Some(w)) = (upperdir, workdir) {
1006        let upper_fd = rustix::fs::open(
1007            u,
1008            OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
1009            Mode::empty(),
1010        )
1011        .with_context(|| format!("Opening upperdir '{}'", u.display()))?;
1012        let work_fd = rustix::fs::open(
1013            w,
1014            OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
1015            Mode::empty(),
1016        )
1017        .with_context(|| format!("Opening workdir '{}'", w.display()))?;
1018        options.set_overlay(upper_fd, work_fd);
1019    }
1020    options.set_read_write(read_write);
1021    Ok(options)
1022}
1023
1024#[cfg(feature = "fuse")]
1025use fuse::{FuseMode, MountMode, detect_mount_mode, run_fuse_mount};
1026
1027#[cfg(feature = "oci")]
1028pub(crate) fn verity_opt<ObjectID>(opt: &Option<String>) -> Result<Option<ObjectID>>
1029where
1030    ObjectID: FsVerityHashValue,
1031{
1032    Ok(match opt {
1033        Some(value) => Some(FsVerityHashValue::from_hex(value)?),
1034        None => None,
1035    })
1036}
1037
1038/// Resolve the default repository path based on the effective uid.
1039///
1040/// Root operates on the system repository; everyone else on their per-user
1041/// repository. Used both when no `--repo`/`--user`/`--system` is given and by
1042/// the socket-activated path (which has no CLI args to consult).
1043pub(crate) fn default_repo_path() -> Result<PathBuf> {
1044    if rustix::process::getuid().is_root() {
1045        Ok(system_path())
1046    } else {
1047        user_path()
1048    }
1049}
1050
1051/// Resolve the repository path from CLI args without opening it.
1052///
1053/// Uses [`user_path`] and [`system_path`] to avoid duplicating
1054/// path constants.
1055pub(crate) fn resolve_repo_path(args: &App) -> Result<PathBuf> {
1056    if let Some(path) = &args.repo {
1057        Ok(path.clone())
1058    } else if args.system {
1059        Ok(system_path())
1060    } else if args.user {
1061        user_path()
1062    } else {
1063        default_repo_path()
1064    }
1065}
1066
1067/// Determine the effective hash type for a repository.
1068///
1069/// Resolution order:
1070/// 1. If `meta.json` exists, use its algorithm. Error if `--hash` was
1071///    explicitly passed and conflicts.
1072/// 2. If no metadata and `upgrade` is true, infer from existing objects.
1073/// 3. If no metadata and `upgrade` is false, error.
1074///
1075/// Note: we read the metadata file directly here (rather than via
1076/// `Repository::metadata`) because this runs *before* we know which
1077/// generic `ObjectID` type to use — that's exactly what we're deciding.
1078pub(crate) fn resolve_hash_type(
1079    repo_path: &Path,
1080    cli_hash: Option<HashType>,
1081    upgrade: bool,
1082) -> Result<HashType> {
1083    let repo_fd = rustix::fs::open(
1084        repo_path,
1085        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1086        Mode::empty(),
1087    )
1088    .with_context(|| format!("opening repository {}", repo_path.display()))?;
1089
1090    let algorithm = match read_repo_algorithm(&repo_fd)? {
1091        Some(alg) => alg,
1092        None if upgrade => {
1093            // No meta.json — try to infer from objects (old-format repo).
1094            // open_upgrade will write meta.json later when the repo is opened.
1095            composefs::repository::infer_repo_algorithm(&repo_fd).with_context(|| {
1096                format!(
1097                    "no {REPO_METADATA_FILENAME} in {}; tried to infer algorithm from objects",
1098                    repo_path.display(),
1099                )
1100            })?
1101        }
1102        None => {
1103            anyhow::bail!(
1104                "{REPO_METADATA_FILENAME} not found in {}; \
1105                 this repository must be initialized with `cfsctl init`",
1106                repo_path.display(),
1107            );
1108        }
1109    };
1110
1111    let detected = match algorithm {
1112        Algorithm::Sha256 { .. } => HashType::Sha256,
1113        Algorithm::Sha512 { .. } => HashType::Sha512,
1114    };
1115
1116    // If the user explicitly passed --hash and it doesn't match, error
1117    if let Some(explicit) = cli_hash
1118        && explicit != detected
1119    {
1120        anyhow::bail!(
1121            "repository is configured for {algorithm} (from {REPO_METADATA_FILENAME}) \
1122             but --hash {} was specified",
1123            match explicit {
1124                HashType::Sha256 => "sha256",
1125                HashType::Sha512 => "sha512",
1126            },
1127        );
1128    }
1129
1130    Ok(detected)
1131}
1132
1133/// If the process was started *bare* via systemd socket activation, serve the
1134/// varlink API on the activated socket and return `Ok(true)`. Otherwise return
1135/// `Ok(false)` so the caller falls through to normal CLI parsing.
1136///
1137/// This runs *before* clap to support a truly argument-less invocation —
1138/// notably `varlinkctl exec:cfsctl`, which hands us the connected socket on fd
1139/// 3 but passes no subcommand for clap to parse. A client selects a repository
1140/// at runtime via the `OpenRepository` method.
1141///
1142/// The shortcut is taken *only* when there are no command-line arguments
1143/// (`argv` is just the program name). When any argument is present — e.g. a
1144/// systemd unit running `cfsctl varlink` — we fall through to clap; the
1145/// `varlink`/`oci varlink` subcommand's [`serve`](crate::varlink::serve)
1146/// detects and serves on the activation fd itself. We must NOT call
1147/// [`try_activated_listener`](crate::varlink::try_activated_listener) on that
1148/// path: it consumes `LISTEN_FDS`/`LISTEN_PID` (via `receive_descriptors`),
1149/// which would prevent `serve` from finding the fd later.
1150pub async fn run_if_socket_activated() -> Result<bool> {
1151    // Only take the pre-clap shortcut for a bare invocation (`argv[0]` only).
1152    // Check argv before touching the activation env so the latter is consumed
1153    // only when we actually intend to serve from this shortcut.
1154    if std::env::args_os().len() != 1 {
1155        return Ok(false);
1156    }
1157    let service = crate::varlink::CfsctlService::activated();
1158    match crate::varlink::try_activated_listener()? {
1159        Some(crate::varlink::ActivatedSocket::Connected(l)) => {
1160            crate::varlink::serve_activated(service, l).await?;
1161            Ok(true)
1162        }
1163        Some(crate::varlink::ActivatedSocket::Listening(listener)) => {
1164            crate::varlink::serve_on_listener(service, listener).await?;
1165            Ok(true)
1166        }
1167        None => Ok(false),
1168    }
1169}
1170
1171/// Top-level dispatch: handle init specially, otherwise open repo and run.
1172pub async fn run_app(args: App) -> Result<()> {
1173    // Hidden compat subcommands: forward all trailing args to the respective tool.
1174    if let Command::Mkcomposefs { args: extra } = args.cmd {
1175        return mkcomposefs::run_from_args(extra);
1176    }
1177    if let Command::ComposefsInfo { args: extra } = args.cmd {
1178        return composefs_info::run_from_args(extra);
1179    }
1180
1181    // Init is handled before opening a repo since it creates one
1182    if let Command::Init {
1183        ref algorithm,
1184        ref path,
1185        insecure,
1186        reset_metadata,
1187        ensure,
1188        erofs_version: ref init_erofs_version,
1189    } = args.cmd
1190    {
1191        // Prefer the subcommand-level --erofs-version; fall back to global flag; default V1.
1192        let erofs_version = init_erofs_version
1193            .or(args.erofs_version)
1194            .map(composefs::erofs::format::FormatVersion::from)
1195            .unwrap_or(composefs::erofs::format::FormatVersion::V1);
1196        return run_init(
1197            algorithm,
1198            path.as_deref(),
1199            insecure || args.insecure,
1200            reset_metadata,
1201            ensure,
1202            erofs_version,
1203            &args,
1204        );
1205    }
1206
1207    // The varlink service opens repositories on demand via `OpenRepository`
1208    // (handling both hash types), so it bypasses the generic repo-open dispatch
1209    // below. A single `CfsctlService` answers both the `org.composefs.Repository`
1210    // and (when the `oci` feature is enabled) `org.composefs.Oci` interfaces, so
1211    // `cfsctl varlink` and `cfsctl oci varlink` serve the same combined service.
1212    if let Command::Varlink { ref address } = args.cmd {
1213        let service = crate::varlink::CfsctlService::from_app(&args);
1214        return crate::varlink::serve(service, address.as_deref()).await;
1215    }
1216
1217    #[cfg(feature = "oci")]
1218    if let Command::Oci {
1219        cmd: OciCommand::Varlink { ref address },
1220    } = args.cmd
1221    {
1222        let service = crate::varlink::CfsctlService::from_app(&args);
1223        return crate::varlink::serve(service, address.as_deref()).await;
1224    }
1225
1226    // Commands that only need verity digests (no object storage) can
1227    // run without opening a repository.
1228    if args.no_repo
1229        || matches!(
1230            args.cmd,
1231            Command::ComputeId { .. }
1232                | Command::ComputeKarg { .. }
1233                | Command::CreateDumpfile { .. }
1234        )
1235    {
1236        // If a repo path is available and --no-repo wasn't passed,
1237        // try to read the hash type from the repo's metadata so that
1238        // e.g. `cfsctl --repo <sha256-repo> compute-id` uses SHA-256
1239        // instead of the default SHA-512.
1240        let effective_hash = if !args.no_repo {
1241            if let Ok(repo_path) = resolve_repo_path(&args) {
1242                resolve_hash_type(&repo_path, args.hash, !args.no_upgrade)
1243                    .unwrap_or(args.hash.unwrap_or(HashType::Sha512))
1244            } else {
1245                args.hash.unwrap_or(HashType::Sha512)
1246            }
1247        } else {
1248            args.hash.unwrap_or(HashType::Sha512)
1249        };
1250        return match effective_hash {
1251            HashType::Sha256 => run_cmd_without_repo::<Sha256HashValue>(args).await,
1252            HashType::Sha512 => run_cmd_without_repo::<Sha512HashValue>(args).await,
1253        };
1254    }
1255
1256    let repo_path = resolve_repo_path(&args)?;
1257    let effective_hash = resolve_hash_type(&repo_path, args.hash, !args.no_upgrade)?;
1258
1259    match effective_hash {
1260        HashType::Sha256 => run_cmd_with_repo(open_repo::<Sha256HashValue>(&args)?, args).await,
1261        HashType::Sha512 => run_cmd_with_repo(open_repo::<Sha512HashValue>(&args)?, args).await,
1262    }
1263}
1264
1265/// Handle `cfsctl init`
1266fn run_init(
1267    algorithm: &Algorithm,
1268    path: Option<&Path>,
1269    insecure: bool,
1270    reset_metadata: bool,
1271    ensure: bool,
1272    erofs_version: composefs::erofs::format::FormatVersion,
1273    args: &App,
1274) -> Result<()> {
1275    let repo_path = if let Some(p) = path {
1276        p.to_path_buf()
1277    } else {
1278        resolve_repo_path(args)?
1279    };
1280
1281    if reset_metadata {
1282        composefs::repository::reset_metadata(&repo_path)?;
1283    }
1284
1285    if ensure {
1286        let formats = composefs::erofs::format::FormatConfig::single(erofs_version);
1287        let status =
1288            crate::varlink::run_ensure_repository(&repo_path, *algorithm, insecure, Some(formats))?;
1289        match status {
1290            composefs::repository::EnsureStatus::Created => {
1291                println!(
1292                    "Initialized composefs repository at {}",
1293                    repo_path.display()
1294                );
1295                println!("  algorithm: {algorithm}");
1296                if insecure {
1297                    println!("  verity:    not required (insecure)");
1298                } else {
1299                    println!("  verity:    required");
1300                }
1301            }
1302            composefs::repository::EnsureStatus::Opened => {
1303                println!(
1304                    "Repository already initialized at {} (existing configuration preserved)",
1305                    repo_path.display()
1306                );
1307            }
1308            composefs::repository::EnsureStatus::Upgraded => {
1309                println!("Upgraded legacy repository at {}", repo_path.display());
1310            }
1311        }
1312        return Ok(());
1313    }
1314
1315    // Ensure parent directories exist (init_path only creates the final dir).
1316    if let Some(parent) = repo_path.parent() {
1317        std::fs::create_dir_all(parent)
1318            .with_context(|| format!("creating parent directories for {}", repo_path.display()))?;
1319    }
1320
1321    // init_path handles idempotency: same algorithm is a no-op,
1322    // different algorithm is an error.
1323    let config = {
1324        let mut c = RepositoryConfig::new(*algorithm);
1325        c.erofs_formats = composefs::erofs::format::FormatConfig::single(erofs_version);
1326        if insecure { c.set_insecure() } else { c }
1327    };
1328    let created = match algorithm {
1329        Algorithm::Sha256 { .. } => {
1330            Repository::<Sha256HashValue>::init_path(CWD, &repo_path, config)?.1
1331        }
1332        Algorithm::Sha512 { .. } => {
1333            Repository::<Sha512HashValue>::init_path(CWD, &repo_path, config)?.1
1334        }
1335    };
1336
1337    if created {
1338        println!(
1339            "Initialized composefs repository at {}",
1340            repo_path.display()
1341        );
1342        println!("  algorithm: {algorithm}");
1343        if insecure {
1344            println!("  verity:    not required (insecure)");
1345        } else {
1346            println!("  verity:    required");
1347        }
1348    } else {
1349        println!("Repository already initialized at {}", repo_path.display());
1350    }
1351
1352    Ok(())
1353}
1354
1355/// Open a repo at an explicit path, auto-upgrading old-format repos unless
1356/// `no_upgrade` is set.
1357///
1358/// This is the parameterized core shared by [`open_repo`] (which derives the
1359/// path and flags from [`App`]) and the varlink service (which holds these
1360/// values directly).
1361pub(crate) fn open_repo_at<ObjectID>(
1362    path: &Path,
1363    insecure: bool,
1364    require_verity: bool,
1365    no_upgrade: bool,
1366) -> Result<Repository<ObjectID>>
1367where
1368    ObjectID: FsVerityHashValue,
1369{
1370    let mut repo = if no_upgrade {
1371        Repository::open_path(CWD, path)?
1372    } else {
1373        let (repo, _upgraded) = Repository::open_upgrade(CWD, path)?;
1374        repo
1375    };
1376    // Hidden --insecure flag for backward compatibility; the default
1377    // now is to inherit the repo config, but if it's specified we
1378    // disable requiring verity even if the repo says to use it.
1379    if insecure {
1380        repo.set_insecure();
1381    }
1382    if require_verity {
1383        repo.require_verity()?;
1384    }
1385    Ok(repo)
1386}
1387
1388/// Open a repo, auto-upgrading old-format repos unless `--no-upgrade` was passed.
1389pub fn open_repo<ObjectID>(args: &App) -> Result<Repository<ObjectID>>
1390where
1391    ObjectID: FsVerityHashValue,
1392{
1393    let path = resolve_repo_path(args)?;
1394    let mut repo = open_repo_at(&path, args.insecure, args.require_verity, args.no_upgrade)?;
1395    // If the user explicitly passed --erofs-version, override the stored
1396    // repo setting for this invocation only (does not rewrite meta.json).
1397    if let Some(version) = args.erofs_version {
1398        repo.set_erofs_version(version.into());
1399    }
1400    Ok(repo)
1401}
1402
1403/// Copy an OCI image (and all its layers) from one repository to another using varlink connections.
1404#[cfg(feature = "oci")]
1405pub async fn copy_image(
1406    conn_src: &mut zlink::tokio::unix::Connection,
1407    conn_dest: &mut zlink::tokio::unix::Connection,
1408    handle_src: u64,
1409    handle_dest: u64,
1410    image: &OciReference,
1411    name: Option<&str>,
1412    zerocopy: bool,
1413) -> Result<crate::varlink::layer_sync::FinalizeImageReply> {
1414    use crate::varlink::layer_sync::LayerRef;
1415    use crate::varlink::oci::OciError;
1416    use crate::varlink::proxy::{GetLayerParams, OciProxy};
1417    use anyhow::ensure;
1418    use zlink::futures_util::StreamExt as _;
1419
1420    let image_str = image.to_string();
1421    let inspect = conn_src
1422        .inspect(handle_src, &image_str)
1423        .await
1424        .context("zlink transport error calling Inspect")?
1425        .map_err(|e: OciError| anyhow::anyhow!("Inspect failed: {e:?}"))?;
1426
1427    ensure!(
1428        !inspect.manifest.is_empty(),
1429        "inspect returned empty manifest"
1430    );
1431    ensure!(!inspect.config.is_empty(), "inspect returned empty config");
1432
1433    // Extract ordered layer identifiers via the shared helper that handles
1434    // both container images (rootfs.diff_ids) and OCI artifacts (manifest
1435    // layer digests).
1436    let diff_ids_ordered = composefs_oci::extract_layer_ids(&inspect.manifest, &inspect.config)
1437        .context("extracting layer identifiers")?;
1438
1439    let mut layer_refs: Vec<LayerRef> = Vec::with_capacity(diff_ids_ordered.len());
1440
1441    for diff_id in &diff_ids_ordered {
1442        let has = conn_dest
1443            .has_layer(handle_dest, diff_id)
1444            .await
1445            .context("zlink transport error calling HasLayer")?
1446            .map_err(|e: OciError| anyhow::anyhow!("HasLayer failed: {e:?}"))?;
1447
1448        let layer_verity = if has.present {
1449            has.layer_verity
1450                .context("HasLayer returned present=true but no layer_verity")?
1451        } else {
1452            let get_params = GetLayerParams {
1453                diff_id: Some(diff_id.to_string()),
1454                storage: None,
1455                ..Default::default()
1456            };
1457            let mut get_stream = std::pin::pin!(
1458                conn_src
1459                    .get_layer(handle_src, get_params)
1460                    .await
1461                    .context("zlink transport error calling GetLayer")?
1462            );
1463            let mut all_fds: Vec<std::os::fd::OwnedFd> = Vec::new();
1464            let mut get_reply = None;
1465            while let Some(item) = get_stream.next().await {
1466                let (result, fds) = item.context("GetLayer stream frame error")?;
1467                let reply =
1468                    result.map_err(|e: OciError| anyhow::anyhow!("GetLayer failed: {e:?}"))?;
1469                get_reply = Some(reply);
1470                all_fds.extend(fds);
1471            }
1472            let get_reply = get_reply.context("GetLayer returned empty stream")?;
1473            let dir_count = get_reply.dir_count as usize;
1474
1475            let pipe_and_dirfds_len = 1 + dir_count;
1476            let lifetime_fds = all_fds.split_off(pipe_and_dirfds_len);
1477
1478            let put_reply = conn_dest
1479                .put_layer(handle_dest, diff_id, zerocopy, all_fds)
1480                .await
1481                .context("zlink transport error calling PutLayer")?
1482                .map_err(|e: OciError| anyhow::anyhow!("PutLayer failed: {e:?}"))?;
1483            drop(lifetime_fds);
1484
1485            put_reply.layer_verity
1486        };
1487
1488        layer_refs.push(LayerRef {
1489            diff_id: diff_id.clone(),
1490            layer_verity,
1491        });
1492    }
1493
1494    let finalize = conn_dest
1495        .finalize_image(
1496            handle_dest,
1497            &inspect.manifest,
1498            &inspect.config,
1499            layer_refs,
1500            name,
1501        )
1502        .await
1503        .context("zlink transport error calling FinalizeImage")?
1504        .map_err(|e: OciError| anyhow::anyhow!("FinalizeImage failed: {e:?}"))?;
1505
1506    Ok(finalize)
1507}
1508
1509/// Resolve an [`OciReference`] to an [`OciImage`].
1510#[cfg(feature = "oci")]
1511pub(crate) fn resolve_oci_image<ObjectID: FsVerityHashValue>(
1512    repo: &Repository<ObjectID>,
1513    reference: &OciReference,
1514) -> Result<composefs_oci::oci_image::OciImage<ObjectID>> {
1515    match reference {
1516        OciReference::Digest(digest) => {
1517            composefs_oci::oci_image::OciImage::open(repo, digest, None)
1518        }
1519        OciReference::Named(name) => composefs_oci::oci_image::OciImage::open_ref(repo, name),
1520    }
1521}
1522
1523/// Resolve an [`OciReference`] to a config digest and optional verity.
1524///
1525/// When resolving via a named ref, the verity override is ignored since
1526/// the image metadata provides the correct verity.
1527#[cfg(feature = "oci")]
1528pub(crate) fn resolve_oci_config<ObjectID: FsVerityHashValue>(
1529    repo: &Repository<ObjectID>,
1530    reference: &OciReference,
1531    verity_override: Option<ObjectID>,
1532) -> Result<(composefs_oci::OciDigest, Option<ObjectID>)> {
1533    match reference {
1534        OciReference::Digest(digest) => Ok((digest.clone(), verity_override)),
1535        OciReference::Named(_) => {
1536            let img = resolve_oci_image(repo, reference)?;
1537            Ok((
1538                img.config_digest().clone(),
1539                Some(img.config_verity().clone()),
1540            ))
1541        }
1542    }
1543}
1544
1545#[cfg(feature = "oci")]
1546fn load_filesystem_from_oci_image<ObjectID: FsVerityHashValue>(
1547    repo: &Repository<ObjectID>,
1548    opts: OCIConfigFilesystemOptions,
1549) -> Result<FileSystem<RegularFile<ObjectID>>> {
1550    let verity = verity_opt(&opts.base_config.config_verity)?;
1551    let (config_digest, config_verity) =
1552        resolve_oci_config(repo, &opts.base_config.config_name, verity)?;
1553    let transform_opts = composefs_oci::OciTransformOptions {
1554        xattrs: opts.xattrs,
1555    };
1556    let mut fs = composefs_oci::image::create_filesystem(
1557        repo,
1558        &config_digest,
1559        config_verity.as_ref(),
1560        &transform_opts,
1561    )?;
1562    if opts.bootable {
1563        fs.transform_for_boot(repo)?;
1564    }
1565    Ok(fs)
1566}
1567
1568async fn load_filesystem_from_ondisk_fs<ObjectID: FsVerityHashValue>(
1569    fs_opts: &FsReadOptions,
1570    repo: Option<Arc<Repository<ObjectID>>>,
1571) -> Result<FileSystem<RegularFile<ObjectID>>> {
1572    // The async API needs an OwnedFd; fs_opts.path is typically absolute
1573    // so the dirfd is unused for path resolution, but required by the API.
1574    let dirfd = rustix::fs::openat(
1575        CWD,
1576        ".",
1577        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1578        Mode::empty(),
1579    )?;
1580    let mut fs = if fs_opts.no_propagate_usr_to_root {
1581        composefs::fs::read_filesystem(dirfd, fs_opts.path.clone(), repo.clone()).await?
1582    } else {
1583        let transform_opts = composefs::generic_tree::OciTransformOptions {
1584            xattrs: fs_opts.xattrs,
1585        };
1586        composefs::fs::read_container_root(
1587            dirfd,
1588            fs_opts.path.clone(),
1589            repo.clone(),
1590            &transform_opts,
1591        )
1592        .await?
1593    };
1594    if fs_opts.bootable {
1595        if let Some(repo) = &repo {
1596            fs.transform_for_boot(repo)?;
1597        } else {
1598            let rootfd = rustix::fs::openat(
1599                CWD,
1600                &fs_opts.path,
1601                OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1602                Mode::empty(),
1603            )?;
1604            fs.transform_for_boot_from_dir(rootfd)?;
1605        }
1606    }
1607    Ok(fs)
1608}
1609
1610/// Print file information from a composefs filesystem for the given paths
1611///
1612/// For each path in `files`, looks up the entry in the filesystem and either
1613/// outputs composefs dumpfile-format metadata or, when `backing_path_only` is
1614/// set, prints whether the file is stored inline or its object-relative path.
1615/// Directory paths have their contents listed.
1616pub fn dump_files<ObjectID: FsVerityHashValue>(
1617    repo: &Repository<ObjectID>,
1618    image_name: &str,
1619    files: &Vec<PathBuf>,
1620    backing_path_only: bool,
1621) -> Result<Vec<u8>> {
1622    let (img_fd, _) = repo.open_image(image_name)?;
1623
1624    let mut img_buf = Vec::new();
1625    std::fs::File::from(img_fd).read_to_end(&mut img_buf)?;
1626
1627    let fs = erofs_to_filesystem::<ObjectID>(&img_buf)?;
1628
1629    let mut out = Vec::new();
1630    let nlink_map = fs.nlinks();
1631
1632    for file_path in files {
1633        let (dir, file) = fs.root.split(file_path.as_os_str())?;
1634
1635        let (_, file) = dir
1636            .entries()
1637            .find(|ent| ent.0 == file)
1638            .ok_or_else(|| anyhow::anyhow!("{} not found", file_path.display()))?;
1639
1640        match &file {
1641            Inode::Directory(directory) => {
1642                if backing_path_only {
1643                    anyhow::bail!("{} is a directory", file_path.display());
1644                }
1645
1646                dump_single_dir(&mut out, directory, &fs, &nlink_map, file_path.clone())?
1647            }
1648
1649            Inode::Leaf(leaf_id, _) => {
1650                use composefs::generic_tree::LeafContent::*;
1651                use composefs::tree::RegularFile::*;
1652
1653                if backing_path_only {
1654                    let leaf = fs.leaf(*leaf_id);
1655                    match &leaf.content {
1656                        Regular(f) => match f {
1657                            Inline(..) | Sparse(..) => {
1658                                writeln!(&mut out, "{} inline", file_path.display())?;
1659                            }
1660                            External(id, _) | ExternalNoVerity(id, _) => {
1661                                writeln!(
1662                                    &mut out,
1663                                    "{} {}",
1664                                    file_path.display(),
1665                                    id.to_object_pathname()
1666                                )?;
1667                            }
1668                        },
1669                        _ => {
1670                            writeln!(&mut out, "{} inline", file_path.display())?;
1671                        }
1672                    }
1673
1674                    continue;
1675                }
1676
1677                dump_single_file(&mut out, *leaf_id, &fs, &nlink_map, file_path.clone())?
1678            }
1679        };
1680    }
1681
1682    Ok(out)
1683}
1684
1685/// Run commands that don't require a repository.
1686pub async fn run_cmd_without_repo<ObjectID: FsVerityHashValue>(args: App) -> Result<()> {
1687    let erofs_version = args
1688        .erofs_version
1689        .map(composefs::erofs::format::FormatVersion::from);
1690    match args.cmd {
1691        Command::ComputeId { fs_opts } => {
1692            let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
1693            let version = erofs_version.unwrap_or_default();
1694            let id = composefs::fsverity::compute_verity::<ObjectID>(
1695                &composefs::erofs::writer::mkfs_erofs_versioned(
1696                    &composefs::erofs::writer::ValidatedFileSystem::new(fs)?,
1697                    version,
1698                ),
1699            );
1700            println!("{}", id.to_hex());
1701        }
1702        Command::ComputeKarg {
1703            path,
1704            no_propagate_usr_to_root,
1705        } => {
1706            let fs_opts = FsReadOptions {
1707                path,
1708                bootable: true,
1709                no_propagate_usr_to_root,
1710                // compute-karg produces a karg for a sealed boot image; it
1711                // doesn't expose an --xattrs flag of its own.
1712                xattrs: composefs::generic_tree::XattrFiltering::AllowlistOnly,
1713            };
1714            let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
1715            let version = erofs_version.unwrap_or_default();
1716            let id = composefs::fsverity::compute_verity::<ObjectID>(
1717                &composefs::erofs::writer::mkfs_erofs_versioned(
1718                    &composefs::erofs::writer::ValidatedFileSystem::new(fs)?,
1719                    version,
1720                ),
1721            );
1722            let karg = match version {
1723                FormatVersion::V0 | FormatVersion::V1 => {
1724                    ComposefsCmdline::new_v1(id, args.insecure)
1725                }
1726                FormatVersion::V2 => ComposefsCmdline::new_v2(id, args.insecure),
1727            };
1728            println!("{}", karg.to_cmdline_arg());
1729        }
1730        Command::CreateDumpfile { fs_opts } => {
1731            let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
1732            fs.print_dumpfile()?;
1733        }
1734        _ => {
1735            anyhow::bail!("--no-repo is only supported for compute-id and create-dumpfile");
1736        }
1737    }
1738    Ok(())
1739}
1740
1741/// Run with cmd
1742pub async fn run_cmd_with_repo<ObjectID>(repo: Repository<ObjectID>, args: App) -> Result<()>
1743where
1744    ObjectID: FsVerityHashValue,
1745{
1746    let repo = Arc::new(repo);
1747    #[cfg(feature = "oci")]
1748    let dest_path = resolve_repo_path(&args)?;
1749    match args.cmd {
1750        Command::Init { .. } => {
1751            // Handled in run_app before we get here
1752            unreachable!("init is handled before opening a repository");
1753        }
1754        Command::Transaction => {
1755            // just wait for ^C
1756            loop {
1757                std::thread::park();
1758            }
1759        }
1760        Command::Cat { name } => {
1761            repo.merge_splitstream(&name, None, None, &mut std::io::stdout())?;
1762        }
1763        Command::ImportImage { reference } => {
1764            let image_id = repo.import_image(&reference, &mut std::io::stdin())?;
1765            println!("{}", image_id.to_id());
1766        }
1767        #[cfg(feature = "oci")]
1768        Command::Oci { cmd: oci_cmd } => match oci_cmd {
1769            OciCommand::ImportLayer { name, ref digest } => {
1770                let (object_id, _stats) = composefs_oci::import_layer(
1771                    &repo,
1772                    digest,
1773                    name.as_deref(),
1774                    tokio::io::BufReader::with_capacity(IO_BUF_CAPACITY, tokio::io::stdin()),
1775                )
1776                .await?;
1777                println!("{}", object_id.to_id());
1778            }
1779            OciCommand::Dump { config_opts } => {
1780                let fs = load_filesystem_from_oci_image(&repo, config_opts)?;
1781                fs.print_dumpfile()?;
1782            }
1783            OciCommand::Mount {
1784                ref image,
1785                ref mountpoint,
1786                bootable,
1787                ref mount_opts,
1788            } => {
1789                let img = if image.starts_with("sha256:") {
1790                    let digest: composefs_oci::OciDigest =
1791                        image.parse().context("Parsing manifest digest")?;
1792                    composefs_oci::oci_image::OciImage::open(&repo, &digest, None)?
1793                } else {
1794                    composefs_oci::oci_image::OciImage::open_ref(&repo, image)?
1795                };
1796                let erofs_id = if bootable {
1797                    match img.boot_image_ref(repo.erofs_version()) {
1798                        Some(id) => id,
1799                        None => anyhow::bail!(
1800                            "No boot EROFS image linked — try pulling with --bootable"
1801                        ),
1802                    }
1803                } else {
1804                    match img.image_ref(repo.erofs_version()) {
1805                        Some(id) => id,
1806                        None => anyhow::bail!(
1807                            "No composefs EROFS image linked — try re-pulling the image"
1808                        ),
1809                    }
1810                };
1811                mount_opts.mount_image(&repo, &erofs_id.to_hex(), mountpoint.as_str())?;
1812            }
1813            OciCommand::ComputeId { config_opts } => {
1814                let fs = load_filesystem_from_oci_image(&repo, config_opts)?;
1815                let id = fs.compute_image_id(repo.erofs_version());
1816                println!("{}", id.to_hex());
1817            }
1818            OciCommand::Pull {
1819                ref image,
1820                name,
1821                bootable,
1822                expected_digest,
1823                local_fetch,
1824            } => {
1825                // Parse before pulling so a malformed digest fails fast,
1826                // rather than after a potentially long-running fetch.
1827                let expected_digest = expected_digest
1828                    .map(|hex| ObjectID::from_hex(&hex))
1829                    .transpose()
1830                    .context("Parsing --expected-digest")?;
1831
1832                // If no explicit name provided, use the image reference as the tag
1833                let tag_name = name.as_deref().unwrap_or(image);
1834
1835                let reporter: SharedReporter = IndicatifReporter::new().into_shared();
1836                let opts = composefs_oci::PullOptions {
1837                    local_fetch: local_fetch.into(),
1838                    progress: Some(reporter),
1839                    ..Default::default()
1840                };
1841
1842                let result = composefs_oci::pull(&repo, image, Some(tag_name), opts).await?;
1843
1844                println!("manifest {}", result.manifest_digest);
1845                println!("config   {}", result.config_digest);
1846                println!("verity   {}", result.manifest_verity.to_hex());
1847                println!("tagged   {tag_name}");
1848                println!("objects  {}", result.stats);
1849
1850                if let Some(expected) = expected_digest {
1851                    // `#[arg(requires = "bootable")]` on `expected_digest` guarantees
1852                    // this, but assert it since `find_matching_boot_image` only
1853                    // searches boot image (mode, format version) combinations, not
1854                    // plain rootfs ones.
1855                    assert!(
1856                        bootable,
1857                        "clap should have enforced --expected-digest requires --bootable"
1858                    );
1859                    match composefs_oci::find_matching_boot_image(
1860                        &repo,
1861                        &result.manifest_digest,
1862                        &expected,
1863                    )? {
1864                        composefs_oci::BootImageMatch::Found {
1865                            mode,
1866                            version,
1867                            digest,
1868                        } => {
1869                            println!(
1870                                "Boot image: {} (xattr-mode={mode}, format-version={version:?})",
1871                                digest.to_hex()
1872                            );
1873                        }
1874                        composefs_oci::BootImageMatch::NotFound(tried) => {
1875                            anyhow::bail!(
1876                                "No boot image configuration matched expected digest \
1877                                 {} (tried {tried} mode/version combinations)",
1878                                expected.to_hex()
1879                            );
1880                        }
1881                    }
1882                } else if bootable {
1883                    let image_verity = composefs_oci::generate_boot_image(
1884                        &repo,
1885                        &result.manifest_digest,
1886                        &composefs_oci::OciTransformOptions::default(),
1887                    )?;
1888                    println!("Boot image: {}", image_verity.to_hex());
1889                }
1890            }
1891            OciCommand::Copy {
1892                ref image,
1893                ref from,
1894                ref name,
1895                zerocopy,
1896            } => {
1897                use crate::varlink::proxy::RepositoryProxy;
1898
1899                let src_hash = resolve_hash_type(from, args.hash, !args.no_upgrade)
1900                    .with_context(|| format!("opening source repository {}", from.display()))?;
1901                let dest_hash = resolve_hash_type(&dest_path, args.hash, !args.no_upgrade)
1902                    .with_context(|| {
1903                        format!("opening destination repository {}", dest_path.display())
1904                    })?;
1905
1906                if zerocopy && src_hash != dest_hash {
1907                    anyhow::bail!(
1908                        "--zerocopy requires matching hash algorithms; \
1909                         source uses {src_hash:?} but destination uses {dest_hash:?}"
1910                    );
1911                }
1912
1913                let from_str = from.to_str().context("source path is not valid UTF-8")?;
1914                let dest_str = dest_path
1915                    .to_str()
1916                    .context("destination path is not valid UTF-8")?;
1917
1918                let service_src = crate::varlink::CfsctlService::new();
1919                let service_dest = crate::varlink::CfsctlService::new();
1920
1921                let (mut conn_src, _srv_src) = crate::varlink::spawn_in_process(service_src)
1922                    .context("spawning source in-process service")?;
1923                let (mut conn_dest, _srv_dest) = crate::varlink::spawn_in_process(service_dest)
1924                    .context("spawning destination in-process service")?;
1925
1926                let handle_src = conn_src
1927                    .open_repository(Some(from_str), None, None)
1928                    .await
1929                    .context("zlink transport error calling OpenRepository on source")?
1930                    .map_err(|e| anyhow::anyhow!("OpenRepository failed on source: {e:?}"))?
1931                    .handle;
1932
1933                let handle_dest = conn_dest
1934                    .open_repository(Some(dest_str), None, None)
1935                    .await
1936                    .context("zlink transport error calling OpenRepository on destination")?
1937                    .map_err(|e| anyhow::anyhow!("OpenRepository failed on destination: {e:?}"))?
1938                    .handle;
1939
1940                let finalize_reply = copy_image(
1941                    &mut conn_src,
1942                    &mut conn_dest,
1943                    handle_src,
1944                    handle_dest,
1945                    image,
1946                    name.as_deref(),
1947                    zerocopy,
1948                )
1949                .await?;
1950
1951                let tag_info = if let Some(n) = name {
1952                    format!(", tagged as {n}")
1953                } else {
1954                    String::new()
1955                };
1956                println!(
1957                    "Copied image {image} from {} to destination repo{}",
1958                    from.display(),
1959                    tag_info
1960                );
1961                println!("Manifest digest: {}", finalize_reply.manifest_digest);
1962                println!("Manifest verity: {}", finalize_reply.manifest_verity);
1963                println!("Config digest:   {}", finalize_reply.config_digest);
1964                println!("Config verity:   {}", finalize_reply.config_verity);
1965            }
1966            OciCommand::ListImages { json } => {
1967                let images = composefs_oci::oci_image::list_images(&repo)?;
1968
1969                if json {
1970                    let reply = crate::varlink::ListImagesReply {
1971                        images: images
1972                            .iter()
1973                            .map(crate::varlink::ImageEntry::from)
1974                            .collect(),
1975                    };
1976                    serde_json::to_writer_pretty(std::io::stdout().lock(), &reply)?;
1977                    println!();
1978                } else if images.is_empty() {
1979                    println!("No images found");
1980                } else {
1981                    let mut table = Table::new();
1982                    table.load_preset(UTF8_FULL);
1983                    table.set_header(["NAME", "DIGEST", "ARCH", "LAYERS", "REFS"]);
1984
1985                    for img in images {
1986                        let digest_str: &str = img.manifest_digest.as_ref();
1987                        let digest_short = digest_str.strip_prefix("sha256:").unwrap_or(digest_str);
1988                        let digest_display = if digest_short.len() > 12 {
1989                            &digest_short[..12]
1990                        } else {
1991                            digest_short
1992                        };
1993                        let arch = if img.architecture.is_empty() {
1994                            "artifact"
1995                        } else {
1996                            &img.architecture
1997                        };
1998                        table.add_row([
1999                            img.name.as_str(),
2000                            digest_display,
2001                            arch,
2002                            &img.layer_count.to_string(),
2003                            &img.referrer_count.to_string(),
2004                        ]);
2005                    }
2006                    println!("{table}");
2007                }
2008            }
2009            OciCommand::Inspect {
2010                ref image,
2011                manifest,
2012                config,
2013            } => {
2014                let img = resolve_oci_image(&repo, image)?;
2015
2016                if manifest {
2017                    // Output raw manifest JSON exactly as stored
2018                    let manifest_json = img.read_manifest_json(&repo)?;
2019                    std::io::Write::write_all(&mut std::io::stdout(), &manifest_json)?;
2020                    println!();
2021                } else if config {
2022                    // Output raw config JSON exactly as stored
2023                    let config_json = img.read_config_json(&repo)?;
2024                    std::io::Write::write_all(&mut std::io::stdout(), &config_json)?;
2025                    println!();
2026                } else {
2027                    // Default: output combined JSON with manifest, config, and referrers
2028                    let output = crate::varlink::OciInspectReply::from_image(&repo, &img)?;
2029                    serde_json::to_writer_pretty(std::io::stdout().lock(), &output)?;
2030                    println!();
2031                }
2032            }
2033            OciCommand::Tag {
2034                ref manifest_digest,
2035                ref name,
2036            } => {
2037                composefs_oci::oci_image::tag_image(&repo, manifest_digest, name)?;
2038                println!("Tagged {manifest_digest} as {name}");
2039            }
2040            OciCommand::Untag { ref name } => {
2041                composefs_oci::oci_image::untag_image(&repo, name)?;
2042                println!("Removed tag {name}");
2043            }
2044            OciCommand::LayerInspect {
2045                ref layer,
2046                dumpfile,
2047                json,
2048            } => {
2049                if json {
2050                    let info = composefs_oci::layer_info(&repo, layer)?;
2051                    serde_json::to_writer_pretty(std::io::stdout().lock(), &info)?;
2052                    println!();
2053                } else if dumpfile {
2054                    composefs_oci::layer_dumpfile(&repo, layer, &mut std::io::stdout())?;
2055                } else {
2056                    // Default: output raw tar, but not to a tty
2057                    let mut out = std::io::stdout().lock();
2058                    if out.is_terminal() {
2059                        anyhow::bail!(
2060                            "Refusing to write tar data to terminal. \
2061                            Redirect to a file, pipe to tar, or use --json for metadata."
2062                        );
2063                    }
2064                    composefs_oci::layer_tar(&repo, layer, &mut out)?;
2065                }
2066            }
2067
2068            OciCommand::PrepareBoot {
2069                config_opts:
2070                    OCIConfigOptions {
2071                        ref config_name,
2072                        ref config_verity,
2073                    },
2074                ref bootdir,
2075                ref entry_id,
2076                ref cmdline,
2077            } => {
2078                let verity = verity_opt(config_verity)?;
2079                let (config_digest, config_verity) =
2080                    resolve_oci_config(&repo, config_name, verity)?;
2081                let mut fs = composefs_oci::image::create_filesystem(
2082                    &repo,
2083                    &config_digest,
2084                    config_verity.as_ref(),
2085                    &composefs_oci::OciTransformOptions::default(),
2086                )?;
2087                let entries = fs.transform_for_boot(&repo)?;
2088                let ids = fs.commit_images(&repo, None)?;
2089                let fmt_config = repo.default_format_config();
2090                // Prefer V1 digest; fall back to V2.
2091                let id = ids
2092                    .get(&FormatVersion::V1)
2093                    .or_else(|| ids.get(&FormatVersion::V2))
2094                    .ok_or_else(|| anyhow::anyhow!("commit_images produced no images"))?
2095                    .clone();
2096
2097                let insecure = repo.is_insecure();
2098                let karg = if fmt_config.default == FormatVersion::V1
2099                    && !fmt_config.extra.contains(&FormatVersion::V2)
2100                {
2101                    // V1-only repo → composefs.digest=v1-...: (with optional ? for insecure)
2102                    ComposefsCmdline::new_v1(id, insecure)
2103                } else {
2104                    // BOTH or V2-only repo → composefs= (with optional ? for insecure)
2105                    ComposefsCmdline::new_v2(id, insecure)
2106                };
2107
2108                let Some(entry) = entries.into_iter().next() else {
2109                    anyhow::bail!("No boot entries!");
2110                };
2111
2112                let cmdline_refs: Vec<&str> = cmdline.iter().map(String::as_str).collect();
2113                write_boot::write_boot_simple(
2114                    &repo,
2115                    entry,
2116                    &karg,
2117                    bootdir,
2118                    None,
2119                    entry_id.as_deref(),
2120                    &cmdline_refs,
2121                )?;
2122
2123                let state = args
2124                    .repo
2125                    .as_ref()
2126                    .map(|p: &PathBuf| p.parent().unwrap())
2127                    .unwrap_or(Path::new("/sysroot"))
2128                    .join("state/deploy")
2129                    .join(karg.digest().to_hex());
2130
2131                create_dir_all(state.join("var"))?;
2132                create_dir_all(state.join("etc/upper"))?;
2133                create_dir_all(state.join("etc/work"))?;
2134            }
2135            OciCommand::Fsck { image, json } => {
2136                let result = if let Some(ref name) = image {
2137                    composefs_oci::oci_fsck_image(&repo, name).await?
2138                } else {
2139                    composefs_oci::oci_fsck(&repo).await?
2140                };
2141                if json {
2142                    let output = crate::varlink::OciFsckReply::from(&result);
2143                    serde_json::to_writer_pretty(std::io::stdout().lock(), &output)?;
2144                    println!();
2145                } else {
2146                    print!("{result}");
2147                    if !result.is_ok() {
2148                        anyhow::bail!("OCI integrity check failed");
2149                    }
2150                }
2151            }
2152            OciCommand::Varlink { .. } => {
2153                unreachable!("oci varlink is handled before opening a repository");
2154            }
2155        },
2156        #[cfg(feature = "ostree")]
2157        Command::Ostree { cmd: ostree_cmd } => match ostree_cmd {
2158            OstreeCommand::PullLocal {
2159                ref ostree_repo_path,
2160                ref ostree_ref,
2161                base_name,
2162            } => {
2163                let ostree_repo =
2164                    composefs_ostree::LocalRepo::open_path(&repo, CWD, ostree_repo_path)?;
2165                let reporter: SharedReporter = IndicatifReporter::new().into_shared();
2166                let opts = composefs_ostree::PullOptions {
2167                    base_reference: base_name.as_deref(),
2168                    progress: Some(reporter),
2169                    ..Default::default()
2170                };
2171                let (verity, stats) =
2172                    composefs_ostree::pull(&repo, ostree_repo, ostree_ref, opts).await?;
2173
2174                let image_id = composefs_ostree::get_image_ref(&repo, &stats.commit_id)?;
2175                println!("commit  {}", stats.commit_id);
2176                println!("verity  {}", verity.to_hex());
2177                println!("image   {}", image_id.to_hex());
2178                if !composefs_ostree::is_commit_id(ostree_ref) {
2179                    println!("tagged  {ostree_ref}");
2180                }
2181                print_pull_stats(&stats);
2182            }
2183            OstreeCommand::Pull {
2184                ref ostree_repo_url,
2185                ref ostree_ref,
2186                base_name,
2187                no_delta,
2188            } => {
2189                let ostree_repo = composefs_ostree::RemoteRepo::new(&repo, ostree_repo_url)?;
2190                let reporter: SharedReporter = IndicatifReporter::new().into_shared();
2191                let opts = composefs_ostree::PullOptions {
2192                    base_reference: base_name.as_deref(),
2193                    progress: Some(reporter),
2194                    disable_deltas: no_delta,
2195                };
2196                let (verity, stats) =
2197                    composefs_ostree::pull(&repo, ostree_repo, ostree_ref, opts).await?;
2198
2199                let image_id = composefs_ostree::get_image_ref(&repo, &stats.commit_id)?;
2200                println!("commit  {}", stats.commit_id);
2201                println!("verity  {}", verity.to_hex());
2202                println!("image   {}", image_id.to_hex());
2203                if !composefs_ostree::is_commit_id(ostree_ref) {
2204                    println!("tagged  {ostree_ref}");
2205                }
2206                print_pull_stats(&stats);
2207            }
2208            OstreeCommand::Mount {
2209                ref commit,
2210                ref mountpoint,
2211                ref mount_opts,
2212            } => {
2213                let image_id = composefs_ostree::get_image_ref(&repo, commit)?;
2214                mount_opts.mount_image(&repo, &image_id.to_hex(), mountpoint.as_str())?;
2215            }
2216            OstreeCommand::Dump { ref commit_name } => {
2217                let fs = composefs_ostree::create_filesystem(&repo, commit_name)?;
2218                fs.print_dumpfile()?;
2219            }
2220            OstreeCommand::ComputeId { ref commit_name } => {
2221                let image_id = composefs_ostree::ensure_ostree_erofs(&repo, commit_name)?;
2222                println!("{}", image_id.to_hex());
2223            }
2224            OstreeCommand::Inspect {
2225                ref source,
2226                metadata,
2227            } => {
2228                composefs_ostree::inspect(&repo, source, metadata)?;
2229            }
2230            OstreeCommand::Tag {
2231                ref source,
2232                ref name,
2233            } => {
2234                composefs_ostree::tag(&repo, source, name)?;
2235                println!("Tagged {source} as {name}");
2236            }
2237            OstreeCommand::Untag { ref name } => {
2238                composefs_ostree::untag(&repo, name)?;
2239            }
2240            OstreeCommand::Commit {
2241                ref image,
2242                ref reference,
2243                ref subject,
2244            } => {
2245                use std::time::{SystemTime, UNIX_EPOCH};
2246
2247                let (img_fd, _) = repo.open_image(image)?;
2248                let mut img_buf = Vec::new();
2249                std::fs::File::from(img_fd).read_to_end(&mut img_buf)?;
2250                let fs = composefs::erofs::reader::erofs_to_filesystem(&img_buf)?;
2251
2252                let timestamp = SystemTime::now()
2253                    .duration_since(UNIX_EPOCH)
2254                    .unwrap_or_default()
2255                    .as_secs();
2256                let mut commit_meta = composefs_ostree::ostree::CommitMetadata::default()
2257                    .subject(subject.as_str())
2258                    .timestamp(timestamp);
2259                if let Some(ref_name) = reference {
2260                    commit_meta = commit_meta.add_metadata(
2261                        "ostree.ref-binding",
2262                        composefs_ostree::ostree::MetadataValue::StringArray(vec![
2263                            ref_name.clone(),
2264                        ]),
2265                    );
2266                }
2267
2268                let (verity, commit_id) = composefs_ostree::commit_filesystem(
2269                    &repo,
2270                    &fs,
2271                    commit_meta,
2272                    reference.as_deref(),
2273                )?;
2274                println!("commit  {commit_id}");
2275                println!("verity  {}", verity.to_hex());
2276                if let Some(ref_name) = reference {
2277                    println!("tagged  {ref_name}");
2278                }
2279            }
2280            OstreeCommand::Export {
2281                ref source,
2282                ref ostree_repo_path,
2283                ref reference,
2284            } => {
2285                let dest = composefs_ostree::LocalRepo::open_path(&repo, CWD, ostree_repo_path)?;
2286                let commit_id =
2287                    composefs_ostree::export_commit(&repo, source, &dest, reference.as_deref())?;
2288                println!("commit  {commit_id}");
2289                if let Some(ref_name) = reference {
2290                    println!("tagged  {ref_name}");
2291                }
2292            }
2293            OstreeCommand::ListCommits => {
2294                let commits = composefs_ostree::list_commits(&repo)?;
2295                if commits.is_empty() {
2296                    println!("No ostree commits found");
2297                } else {
2298                    let mut table = Table::new();
2299                    table.load_preset(UTF8_FULL);
2300                    table.set_header(["NAME", "COMMIT"]);
2301                    for c in commits {
2302                        table.add_row([c.name.as_str(), &c.commit_id]);
2303                    }
2304                    println!("{table}");
2305                }
2306            }
2307            OstreeCommand::ApplyDelta { ref delta_path } => {
2308                let (verity, stats) = composefs_ostree::apply_delta_offline(&repo, delta_path)?;
2309                let image_id = composefs_ostree::get_image_ref(&repo, &stats.commit_id)?;
2310                println!("commit  {}", stats.commit_id);
2311                println!("verity  {}", verity.to_hex());
2312                println!("image   {}", image_id.to_hex());
2313                println!(
2314                    "objects {} metadata + {} files applied",
2315                    stats.metadata_fetched, stats.files_fetched
2316                );
2317            }
2318            OstreeCommand::ListRefs {
2319                ref ostree_repo_url,
2320                ref subset,
2321            } => {
2322                let mut ostree_repo = composefs_ostree::RemoteRepo::new(&repo, ostree_repo_url)?;
2323                if let Some(s) = subset {
2324                    ostree_repo = ostree_repo.with_summary_subset(s);
2325                }
2326                let refs = ostree_repo.list_remote_refs().await?;
2327                if refs.is_empty() {
2328                    println!("No refs found");
2329                } else {
2330                    let mut table = Table::new();
2331                    table.load_preset(UTF8_FULL);
2332                    table.set_header(["REF", "COMMIT"]);
2333                    for (name, checksum) in &refs {
2334                        table.add_row([name.as_str(), &hex::encode(checksum)]);
2335                    }
2336                    println!("{table}");
2337                }
2338            }
2339        },
2340        Command::CreateImage {
2341            fs_opts,
2342            ref image_name,
2343        } => {
2344            let fs = load_filesystem_from_ondisk_fs(&fs_opts, Some(Arc::clone(&repo))).await?;
2345            let id = fs.commit_image(&repo, image_name.as_deref())?;
2346            println!("{}", id.to_id());
2347        }
2348        Command::ComputeId { .. }
2349        | Command::ComputeKarg { .. }
2350        | Command::CreateDumpfile { .. } => {
2351            // Handled in run_app before opening the repo
2352            unreachable!(
2353                "compute-id, compute-karg, and create-dumpfile are dispatched without a repo"
2354            );
2355        }
2356        Command::Mount {
2357            name,
2358            mountpoint,
2359            ref mount_opts,
2360        } => {
2361            mount_opts.mount_image(&repo, &name, &mountpoint)?;
2362        }
2363        Command::Images { json, no_trunc } => {
2364            let reply =
2365                varlink::run_list_image_refs(&repo).map_err(|e| anyhow::anyhow!("{e:?}"))?;
2366
2367            if json {
2368                serde_json::to_writer_pretty(std::io::stdout().lock(), &reply)?;
2369                println!();
2370            } else if reply.images.is_empty() {
2371                println!("No images found");
2372            } else {
2373                let mut table = Table::new();
2374                table.load_preset(UTF8_FULL);
2375                table.set_header(["NAME", "DIGEST"]);
2376
2377                for entry in &reply.images {
2378                    let digest_display = if !no_trunc && entry.digest.len() > 12 {
2379                        &entry.digest[..12]
2380                    } else {
2381                        &entry.digest
2382                    };
2383                    table.add_row([entry.name.as_str(), digest_display]);
2384                }
2385                println!("{table}");
2386            }
2387        }
2388        Command::ImageObjects { name } => {
2389            let objects = repo.objects_for_image(&name)?;
2390            for object in objects {
2391                println!("{}", object.to_id());
2392            }
2393        }
2394        Command::GC { root, dry_run } => {
2395            let roots: Vec<&str> = root.iter().map(|s| s.as_str()).collect();
2396            let result = if dry_run {
2397                repo.gc_dry_run(&roots)?
2398            } else {
2399                repo.gc(&roots)?
2400            };
2401            if dry_run {
2402                println!("Dry run (no files deleted):");
2403            }
2404            println!(
2405                "Objects: {} removed ({} bytes)",
2406                result.objects_removed, result.objects_bytes
2407            );
2408            if result.images_pruned > 0 || result.streams_pruned > 0 {
2409                println!(
2410                    "Pruned symlinks: {} images, {} streams",
2411                    result.images_pruned, result.streams_pruned
2412                );
2413            }
2414        }
2415        Command::DumpFiles {
2416            image_name,
2417            files,
2418            backing_path_only,
2419        } => {
2420            let out = dump_files(&repo, &image_name, &files, backing_path_only)?;
2421
2422            if !out.is_empty() {
2423                let out_str = std::str::from_utf8(&out).unwrap();
2424                print!("{}", out_str);
2425            }
2426        }
2427        Command::Fsck {
2428            json,
2429            metadata_only,
2430        } => {
2431            let result = if metadata_only {
2432                repo.fsck_metadata_only().await?
2433            } else {
2434                repo.fsck().await?
2435            };
2436            if json {
2437                let output = crate::varlink::FsckReply::from(&result);
2438                serde_json::to_writer_pretty(std::io::stdout().lock(), &output)?;
2439                println!();
2440            } else {
2441                print!("{result}");
2442                if !result.is_ok() {
2443                    anyhow::bail!("repository integrity check failed");
2444                }
2445            }
2446        }
2447        Command::Varlink { .. } => {
2448            // Handled in run_app before opening the repo.
2449            unreachable!("varlink is handled before opening a repository");
2450        }
2451        #[cfg(feature = "http")]
2452        Command::Fetch { url, name } => {
2453            let reporter: SharedReporter = IndicatifReporter::new().into_shared();
2454            let (digest, verity) = composefs_http::download(
2455                &url,
2456                &name,
2457                Arc::clone(&repo),
2458                composefs_http::DownloadOptions {
2459                    progress: Some(reporter),
2460                },
2461            )
2462            .await?;
2463            println!("content {digest}");
2464            println!("verity {}", verity.to_hex());
2465        }
2466        Command::Mkcomposefs { .. } | Command::ComposefsInfo { .. } => {
2467            // Dispatched in run_app before a repository is opened
2468            unreachable!("mkcomposefs/composefs-info are dispatched before opening a repository");
2469        }
2470    }
2471    Ok(())
2472}
2473
2474#[cfg(test)]
2475#[cfg(any(feature = "oci", feature = "http"))]
2476mod tests {
2477    use super::*;
2478    use composefs::progress::{ProgressEvent, ProgressUnit};
2479
2480    // ── IndicatifReporter ────────────────────────────────────────────────────
2481
2482    /// A complete valid lifecycle (Started → Progress → Done) must not panic,
2483    /// even without a real terminal (indicatif handles headless gracefully).
2484    #[test]
2485    fn test_indicatif_reporter_valid_lifecycle() {
2486        let reporter = IndicatifReporter::new();
2487        // Message before any component
2488        reporter.report(ProgressEvent::Message("starting pull".into()));
2489        // Byte-tracked component
2490        reporter.report(ProgressEvent::Started {
2491            id: "sha256:abc".into(),
2492            total: Some(1_000_000),
2493            unit: ProgressUnit::Bytes,
2494        });
2495        reporter.report(ProgressEvent::Progress {
2496            id: "sha256:abc".into(),
2497            fetched: 500_000,
2498            total: Some(1_000_000),
2499        });
2500        reporter.report(ProgressEvent::Done {
2501            id: "sha256:abc".into(),
2502            transferred: 1_000_000,
2503        });
2504        // Item-counted component (HTTP objects)
2505        reporter.report(ProgressEvent::Started {
2506            id: "objects:stream".into(),
2507            total: Some(200),
2508            unit: ProgressUnit::Items,
2509        });
2510        reporter.report(ProgressEvent::Progress {
2511            id: "objects:stream".into(),
2512            fetched: 100,
2513            total: Some(200),
2514        });
2515        reporter.report(ProgressEvent::Done {
2516            id: "objects:stream".into(),
2517            transferred: 200,
2518        });
2519        // Skipped component
2520        reporter.report(ProgressEvent::Started {
2521            id: "sha256:cached".into(),
2522            total: None,
2523            unit: ProgressUnit::Bytes,
2524        });
2525        reporter.report(ProgressEvent::Skipped {
2526            id: "sha256:cached".into(),
2527        });
2528    }
2529
2530    /// Progress/Done events for an ID that was never `Started` must not panic.
2531    ///
2532    /// This guards against error-recovery paths where a `Started` event may
2533    /// have been suppressed or the reporter was attached after the operation
2534    /// began.
2535    #[test]
2536    fn test_indicatif_reporter_unknown_id_no_panic() {
2537        let reporter = IndicatifReporter::new();
2538        // Progress for unknown ID — should silently ignore
2539        reporter.report(ProgressEvent::Progress {
2540            id: "ghost".into(),
2541            fetched: 42,
2542            total: None,
2543        });
2544        // Done for unknown ID — should silently ignore
2545        reporter.report(ProgressEvent::Done {
2546            id: "ghost".into(),
2547            transferred: 42,
2548        });
2549        // Skipped for unknown ID — should silently ignore
2550        reporter.report(ProgressEvent::Skipped { id: "ghost".into() });
2551    }
2552
2553    /// A spinner-style bar (unknown total) must not panic.
2554    #[test]
2555    fn test_indicatif_reporter_spinner_lifecycle() {
2556        let reporter = IndicatifReporter::new();
2557        // Started with unknown total → spinner
2558        reporter.report(ProgressEvent::Started {
2559            id: "layer:unknown-size".into(),
2560            total: None,
2561            unit: ProgressUnit::Bytes,
2562        });
2563        reporter.report(ProgressEvent::Progress {
2564            id: "layer:unknown-size".into(),
2565            fetched: 1024,
2566            total: None,
2567        });
2568        reporter.report(ProgressEvent::Done {
2569            id: "layer:unknown-size".into(),
2570            transferred: 2048,
2571        });
2572    }
2573
2574    /// Multiple concurrent components must not interfere with each other.
2575    #[test]
2576    fn test_indicatif_reporter_multiple_concurrent_components() {
2577        let reporter = IndicatifReporter::new();
2578        // Start two layers in parallel
2579        reporter.report(ProgressEvent::Started {
2580            id: "layer:a".into(),
2581            total: Some(100),
2582            unit: ProgressUnit::Bytes,
2583        });
2584        reporter.report(ProgressEvent::Started {
2585            id: "layer:b".into(),
2586            total: Some(200),
2587            unit: ProgressUnit::Bytes,
2588        });
2589        // Interleaved progress
2590        reporter.report(ProgressEvent::Progress {
2591            id: "layer:a".into(),
2592            fetched: 50,
2593            total: Some(100),
2594        });
2595        reporter.report(ProgressEvent::Progress {
2596            id: "layer:b".into(),
2597            fetched: 100,
2598            total: Some(200),
2599        });
2600        // Layer B finishes first
2601        reporter.report(ProgressEvent::Done {
2602            id: "layer:b".into(),
2603            transferred: 200,
2604        });
2605        // Layer A finishes
2606        reporter.report(ProgressEvent::Done {
2607            id: "layer:a".into(),
2608            transferred: 100,
2609        });
2610    }
2611}