cfsctl/
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 cfsctl::composefs::repository::Repository;
9//! use cfsctl::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::{
26    ffi::OsString,
27    fs::create_dir_all,
28    io::IsTerminal,
29    path::{Path, PathBuf},
30    sync::Arc,
31};
32
33use anyhow::Result;
34use clap::{Parser, Subcommand, ValueEnum};
35use comfy_table::{presets::UTF8_FULL, Table};
36
37use rustix::fs::CWD;
38
39use composefs_boot::{write_boot, BootOps};
40
41use composefs::{
42    fsverity::{FsVerityHashValue, Sha256HashValue, Sha512HashValue},
43    repository::Repository,
44    shared_internals::IO_BUF_CAPACITY,
45};
46
47/// cfsctl
48#[derive(Debug, Parser)]
49#[clap(name = "cfsctl", version)]
50pub struct App {
51    /// Operate on repo at path
52    #[clap(long, group = "repopath")]
53    repo: Option<PathBuf>,
54    /// Operate on repo at standard user location $HOME/.var/lib/composefs
55    #[clap(long, group = "repopath")]
56    user: bool,
57    /// Operate on repo at standard system location /sysroot/composefs
58    #[clap(long, group = "repopath")]
59    system: bool,
60
61    /// What hash digest type to use for composefs repo
62    #[clap(long, value_enum, default_value_t = HashType::Sha512)]
63    pub hash: HashType,
64
65    /// Sets the repository to insecure before running any operation and
66    /// prepend '?' to the composefs kernel command line when writing
67    /// boot entry.
68    #[clap(long)]
69    insecure: bool,
70
71    #[clap(subcommand)]
72    cmd: Command,
73}
74
75#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum, Default)]
76/// TODO: Hash type
77pub enum HashType {
78    /// Sha256
79    Sha256,
80    /// Sha512
81    #[default]
82    Sha512,
83}
84
85/// Common options for operations using OCI config manifest streams that may transform the image rootfs
86#[derive(Debug, Parser)]
87struct OCIConfigFilesystemOptions {
88    #[clap(flatten)]
89    base_config: OCIConfigOptions,
90    /// Whether bootable transformation should be performed on the image rootfs
91    #[clap(long)]
92    bootable: bool,
93}
94
95/// Common options for operations using OCI config manifest streams
96#[derive(Debug, Parser)]
97struct OCIConfigOptions {
98    /// the name of the target OCI manifest stream, either a stream ID in format oci-config-<hash_type>:<hash_digest> or a reference in 'ref/'
99    config_name: String,
100    /// verity digest for the manifest stream to be verified against
101    config_verity: Option<String>,
102}
103
104#[cfg(feature = "oci")]
105#[derive(Debug, Subcommand)]
106enum OciCommand {
107    /// Stores a tar layer file as a splitstream in the repository.
108    ImportLayer {
109        digest: String,
110        name: Option<String>,
111    },
112    /// Lists the contents of a tar stream
113    LsLayer {
114        /// the name of the stream to list, either a stream ID in format oci-config-<hash_type>:<hash_digest> or a reference in 'ref/'
115        name: String,
116    },
117    /// Dump full content of the rootfs of a stored OCI image to a composefs dumpfile and write to stdout
118    Dump {
119        #[clap(flatten)]
120        config_opts: OCIConfigFilesystemOptions,
121    },
122    /// Pull an OCI image to be stored in repo then prints the stream and verity digest of its manifest
123    Pull {
124        /// source image reference, as accepted by skopeo
125        image: String,
126        /// optional reference name for the manifest, use as 'ref/<name>' elsewhere
127        name: Option<String>,
128    },
129    /// List all tagged OCI images in the repository
130    #[clap(name = "images")]
131    ListImages {
132        /// Output as JSON array
133        #[clap(long)]
134        json: bool,
135    },
136    /// Show information about an OCI image
137    ///
138    /// By default, outputs JSON with manifest, config, and referrers.
139    /// Use --manifest or --config to output just that raw JSON.
140    #[clap(name = "inspect")]
141    Inspect {
142        /// Image reference (tag name or manifest digest)
143        image: String,
144        /// Output only the raw manifest JSON (as originally stored)
145        #[clap(long, conflicts_with = "config")]
146        manifest: bool,
147        /// Output only the raw config JSON (as originally stored)
148        #[clap(long, conflicts_with = "manifest")]
149        config: bool,
150    },
151    /// Tag an image with a new name
152    Tag {
153        /// Manifest digest (sha256:...)
154        manifest_digest: String,
155        /// Tag name to assign
156        name: String,
157    },
158    /// Remove a tag from an image
159    Untag {
160        /// Tag name to remove
161        name: String,
162    },
163    /// Inspect a stored layer
164    ///
165    /// By default, outputs the raw tar stream to stdout.
166    /// Use --dumpfile for composefs dumpfile format, or --json for metadata.
167    #[clap(name = "layer")]
168    LayerInspect {
169        /// Layer diff_id (sha256:...)
170        layer: String,
171        /// Output as composefs dumpfile format (one entry per line)
172        #[clap(long, conflicts_with = "json")]
173        dumpfile: bool,
174        /// Output layer metadata as JSON
175        #[clap(long, conflicts_with = "dumpfile")]
176        json: bool,
177    },
178    /// Compute the composefs image object id of the rootfs of a stored OCI image
179    ComputeId {
180        #[clap(flatten)]
181        config_opts: OCIConfigFilesystemOptions,
182    },
183    /// Create the composefs image of the rootfs of a stored OCI image, commit it to the repo, and print its image object ID
184    CreateImage {
185        #[clap(flatten)]
186        config_opts: OCIConfigFilesystemOptions,
187        /// optional reference name for the image, use as 'ref/<name>' elsewhere
188        #[clap(long)]
189        image_name: Option<String>,
190    },
191    /// Seal a stored OCI image by creating a cloned manifest with embedded verity digest (a.k.a. composefs image object ID)
192    /// in the repo, then prints the stream and verity digest of the new sealed manifest
193    Seal {
194        #[clap(flatten)]
195        config_opts: OCIConfigOptions,
196    },
197    /// Mounts a stored and sealed OCI image by looking up its composefs image. Note that the composefs image must be built
198    /// and committed to the repo first
199    Mount {
200        /// the name of the target OCI manifest stream, either a stream ID in format oci-config-<hash_type>:<hash_digest> or a reference in 'ref/'
201        name: String,
202        /// the mountpoint
203        mountpoint: String,
204    },
205    /// Create the composefs image of the rootfs of a stored OCI image, perform bootable transformation, commit it to the repo,
206    /// then configure boot for the image by writing new boot resources and bootloader entries to boot partition. Performs
207    /// state preparation for composefs-setup-root consumption as well. Note that state preparation here is not suitable for
208    /// consumption by bootc.
209    PrepareBoot {
210        #[clap(flatten)]
211        config_opts: OCIConfigOptions,
212        /// boot partition mount point
213        #[clap(long, default_value = "/boot")]
214        bootdir: PathBuf,
215        /// Boot entry identifier to use. By default uses ID provided by the image or kernel version
216        #[clap(long)]
217        entry_id: Option<String>,
218        /// additional kernel command line
219        #[clap(long)]
220        cmdline: Vec<String>,
221    },
222}
223
224/// Common options for reading a filesystem from a path
225#[derive(Debug, Parser)]
226struct FsReadOptions {
227    /// The path to the filesystem
228    path: PathBuf,
229    /// Transform the filesystem for boot (SELinux labels, empty /boot and /sysroot)
230    #[clap(long)]
231    bootable: bool,
232    /// Don't copy /usr metadata to root directory (use if root already has well-defined metadata)
233    #[clap(long)]
234    no_propagate_usr_to_root: bool,
235}
236
237#[derive(Debug, Subcommand)]
238enum Command {
239    /// Take a transaction lock on the repository.
240    /// This prevents garbage collection from occurring.
241    Transaction,
242    /// Reconstitutes a split stream and writes it to stdout
243    Cat {
244        /// the name of the stream to cat, either a content identifier or prefixed with 'ref/'
245        name: String,
246    },
247    /// Perform garbage collection
248    GC {
249        /// Additional roots to keep (image or stream names)
250        #[clap(long, short = 'r')]
251        root: Vec<String>,
252        /// Preview what would be deleted without actually deleting
253        #[clap(long, short = 'n')]
254        dry_run: bool,
255    },
256    /// Imports a composefs image (unsafe!)
257    ImportImage { reference: String },
258    /// Commands for dealing with OCI images and layers
259    #[cfg(feature = "oci")]
260    Oci {
261        #[clap(subcommand)]
262        cmd: OciCommand,
263    },
264    /// Mounts a composefs image, possibly enforcing fsverity of the image
265    Mount {
266        /// the name of the image to mount, either an fs-verity hash or prefixed with 'ref/'
267        name: String,
268        /// the mountpoint
269        mountpoint: String,
270    },
271    /// Read rootfs located at a path, add all files to the repo, then create the composefs image of the rootfs,
272    /// commit it to the repo, and print its image object ID
273    CreateImage {
274        #[clap(flatten)]
275        fs_opts: FsReadOptions,
276        /// optional reference name for the image, use as 'ref/<name>' elsewhere
277        image_name: Option<String>,
278    },
279    /// Read rootfs located at a path, add all files to the repo, then compute the composefs image object id of the rootfs.
280    /// Note that this does not create or commit the composefs image itself.
281    ComputeId {
282        #[clap(flatten)]
283        fs_opts: FsReadOptions,
284    },
285    /// Read rootfs located at a path, add all files to the repo, then dump full content of the rootfs to a composefs dumpfile
286    /// and write to stdout.
287    CreateDumpfile {
288        #[clap(flatten)]
289        fs_opts: FsReadOptions,
290    },
291    /// Lists all object IDs referenced by an image
292    ImageObjects {
293        /// the name of the image to read, either an object ID digest or prefixed with 'ref/'
294        name: String,
295    },
296    #[cfg(feature = "http")]
297    Fetch { url: String, name: String },
298}
299
300/// Acts as a proxy for the `cfsctl` CLI by executing the CLI logic programmatically
301///
302/// This function behaves the same as invoking the `cfsctl` binary from the
303/// command line. It accepts an iterator of CLI-style arguments (excluding
304/// the binary name), parses them using `clap`
305pub async fn run_from_iter<I>(args: I) -> Result<()>
306where
307    I: IntoIterator,
308    I::Item: Into<OsString> + Clone,
309{
310    let args = App::parse_from(
311        std::iter::once(OsString::from("cfsctl")).chain(args.into_iter().map(Into::into)),
312    );
313
314    match args.hash {
315        HashType::Sha256 => run_cmd_with_repo(open_repo::<Sha256HashValue>(&args)?, args).await,
316        HashType::Sha512 => run_cmd_with_repo(open_repo::<Sha512HashValue>(&args)?, args).await,
317    }
318}
319
320fn verity_opt<ObjectID>(opt: &Option<String>) -> Result<Option<ObjectID>>
321where
322    ObjectID: FsVerityHashValue,
323{
324    Ok(match opt {
325        Some(value) => Some(FsVerityHashValue::from_hex(value)?),
326        None => None,
327    })
328}
329
330/// Open a repo
331pub fn open_repo<ObjectID>(args: &App) -> Result<Repository<ObjectID>>
332where
333    ObjectID: FsVerityHashValue,
334{
335    let mut repo = (if let Some(path) = &args.repo {
336        Repository::open_path(CWD, path)
337    } else if args.system {
338        Repository::open_system()
339    } else if args.user {
340        Repository::open_user()
341    } else if rustix::process::getuid().is_root() {
342        Repository::open_system()
343    } else {
344        Repository::open_user()
345    })?;
346
347    repo.set_insecure(args.insecure);
348
349    Ok(repo)
350}
351
352/// Run with cmd
353pub async fn run_cmd_with_repo<ObjectID>(repo: Repository<ObjectID>, args: App) -> Result<()>
354where
355    ObjectID: FsVerityHashValue,
356{
357    match args.cmd {
358        Command::Transaction => {
359            // just wait for ^C
360            loop {
361                std::thread::park();
362            }
363        }
364        Command::Cat { name } => {
365            repo.merge_splitstream(&name, None, None, &mut std::io::stdout())?;
366        }
367        Command::ImportImage { reference } => {
368            let image_id = repo.import_image(&reference, &mut std::io::stdin())?;
369            println!("{}", image_id.to_id());
370        }
371        #[cfg(feature = "oci")]
372        Command::Oci { cmd: oci_cmd } => match oci_cmd {
373            OciCommand::ImportLayer { name, digest } => {
374                let repo = Arc::new(repo);
375                let (object_id, _stats) = composefs_oci::import_layer(
376                    &repo,
377                    &digest,
378                    name.as_deref(),
379                    tokio::io::BufReader::with_capacity(IO_BUF_CAPACITY, tokio::io::stdin()),
380                )
381                .await?;
382                println!("{}", object_id.to_id());
383            }
384            OciCommand::LsLayer { name } => {
385                composefs_oci::ls_layer(&repo, &name)?;
386            }
387            OciCommand::Dump {
388                config_opts:
389                    OCIConfigFilesystemOptions {
390                        base_config:
391                            OCIConfigOptions {
392                                ref config_name,
393                                ref config_verity,
394                            },
395                        bootable,
396                    },
397            } => {
398                let verity = verity_opt(config_verity)?;
399                let mut fs =
400                    composefs_oci::image::create_filesystem(&repo, config_name, verity.as_ref())?;
401                if bootable {
402                    fs.transform_for_boot(&repo)?;
403                }
404                fs.print_dumpfile()?;
405            }
406            OciCommand::ComputeId {
407                config_opts:
408                    OCIConfigFilesystemOptions {
409                        base_config:
410                            OCIConfigOptions {
411                                ref config_name,
412                                ref config_verity,
413                            },
414                        bootable,
415                    },
416            } => {
417                let verity = verity_opt(config_verity)?;
418                let mut fs =
419                    composefs_oci::image::create_filesystem(&repo, config_name, verity.as_ref())?;
420                if bootable {
421                    fs.transform_for_boot(&repo)?;
422                }
423                let id = fs.compute_image_id();
424                println!("{}", id.to_hex());
425            }
426            OciCommand::CreateImage {
427                config_opts:
428                    OCIConfigFilesystemOptions {
429                        base_config:
430                            OCIConfigOptions {
431                                ref config_name,
432                                ref config_verity,
433                            },
434                        bootable,
435                    },
436                ref image_name,
437            } => {
438                let verity = verity_opt(config_verity)?;
439                let mut fs =
440                    composefs_oci::image::create_filesystem(&repo, config_name, verity.as_ref())?;
441                if bootable {
442                    fs.transform_for_boot(&repo)?;
443                }
444                let image_id = fs.commit_image(&repo, image_name.as_deref())?;
445                println!("{}", image_id.to_id());
446            }
447            OciCommand::Pull { ref image, name } => {
448                // If no explicit name provided, use the image reference as the tag
449                let tag_name = name.as_deref().unwrap_or(image);
450                let (result, stats) =
451                    composefs_oci::pull_image(&Arc::new(repo), image, Some(tag_name), None).await?;
452
453                println!("manifest {}", result.manifest_digest);
454                println!("config   {}", result.config_digest);
455                println!("verity   {}", result.manifest_verity.to_hex());
456                println!("tagged   {tag_name}");
457                println!(
458                    "objects  {} copied, {} already present, {} bytes copied, {} bytes inlined",
459                    stats.objects_copied,
460                    stats.objects_already_present,
461                    stats.bytes_copied,
462                    stats.bytes_inlined,
463                );
464            }
465            OciCommand::ListImages { json } => {
466                let images = composefs_oci::oci_image::list_images(&repo)?;
467
468                if json {
469                    println!("{}", serde_json::to_string_pretty(&images)?);
470                } else if images.is_empty() {
471                    println!("No images found");
472                } else {
473                    let mut table = Table::new();
474                    table.load_preset(UTF8_FULL);
475                    table.set_header(["NAME", "DIGEST", "ARCH", "SEALED", "LAYERS", "REFS"]);
476
477                    for img in images {
478                        let digest_short = img
479                            .manifest_digest
480                            .strip_prefix("sha256:")
481                            .unwrap_or(&img.manifest_digest);
482                        let digest_display = if digest_short.len() > 12 {
483                            &digest_short[..12]
484                        } else {
485                            digest_short
486                        };
487                        let arch = if img.architecture.is_empty() {
488                            "artifact"
489                        } else {
490                            &img.architecture
491                        };
492                        let sealed = if img.sealed { "yes" } else { "no" };
493                        table.add_row([
494                            img.name.as_str(),
495                            digest_display,
496                            arch,
497                            sealed,
498                            &img.layer_count.to_string(),
499                            &img.referrer_count.to_string(),
500                        ]);
501                    }
502                    println!("{table}");
503                }
504            }
505            OciCommand::Inspect {
506                ref image,
507                manifest,
508                config,
509            } => {
510                let img = if image.starts_with("sha256:") {
511                    composefs_oci::oci_image::OciImage::open(&repo, image, None)?
512                } else {
513                    composefs_oci::oci_image::OciImage::open_ref(&repo, image)?
514                };
515
516                if manifest {
517                    // Output raw manifest JSON exactly as stored
518                    let manifest_json = img.read_manifest_json(&repo)?;
519                    std::io::Write::write_all(&mut std::io::stdout(), &manifest_json)?;
520                    println!();
521                } else if config {
522                    // Output raw config JSON exactly as stored
523                    let config_json = img.read_config_json(&repo)?;
524                    std::io::Write::write_all(&mut std::io::stdout(), &config_json)?;
525                    println!();
526                } else {
527                    // Default: output combined JSON with manifest, config, and referrers
528                    let output = img.inspect_json(&repo)?;
529                    println!("{}", serde_json::to_string_pretty(&output)?);
530                }
531            }
532            OciCommand::Tag {
533                ref manifest_digest,
534                ref name,
535            } => {
536                composefs_oci::oci_image::tag_image(&repo, manifest_digest, name)?;
537                println!("Tagged {manifest_digest} as {name}");
538            }
539            OciCommand::Untag { ref name } => {
540                composefs_oci::oci_image::untag_image(&repo, name)?;
541                println!("Removed tag {name}");
542            }
543            OciCommand::LayerInspect {
544                ref layer,
545                dumpfile,
546                json,
547            } => {
548                if json {
549                    let info = composefs_oci::layer_info(&repo, layer)?;
550                    println!("{}", serde_json::to_string_pretty(&info)?);
551                } else if dumpfile {
552                    composefs_oci::layer_dumpfile(&repo, layer, &mut std::io::stdout())?;
553                } else {
554                    // Default: output raw tar, but not to a tty
555                    let mut out = std::io::stdout().lock();
556                    if out.is_terminal() {
557                        anyhow::bail!(
558                            "Refusing to write tar data to terminal. \
559                            Redirect to a file, pipe to tar, or use --json for metadata."
560                        );
561                    }
562                    composefs_oci::layer_tar(&repo, layer, &mut out)?;
563                }
564            }
565            OciCommand::Seal {
566                config_opts:
567                    OCIConfigOptions {
568                        ref config_name,
569                        ref config_verity,
570                    },
571            } => {
572                let verity = verity_opt(config_verity)?;
573                let (digest, verity) =
574                    composefs_oci::seal(&Arc::new(repo), config_name, verity.as_ref())?;
575                println!("config {digest}");
576                println!("verity {}", verity.to_id());
577            }
578            OciCommand::Mount {
579                ref name,
580                ref mountpoint,
581            } => {
582                composefs_oci::mount(&repo, name, mountpoint, None)?;
583            }
584            OciCommand::PrepareBoot {
585                config_opts:
586                    OCIConfigOptions {
587                        ref config_name,
588                        ref config_verity,
589                    },
590                ref bootdir,
591                ref entry_id,
592                ref cmdline,
593            } => {
594                let verity = verity_opt(config_verity)?;
595                let mut fs =
596                    composefs_oci::image::create_filesystem(&repo, config_name, verity.as_ref())?;
597                let entries = fs.transform_for_boot(&repo)?;
598                let id = fs.commit_image(&repo, None)?;
599
600                let Some(entry) = entries.into_iter().next() else {
601                    anyhow::bail!("No boot entries!");
602                };
603
604                let cmdline_refs: Vec<&str> = cmdline.iter().map(String::as_str).collect();
605                write_boot::write_boot_simple(
606                    &repo,
607                    entry,
608                    &id,
609                    args.insecure,
610                    bootdir,
611                    None,
612                    entry_id.as_deref(),
613                    &cmdline_refs,
614                )?;
615
616                let state = args
617                    .repo
618                    .as_ref()
619                    .map(|p: &PathBuf| p.parent().unwrap())
620                    .unwrap_or(Path::new("/sysroot"))
621                    .join("state/deploy")
622                    .join(id.to_hex());
623
624                create_dir_all(state.join("var"))?;
625                create_dir_all(state.join("etc/upper"))?;
626                create_dir_all(state.join("etc/work"))?;
627            }
628        },
629        Command::ComputeId { fs_opts } => {
630            let mut fs = if fs_opts.no_propagate_usr_to_root {
631                composefs::fs::read_filesystem(CWD, &fs_opts.path, Some(&repo))?
632            } else {
633                composefs::fs::read_container_root(CWD, &fs_opts.path, Some(&repo))?
634            };
635            if fs_opts.bootable {
636                fs.transform_for_boot(&repo)?;
637            }
638            let id = fs.compute_image_id();
639            println!("{}", id.to_hex());
640        }
641        Command::CreateImage {
642            fs_opts,
643            ref image_name,
644        } => {
645            let mut fs = if fs_opts.no_propagate_usr_to_root {
646                composefs::fs::read_filesystem(CWD, &fs_opts.path, Some(&repo))?
647            } else {
648                composefs::fs::read_container_root(CWD, &fs_opts.path, Some(&repo))?
649            };
650            if fs_opts.bootable {
651                fs.transform_for_boot(&repo)?;
652            }
653            let id = fs.commit_image(&repo, image_name.as_deref())?;
654            println!("{}", id.to_id());
655        }
656        Command::CreateDumpfile { fs_opts } => {
657            let mut fs = if fs_opts.no_propagate_usr_to_root {
658                composefs::fs::read_filesystem(CWD, &fs_opts.path, Some(&repo))?
659            } else {
660                composefs::fs::read_container_root(CWD, &fs_opts.path, Some(&repo))?
661            };
662            if fs_opts.bootable {
663                fs.transform_for_boot(&repo)?;
664            }
665            fs.print_dumpfile()?;
666        }
667        Command::Mount { name, mountpoint } => {
668            repo.mount_at(&name, &mountpoint)?;
669        }
670        Command::ImageObjects { name } => {
671            let objects = repo.objects_for_image(&name)?;
672            for object in objects {
673                println!("{}", object.to_id());
674            }
675        }
676        Command::GC { root, dry_run } => {
677            let roots: Vec<&str> = root.iter().map(|s| s.as_str()).collect();
678            let result = if dry_run {
679                repo.gc_dry_run(&roots)?
680            } else {
681                repo.gc(&roots)?
682            };
683            if dry_run {
684                println!("Dry run (no files deleted):");
685            }
686            println!(
687                "Objects: {} removed ({} bytes)",
688                result.objects_removed, result.objects_bytes
689            );
690            if result.images_pruned > 0 || result.streams_pruned > 0 {
691                println!(
692                    "Pruned symlinks: {} images, {} streams",
693                    result.images_pruned, result.streams_pruned
694                );
695            }
696        }
697        #[cfg(feature = "http")]
698        Command::Fetch { url, name } => {
699            let (digest, verity) = composefs_http::download(&url, &name, Arc::new(repo)).await?;
700            println!("content {digest}");
701            println!("verity {}", verity.to_hex());
702        }
703    }
704    Ok(())
705}