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