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
25use std::io::Read;
26use std::path::Path;
27use std::{ffi::OsString, path::PathBuf};
28
29#[cfg(feature = "oci")]
30use std::{fs::create_dir_all, io::IsTerminal};
31
32use std::sync::Arc;
33
34use anyhow::{Context as _, Result};
35use clap::{Parser, Subcommand, ValueEnum};
36#[cfg(feature = "oci")]
37use comfy_table::{Table, presets::UTF8_FULL};
38use rustix::fs::{CWD, Mode, OFlags};
39use serde::Serialize;
40
41use composefs_boot::BootOps;
42#[cfg(feature = "oci")]
43use composefs_boot::write_boot;
44
45#[cfg(feature = "oci")]
46use composefs::shared_internals::IO_BUF_CAPACITY;
47use composefs::{
48    dumpfile::{dump_single_dir, dump_single_file},
49    erofs::reader::erofs_to_filesystem,
50    fsverity::{Algorithm, FsVerityHashValue, Sha256HashValue, Sha512HashValue},
51    generic_tree::{FileSystem, Inode},
52    repository::{REPO_METADATA_FILENAME, Repository, read_repo_algorithm, system_path, user_path},
53    tree::RegularFile,
54};
55
56/// JSON output wrapper for `cfsctl fsck --json`.
57#[derive(Serialize)]
58struct FsckJsonOutput {
59    ok: bool,
60    #[serde(flatten)]
61    result: composefs::repository::FsckResult,
62}
63
64/// JSON output wrapper for `cfsctl oci fsck --json`.
65#[cfg(feature = "oci")]
66#[derive(Serialize)]
67struct OciFsckJsonOutput {
68    ok: bool,
69    #[serde(flatten)]
70    result: composefs_oci::OciFsckResult,
71}
72
73/// cfsctl
74#[derive(Debug, Parser)]
75#[clap(name = "cfsctl", version)]
76pub struct App {
77    /// Operate on repo at path
78    #[clap(long, group = "repopath")]
79    repo: Option<PathBuf>,
80    /// Operate on repo at standard user location $HOME/.var/lib/composefs
81    #[clap(long, group = "repopath")]
82    user: bool,
83    /// Operate on repo at standard system location /sysroot/composefs
84    #[clap(long, group = "repopath")]
85    system: bool,
86
87    /// What hash digest type to use for composefs repo.
88    /// If omitted, auto-detected from repository metadata (meta.json).
89    #[clap(long, value_enum)]
90    pub hash: Option<HashType>,
91
92    /// Deprecated: security mode is now auto-detected from meta.json.
93    /// Use `cfsctl init --insecure` to create a repo without verity.
94    /// Kept for backward compatibility.
95    #[clap(long, hide = true)]
96    insecure: bool,
97
98    /// Error if the repository does not have fs-verity enabled.
99    #[clap(long)]
100    require_verity: bool,
101
102    /// Don't automatically upgrade old-format repositories.
103    /// When set, commands will fail on repos without meta.json instead
104    /// of inferring metadata from existing objects.
105    #[clap(long)]
106    no_upgrade: bool,
107
108    /// Don't open a repository. Only valid for commands that don't need one
109    /// (compute-id, create-dumpfile).
110    #[clap(long)]
111    pub no_repo: bool,
112
113    #[clap(subcommand)]
114    cmd: Command,
115}
116
117/// The Hash algorithm used for FsVerity computation
118#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum)]
119pub enum HashType {
120    /// Sha256
121    Sha256,
122    /// Sha512
123    Sha512,
124}
125
126/// A reference to an OCI image: either a content digest or a named ref.
127///
128/// Digests are prefixed with `@` (e.g. `@sha256:abc123…`), while bare
129/// names are refs resolved through the repository's ref tree. The `@`
130/// prefix is necessary to disambiguate because ref names may contain `:`
131/// — OCI digest algorithms are intentionally extensible, so we cannot
132/// rely on parse heuristics to distinguish the two.
133///
134/// Note this differs from the podman/docker convention where `@` appears
135/// between the image name and the digest (e.g. `fedora@sha256:abc…`).
136/// Here, `@` is always a leading prefix on the entire argument.
137///
138/// At the repository level, ref names are freeform strings (the only
139/// restriction is that they must not start with `@`). In practice,
140/// `oci pull` defaults to tagging with the source transport reference
141/// (e.g. `docker://quay.io/fedora/fedora:latest`), so most refs in a
142/// repository will be container transport names — which naturally never
143/// start with `@`.
144#[cfg(feature = "oci")]
145#[derive(Debug, Clone)]
146enum OciReference {
147    /// A content-addressable digest such as `sha256:abcdef…`.
148    Digest(composefs_oci::OciDigest),
149    /// A named ref resolved through the repository's ref tree, typically
150    /// a container transport name (e.g. `docker://quay.io/foo:latest`).
151    Named(String),
152}
153
154#[cfg(feature = "oci")]
155impl std::str::FromStr for OciReference {
156    type Err = anyhow::Error;
157
158    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
159        if let Some(digest_str) = s.strip_prefix('@') {
160            let digest: composefs_oci::OciDigest =
161                digest_str.parse().context("Invalid OCI digest after '@'")?;
162            Ok(Self::Digest(digest))
163        } else {
164            Ok(Self::Named(s.to_owned()))
165        }
166    }
167}
168
169#[cfg(feature = "oci")]
170impl std::fmt::Display for OciReference {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        match self {
173            Self::Digest(d) => write!(f, "@{d}"),
174            Self::Named(n) => write!(f, "{n}"),
175        }
176    }
177}
178
179/// CLI representation of [`composefs_oci::LocalFetchOpt`].
180#[cfg(feature = "oci")]
181#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
182enum LocalFetchCli {
183    /// Do not use native containers-storage import; use skopeo.
184    #[default]
185    Disabled,
186    /// Use native import with reflink/hardlink/copy fallback.
187    Auto,
188    /// Use native import; error if zero-copy is not possible.
189    Zerocopy,
190}
191
192#[cfg(feature = "oci")]
193impl From<LocalFetchCli> for composefs_oci::LocalFetchOpt {
194    fn from(cli: LocalFetchCli) -> Self {
195        match cli {
196            LocalFetchCli::Disabled => Self::Disabled,
197            LocalFetchCli::Auto => Self::IfPossible,
198            LocalFetchCli::Zerocopy => Self::ZeroCopy,
199        }
200    }
201}
202
203/// Common options for operations using OCI config manifest streams that may transform the image rootfs
204#[cfg(feature = "oci")]
205#[derive(Debug, Parser)]
206struct OCIConfigFilesystemOptions {
207    #[clap(flatten)]
208    base_config: OCIConfigOptions,
209    /// Whether bootable transformation should be performed on the image rootfs
210    #[clap(long)]
211    bootable: bool,
212}
213
214/// Common options for operations using OCI config manifest streams
215#[cfg(feature = "oci")]
216#[derive(Debug, Parser)]
217struct OCIConfigOptions {
218    /// Ref name (e.g. myimage:latest) or @digest (e.g. @sha256:a1b2c3...)
219    config_name: OciReference,
220    /// verity digest for the manifest stream to be verified against
221    config_verity: Option<String>,
222}
223
224#[cfg(feature = "oci")]
225#[derive(Debug, Subcommand)]
226enum OciCommand {
227    /// Import a tar layer as a splitstream in the repository
228    ImportLayer {
229        /// Layer content digest, e.g. sha256:a1b2c3...
230        digest: composefs_oci::OciDigest,
231        /// Optional human-readable name for the layer
232        name: Option<String>,
233    },
234    /// List the contents of a stored tar layer
235    LsLayer {
236        /// Layer content digest, e.g. sha256:a1b2c3...
237        name: composefs_oci::OciDigest,
238    },
239    /// Dump the rootfs of a stored OCI image as a composefs dumpfile to stdout
240    ///
241    /// The image can be specified by ref name or @digest:
242    ///   cfsctl oci dump myimage:latest
243    ///   cfsctl oci dump @sha256:a1b2c3...
244    Dump {
245        #[clap(flatten)]
246        config_opts: OCIConfigFilesystemOptions,
247    },
248    /// Pull an OCI image into the repository
249    ///
250    /// Prints the config stream digest and verity of the stored manifest.
251    Pull {
252        /// Source image reference, as accepted by skopeo
253        image: String,
254        /// Tag name to assign to the pulled image (defaults to the image reference)
255        name: Option<String>,
256        /// Also generate a bootable EROFS image from the pulled OCI image
257        #[arg(long)]
258        bootable: bool,
259        /// Controls whether containers-storage: references use the native
260        /// import path with zero-copy reflink/hardlink support.
261        #[arg(long, value_enum, default_value_t = LocalFetchCli::Disabled)]
262        local_fetch: LocalFetchCli,
263    },
264    /// List all tagged OCI images in the repository
265    #[clap(name = "images")]
266    ListImages {
267        /// Output as JSON array
268        #[clap(long)]
269        json: bool,
270    },
271    /// Show information about an OCI image
272    ///
273    /// The image can be specified by ref name or @digest:
274    ///   cfsctl oci inspect myimage:latest
275    ///   cfsctl oci inspect @sha256:a1b2c3...
276    ///
277    /// By default, outputs JSON with manifest, config, and referrers.
278    /// Use --manifest or --config to output just that raw JSON.
279    #[clap(name = "inspect")]
280    Inspect {
281        /// Ref name (e.g. myimage:latest) or @digest (e.g. @sha256:a1b2c3...)
282        image: OciReference,
283        /// Output only the raw manifest JSON (as originally stored)
284        #[clap(long, conflicts_with = "config")]
285        manifest: bool,
286        /// Output only the raw config JSON (as originally stored)
287        #[clap(long, conflicts_with = "manifest")]
288        config: bool,
289    },
290    /// Tag an image with a new name
291    ///
292    /// Example: cfsctl oci tag sha256:a1b2c3... myimage:latest
293    Tag {
294        /// Manifest digest, e.g. sha256:a1b2c3...
295        manifest_digest: composefs_oci::OciDigest,
296        /// Tag name to assign (must not contain '@')
297        name: String,
298    },
299    /// Remove a tag from an image
300    Untag {
301        /// Tag name to remove
302        name: String,
303    },
304    /// Inspect a stored layer
305    ///
306    /// By default, outputs the raw tar stream to stdout.
307    /// Use --dumpfile for composefs dumpfile format, or --json for metadata.
308    #[clap(name = "layer")]
309    LayerInspect {
310        /// Layer diff_id, e.g. sha256:a1b2c3...
311        layer: composefs_oci::OciDigest,
312        /// Output as composefs dumpfile format (one entry per line)
313        #[clap(long, conflicts_with = "json")]
314        dumpfile: bool,
315        /// Output layer metadata as JSON
316        #[clap(long, conflicts_with = "dumpfile")]
317        json: bool,
318    },
319    /// Mount an OCI image's composefs EROFS at the given mountpoint
320    Mount {
321        /// Image reference (tag name or manifest digest)
322        image: String,
323        /// Target mountpoint
324        mountpoint: String,
325        /// Mount the bootable variant instead of the regular EROFS image
326        #[arg(long)]
327        bootable: bool,
328    },
329    /// Compute the composefs image ID of a stored OCI image's rootfs
330    ///
331    /// The image can be specified by ref name or @digest:
332    ///   cfsctl oci compute-id myimage:latest
333    ///   cfsctl oci compute-id @sha256:a1b2c3...
334    ComputeId {
335        #[clap(flatten)]
336        config_opts: OCIConfigFilesystemOptions,
337    },
338
339    /// Create the composefs image of the rootfs of a stored OCI image, perform bootable transformation, commit it to the repo,
340    /// then configure boot for the image by writing new boot resources and bootloader entries to boot partition. Performs
341    /// state preparation for composefs-setup-root consumption as well. Note that state preparation here is not suitable for
342    /// consumption by bootc.
343    PrepareBoot {
344        #[clap(flatten)]
345        config_opts: OCIConfigOptions,
346        /// boot partition mount point
347        #[clap(long, default_value = "/boot")]
348        bootdir: PathBuf,
349        /// Boot entry identifier to use. By default uses ID provided by the image or kernel version
350        #[clap(long)]
351        entry_id: Option<String>,
352        /// additional kernel command line
353        #[clap(long)]
354        cmdline: Vec<String>,
355    },
356    /// Check integrity of OCI images in the repository
357    ///
358    /// Verifies manifest and config content digests, layer references, seal
359    /// consistency, and delegates to the underlying repository fsck for object
360    /// integrity and splitstream validation.
361    Fsck {
362        /// Check only the named image instead of all tagged images
363        image: Option<String>,
364        /// Output results as JSON (always exits 0 unless the check itself fails)
365        #[clap(long)]
366        json: bool,
367    },
368}
369
370/// Common options for reading a filesystem from a path
371#[derive(Debug, Parser)]
372struct FsReadOptions {
373    /// The path to the filesystem
374    path: PathBuf,
375    /// Transform the filesystem for boot (SELinux labels, empty /boot and /sysroot)
376    #[clap(long)]
377    bootable: bool,
378    /// Don't copy /usr metadata to root directory (use if root already has well-defined metadata)
379    #[clap(long)]
380    no_propagate_usr_to_root: bool,
381}
382
383#[derive(Debug, Subcommand)]
384enum Command {
385    /// Initialize a new composefs repository with a metadata file.
386    ///
387    /// Creates the repository directory (if it doesn't exist) and writes
388    /// a `meta.json` recording the digest algorithm.  By default fs-verity
389    /// is enabled on `meta.json`, signaling that all objects require
390    /// verity.  Use `--insecure` to skip (e.g. on tmpfs).
391    Init {
392        /// The fs-verity algorithm identifier.
393        /// Format: fsverity-<hash>-<lg_blocksize>, e.g. fsverity-sha512-12
394        #[clap(long, value_parser = clap::value_parser!(Algorithm), default_value = "fsverity-sha512-12")]
395        algorithm: Algorithm,
396        /// Path to the repository directory (created if it doesn't exist).
397        /// If omitted, uses --repo/--user/--system location.
398        path: Option<PathBuf>,
399        /// Do not enable fs-verity on meta.json (insecure repository).
400        #[clap(long)]
401        insecure: bool,
402        /// Migrate an old-format repository: remove streams/ and images/
403        /// (which encode the algorithm) but keep objects/, then write
404        /// fresh meta.json.  Streams and images will need to be
405        /// re-imported after migration.
406        #[clap(long)]
407        reset_metadata: bool,
408    },
409    /// Take a transaction lock on the repository.
410    /// This prevents garbage collection from occurring.
411    Transaction,
412    /// Reconstitutes a split stream and writes it to stdout
413    Cat {
414        /// the name of the stream to cat, either a content identifier or prefixed with 'ref/'
415        name: String,
416    },
417    /// Perform garbage collection
418    GC {
419        /// Additional roots to keep (image or stream names)
420        #[clap(long, short = 'r')]
421        root: Vec<String>,
422        /// Preview what would be deleted without actually deleting
423        #[clap(long, short = 'n')]
424        dry_run: bool,
425    },
426    /// Imports a composefs image (unsafe!)
427    ImportImage { reference: String },
428    /// Commands for dealing with OCI images and layers
429    #[cfg(feature = "oci")]
430    Oci {
431        #[clap(subcommand)]
432        cmd: OciCommand,
433    },
434    /// Mounts a composefs image, possibly enforcing fsverity of the image
435    Mount {
436        /// the name of the image to mount, either an fs-verity hash or prefixed with 'ref/'
437        name: String,
438        /// the mountpoint
439        mountpoint: String,
440    },
441    /// Read rootfs located at a path, add all files to the repo, then create the composefs image of the rootfs,
442    /// commit it to the repo, and print its image object ID
443    CreateImage {
444        #[clap(flatten)]
445        fs_opts: FsReadOptions,
446        /// optional reference name for the image, use as 'ref/<name>' elsewhere
447        image_name: Option<String>,
448    },
449    /// Read rootfs located at a path and compute the composefs image object id of the rootfs.
450    /// Note that this does not create or commit the composefs image itself, and does not
451    /// store any file objects in the repository.
452    ComputeId {
453        #[clap(flatten)]
454        fs_opts: FsReadOptions,
455    },
456    /// Read rootfs located at a path and dump full content of the rootfs to a composefs dumpfile,
457    /// writing to stdout. Does not store any file objects in the repository.
458    CreateDumpfile {
459        #[clap(flatten)]
460        fs_opts: FsReadOptions,
461    },
462    /// Lists all object IDs referenced by an image
463    ImageObjects {
464        /// the name of the image to read, either an object ID digest or prefixed with 'ref/'
465        name: String,
466    },
467    /// Extract file information from a composefs image for specified files or directories
468    ///
469    /// By default, outputs information in composefs dumpfile format
470    DumpFiles {
471        /// The name of the composefs image to read from, either an object ID digest or prefixed with 'ref/'
472        image_name: String,
473        /// File or directory paths to process. If a path is a directory, its contents will be listed.
474        files: Vec<PathBuf>,
475        /// Show backing path information instead of dumpfile format
476        /// For each file, prints either "inline" for files stored within the image,
477        /// or a path relative to the object store for files stored extrenally
478        #[clap(long)]
479        backing_path_only: bool,
480    },
481    /// Check repository integrity
482    ///
483    /// Verifies fsverity digests of all objects, validates stream and image
484    /// symlinks, and checks splitstream internal consistency. Exits with
485    /// a non-zero status if corruption is found.
486    Fsck {
487        /// Output results as JSON (always exits 0 unless the check itself fails)
488        #[clap(long)]
489        json: bool,
490    },
491    #[cfg(feature = "http")]
492    Fetch { url: String, name: String },
493}
494
495/// Acts as a proxy for the `cfsctl` CLI by executing the CLI logic programmatically
496///
497/// This function behaves the same as invoking the `cfsctl` binary from the
498/// command line. It accepts an iterator of CLI-style arguments (excluding
499/// the binary name), parses them using `clap`
500pub async fn run_from_iter<I>(args: I) -> Result<()>
501where
502    I: IntoIterator,
503    I::Item: Into<OsString> + Clone,
504{
505    let args = App::parse_from(
506        std::iter::once(OsString::from("cfsctl")).chain(args.into_iter().map(Into::into)),
507    );
508
509    run_app(args).await
510}
511
512#[cfg(feature = "oci")]
513fn verity_opt<ObjectID>(opt: &Option<String>) -> Result<Option<ObjectID>>
514where
515    ObjectID: FsVerityHashValue,
516{
517    Ok(match opt {
518        Some(value) => Some(FsVerityHashValue::from_hex(value)?),
519        None => None,
520    })
521}
522
523/// Resolve the repository path from CLI args without opening it.
524///
525/// Uses [`user_path`] and [`system_path`] to avoid duplicating
526/// path constants.
527fn resolve_repo_path(args: &App) -> Result<PathBuf> {
528    if let Some(path) = &args.repo {
529        Ok(path.clone())
530    } else if args.system {
531        Ok(system_path())
532    } else if args.user {
533        user_path()
534    } else if rustix::process::getuid().is_root() {
535        Ok(system_path())
536    } else {
537        user_path()
538    }
539}
540
541/// Determine the effective hash type for a repository.
542///
543/// Resolution order:
544/// 1. If `meta.json` exists, use its algorithm. Error if `--hash` was
545///    explicitly passed and conflicts.
546/// 2. If no metadata and `upgrade` is true, infer from existing objects.
547/// 3. If no metadata and `upgrade` is false, error.
548///
549/// Note: we read the metadata file directly here (rather than via
550/// `Repository::metadata`) because this runs *before* we know which
551/// generic `ObjectID` type to use — that's exactly what we're deciding.
552fn resolve_hash_type(
553    repo_path: &Path,
554    cli_hash: Option<HashType>,
555    upgrade: bool,
556) -> Result<HashType> {
557    let repo_fd = rustix::fs::open(
558        repo_path,
559        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
560        Mode::empty(),
561    )
562    .with_context(|| format!("opening repository {}", repo_path.display()))?;
563
564    let algorithm = match read_repo_algorithm(&repo_fd)? {
565        Some(alg) => alg,
566        None if upgrade => {
567            // No meta.json — try to infer from objects (old-format repo).
568            // open_upgrade will write meta.json later when the repo is opened.
569            composefs::repository::infer_repo_algorithm(&repo_fd).with_context(|| {
570                format!(
571                    "no {REPO_METADATA_FILENAME} in {}; tried to infer algorithm from objects",
572                    repo_path.display(),
573                )
574            })?
575        }
576        None => {
577            anyhow::bail!(
578                "{REPO_METADATA_FILENAME} not found in {}; \
579                 this repository must be initialized with `cfsctl init`",
580                repo_path.display(),
581            );
582        }
583    };
584
585    let detected = match algorithm {
586        Algorithm::Sha256 { .. } => HashType::Sha256,
587        Algorithm::Sha512 { .. } => HashType::Sha512,
588    };
589
590    // If the user explicitly passed --hash and it doesn't match, error
591    if let Some(explicit) = cli_hash
592        && explicit != detected
593    {
594        anyhow::bail!(
595            "repository is configured for {algorithm} (from {REPO_METADATA_FILENAME}) \
596             but --hash {} was specified",
597            match explicit {
598                HashType::Sha256 => "sha256",
599                HashType::Sha512 => "sha512",
600            },
601        );
602    }
603
604    Ok(detected)
605}
606
607/// Top-level dispatch: handle init specially, otherwise open repo and run.
608pub async fn run_app(args: App) -> Result<()> {
609    // Init is handled before opening a repo since it creates one
610    if let Command::Init {
611        ref algorithm,
612        ref path,
613        insecure,
614        reset_metadata,
615    } = args.cmd
616    {
617        return run_init(
618            algorithm,
619            path.as_deref(),
620            insecure || args.insecure,
621            reset_metadata,
622            &args,
623        );
624    }
625
626    // Commands that only need verity digests (no object storage) can
627    // run without opening a repository.
628    if args.no_repo
629        || matches!(
630            args.cmd,
631            Command::ComputeId { .. } | Command::CreateDumpfile { .. }
632        )
633    {
634        // If a repo path is available and --no-repo wasn't passed,
635        // try to read the hash type from the repo's metadata so that
636        // e.g. `cfsctl --repo <sha256-repo> compute-id` uses SHA-256
637        // instead of the default SHA-512.
638        let effective_hash = if !args.no_repo {
639            if let Ok(repo_path) = resolve_repo_path(&args) {
640                resolve_hash_type(&repo_path, args.hash, !args.no_upgrade)
641                    .unwrap_or(args.hash.unwrap_or(HashType::Sha512))
642            } else {
643                args.hash.unwrap_or(HashType::Sha512)
644            }
645        } else {
646            args.hash.unwrap_or(HashType::Sha512)
647        };
648        return match effective_hash {
649            HashType::Sha256 => run_cmd_without_repo::<Sha256HashValue>(args).await,
650            HashType::Sha512 => run_cmd_without_repo::<Sha512HashValue>(args).await,
651        };
652    }
653
654    let repo_path = resolve_repo_path(&args)?;
655    let effective_hash = resolve_hash_type(&repo_path, args.hash, !args.no_upgrade)?;
656
657    match effective_hash {
658        HashType::Sha256 => run_cmd_with_repo(open_repo::<Sha256HashValue>(&args)?, args).await,
659        HashType::Sha512 => run_cmd_with_repo(open_repo::<Sha512HashValue>(&args)?, args).await,
660    }
661}
662
663/// Handle `cfsctl init`
664fn run_init(
665    algorithm: &Algorithm,
666    path: Option<&Path>,
667    insecure: bool,
668    reset_metadata: bool,
669    args: &App,
670) -> Result<()> {
671    let repo_path = if let Some(p) = path {
672        p.to_path_buf()
673    } else {
674        resolve_repo_path(args)?
675    };
676
677    if reset_metadata {
678        composefs::repository::reset_metadata(&repo_path)?;
679    }
680
681    // Ensure parent directories exist (init_path only creates the final dir).
682    if let Some(parent) = repo_path.parent() {
683        std::fs::create_dir_all(parent)
684            .with_context(|| format!("creating parent directories for {}", repo_path.display()))?;
685    }
686
687    // init_path handles idempotency: same algorithm is a no-op,
688    // different algorithm is an error.
689    let created = match algorithm {
690        Algorithm::Sha256 { .. } => {
691            Repository::<Sha256HashValue>::init_path(CWD, &repo_path, *algorithm, !insecure)?.1
692        }
693        Algorithm::Sha512 { .. } => {
694            Repository::<Sha512HashValue>::init_path(CWD, &repo_path, *algorithm, !insecure)?.1
695        }
696    };
697
698    if created {
699        println!(
700            "Initialized composefs repository at {}",
701            repo_path.display()
702        );
703        println!("  algorithm: {algorithm}");
704        if insecure {
705            println!("  verity:    not required (insecure)");
706        } else {
707            println!("  verity:    required");
708        }
709    } else {
710        println!("Repository already initialized at {}", repo_path.display());
711    }
712
713    Ok(())
714}
715
716/// Open a repo, auto-upgrading old-format repos unless `--no-upgrade` was passed.
717pub fn open_repo<ObjectID>(args: &App) -> Result<Repository<ObjectID>>
718where
719    ObjectID: FsVerityHashValue,
720{
721    let path = resolve_repo_path(args)?;
722    let mut repo = if args.no_upgrade {
723        Repository::open_path(CWD, path)?
724    } else {
725        let (repo, _upgraded) = Repository::open_upgrade(CWD, path)?;
726        repo
727    };
728    // Hidden --insecure flag for backward compatibility; the default
729    // now is to inherit the repo config, but if it's specified we
730    // disable requiring verity even if the repo says to use it.
731    if args.insecure {
732        repo.set_insecure();
733    }
734    if args.require_verity {
735        repo.require_verity()?;
736    }
737    Ok(repo)
738}
739
740/// Resolve an [`OciReference`] to an [`OciImage`].
741#[cfg(feature = "oci")]
742fn resolve_oci_image<ObjectID: FsVerityHashValue>(
743    repo: &Repository<ObjectID>,
744    reference: &OciReference,
745) -> Result<composefs_oci::oci_image::OciImage<ObjectID>> {
746    match reference {
747        OciReference::Digest(digest) => {
748            composefs_oci::oci_image::OciImage::open(repo, digest, None)
749        }
750        OciReference::Named(name) => composefs_oci::oci_image::OciImage::open_ref(repo, name),
751    }
752}
753
754/// Resolve an [`OciReference`] to a config digest and optional verity.
755///
756/// When resolving via a named ref, the verity override is ignored since
757/// the image metadata provides the correct verity.
758#[cfg(feature = "oci")]
759fn resolve_oci_config<ObjectID: FsVerityHashValue>(
760    repo: &Repository<ObjectID>,
761    reference: &OciReference,
762    verity_override: Option<ObjectID>,
763) -> Result<(composefs_oci::OciDigest, Option<ObjectID>)> {
764    match reference {
765        OciReference::Digest(digest) => Ok((digest.clone(), verity_override)),
766        OciReference::Named(_) => {
767            let img = resolve_oci_image(repo, reference)?;
768            Ok((
769                img.config_digest().clone(),
770                Some(img.config_verity().clone()),
771            ))
772        }
773    }
774}
775
776#[cfg(feature = "oci")]
777fn load_filesystem_from_oci_image<ObjectID: FsVerityHashValue>(
778    repo: &Repository<ObjectID>,
779    opts: OCIConfigFilesystemOptions,
780) -> Result<FileSystem<RegularFile<ObjectID>>> {
781    let verity = verity_opt(&opts.base_config.config_verity)?;
782    let (config_digest, config_verity) =
783        resolve_oci_config(repo, &opts.base_config.config_name, verity)?;
784    let mut fs =
785        composefs_oci::image::create_filesystem(repo, &config_digest, config_verity.as_ref())?;
786    if opts.bootable {
787        fs.transform_for_boot(repo)?;
788    }
789    Ok(fs)
790}
791
792async fn load_filesystem_from_ondisk_fs<ObjectID: FsVerityHashValue>(
793    fs_opts: &FsReadOptions,
794    repo: Option<Arc<Repository<ObjectID>>>,
795) -> Result<FileSystem<RegularFile<ObjectID>>> {
796    // The async API needs an OwnedFd; fs_opts.path is typically absolute
797    // so the dirfd is unused for path resolution, but required by the API.
798    let dirfd = rustix::fs::openat(
799        CWD,
800        ".",
801        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
802        Mode::empty(),
803    )?;
804    let mut fs = if fs_opts.no_propagate_usr_to_root {
805        composefs::fs::read_filesystem(dirfd, fs_opts.path.clone(), repo.clone()).await?
806    } else {
807        composefs::fs::read_container_root(dirfd, fs_opts.path.clone(), repo.clone()).await?
808    };
809    if fs_opts.bootable {
810        if let Some(repo) = &repo {
811            fs.transform_for_boot(repo)?;
812        } else {
813            let rootfd = rustix::fs::openat(
814                CWD,
815                &fs_opts.path,
816                OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
817                Mode::empty(),
818            )?;
819            fs.transform_for_boot_from_dir(rootfd)?;
820        }
821    }
822    Ok(fs)
823}
824
825fn dump_file_impl(
826    fs: FileSystem<RegularFile<impl FsVerityHashValue>>,
827    files: &Vec<PathBuf>,
828    backing_path_only: bool,
829) -> Result<()> {
830    let mut out = Vec::new();
831    let nlink_map = fs.nlinks();
832
833    for file_path in files {
834        let (dir, file) = fs.root.split(file_path.as_os_str())?;
835
836        let (_, file) = dir
837            .entries()
838            .find(|ent| ent.0 == file)
839            .ok_or_else(|| anyhow::anyhow!("{} not found", file_path.display()))?;
840
841        match &file {
842            Inode::Directory(directory) => {
843                if backing_path_only {
844                    anyhow::bail!("{} is a directory", file_path.display());
845                }
846
847                dump_single_dir(&mut out, directory, &fs, &nlink_map, file_path.clone())?
848            }
849
850            Inode::Leaf(leaf_id, _) => {
851                use composefs::generic_tree::LeafContent::*;
852                use composefs::tree::RegularFile::*;
853
854                if backing_path_only {
855                    let leaf = fs.leaf(*leaf_id);
856                    match &leaf.content {
857                        Regular(f) => match f {
858                            Inline(..) => println!("{} inline", file_path.display()),
859                            External(id, _) => {
860                                println!("{} {}", file_path.display(), id.to_object_pathname());
861                            }
862                        },
863                        _ => {
864                            println!("{} inline", file_path.display())
865                        }
866                    }
867
868                    continue;
869                }
870
871                dump_single_file(&mut out, *leaf_id, &fs, &nlink_map, file_path.clone())?
872            }
873        };
874    }
875
876    if !out.is_empty() {
877        let out_str = std::str::from_utf8(&out).unwrap();
878        println!("{}", out_str);
879    }
880
881    Ok(())
882}
883
884/// Run commands that don't require a repository.
885pub async fn run_cmd_without_repo<ObjectID: FsVerityHashValue>(args: App) -> Result<()> {
886    match args.cmd {
887        Command::ComputeId { fs_opts } => {
888            let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
889            let id = fs.compute_image_id();
890            println!("{}", id.to_hex());
891        }
892        Command::CreateDumpfile { fs_opts } => {
893            let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
894            fs.print_dumpfile()?;
895        }
896        _ => {
897            anyhow::bail!("--no-repo is only supported for compute-id and create-dumpfile");
898        }
899    }
900    Ok(())
901}
902
903/// Run with cmd
904pub async fn run_cmd_with_repo<ObjectID>(repo: Repository<ObjectID>, args: App) -> Result<()>
905where
906    ObjectID: FsVerityHashValue,
907{
908    let repo = Arc::new(repo);
909    match args.cmd {
910        Command::Init { .. } => {
911            // Handled in run_app before we get here
912            unreachable!("init is handled before opening a repository");
913        }
914        Command::Transaction => {
915            // just wait for ^C
916            loop {
917                std::thread::park();
918            }
919        }
920        Command::Cat { name } => {
921            repo.merge_splitstream(&name, None, None, &mut std::io::stdout())?;
922        }
923        Command::ImportImage { reference } => {
924            let image_id = repo.import_image(&reference, &mut std::io::stdin())?;
925            println!("{}", image_id.to_id());
926        }
927        #[cfg(feature = "oci")]
928        Command::Oci { cmd: oci_cmd } => match oci_cmd {
929            OciCommand::ImportLayer { name, ref digest } => {
930                let (object_id, _stats) = composefs_oci::import_layer(
931                    &repo,
932                    digest,
933                    name.as_deref(),
934                    tokio::io::BufReader::with_capacity(IO_BUF_CAPACITY, tokio::io::stdin()),
935                )
936                .await?;
937                println!("{}", object_id.to_id());
938            }
939            OciCommand::LsLayer { ref name } => {
940                composefs_oci::ls_layer(&repo, name)?;
941            }
942            OciCommand::Dump { config_opts } => {
943                let fs = load_filesystem_from_oci_image(&repo, config_opts)?;
944                fs.print_dumpfile()?;
945            }
946            OciCommand::Mount {
947                ref image,
948                ref mountpoint,
949                bootable,
950            } => {
951                let img = if image.starts_with("sha256:") {
952                    let digest: composefs_oci::OciDigest =
953                        image.parse().context("Parsing manifest digest")?;
954                    composefs_oci::oci_image::OciImage::open(&repo, &digest, None)?
955                } else {
956                    composefs_oci::oci_image::OciImage::open_ref(&repo, image)?
957                };
958                let erofs_id = if bootable {
959                    match img.boot_image_ref() {
960                        Some(id) => id,
961                        None => anyhow::bail!(
962                            "No boot EROFS image linked — try pulling with --bootable"
963                        ),
964                    }
965                } else {
966                    match img.image_ref() {
967                        Some(id) => id,
968                        None => anyhow::bail!(
969                            "No composefs EROFS image linked — try re-pulling the image"
970                        ),
971                    }
972                };
973                repo.mount_at(&erofs_id.to_hex(), mountpoint.as_str())?;
974            }
975            OciCommand::ComputeId { config_opts } => {
976                let fs = load_filesystem_from_oci_image(&repo, config_opts)?;
977                let id = fs.compute_image_id();
978                println!("{}", id.to_hex());
979            }
980            OciCommand::Pull {
981                ref image,
982                name,
983                bootable,
984                local_fetch,
985            } => {
986                // If no explicit name provided, use the image reference as the tag
987                let tag_name = name.as_deref().unwrap_or(image);
988
989                let opts = composefs_oci::PullOptions {
990                    local_fetch: local_fetch.into(),
991                    ..Default::default()
992                };
993
994                let result = composefs_oci::pull(&repo, image, Some(tag_name), opts).await?;
995
996                println!("manifest {}", result.manifest_digest);
997                println!("config   {}", result.config_digest);
998                println!("verity   {}", result.manifest_verity.to_hex());
999                println!("tagged   {tag_name}");
1000                println!("objects  {}", result.stats);
1001
1002                if bootable {
1003                    let image_verity =
1004                        composefs_oci::generate_boot_image(&repo, &result.manifest_digest)?;
1005                    println!("Boot image: {}", image_verity.to_hex());
1006                }
1007            }
1008            OciCommand::ListImages { json } => {
1009                let images = composefs_oci::oci_image::list_images(&repo)?;
1010
1011                if json {
1012                    println!("{}", serde_json::to_string_pretty(&images)?);
1013                } else if images.is_empty() {
1014                    println!("No images found");
1015                } else {
1016                    let mut table = Table::new();
1017                    table.load_preset(UTF8_FULL);
1018                    table.set_header(["NAME", "DIGEST", "ARCH", "LAYERS", "REFS"]);
1019
1020                    for img in images {
1021                        let digest_str: &str = img.manifest_digest.as_ref();
1022                        let digest_short = digest_str.strip_prefix("sha256:").unwrap_or(digest_str);
1023                        let digest_display = if digest_short.len() > 12 {
1024                            &digest_short[..12]
1025                        } else {
1026                            digest_short
1027                        };
1028                        let arch = if img.architecture.is_empty() {
1029                            "artifact"
1030                        } else {
1031                            &img.architecture
1032                        };
1033                        table.add_row([
1034                            img.name.as_str(),
1035                            digest_display,
1036                            arch,
1037                            &img.layer_count.to_string(),
1038                            &img.referrer_count.to_string(),
1039                        ]);
1040                    }
1041                    println!("{table}");
1042                }
1043            }
1044            OciCommand::Inspect {
1045                ref image,
1046                manifest,
1047                config,
1048            } => {
1049                let img = resolve_oci_image(&repo, image)?;
1050
1051                if manifest {
1052                    // Output raw manifest JSON exactly as stored
1053                    let manifest_json = img.read_manifest_json(&repo)?;
1054                    std::io::Write::write_all(&mut std::io::stdout(), &manifest_json)?;
1055                    println!();
1056                } else if config {
1057                    // Output raw config JSON exactly as stored
1058                    let config_json = img.read_config_json(&repo)?;
1059                    std::io::Write::write_all(&mut std::io::stdout(), &config_json)?;
1060                    println!();
1061                } else {
1062                    // Default: output combined JSON with manifest, config, and referrers
1063                    let output = img.inspect_json(&repo)?;
1064                    println!("{}", serde_json::to_string_pretty(&output)?);
1065                }
1066            }
1067            OciCommand::Tag {
1068                ref manifest_digest,
1069                ref name,
1070            } => {
1071                composefs_oci::oci_image::tag_image(&repo, manifest_digest, name)?;
1072                println!("Tagged {manifest_digest} as {name}");
1073            }
1074            OciCommand::Untag { ref name } => {
1075                composefs_oci::oci_image::untag_image(&repo, name)?;
1076                println!("Removed tag {name}");
1077            }
1078            OciCommand::LayerInspect {
1079                ref layer,
1080                dumpfile,
1081                json,
1082            } => {
1083                if json {
1084                    let info = composefs_oci::layer_info(&repo, layer)?;
1085                    println!("{}", serde_json::to_string_pretty(&info)?);
1086                } else if dumpfile {
1087                    composefs_oci::layer_dumpfile(&repo, layer, &mut std::io::stdout())?;
1088                } else {
1089                    // Default: output raw tar, but not to a tty
1090                    let mut out = std::io::stdout().lock();
1091                    if out.is_terminal() {
1092                        anyhow::bail!(
1093                            "Refusing to write tar data to terminal. \
1094                            Redirect to a file, pipe to tar, or use --json for metadata."
1095                        );
1096                    }
1097                    composefs_oci::layer_tar(&repo, layer, &mut out)?;
1098                }
1099            }
1100
1101            OciCommand::PrepareBoot {
1102                config_opts:
1103                    OCIConfigOptions {
1104                        ref config_name,
1105                        ref config_verity,
1106                    },
1107                ref bootdir,
1108                ref entry_id,
1109                ref cmdline,
1110            } => {
1111                let verity = verity_opt(config_verity)?;
1112                let (config_digest, config_verity) =
1113                    resolve_oci_config(&repo, config_name, verity)?;
1114                let mut fs = composefs_oci::image::create_filesystem(
1115                    &repo,
1116                    &config_digest,
1117                    config_verity.as_ref(),
1118                )?;
1119                let entries = fs.transform_for_boot(&repo)?;
1120                let id = fs.commit_image(&repo, None)?;
1121
1122                let Some(entry) = entries.into_iter().next() else {
1123                    anyhow::bail!("No boot entries!");
1124                };
1125
1126                let cmdline_refs: Vec<&str> = cmdline.iter().map(String::as_str).collect();
1127                write_boot::write_boot_simple(
1128                    &repo,
1129                    entry,
1130                    &id,
1131                    repo.is_insecure(),
1132                    bootdir,
1133                    None,
1134                    entry_id.as_deref(),
1135                    &cmdline_refs,
1136                )?;
1137
1138                let state = args
1139                    .repo
1140                    .as_ref()
1141                    .map(|p: &PathBuf| p.parent().unwrap())
1142                    .unwrap_or(Path::new("/sysroot"))
1143                    .join("state/deploy")
1144                    .join(id.to_hex());
1145
1146                create_dir_all(state.join("var"))?;
1147                create_dir_all(state.join("etc/upper"))?;
1148                create_dir_all(state.join("etc/work"))?;
1149            }
1150            OciCommand::Fsck { image, json } => {
1151                let result = if let Some(ref name) = image {
1152                    composefs_oci::oci_fsck_image(&repo, name).await?
1153                } else {
1154                    composefs_oci::oci_fsck(&repo).await?
1155                };
1156                if json {
1157                    let output = OciFsckJsonOutput {
1158                        ok: result.is_ok(),
1159                        result,
1160                    };
1161                    serde_json::to_writer_pretty(std::io::stdout().lock(), &output)?;
1162                    println!();
1163                } else {
1164                    print!("{result}");
1165                    if !result.is_ok() {
1166                        anyhow::bail!("OCI integrity check failed");
1167                    }
1168                }
1169            }
1170        },
1171        Command::CreateImage {
1172            fs_opts,
1173            ref image_name,
1174        } => {
1175            let fs = load_filesystem_from_ondisk_fs(&fs_opts, Some(Arc::clone(&repo))).await?;
1176            let id = fs.commit_image(&repo, image_name.as_deref())?;
1177            println!("{}", id.to_id());
1178        }
1179        Command::ComputeId { .. } | Command::CreateDumpfile { .. } => {
1180            // Handled in run_app before opening the repo
1181            unreachable!("compute-id and create-dumpfile are dispatched without a repo");
1182        }
1183        Command::Mount { name, mountpoint } => {
1184            repo.mount_at(&name, &mountpoint)?;
1185        }
1186        Command::ImageObjects { name } => {
1187            let objects = repo.objects_for_image(&name)?;
1188            for object in objects {
1189                println!("{}", object.to_id());
1190            }
1191        }
1192        Command::GC { root, dry_run } => {
1193            let roots: Vec<&str> = root.iter().map(|s| s.as_str()).collect();
1194            let result = if dry_run {
1195                repo.gc_dry_run(&roots)?
1196            } else {
1197                repo.gc(&roots)?
1198            };
1199            if dry_run {
1200                println!("Dry run (no files deleted):");
1201            }
1202            println!(
1203                "Objects: {} removed ({} bytes)",
1204                result.objects_removed, result.objects_bytes
1205            );
1206            if result.images_pruned > 0 || result.streams_pruned > 0 {
1207                println!(
1208                    "Pruned symlinks: {} images, {} streams",
1209                    result.images_pruned, result.streams_pruned
1210                );
1211            }
1212        }
1213        Command::DumpFiles {
1214            image_name,
1215            files,
1216            backing_path_only,
1217        } => {
1218            let (img_fd, _) = repo.open_image(&image_name)?;
1219
1220            let mut img_buf = Vec::new();
1221            std::fs::File::from(img_fd).read_to_end(&mut img_buf)?;
1222
1223            dump_file_impl(
1224                erofs_to_filesystem::<ObjectID>(&img_buf)?,
1225                &files,
1226                backing_path_only,
1227            )?;
1228        }
1229        Command::Fsck { json } => {
1230            let result = repo.fsck().await?;
1231            if json {
1232                let output = FsckJsonOutput {
1233                    ok: result.is_ok(),
1234                    result,
1235                };
1236                serde_json::to_writer_pretty(std::io::stdout().lock(), &output)?;
1237                println!();
1238            } else {
1239                print!("{result}");
1240                if !result.is_ok() {
1241                    anyhow::bail!("repository integrity check failed");
1242                }
1243            }
1244        }
1245        #[cfg(feature = "http")]
1246        Command::Fetch { url, name } => {
1247            let (digest, verity) = composefs_http::download(&url, &name, Arc::clone(&repo)).await?;
1248            println!("content {digest}");
1249            println!("verity {}", verity.to_hex());
1250        }
1251    }
1252    Ok(())
1253}