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