1pub 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#[derive(Serialize)]
58struct FsckJsonOutput {
59 ok: bool,
60 #[serde(flatten)]
61 result: composefs::repository::FsckResult,
62}
63
64#[cfg(feature = "oci")]
66#[derive(Serialize)]
67struct OciFsckJsonOutput {
68 ok: bool,
69 #[serde(flatten)]
70 result: composefs_oci::OciFsckResult,
71}
72
73#[derive(Debug, Parser)]
75#[clap(name = "cfsctl", version)]
76pub struct App {
77 #[clap(long, group = "repopath")]
79 repo: Option<PathBuf>,
80 #[clap(long, group = "repopath")]
82 user: bool,
83 #[clap(long, group = "repopath")]
85 system: bool,
86
87 #[clap(long, value_enum)]
90 pub hash: Option<HashType>,
91
92 #[clap(long, hide = true)]
96 insecure: bool,
97
98 #[clap(long)]
100 require_verity: bool,
101
102 #[clap(long)]
106 no_upgrade: bool,
107
108 #[clap(long)]
111 pub no_repo: bool,
112
113 #[clap(subcommand)]
114 cmd: Command,
115}
116
117#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum)]
119pub enum HashType {
120 Sha256,
122 Sha512,
124}
125
126#[cfg(feature = "oci")]
145#[derive(Debug, Clone)]
146enum OciReference {
147 Digest(composefs_oci::OciDigest),
149 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#[cfg(feature = "oci")]
181#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
182enum LocalFetchCli {
183 #[default]
185 Disabled,
186 Auto,
188 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#[cfg(feature = "oci")]
205#[derive(Debug, Parser)]
206struct OCIConfigFilesystemOptions {
207 #[clap(flatten)]
208 base_config: OCIConfigOptions,
209 #[clap(long)]
211 bootable: bool,
212}
213
214#[cfg(feature = "oci")]
216#[derive(Debug, Parser)]
217struct OCIConfigOptions {
218 config_name: OciReference,
220 config_verity: Option<String>,
222}
223
224#[cfg(feature = "oci")]
225#[derive(Debug, Subcommand)]
226enum OciCommand {
227 ImportLayer {
229 digest: composefs_oci::OciDigest,
231 name: Option<String>,
233 },
234 LsLayer {
236 name: composefs_oci::OciDigest,
238 },
239 Dump {
245 #[clap(flatten)]
246 config_opts: OCIConfigFilesystemOptions,
247 },
248 Pull {
252 image: String,
254 name: Option<String>,
256 #[arg(long)]
258 bootable: bool,
259 #[arg(long, value_enum, default_value_t = LocalFetchCli::Disabled)]
262 local_fetch: LocalFetchCli,
263 },
264 #[clap(name = "images")]
266 ListImages {
267 #[clap(long)]
269 json: bool,
270 },
271 #[clap(name = "inspect")]
280 Inspect {
281 image: OciReference,
283 #[clap(long, conflicts_with = "config")]
285 manifest: bool,
286 #[clap(long, conflicts_with = "manifest")]
288 config: bool,
289 },
290 Tag {
294 manifest_digest: composefs_oci::OciDigest,
296 name: String,
298 },
299 Untag {
301 name: String,
303 },
304 #[clap(name = "layer")]
309 LayerInspect {
310 layer: composefs_oci::OciDigest,
312 #[clap(long, conflicts_with = "json")]
314 dumpfile: bool,
315 #[clap(long, conflicts_with = "dumpfile")]
317 json: bool,
318 },
319 Mount {
321 image: String,
323 mountpoint: String,
325 #[arg(long)]
327 bootable: bool,
328 },
329 ComputeId {
335 #[clap(flatten)]
336 config_opts: OCIConfigFilesystemOptions,
337 },
338
339 PrepareBoot {
344 #[clap(flatten)]
345 config_opts: OCIConfigOptions,
346 #[clap(long, default_value = "/boot")]
348 bootdir: PathBuf,
349 #[clap(long)]
351 entry_id: Option<String>,
352 #[clap(long)]
354 cmdline: Vec<String>,
355 },
356 Fsck {
362 image: Option<String>,
364 #[clap(long)]
366 json: bool,
367 },
368}
369
370#[derive(Debug, Parser)]
372struct FsReadOptions {
373 path: PathBuf,
375 #[clap(long)]
377 bootable: bool,
378 #[clap(long)]
380 no_propagate_usr_to_root: bool,
381}
382
383#[derive(Debug, Subcommand)]
384enum Command {
385 Init {
392 #[clap(long, value_parser = clap::value_parser!(Algorithm), default_value = "fsverity-sha512-12")]
395 algorithm: Algorithm,
396 path: Option<PathBuf>,
399 #[clap(long)]
401 insecure: bool,
402 #[clap(long)]
407 reset_metadata: bool,
408 },
409 Transaction,
412 Cat {
414 name: String,
416 },
417 GC {
419 #[clap(long, short = 'r')]
421 root: Vec<String>,
422 #[clap(long, short = 'n')]
424 dry_run: bool,
425 },
426 ImportImage { reference: String },
428 #[cfg(feature = "oci")]
430 Oci {
431 #[clap(subcommand)]
432 cmd: OciCommand,
433 },
434 Mount {
436 name: String,
438 mountpoint: String,
440 },
441 CreateImage {
444 #[clap(flatten)]
445 fs_opts: FsReadOptions,
446 image_name: Option<String>,
448 },
449 ComputeId {
453 #[clap(flatten)]
454 fs_opts: FsReadOptions,
455 },
456 CreateDumpfile {
459 #[clap(flatten)]
460 fs_opts: FsReadOptions,
461 },
462 ImageObjects {
464 name: String,
466 },
467 DumpFiles {
471 image_name: String,
473 files: Vec<PathBuf>,
475 #[clap(long)]
479 backing_path_only: bool,
480 },
481 Fsck {
487 #[clap(long)]
489 json: bool,
490 },
491 #[cfg(feature = "http")]
492 Fetch { url: String, name: String },
493}
494
495pub 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
523fn 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
541fn 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 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 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
607pub async fn run_app(args: App) -> Result<()> {
609 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 if args.no_repo
629 || matches!(
630 args.cmd,
631 Command::ComputeId { .. } | Command::CreateDumpfile { .. }
632 )
633 {
634 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
663fn 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 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 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
716pub 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 if args.insecure {
732 repo.set_insecure();
733 }
734 if args.require_verity {
735 repo.require_verity()?;
736 }
737 Ok(repo)
738}
739
740#[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#[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 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
884pub 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
903pub 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 unreachable!("init is handled before opening a repository");
913 }
914 Command::Transaction => {
915 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 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 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 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 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 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 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}