1pub use composefs;
19pub use composefs_boot;
20#[cfg(feature = "http")]
21pub use composefs_http;
22#[cfg(feature = "oci")]
23pub use composefs_oci;
24
25pub mod complete;
27pub mod composefs_info;
28#[cfg(feature = "fuse")]
29pub mod fuse;
30pub mod mkcomposefs;
31pub mod mountcomposefs;
32pub mod varlink;
34
35#[cfg(any(feature = "oci", feature = "http"))]
36use std::collections::HashMap;
37use std::io::{Read, Write};
38use std::path::Path;
39#[cfg(any(feature = "oci", feature = "http"))]
40use std::sync::Mutex;
41use std::{ffi::OsString, path::PathBuf};
42
43#[cfg(feature = "oci")]
44use std::{fs::create_dir_all, io::IsTerminal};
45
46use std::sync::Arc;
47
48use anyhow::{Context as _, Result};
49use clap::{Parser, Subcommand, ValueEnum};
50use clap_complete::engine::ArgValueCompleter;
51use comfy_table::{Table, presets::UTF8_FULL};
52#[cfg(feature = "ostree")]
53use complete::complete_ostree_refs;
54use complete::{complete_image_refs, complete_stream_refs};
55#[cfg(feature = "oci")]
56use complete::{complete_oci_digests, complete_oci_tags, complete_oci_tags_and_digests};
57#[cfg(any(feature = "oci", feature = "http", feature = "ostree"))]
58use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
59use rustix::fs::{CWD, Mode, OFlags};
60
61#[cfg(any(feature = "oci", feature = "http", feature = "ostree"))]
62use composefs::progress::{
63 ComponentId, ProgressEvent, ProgressReporter, ProgressUnit, SharedReporter,
64};
65use composefs_boot::BootOps;
66use composefs_boot::cmdline::ComposefsCmdline;
67#[cfg(feature = "oci")]
68use composefs_boot::write_boot;
69
70use composefs::erofs::format::FormatVersion;
71#[cfg(feature = "oci")]
72use composefs::shared_internals::IO_BUF_CAPACITY;
73use composefs::{
74 dumpfile::{dump_single_dir, dump_single_file},
75 erofs::reader::erofs_to_filesystem,
76 fsverity::{Algorithm, FsVerityHashValue, Sha256HashValue, Sha512HashValue},
77 generic_tree::{FileSystem, Inode},
78 mount::MountOptions,
79 repository::{
80 REPO_METADATA_FILENAME, Repository, RepositoryConfig, read_repo_algorithm, system_path,
81 user_path,
82 },
83 tree::RegularFile,
84};
85
86#[cfg(any(feature = "oci", feature = "http", feature = "ostree"))]
92struct IndicatifReporter {
93 multi: MultiProgress,
94 bars: Mutex<HashMap<ComponentId, ProgressBar>>,
95}
96
97#[cfg(any(feature = "oci", feature = "http", feature = "ostree"))]
98impl IndicatifReporter {
99 fn new() -> Self {
100 IndicatifReporter {
101 multi: MultiProgress::new(),
102 bars: Mutex::new(HashMap::new()),
103 }
104 }
105
106 fn into_shared(self) -> SharedReporter {
108 Arc::new(self)
109 }
110}
111
112#[cfg(any(feature = "oci", feature = "http", feature = "ostree"))]
113impl std::fmt::Debug for IndicatifReporter {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 f.debug_struct("IndicatifReporter").finish_non_exhaustive()
116 }
117}
118
119#[cfg(any(feature = "oci", feature = "http", feature = "ostree"))]
120impl ProgressReporter for IndicatifReporter {
121 fn report(&self, event: ProgressEvent) {
122 match event {
123 ProgressEvent::Started { id, total, unit } => {
124 let bar = if let Some(total) = total {
125 self.multi.add(ProgressBar::new(total))
126 } else {
127 self.multi.add(ProgressBar::new_spinner())
128 };
129 let style = match unit {
130 ProgressUnit::Bytes => ProgressStyle::with_template(
131 "[eta {eta}] {bar:40.cyan/blue} {decimal_bytes:>7}/{decimal_total_bytes:7} {msg}",
132 ),
133 ProgressUnit::Items => ProgressStyle::with_template(
134 "[eta {eta}] {bar:40.cyan/blue} {pos:>7}/{len:7} objects {msg}",
135 ),
136 _ => ProgressStyle::with_template(
138 "[eta {eta}] {bar:40.cyan/blue} {pos}/{len} {msg}",
139 ),
140 };
141 bar.set_style(
142 style
143 .unwrap_or_else(|_| ProgressStyle::default_bar())
144 .progress_chars("##-"),
145 );
146 bar.set_message(id.to_string());
147 self.bars.lock().unwrap().insert(id, bar);
148 }
149 ProgressEvent::Progress { id, fetched, .. } => {
150 if let Some(bar) = self.bars.lock().unwrap().get(&id) {
151 bar.set_position(fetched);
152 }
153 }
154 ProgressEvent::Done { id, .. } => {
155 if let Some(bar) = self.bars.lock().unwrap().remove(&id) {
156 bar.finish_and_clear();
157 }
158 }
159 ProgressEvent::Skipped { id } => {
160 if let Some(bar) = self.bars.lock().unwrap().remove(&id) {
161 bar.finish_with_message("skipped");
162 }
163 }
164 ProgressEvent::Message(msg) => {
165 let _ = self.multi.println(msg);
166 }
167 _ => {}
170 }
171 }
172}
173
174#[derive(Debug, Parser)]
176#[clap(name = "cfsctl", version)]
177pub struct App {
178 #[clap(long, group = "repopath", value_hint = clap::ValueHint::DirPath)]
180 repo: Option<PathBuf>,
181 #[clap(long, group = "repopath")]
183 user: bool,
184 #[clap(long, group = "repopath")]
186 system: bool,
187
188 #[clap(long, value_enum)]
191 pub hash: Option<HashType>,
192
193 #[clap(long, value_enum)]
196 pub erofs_version: Option<ErofsVersion>,
197
198 #[clap(long, hide = true)]
202 insecure: bool,
203
204 #[clap(long)]
206 require_verity: bool,
207
208 #[clap(long)]
212 no_upgrade: bool,
213
214 #[clap(long)]
217 pub no_repo: bool,
218
219 #[clap(subcommand)]
220 cmd: Command,
221}
222
223#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum)]
225pub enum HashType {
226 Sha256,
228 Sha512,
230}
231
232#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum)]
234pub enum ErofsVersion {
235 #[clap(name = "0")]
237 V0,
238 #[clap(name = "1")]
240 V1,
241 #[clap(name = "2")]
243 V2,
244}
245
246impl From<ErofsVersion> for composefs::erofs::format::FormatVersion {
247 fn from(v: ErofsVersion) -> Self {
248 match v {
249 ErofsVersion::V0 => Self::V0,
250 ErofsVersion::V1 => Self::V1,
251 ErofsVersion::V2 => Self::V2,
252 }
253 }
254}
255
256#[cfg(feature = "oci")]
275#[derive(Debug, Clone)]
276pub enum OciReference {
277 Digest(composefs_oci::OciDigest),
279 Named(String),
282}
283
284#[cfg(feature = "oci")]
285impl std::str::FromStr for OciReference {
286 type Err = anyhow::Error;
287
288 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
289 if let Some(digest_str) = s.strip_prefix('@') {
290 let digest: composefs_oci::OciDigest =
291 digest_str.parse().context("Invalid OCI digest after '@'")?;
292 Ok(Self::Digest(digest))
293 } else {
294 Ok(Self::Named(s.to_owned()))
295 }
296 }
297}
298
299#[cfg(feature = "oci")]
300impl std::fmt::Display for OciReference {
301 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302 match self {
303 Self::Digest(d) => write!(f, "@{d}"),
304 Self::Named(n) => write!(f, "{n}"),
305 }
306 }
307}
308
309#[cfg(feature = "oci")]
311#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
312enum LocalFetchCli {
313 #[default]
315 Disabled,
316 Auto,
318 Zerocopy,
320}
321
322#[cfg(feature = "oci")]
323impl From<LocalFetchCli> for composefs_oci::LocalFetchOpt {
324 fn from(cli: LocalFetchCli) -> Self {
325 match cli {
326 LocalFetchCli::Disabled => Self::Disabled,
327 LocalFetchCli::Auto => Self::IfPossible,
328 LocalFetchCli::Zerocopy => Self::ZeroCopy,
329 }
330 }
331}
332
333#[cfg(feature = "oci")]
335#[derive(Debug, Parser)]
336struct OCIConfigFilesystemOptions {
337 #[clap(flatten)]
338 base_config: OCIConfigOptions,
339 #[clap(long)]
341 bootable: bool,
342}
343
344#[cfg(feature = "oci")]
346#[derive(Debug, Parser)]
347struct OCIConfigOptions {
348 #[arg(add = ArgValueCompleter::new(complete_oci_tags_and_digests))]
350 config_name: OciReference,
351 config_verity: Option<String>,
353}
354
355#[cfg(feature = "oci")]
356#[derive(Debug, Subcommand)]
357enum OciCommand {
358 ImportLayer {
360 digest: composefs_oci::OciDigest,
362 name: Option<String>,
364 },
365 Dump {
371 #[clap(flatten)]
372 config_opts: OCIConfigFilesystemOptions,
373 },
374 Pull {
378 image: String,
380 name: Option<String>,
382 #[arg(long)]
384 bootable: bool,
385 #[arg(long, value_enum, default_value_t = LocalFetchCli::Disabled)]
388 local_fetch: LocalFetchCli,
389 },
390 Copy {
403 image: OciReference,
405 #[clap(long)]
407 from: PathBuf,
408 #[clap(long)]
410 name: Option<String>,
411 #[clap(long)]
413 zerocopy: bool,
414 },
415 #[clap(name = "images")]
417 ListImages {
418 #[clap(long)]
420 json: bool,
421 },
422 #[clap(name = "inspect")]
431 Inspect {
432 #[arg(add = ArgValueCompleter::new(complete_oci_tags_and_digests))]
434 image: OciReference,
435 #[clap(long, conflicts_with = "config")]
437 manifest: bool,
438 #[clap(long, conflicts_with = "manifest")]
440 config: bool,
441 },
442 Tag {
446 #[arg(add = ArgValueCompleter::new(complete_oci_digests))]
448 manifest_digest: composefs_oci::OciDigest,
449 name: String,
451 },
452 Untag {
454 #[arg(add = ArgValueCompleter::new(complete_oci_tags))]
456 name: String,
457 },
458 #[clap(name = "layer")]
463 LayerInspect {
464 layer: composefs_oci::OciDigest,
466 #[clap(long, conflicts_with = "json")]
468 dumpfile: bool,
469 #[clap(long, conflicts_with = "dumpfile")]
471 json: bool,
472 },
473 Mount {
475 #[arg(add = ArgValueCompleter::new(complete_oci_tags_and_digests))]
477 image: String,
478 #[arg(value_hint = clap::ValueHint::AnyPath)]
480 mountpoint: String,
481 #[arg(long)]
483 bootable: bool,
484 #[clap(flatten)]
485 mount_opts: MountOpts,
486 },
487 ComputeId {
493 #[clap(flatten)]
494 config_opts: OCIConfigFilesystemOptions,
495 },
496
497 PrepareBoot {
502 #[clap(flatten)]
503 config_opts: OCIConfigOptions,
504 #[clap(long, default_value = "/boot", value_hint = clap::ValueHint::DirPath)]
506 bootdir: PathBuf,
507 #[clap(long)]
509 entry_id: Option<String>,
510 #[clap(long)]
512 cmdline: Vec<String>,
513 },
514 Fsck {
520 #[arg(add = ArgValueCompleter::new(complete_oci_tags))]
522 image: Option<String>,
523 #[clap(long)]
525 json: bool,
526 },
527 Varlink {
533 #[clap(long, value_hint = clap::ValueHint::AnyPath)]
535 address: Option<PathBuf>,
536 },
537}
538
539#[cfg(feature = "ostree")]
540#[derive(Debug, Subcommand)]
541enum OstreeCommand {
542 PullLocal {
543 #[arg(value_hint = clap::ValueHint::DirPath)]
544 ostree_repo_path: PathBuf,
545 ostree_ref: String,
547 #[clap(long)]
548 base_name: Option<String>,
549 },
550 Pull {
551 #[arg(value_hint = clap::ValueHint::Url)]
552 ostree_repo_url: String,
553 ostree_ref: String,
555 #[clap(long)]
556 base_name: Option<String>,
557 #[clap(long)]
559 no_delta: bool,
560 },
561 Mount {
563 #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
565 commit: String,
566 #[arg(value_hint = clap::ValueHint::AnyPath)]
568 mountpoint: String,
569 #[clap(flatten)]
570 mount_opts: MountOpts,
571 },
572 Dump {
574 #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
576 commit_name: String,
577 },
578 ComputeId {
580 #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
582 commit_name: String,
583 },
584 Inspect {
586 #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
588 source: String,
589 #[clap(long)]
591 metadata: bool,
592 },
593 Tag {
597 #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
599 source: String,
600 name: String,
602 },
603 Untag {
605 #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
607 name: String,
608 },
609 Commit {
614 #[arg(add = ArgValueCompleter::new(complete_image_refs))]
616 image: String,
617 #[clap(long)]
619 reference: Option<String>,
620 #[clap(long, default_value = "")]
622 subject: String,
623 },
624 Export {
630 #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
632 source: String,
633 #[arg(value_hint = clap::ValueHint::DirPath)]
635 ostree_repo_path: PathBuf,
636 #[clap(long)]
638 reference: Option<String>,
639 },
640 #[clap(name = "images")]
642 ListCommits,
643 ApplyDelta {
645 #[arg(value_hint = clap::ValueHint::FilePath)]
647 delta_path: PathBuf,
648 },
649 ListRefs {
651 #[arg(value_hint = clap::ValueHint::Url)]
653 ostree_repo_url: String,
654 #[clap(long)]
656 subset: Option<String>,
657 },
658}
659
660#[derive(Debug, Parser)]
662struct FsReadOptions {
663 #[arg(value_hint = clap::ValueHint::DirPath)]
665 path: PathBuf,
666 #[clap(long)]
668 bootable: bool,
669 #[clap(long)]
671 no_propagate_usr_to_root: bool,
672}
673
674#[derive(Debug, Parser)]
676struct MountOpts {
677 #[cfg(feature = "fuse")]
679 #[arg(long, value_enum, default_value_t)]
680 fuse: FuseMode,
681 #[cfg(feature = "fuse")]
683 #[arg(long)]
684 foreground: bool,
685 #[arg(long, requires = "workdir", value_hint = clap::ValueHint::DirPath)]
687 upperdir: Option<PathBuf>,
688 #[arg(long, requires = "upperdir", value_hint = clap::ValueHint::DirPath)]
690 workdir: Option<PathBuf>,
691 #[arg(long, requires = "upperdir")]
693 read_write: bool,
694}
695
696impl MountOpts {
697 fn to_mount_options(&self) -> Result<composefs::mount::MountOptions> {
698 get_mount_options(
699 self.upperdir.as_deref(),
700 self.workdir.as_deref(),
701 self.read_write,
702 )
703 }
704
705 fn mount_image<ObjectID: FsVerityHashValue>(
706 &self,
707 repo: &Arc<Repository<ObjectID>>,
708 image_name: &str,
709 mountpoint: &str,
710 ) -> Result<()> {
711 let mount_options = self.to_mount_options()?;
712
713 #[cfg(feature = "fuse")]
714 if let mode @ (MountMode::Fuse | MountMode::FuseOverlay) =
715 detect_mount_mode(self.fuse, self.upperdir.is_some())
716 {
717 return run_fuse_mount(
718 repo,
719 image_name,
720 mountpoint,
721 mode,
722 mount_options,
723 self.foreground,
724 );
725 }
726
727 repo.mount_at(image_name, mountpoint, &mount_options)?;
728 Ok(())
729 }
730}
731
732#[derive(Debug, Subcommand)]
733enum Command {
734 Init {
741 #[clap(long, value_parser = clap::value_parser!(Algorithm), default_value = "fsverity-sha512-12")]
744 algorithm: Algorithm,
745 #[arg(value_hint = clap::ValueHint::DirPath)]
748 path: Option<PathBuf>,
749 #[clap(long)]
751 insecure: bool,
752 #[clap(long)]
757 reset_metadata: bool,
758 #[clap(long)]
762 erofs_version: Option<ErofsVersion>,
763 },
764 Transaction,
767 Cat {
769 #[arg(add = ArgValueCompleter::new(complete_stream_refs))]
771 name: String,
772 },
773 GC {
775 #[clap(long, short = 'r')]
777 root: Vec<String>,
778 #[clap(long, short = 'n')]
780 dry_run: bool,
781 },
782 ImportImage { reference: String },
784 #[clap(name = "images", alias = "list-images")]
786 Images {
787 #[clap(long)]
789 json: bool,
790 #[clap(long)]
792 no_trunc: bool,
793 },
794 #[cfg(feature = "oci")]
796 Oci {
797 #[clap(subcommand)]
798 cmd: OciCommand,
799 },
800 #[cfg(feature = "ostree")]
801 Ostree {
802 #[clap(subcommand)]
803 cmd: OstreeCommand,
804 },
805 Mount {
807 #[arg(add = ArgValueCompleter::new(complete_image_refs))]
809 name: String,
810 #[arg(value_hint = clap::ValueHint::AnyPath)]
812 mountpoint: String,
813 #[clap(flatten)]
814 mount_opts: MountOpts,
815 },
816 CreateImage {
819 #[clap(flatten)]
820 fs_opts: FsReadOptions,
821 image_name: Option<String>,
823 },
824 ComputeId {
828 #[clap(flatten)]
829 fs_opts: FsReadOptions,
830 },
831 #[clap(name = "compute-karg")]
846 ComputeKarg {
847 #[arg(value_hint = clap::ValueHint::DirPath)]
849 path: PathBuf,
850 #[clap(long)]
852 no_propagate_usr_to_root: bool,
853 },
854 CreateDumpfile {
857 #[clap(flatten)]
858 fs_opts: FsReadOptions,
859 },
860 ImageObjects {
862 #[arg(add = ArgValueCompleter::new(complete_image_refs))]
864 name: String,
865 },
866 DumpFiles {
870 #[arg(add = ArgValueCompleter::new(complete_image_refs))]
872 image_name: String,
873 #[arg(value_hint = clap::ValueHint::AnyPath)]
875 files: Vec<PathBuf>,
876 #[clap(long)]
880 backing_path_only: bool,
881 },
882 Fsck {
888 #[clap(long)]
890 json: bool,
891 #[clap(long)]
894 metadata_only: bool,
895 },
896 #[cfg(feature = "http")]
897 Fetch {
898 #[arg(value_hint = clap::ValueHint::Url)]
899 url: String,
900 name: String,
901 },
902 Varlink {
908 #[clap(long, value_hint = clap::ValueHint::AnyPath)]
910 address: Option<PathBuf>,
911 },
912
913 #[clap(hide = true, name = "mkcomposefs")]
915 Mkcomposefs {
916 #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
918 args: Vec<std::ffi::OsString>,
919 },
920
921 #[clap(hide = true, name = "composefs-info")]
923 ComposefsInfo {
924 #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
926 args: Vec<std::ffi::OsString>,
927 },
928}
929
930pub async fn run_from_iter<I>(args: I) -> Result<()>
936where
937 I: IntoIterator,
938 I::Item: Into<OsString> + Clone,
939{
940 let args = App::parse_from(
941 std::iter::once(OsString::from("cfsctl")).chain(args.into_iter().map(Into::into)),
942 );
943
944 run_app(args).await
945}
946
947#[cfg(feature = "ostree")]
948fn print_pull_stats(stats: &composefs_ostree::PullStats) {
949 if stats.delta_parts_applied > 0 {
950 println!(
951 "objects {} metadata + {} files via {} delta parts",
952 stats.metadata_fetched, stats.files_fetched, stats.delta_parts_applied
953 );
954 } else {
955 println!(
956 "objects {} metadata + {} files fetched",
957 stats.metadata_fetched, stats.files_fetched
958 );
959 }
960}
961
962fn get_mount_options(
963 upperdir: Option<&Path>,
964 workdir: Option<&Path>,
965 read_write: bool,
966) -> Result<MountOptions> {
967 let mut options = MountOptions::default();
968 if let (Some(u), Some(w)) = (upperdir, workdir) {
969 let upper_fd = rustix::fs::open(
970 u,
971 OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
972 Mode::empty(),
973 )
974 .with_context(|| format!("Opening upperdir '{}'", u.display()))?;
975 let work_fd = rustix::fs::open(
976 w,
977 OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
978 Mode::empty(),
979 )
980 .with_context(|| format!("Opening workdir '{}'", w.display()))?;
981 options.set_overlay(upper_fd, work_fd);
982 }
983 options.set_read_write(read_write);
984 Ok(options)
985}
986
987#[cfg(feature = "fuse")]
988use fuse::{FuseMode, MountMode, detect_mount_mode, run_fuse_mount};
989
990#[cfg(feature = "oci")]
991pub(crate) fn verity_opt<ObjectID>(opt: &Option<String>) -> Result<Option<ObjectID>>
992where
993 ObjectID: FsVerityHashValue,
994{
995 Ok(match opt {
996 Some(value) => Some(FsVerityHashValue::from_hex(value)?),
997 None => None,
998 })
999}
1000
1001pub(crate) fn default_repo_path() -> Result<PathBuf> {
1007 if rustix::process::getuid().is_root() {
1008 Ok(system_path())
1009 } else {
1010 user_path()
1011 }
1012}
1013
1014pub(crate) fn resolve_repo_path(args: &App) -> Result<PathBuf> {
1019 if let Some(path) = &args.repo {
1020 Ok(path.clone())
1021 } else if args.system {
1022 Ok(system_path())
1023 } else if args.user {
1024 user_path()
1025 } else {
1026 default_repo_path()
1027 }
1028}
1029
1030pub(crate) fn resolve_hash_type(
1042 repo_path: &Path,
1043 cli_hash: Option<HashType>,
1044 upgrade: bool,
1045) -> Result<HashType> {
1046 let repo_fd = rustix::fs::open(
1047 repo_path,
1048 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1049 Mode::empty(),
1050 )
1051 .with_context(|| format!("opening repository {}", repo_path.display()))?;
1052
1053 let algorithm = match read_repo_algorithm(&repo_fd)? {
1054 Some(alg) => alg,
1055 None if upgrade => {
1056 composefs::repository::infer_repo_algorithm(&repo_fd).with_context(|| {
1059 format!(
1060 "no {REPO_METADATA_FILENAME} in {}; tried to infer algorithm from objects",
1061 repo_path.display(),
1062 )
1063 })?
1064 }
1065 None => {
1066 anyhow::bail!(
1067 "{REPO_METADATA_FILENAME} not found in {}; \
1068 this repository must be initialized with `cfsctl init`",
1069 repo_path.display(),
1070 );
1071 }
1072 };
1073
1074 let detected = match algorithm {
1075 Algorithm::Sha256 { .. } => HashType::Sha256,
1076 Algorithm::Sha512 { .. } => HashType::Sha512,
1077 };
1078
1079 if let Some(explicit) = cli_hash
1081 && explicit != detected
1082 {
1083 anyhow::bail!(
1084 "repository is configured for {algorithm} (from {REPO_METADATA_FILENAME}) \
1085 but --hash {} was specified",
1086 match explicit {
1087 HashType::Sha256 => "sha256",
1088 HashType::Sha512 => "sha512",
1089 },
1090 );
1091 }
1092
1093 Ok(detected)
1094}
1095
1096pub async fn run_if_socket_activated() -> Result<bool> {
1114 if std::env::args_os().len() != 1 {
1118 return Ok(false);
1119 }
1120 let service = crate::varlink::CfsctlService::activated();
1121 match crate::varlink::try_activated_listener()? {
1122 Some(crate::varlink::ActivatedSocket::Connected(l)) => {
1123 crate::varlink::serve_activated(service, l).await?;
1124 Ok(true)
1125 }
1126 Some(crate::varlink::ActivatedSocket::Listening(listener)) => {
1127 crate::varlink::serve_on_listener(service, listener).await?;
1128 Ok(true)
1129 }
1130 None => Ok(false),
1131 }
1132}
1133
1134pub async fn run_app(args: App) -> Result<()> {
1136 if let Command::Mkcomposefs { args: extra } = args.cmd {
1138 return mkcomposefs::run_from_args(extra);
1139 }
1140 if let Command::ComposefsInfo { args: extra } = args.cmd {
1141 return composefs_info::run_from_args(extra);
1142 }
1143
1144 if let Command::Init {
1146 ref algorithm,
1147 ref path,
1148 insecure,
1149 reset_metadata,
1150 erofs_version: ref init_erofs_version,
1151 } = args.cmd
1152 {
1153 let erofs_version = init_erofs_version
1155 .or(args.erofs_version)
1156 .map(composefs::erofs::format::FormatVersion::from)
1157 .unwrap_or(composefs::erofs::format::FormatVersion::V1);
1158 return run_init(
1159 algorithm,
1160 path.as_deref(),
1161 insecure || args.insecure,
1162 reset_metadata,
1163 erofs_version,
1164 &args,
1165 );
1166 }
1167
1168 if let Command::Varlink { ref address } = args.cmd {
1174 let service = crate::varlink::CfsctlService::from_app(&args);
1175 return crate::varlink::serve(service, address.as_deref()).await;
1176 }
1177
1178 #[cfg(feature = "oci")]
1179 if let Command::Oci {
1180 cmd: OciCommand::Varlink { ref address },
1181 } = args.cmd
1182 {
1183 let service = crate::varlink::CfsctlService::from_app(&args);
1184 return crate::varlink::serve(service, address.as_deref()).await;
1185 }
1186
1187 if args.no_repo
1190 || matches!(
1191 args.cmd,
1192 Command::ComputeId { .. }
1193 | Command::ComputeKarg { .. }
1194 | Command::CreateDumpfile { .. }
1195 )
1196 {
1197 let effective_hash = if !args.no_repo {
1202 if let Ok(repo_path) = resolve_repo_path(&args) {
1203 resolve_hash_type(&repo_path, args.hash, !args.no_upgrade)
1204 .unwrap_or(args.hash.unwrap_or(HashType::Sha512))
1205 } else {
1206 args.hash.unwrap_or(HashType::Sha512)
1207 }
1208 } else {
1209 args.hash.unwrap_or(HashType::Sha512)
1210 };
1211 return match effective_hash {
1212 HashType::Sha256 => run_cmd_without_repo::<Sha256HashValue>(args).await,
1213 HashType::Sha512 => run_cmd_without_repo::<Sha512HashValue>(args).await,
1214 };
1215 }
1216
1217 let repo_path = resolve_repo_path(&args)?;
1218 let effective_hash = resolve_hash_type(&repo_path, args.hash, !args.no_upgrade)?;
1219
1220 match effective_hash {
1221 HashType::Sha256 => run_cmd_with_repo(open_repo::<Sha256HashValue>(&args)?, args).await,
1222 HashType::Sha512 => run_cmd_with_repo(open_repo::<Sha512HashValue>(&args)?, args).await,
1223 }
1224}
1225
1226fn run_init(
1228 algorithm: &Algorithm,
1229 path: Option<&Path>,
1230 insecure: bool,
1231 reset_metadata: bool,
1232 erofs_version: composefs::erofs::format::FormatVersion,
1233 args: &App,
1234) -> Result<()> {
1235 let repo_path = if let Some(p) = path {
1236 p.to_path_buf()
1237 } else {
1238 resolve_repo_path(args)?
1239 };
1240
1241 if reset_metadata {
1242 composefs::repository::reset_metadata(&repo_path)?;
1243 }
1244
1245 if let Some(parent) = repo_path.parent() {
1247 std::fs::create_dir_all(parent)
1248 .with_context(|| format!("creating parent directories for {}", repo_path.display()))?;
1249 }
1250
1251 let config = {
1254 let mut c = RepositoryConfig::new(*algorithm);
1255 c.erofs_formats = composefs::erofs::format::FormatConfig::single(erofs_version);
1256 if insecure { c.set_insecure() } else { c }
1257 };
1258 let created = match algorithm {
1259 Algorithm::Sha256 { .. } => {
1260 Repository::<Sha256HashValue>::init_path(CWD, &repo_path, config)?.1
1261 }
1262 Algorithm::Sha512 { .. } => {
1263 Repository::<Sha512HashValue>::init_path(CWD, &repo_path, config)?.1
1264 }
1265 };
1266
1267 if created {
1268 println!(
1269 "Initialized composefs repository at {}",
1270 repo_path.display()
1271 );
1272 println!(" algorithm: {algorithm}");
1273 if insecure {
1274 println!(" verity: not required (insecure)");
1275 } else {
1276 println!(" verity: required");
1277 }
1278 } else {
1279 println!("Repository already initialized at {}", repo_path.display());
1280 }
1281
1282 Ok(())
1283}
1284
1285pub(crate) fn open_repo_at<ObjectID>(
1292 path: &Path,
1293 insecure: bool,
1294 require_verity: bool,
1295 no_upgrade: bool,
1296) -> Result<Repository<ObjectID>>
1297where
1298 ObjectID: FsVerityHashValue,
1299{
1300 let mut repo = if no_upgrade {
1301 Repository::open_path(CWD, path)?
1302 } else {
1303 let (repo, _upgraded) = Repository::open_upgrade(CWD, path)?;
1304 repo
1305 };
1306 if insecure {
1310 repo.set_insecure();
1311 }
1312 if require_verity {
1313 repo.require_verity()?;
1314 }
1315 Ok(repo)
1316}
1317
1318pub fn open_repo<ObjectID>(args: &App) -> Result<Repository<ObjectID>>
1320where
1321 ObjectID: FsVerityHashValue,
1322{
1323 let path = resolve_repo_path(args)?;
1324 let mut repo = open_repo_at(&path, args.insecure, args.require_verity, args.no_upgrade)?;
1325 if let Some(version) = args.erofs_version {
1328 repo.set_erofs_version(version.into());
1329 }
1330 Ok(repo)
1331}
1332
1333#[cfg(feature = "oci")]
1335pub async fn copy_image(
1336 conn_src: &mut zlink::unix::Connection,
1337 conn_dest: &mut zlink::unix::Connection,
1338 handle_src: u64,
1339 handle_dest: u64,
1340 image: &OciReference,
1341 name: Option<&str>,
1342 zerocopy: bool,
1343) -> Result<crate::varlink::layer_sync::FinalizeImageReply> {
1344 use crate::varlink::layer_sync::LayerRef;
1345 use crate::varlink::oci::OciError;
1346 use crate::varlink::proxy::{GetLayerParams, OciProxy};
1347 use anyhow::ensure;
1348 use zlink::futures_util::StreamExt as _;
1349
1350 let image_str = image.to_string();
1351 let inspect = conn_src
1352 .inspect(handle_src, &image_str)
1353 .await
1354 .context("zlink transport error calling Inspect")?
1355 .map_err(|e: OciError| anyhow::anyhow!("Inspect failed: {e:?}"))?;
1356
1357 ensure!(
1358 !inspect.manifest.is_empty(),
1359 "inspect returned empty manifest"
1360 );
1361 ensure!(!inspect.config.is_empty(), "inspect returned empty config");
1362
1363 let diff_ids_ordered = composefs_oci::extract_layer_ids(&inspect.manifest, &inspect.config)
1367 .context("extracting layer identifiers")?;
1368
1369 let mut layer_refs: Vec<LayerRef> = Vec::with_capacity(diff_ids_ordered.len());
1370
1371 for diff_id in &diff_ids_ordered {
1372 let has = conn_dest
1373 .has_layer(handle_dest, diff_id)
1374 .await
1375 .context("zlink transport error calling HasLayer")?
1376 .map_err(|e: OciError| anyhow::anyhow!("HasLayer failed: {e:?}"))?;
1377
1378 let layer_verity = if has.present {
1379 has.layer_verity
1380 .context("HasLayer returned present=true but no layer_verity")?
1381 } else {
1382 let get_params = GetLayerParams {
1383 diff_id: Some(diff_id.to_string()),
1384 storage: None,
1385 };
1386 let mut get_stream = std::pin::pin!(
1387 conn_src
1388 .get_layer(handle_src, get_params)
1389 .await
1390 .context("zlink transport error calling GetLayer")?
1391 );
1392 let mut all_fds: Vec<std::os::fd::OwnedFd> = Vec::new();
1393 let mut get_reply = None;
1394 while let Some(item) = get_stream.next().await {
1395 let (result, fds) = item.context("GetLayer stream frame error")?;
1396 let reply =
1397 result.map_err(|e: OciError| anyhow::anyhow!("GetLayer failed: {e:?}"))?;
1398 get_reply = Some(reply);
1399 all_fds.extend(fds);
1400 }
1401 let get_reply = get_reply.context("GetLayer returned empty stream")?;
1402 let dir_count = get_reply.dir_count as usize;
1403
1404 let pipe_and_dirfds_len = 1 + dir_count;
1405 let lifetime_fds = all_fds.split_off(pipe_and_dirfds_len);
1406
1407 let put_reply = conn_dest
1408 .put_layer(handle_dest, diff_id, zerocopy, all_fds)
1409 .await
1410 .context("zlink transport error calling PutLayer")?
1411 .map_err(|e: OciError| anyhow::anyhow!("PutLayer failed: {e:?}"))?;
1412 drop(lifetime_fds);
1413
1414 put_reply.layer_verity
1415 };
1416
1417 layer_refs.push(LayerRef {
1418 diff_id: diff_id.clone(),
1419 layer_verity,
1420 });
1421 }
1422
1423 let finalize = conn_dest
1424 .finalize_image(
1425 handle_dest,
1426 &inspect.manifest,
1427 &inspect.config,
1428 layer_refs,
1429 name,
1430 )
1431 .await
1432 .context("zlink transport error calling FinalizeImage")?
1433 .map_err(|e: OciError| anyhow::anyhow!("FinalizeImage failed: {e:?}"))?;
1434
1435 Ok(finalize)
1436}
1437
1438#[cfg(feature = "oci")]
1440pub(crate) fn resolve_oci_image<ObjectID: FsVerityHashValue>(
1441 repo: &Repository<ObjectID>,
1442 reference: &OciReference,
1443) -> Result<composefs_oci::oci_image::OciImage<ObjectID>> {
1444 match reference {
1445 OciReference::Digest(digest) => {
1446 composefs_oci::oci_image::OciImage::open(repo, digest, None)
1447 }
1448 OciReference::Named(name) => composefs_oci::oci_image::OciImage::open_ref(repo, name),
1449 }
1450}
1451
1452#[cfg(feature = "oci")]
1457pub(crate) fn resolve_oci_config<ObjectID: FsVerityHashValue>(
1458 repo: &Repository<ObjectID>,
1459 reference: &OciReference,
1460 verity_override: Option<ObjectID>,
1461) -> Result<(composefs_oci::OciDigest, Option<ObjectID>)> {
1462 match reference {
1463 OciReference::Digest(digest) => Ok((digest.clone(), verity_override)),
1464 OciReference::Named(_) => {
1465 let img = resolve_oci_image(repo, reference)?;
1466 Ok((
1467 img.config_digest().clone(),
1468 Some(img.config_verity().clone()),
1469 ))
1470 }
1471 }
1472}
1473
1474#[cfg(feature = "oci")]
1475fn load_filesystem_from_oci_image<ObjectID: FsVerityHashValue>(
1476 repo: &Repository<ObjectID>,
1477 opts: OCIConfigFilesystemOptions,
1478) -> Result<FileSystem<RegularFile<ObjectID>>> {
1479 let verity = verity_opt(&opts.base_config.config_verity)?;
1480 let (config_digest, config_verity) =
1481 resolve_oci_config(repo, &opts.base_config.config_name, verity)?;
1482 let mut fs =
1483 composefs_oci::image::create_filesystem(repo, &config_digest, config_verity.as_ref())?;
1484 if opts.bootable {
1485 fs.transform_for_boot(repo)?;
1486 }
1487 Ok(fs)
1488}
1489
1490async fn load_filesystem_from_ondisk_fs<ObjectID: FsVerityHashValue>(
1491 fs_opts: &FsReadOptions,
1492 repo: Option<Arc<Repository<ObjectID>>>,
1493) -> Result<FileSystem<RegularFile<ObjectID>>> {
1494 let dirfd = rustix::fs::openat(
1497 CWD,
1498 ".",
1499 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1500 Mode::empty(),
1501 )?;
1502 let mut fs = if fs_opts.no_propagate_usr_to_root {
1503 composefs::fs::read_filesystem(dirfd, fs_opts.path.clone(), repo.clone()).await?
1504 } else {
1505 composefs::fs::read_container_root(dirfd, fs_opts.path.clone(), repo.clone()).await?
1506 };
1507 if fs_opts.bootable {
1508 if let Some(repo) = &repo {
1509 fs.transform_for_boot(repo)?;
1510 } else {
1511 let rootfd = rustix::fs::openat(
1512 CWD,
1513 &fs_opts.path,
1514 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1515 Mode::empty(),
1516 )?;
1517 fs.transform_for_boot_from_dir(rootfd)?;
1518 }
1519 }
1520 Ok(fs)
1521}
1522
1523pub fn dump_files<ObjectID: FsVerityHashValue>(
1530 repo: &Repository<ObjectID>,
1531 image_name: &str,
1532 files: &Vec<PathBuf>,
1533 backing_path_only: bool,
1534) -> Result<Vec<u8>> {
1535 let (img_fd, _) = repo.open_image(image_name)?;
1536
1537 let mut img_buf = Vec::new();
1538 std::fs::File::from(img_fd).read_to_end(&mut img_buf)?;
1539
1540 let fs = erofs_to_filesystem::<ObjectID>(&img_buf)?;
1541
1542 let mut out = Vec::new();
1543 let nlink_map = fs.nlinks();
1544
1545 for file_path in files {
1546 let (dir, file) = fs.root.split(file_path.as_os_str())?;
1547
1548 let (_, file) = dir
1549 .entries()
1550 .find(|ent| ent.0 == file)
1551 .ok_or_else(|| anyhow::anyhow!("{} not found", file_path.display()))?;
1552
1553 match &file {
1554 Inode::Directory(directory) => {
1555 if backing_path_only {
1556 anyhow::bail!("{} is a directory", file_path.display());
1557 }
1558
1559 dump_single_dir(&mut out, directory, &fs, &nlink_map, file_path.clone())?
1560 }
1561
1562 Inode::Leaf(leaf_id, _) => {
1563 use composefs::generic_tree::LeafContent::*;
1564 use composefs::tree::RegularFile::*;
1565
1566 if backing_path_only {
1567 let leaf = fs.leaf(*leaf_id);
1568 match &leaf.content {
1569 Regular(f) => match f {
1570 Inline(..) | Sparse(..) => {
1571 writeln!(&mut out, "{} inline", file_path.display())?;
1572 }
1573 External(id, _) | ExternalNoVerity(id, _) => {
1574 writeln!(
1575 &mut out,
1576 "{} {}",
1577 file_path.display(),
1578 id.to_object_pathname()
1579 )?;
1580 }
1581 },
1582 _ => {
1583 writeln!(&mut out, "{} inline", file_path.display())?;
1584 }
1585 }
1586
1587 continue;
1588 }
1589
1590 dump_single_file(&mut out, *leaf_id, &fs, &nlink_map, file_path.clone())?
1591 }
1592 };
1593 }
1594
1595 Ok(out)
1596}
1597
1598pub async fn run_cmd_without_repo<ObjectID: FsVerityHashValue>(args: App) -> Result<()> {
1600 let erofs_version = args
1601 .erofs_version
1602 .map(composefs::erofs::format::FormatVersion::from);
1603 match args.cmd {
1604 Command::ComputeId { fs_opts } => {
1605 let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
1606 let version = erofs_version.unwrap_or_default();
1607 let id = composefs::fsverity::compute_verity::<ObjectID>(
1608 &composefs::erofs::writer::mkfs_erofs_versioned(
1609 &composefs::erofs::writer::ValidatedFileSystem::new(fs)?,
1610 version,
1611 ),
1612 );
1613 println!("{}", id.to_hex());
1614 }
1615 Command::ComputeKarg {
1616 path,
1617 no_propagate_usr_to_root,
1618 } => {
1619 let fs_opts = FsReadOptions {
1620 path,
1621 bootable: true,
1622 no_propagate_usr_to_root,
1623 };
1624 let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
1625 let version = erofs_version.unwrap_or_default();
1626 let id = composefs::fsverity::compute_verity::<ObjectID>(
1627 &composefs::erofs::writer::mkfs_erofs_versioned(
1628 &composefs::erofs::writer::ValidatedFileSystem::new(fs)?,
1629 version,
1630 ),
1631 );
1632 let karg = match version {
1633 FormatVersion::V0 | FormatVersion::V1 => {
1634 ComposefsCmdline::new_v1(id, args.insecure)
1635 }
1636 FormatVersion::V2 => ComposefsCmdline::new_v2(id, args.insecure),
1637 };
1638 println!("{}", karg.to_cmdline_arg());
1639 }
1640 Command::CreateDumpfile { fs_opts } => {
1641 let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
1642 fs.print_dumpfile()?;
1643 }
1644 _ => {
1645 anyhow::bail!("--no-repo is only supported for compute-id and create-dumpfile");
1646 }
1647 }
1648 Ok(())
1649}
1650
1651pub async fn run_cmd_with_repo<ObjectID>(repo: Repository<ObjectID>, args: App) -> Result<()>
1653where
1654 ObjectID: FsVerityHashValue,
1655{
1656 let repo = Arc::new(repo);
1657 #[cfg(feature = "oci")]
1658 let dest_path = resolve_repo_path(&args)?;
1659 match args.cmd {
1660 Command::Init { .. } => {
1661 unreachable!("init is handled before opening a repository");
1663 }
1664 Command::Transaction => {
1665 loop {
1667 std::thread::park();
1668 }
1669 }
1670 Command::Cat { name } => {
1671 repo.merge_splitstream(&name, None, None, &mut std::io::stdout())?;
1672 }
1673 Command::ImportImage { reference } => {
1674 let image_id = repo.import_image(&reference, &mut std::io::stdin())?;
1675 println!("{}", image_id.to_id());
1676 }
1677 #[cfg(feature = "oci")]
1678 Command::Oci { cmd: oci_cmd } => match oci_cmd {
1679 OciCommand::ImportLayer { name, ref digest } => {
1680 let (object_id, _stats) = composefs_oci::import_layer(
1681 &repo,
1682 digest,
1683 name.as_deref(),
1684 tokio::io::BufReader::with_capacity(IO_BUF_CAPACITY, tokio::io::stdin()),
1685 )
1686 .await?;
1687 println!("{}", object_id.to_id());
1688 }
1689 OciCommand::Dump { config_opts } => {
1690 let fs = load_filesystem_from_oci_image(&repo, config_opts)?;
1691 fs.print_dumpfile()?;
1692 }
1693 OciCommand::Mount {
1694 ref image,
1695 ref mountpoint,
1696 bootable,
1697 ref mount_opts,
1698 } => {
1699 let img = if image.starts_with("sha256:") {
1700 let digest: composefs_oci::OciDigest =
1701 image.parse().context("Parsing manifest digest")?;
1702 composefs_oci::oci_image::OciImage::open(&repo, &digest, None)?
1703 } else {
1704 composefs_oci::oci_image::OciImage::open_ref(&repo, image)?
1705 };
1706 let erofs_id = if bootable {
1707 match img.boot_image_ref(repo.erofs_version()) {
1708 Some(id) => id,
1709 None => anyhow::bail!(
1710 "No boot EROFS image linked — try pulling with --bootable"
1711 ),
1712 }
1713 } else {
1714 match img.image_ref(repo.erofs_version()) {
1715 Some(id) => id,
1716 None => anyhow::bail!(
1717 "No composefs EROFS image linked — try re-pulling the image"
1718 ),
1719 }
1720 };
1721 mount_opts.mount_image(&repo, &erofs_id.to_hex(), mountpoint.as_str())?;
1722 }
1723 OciCommand::ComputeId { config_opts } => {
1724 let fs = load_filesystem_from_oci_image(&repo, config_opts)?;
1725 let id = fs.compute_image_id(repo.erofs_version());
1726 println!("{}", id.to_hex());
1727 }
1728 OciCommand::Pull {
1729 ref image,
1730 name,
1731 bootable,
1732 local_fetch,
1733 } => {
1734 let tag_name = name.as_deref().unwrap_or(image);
1736
1737 let reporter: SharedReporter = IndicatifReporter::new().into_shared();
1738 let opts = composefs_oci::PullOptions {
1739 local_fetch: local_fetch.into(),
1740 progress: Some(reporter),
1741 ..Default::default()
1742 };
1743
1744 let result = composefs_oci::pull(&repo, image, Some(tag_name), opts).await?;
1745
1746 println!("manifest {}", result.manifest_digest);
1747 println!("config {}", result.config_digest);
1748 println!("verity {}", result.manifest_verity.to_hex());
1749 println!("tagged {tag_name}");
1750 println!("objects {}", result.stats);
1751
1752 if bootable {
1753 let image_verity =
1754 composefs_oci::generate_boot_image(&repo, &result.manifest_digest)?;
1755 println!("Boot image: {}", image_verity.to_hex());
1756 }
1757 }
1758 OciCommand::Copy {
1759 ref image,
1760 ref from,
1761 ref name,
1762 zerocopy,
1763 } => {
1764 use crate::varlink::proxy::RepositoryProxy;
1765
1766 let src_hash = resolve_hash_type(from, args.hash, !args.no_upgrade)
1767 .with_context(|| format!("opening source repository {}", from.display()))?;
1768 let dest_hash = resolve_hash_type(&dest_path, args.hash, !args.no_upgrade)
1769 .with_context(|| {
1770 format!("opening destination repository {}", dest_path.display())
1771 })?;
1772
1773 if zerocopy && src_hash != dest_hash {
1774 anyhow::bail!(
1775 "--zerocopy requires matching hash algorithms; \
1776 source uses {src_hash:?} but destination uses {dest_hash:?}"
1777 );
1778 }
1779
1780 let from_str = from.to_str().context("source path is not valid UTF-8")?;
1781 let dest_str = dest_path
1782 .to_str()
1783 .context("destination path is not valid UTF-8")?;
1784
1785 let service_src = crate::varlink::CfsctlService::new();
1786 let service_dest = crate::varlink::CfsctlService::new();
1787
1788 let (mut conn_src, _srv_src) = crate::varlink::spawn_in_process(service_src)
1789 .context("spawning source in-process service")?;
1790 let (mut conn_dest, _srv_dest) = crate::varlink::spawn_in_process(service_dest)
1791 .context("spawning destination in-process service")?;
1792
1793 let handle_src = conn_src
1794 .open_repository(Some(from_str), None, None)
1795 .await
1796 .context("zlink transport error calling OpenRepository on source")?
1797 .map_err(|e| anyhow::anyhow!("OpenRepository failed on source: {e:?}"))?
1798 .handle;
1799
1800 let handle_dest = conn_dest
1801 .open_repository(Some(dest_str), None, None)
1802 .await
1803 .context("zlink transport error calling OpenRepository on destination")?
1804 .map_err(|e| anyhow::anyhow!("OpenRepository failed on destination: {e:?}"))?
1805 .handle;
1806
1807 let finalize_reply = copy_image(
1808 &mut conn_src,
1809 &mut conn_dest,
1810 handle_src,
1811 handle_dest,
1812 image,
1813 name.as_deref(),
1814 zerocopy,
1815 )
1816 .await?;
1817
1818 let tag_info = if let Some(n) = name {
1819 format!(", tagged as {n}")
1820 } else {
1821 String::new()
1822 };
1823 println!(
1824 "Copied image {image} from {} to destination repo{}",
1825 from.display(),
1826 tag_info
1827 );
1828 println!("Manifest digest: {}", finalize_reply.manifest_digest);
1829 println!("Manifest verity: {}", finalize_reply.manifest_verity);
1830 println!("Config digest: {}", finalize_reply.config_digest);
1831 println!("Config verity: {}", finalize_reply.config_verity);
1832 }
1833 OciCommand::ListImages { json } => {
1834 let images = composefs_oci::oci_image::list_images(&repo)?;
1835
1836 if json {
1837 let reply = crate::varlink::ListImagesReply {
1838 images: images
1839 .iter()
1840 .map(crate::varlink::ImageEntry::from)
1841 .collect(),
1842 };
1843 serde_json::to_writer_pretty(std::io::stdout().lock(), &reply)?;
1844 println!();
1845 } else if images.is_empty() {
1846 println!("No images found");
1847 } else {
1848 let mut table = Table::new();
1849 table.load_preset(UTF8_FULL);
1850 table.set_header(["NAME", "DIGEST", "ARCH", "LAYERS", "REFS"]);
1851
1852 for img in images {
1853 let digest_str: &str = img.manifest_digest.as_ref();
1854 let digest_short = digest_str.strip_prefix("sha256:").unwrap_or(digest_str);
1855 let digest_display = if digest_short.len() > 12 {
1856 &digest_short[..12]
1857 } else {
1858 digest_short
1859 };
1860 let arch = if img.architecture.is_empty() {
1861 "artifact"
1862 } else {
1863 &img.architecture
1864 };
1865 table.add_row([
1866 img.name.as_str(),
1867 digest_display,
1868 arch,
1869 &img.layer_count.to_string(),
1870 &img.referrer_count.to_string(),
1871 ]);
1872 }
1873 println!("{table}");
1874 }
1875 }
1876 OciCommand::Inspect {
1877 ref image,
1878 manifest,
1879 config,
1880 } => {
1881 let img = resolve_oci_image(&repo, image)?;
1882
1883 if manifest {
1884 let manifest_json = img.read_manifest_json(&repo)?;
1886 std::io::Write::write_all(&mut std::io::stdout(), &manifest_json)?;
1887 println!();
1888 } else if config {
1889 let config_json = img.read_config_json(&repo)?;
1891 std::io::Write::write_all(&mut std::io::stdout(), &config_json)?;
1892 println!();
1893 } else {
1894 let output = crate::varlink::OciInspectReply::from_image(&repo, &img)?;
1896 serde_json::to_writer_pretty(std::io::stdout().lock(), &output)?;
1897 println!();
1898 }
1899 }
1900 OciCommand::Tag {
1901 ref manifest_digest,
1902 ref name,
1903 } => {
1904 composefs_oci::oci_image::tag_image(&repo, manifest_digest, name)?;
1905 println!("Tagged {manifest_digest} as {name}");
1906 }
1907 OciCommand::Untag { ref name } => {
1908 composefs_oci::oci_image::untag_image(&repo, name)?;
1909 println!("Removed tag {name}");
1910 }
1911 OciCommand::LayerInspect {
1912 ref layer,
1913 dumpfile,
1914 json,
1915 } => {
1916 if json {
1917 let info = composefs_oci::layer_info(&repo, layer)?;
1918 serde_json::to_writer_pretty(std::io::stdout().lock(), &info)?;
1919 println!();
1920 } else if dumpfile {
1921 composefs_oci::layer_dumpfile(&repo, layer, &mut std::io::stdout())?;
1922 } else {
1923 let mut out = std::io::stdout().lock();
1925 if out.is_terminal() {
1926 anyhow::bail!(
1927 "Refusing to write tar data to terminal. \
1928 Redirect to a file, pipe to tar, or use --json for metadata."
1929 );
1930 }
1931 composefs_oci::layer_tar(&repo, layer, &mut out)?;
1932 }
1933 }
1934
1935 OciCommand::PrepareBoot {
1936 config_opts:
1937 OCIConfigOptions {
1938 ref config_name,
1939 ref config_verity,
1940 },
1941 ref bootdir,
1942 ref entry_id,
1943 ref cmdline,
1944 } => {
1945 let verity = verity_opt(config_verity)?;
1946 let (config_digest, config_verity) =
1947 resolve_oci_config(&repo, config_name, verity)?;
1948 let mut fs = composefs_oci::image::create_filesystem(
1949 &repo,
1950 &config_digest,
1951 config_verity.as_ref(),
1952 )?;
1953 let entries = fs.transform_for_boot(&repo)?;
1954 let ids = fs.commit_images(&repo, None)?;
1955 let fmt_config = repo.default_format_config();
1956 let id = ids
1958 .get(&FormatVersion::V1)
1959 .or_else(|| ids.get(&FormatVersion::V2))
1960 .ok_or_else(|| anyhow::anyhow!("commit_images produced no images"))?
1961 .clone();
1962
1963 let insecure = repo.is_insecure();
1964 let karg = if fmt_config.default == FormatVersion::V1
1965 && !fmt_config.extra.contains(&FormatVersion::V2)
1966 {
1967 ComposefsCmdline::new_v1(id, insecure)
1969 } else {
1970 ComposefsCmdline::new_v2(id, insecure)
1972 };
1973
1974 let Some(entry) = entries.into_iter().next() else {
1975 anyhow::bail!("No boot entries!");
1976 };
1977
1978 let cmdline_refs: Vec<&str> = cmdline.iter().map(String::as_str).collect();
1979 write_boot::write_boot_simple(
1980 &repo,
1981 entry,
1982 &karg,
1983 bootdir,
1984 None,
1985 entry_id.as_deref(),
1986 &cmdline_refs,
1987 )?;
1988
1989 let state = args
1990 .repo
1991 .as_ref()
1992 .map(|p: &PathBuf| p.parent().unwrap())
1993 .unwrap_or(Path::new("/sysroot"))
1994 .join("state/deploy")
1995 .join(karg.digest().to_hex());
1996
1997 create_dir_all(state.join("var"))?;
1998 create_dir_all(state.join("etc/upper"))?;
1999 create_dir_all(state.join("etc/work"))?;
2000 }
2001 OciCommand::Fsck { image, json } => {
2002 let result = if let Some(ref name) = image {
2003 composefs_oci::oci_fsck_image(&repo, name).await?
2004 } else {
2005 composefs_oci::oci_fsck(&repo).await?
2006 };
2007 if json {
2008 let output = crate::varlink::OciFsckReply::from(&result);
2009 serde_json::to_writer_pretty(std::io::stdout().lock(), &output)?;
2010 println!();
2011 } else {
2012 print!("{result}");
2013 if !result.is_ok() {
2014 anyhow::bail!("OCI integrity check failed");
2015 }
2016 }
2017 }
2018 OciCommand::Varlink { .. } => {
2019 unreachable!("oci varlink is handled before opening a repository");
2020 }
2021 },
2022 #[cfg(feature = "ostree")]
2023 Command::Ostree { cmd: ostree_cmd } => match ostree_cmd {
2024 OstreeCommand::PullLocal {
2025 ref ostree_repo_path,
2026 ref ostree_ref,
2027 base_name,
2028 } => {
2029 let ostree_repo =
2030 composefs_ostree::LocalRepo::open_path(&repo, CWD, ostree_repo_path)?;
2031 let reporter: SharedReporter = IndicatifReporter::new().into_shared();
2032 let opts = composefs_ostree::PullOptions {
2033 base_reference: base_name.as_deref(),
2034 progress: Some(reporter),
2035 ..Default::default()
2036 };
2037 let (verity, stats) =
2038 composefs_ostree::pull(&repo, ostree_repo, ostree_ref, opts).await?;
2039
2040 let image_id = composefs_ostree::get_image_ref(&repo, &stats.commit_id)?;
2041 println!("commit {}", stats.commit_id);
2042 println!("verity {}", verity.to_hex());
2043 println!("image {}", image_id.to_hex());
2044 if !composefs_ostree::is_commit_id(ostree_ref) {
2045 println!("tagged {ostree_ref}");
2046 }
2047 print_pull_stats(&stats);
2048 }
2049 OstreeCommand::Pull {
2050 ref ostree_repo_url,
2051 ref ostree_ref,
2052 base_name,
2053 no_delta,
2054 } => {
2055 let ostree_repo = composefs_ostree::RemoteRepo::new(&repo, ostree_repo_url)?;
2056 let reporter: SharedReporter = IndicatifReporter::new().into_shared();
2057 let opts = composefs_ostree::PullOptions {
2058 base_reference: base_name.as_deref(),
2059 progress: Some(reporter),
2060 disable_deltas: no_delta,
2061 };
2062 let (verity, stats) =
2063 composefs_ostree::pull(&repo, ostree_repo, ostree_ref, opts).await?;
2064
2065 let image_id = composefs_ostree::get_image_ref(&repo, &stats.commit_id)?;
2066 println!("commit {}", stats.commit_id);
2067 println!("verity {}", verity.to_hex());
2068 println!("image {}", image_id.to_hex());
2069 if !composefs_ostree::is_commit_id(ostree_ref) {
2070 println!("tagged {ostree_ref}");
2071 }
2072 print_pull_stats(&stats);
2073 }
2074 OstreeCommand::Mount {
2075 ref commit,
2076 ref mountpoint,
2077 ref mount_opts,
2078 } => {
2079 let image_id = composefs_ostree::get_image_ref(&repo, commit)?;
2080 mount_opts.mount_image(&repo, &image_id.to_hex(), mountpoint.as_str())?;
2081 }
2082 OstreeCommand::Dump { ref commit_name } => {
2083 let fs = composefs_ostree::create_filesystem(&repo, commit_name)?;
2084 fs.print_dumpfile()?;
2085 }
2086 OstreeCommand::ComputeId { ref commit_name } => {
2087 let image_id = composefs_ostree::ensure_ostree_erofs(&repo, commit_name)?;
2088 println!("{}", image_id.to_hex());
2089 }
2090 OstreeCommand::Inspect {
2091 ref source,
2092 metadata,
2093 } => {
2094 composefs_ostree::inspect(&repo, source, metadata)?;
2095 }
2096 OstreeCommand::Tag {
2097 ref source,
2098 ref name,
2099 } => {
2100 composefs_ostree::tag(&repo, source, name)?;
2101 println!("Tagged {source} as {name}");
2102 }
2103 OstreeCommand::Untag { ref name } => {
2104 composefs_ostree::untag(&repo, name)?;
2105 }
2106 OstreeCommand::Commit {
2107 ref image,
2108 ref reference,
2109 ref subject,
2110 } => {
2111 use std::time::{SystemTime, UNIX_EPOCH};
2112
2113 let (img_fd, _) = repo.open_image(image)?;
2114 let mut img_buf = Vec::new();
2115 std::fs::File::from(img_fd).read_to_end(&mut img_buf)?;
2116 let fs = composefs::erofs::reader::erofs_to_filesystem(&img_buf)?;
2117
2118 let timestamp = SystemTime::now()
2119 .duration_since(UNIX_EPOCH)
2120 .unwrap_or_default()
2121 .as_secs();
2122 let mut commit_meta = composefs_ostree::ostree::CommitMetadata::default()
2123 .subject(subject.as_str())
2124 .timestamp(timestamp);
2125 if let Some(ref_name) = reference {
2126 commit_meta = commit_meta.add_metadata(
2127 "ostree.ref-binding",
2128 composefs_ostree::ostree::MetadataValue::StringArray(vec![
2129 ref_name.clone(),
2130 ]),
2131 );
2132 }
2133
2134 let (verity, commit_id) = composefs_ostree::commit_filesystem(
2135 &repo,
2136 &fs,
2137 commit_meta,
2138 reference.as_deref(),
2139 )?;
2140 println!("commit {commit_id}");
2141 println!("verity {}", verity.to_hex());
2142 if let Some(ref_name) = reference {
2143 println!("tagged {ref_name}");
2144 }
2145 }
2146 OstreeCommand::Export {
2147 ref source,
2148 ref ostree_repo_path,
2149 ref reference,
2150 } => {
2151 let dest = composefs_ostree::LocalRepo::open_path(&repo, CWD, ostree_repo_path)?;
2152 let commit_id =
2153 composefs_ostree::export_commit(&repo, source, &dest, reference.as_deref())?;
2154 println!("commit {commit_id}");
2155 if let Some(ref_name) = reference {
2156 println!("tagged {ref_name}");
2157 }
2158 }
2159 OstreeCommand::ListCommits => {
2160 let commits = composefs_ostree::list_commits(&repo)?;
2161 if commits.is_empty() {
2162 println!("No ostree commits found");
2163 } else {
2164 let mut table = Table::new();
2165 table.load_preset(UTF8_FULL);
2166 table.set_header(["NAME", "COMMIT"]);
2167 for c in commits {
2168 table.add_row([c.name.as_str(), &c.commit_id]);
2169 }
2170 println!("{table}");
2171 }
2172 }
2173 OstreeCommand::ApplyDelta { ref delta_path } => {
2174 let (verity, stats) = composefs_ostree::apply_delta_offline(&repo, delta_path)?;
2175 let image_id = composefs_ostree::get_image_ref(&repo, &stats.commit_id)?;
2176 println!("commit {}", stats.commit_id);
2177 println!("verity {}", verity.to_hex());
2178 println!("image {}", image_id.to_hex());
2179 println!(
2180 "objects {} metadata + {} files applied",
2181 stats.metadata_fetched, stats.files_fetched
2182 );
2183 }
2184 OstreeCommand::ListRefs {
2185 ref ostree_repo_url,
2186 ref subset,
2187 } => {
2188 let mut ostree_repo = composefs_ostree::RemoteRepo::new(&repo, ostree_repo_url)?;
2189 if let Some(s) = subset {
2190 ostree_repo = ostree_repo.with_summary_subset(s);
2191 }
2192 let refs = ostree_repo.list_remote_refs().await?;
2193 if refs.is_empty() {
2194 println!("No refs found");
2195 } else {
2196 let mut table = Table::new();
2197 table.load_preset(UTF8_FULL);
2198 table.set_header(["REF", "COMMIT"]);
2199 for (name, checksum) in &refs {
2200 table.add_row([name.as_str(), &hex::encode(checksum)]);
2201 }
2202 println!("{table}");
2203 }
2204 }
2205 },
2206 Command::CreateImage {
2207 fs_opts,
2208 ref image_name,
2209 } => {
2210 let fs = load_filesystem_from_ondisk_fs(&fs_opts, Some(Arc::clone(&repo))).await?;
2211 let id = fs.commit_image(&repo, image_name.as_deref())?;
2212 println!("{}", id.to_id());
2213 }
2214 Command::ComputeId { .. }
2215 | Command::ComputeKarg { .. }
2216 | Command::CreateDumpfile { .. } => {
2217 unreachable!(
2219 "compute-id, compute-karg, and create-dumpfile are dispatched without a repo"
2220 );
2221 }
2222 Command::Mount {
2223 name,
2224 mountpoint,
2225 ref mount_opts,
2226 } => {
2227 mount_opts.mount_image(&repo, &name, &mountpoint)?;
2228 }
2229 Command::Images { json, no_trunc } => {
2230 let reply =
2231 varlink::run_list_image_refs(&repo).map_err(|e| anyhow::anyhow!("{e:?}"))?;
2232
2233 if json {
2234 serde_json::to_writer_pretty(std::io::stdout().lock(), &reply)?;
2235 println!();
2236 } else if reply.images.is_empty() {
2237 println!("No images found");
2238 } else {
2239 let mut table = Table::new();
2240 table.load_preset(UTF8_FULL);
2241 table.set_header(["NAME", "DIGEST"]);
2242
2243 for entry in &reply.images {
2244 let digest_display = if !no_trunc && entry.digest.len() > 12 {
2245 &entry.digest[..12]
2246 } else {
2247 &entry.digest
2248 };
2249 table.add_row([entry.name.as_str(), digest_display]);
2250 }
2251 println!("{table}");
2252 }
2253 }
2254 Command::ImageObjects { name } => {
2255 let objects = repo.objects_for_image(&name)?;
2256 for object in objects {
2257 println!("{}", object.to_id());
2258 }
2259 }
2260 Command::GC { root, dry_run } => {
2261 let roots: Vec<&str> = root.iter().map(|s| s.as_str()).collect();
2262 let result = if dry_run {
2263 repo.gc_dry_run(&roots)?
2264 } else {
2265 repo.gc(&roots)?
2266 };
2267 if dry_run {
2268 println!("Dry run (no files deleted):");
2269 }
2270 println!(
2271 "Objects: {} removed ({} bytes)",
2272 result.objects_removed, result.objects_bytes
2273 );
2274 if result.images_pruned > 0 || result.streams_pruned > 0 {
2275 println!(
2276 "Pruned symlinks: {} images, {} streams",
2277 result.images_pruned, result.streams_pruned
2278 );
2279 }
2280 }
2281 Command::DumpFiles {
2282 image_name,
2283 files,
2284 backing_path_only,
2285 } => {
2286 let out = dump_files(&repo, &image_name, &files, backing_path_only)?;
2287
2288 if !out.is_empty() {
2289 let out_str = std::str::from_utf8(&out).unwrap();
2290 print!("{}", out_str);
2291 }
2292 }
2293 Command::Fsck {
2294 json,
2295 metadata_only,
2296 } => {
2297 let result = if metadata_only {
2298 repo.fsck_metadata_only().await?
2299 } else {
2300 repo.fsck().await?
2301 };
2302 if json {
2303 let output = crate::varlink::FsckReply::from(&result);
2304 serde_json::to_writer_pretty(std::io::stdout().lock(), &output)?;
2305 println!();
2306 } else {
2307 print!("{result}");
2308 if !result.is_ok() {
2309 anyhow::bail!("repository integrity check failed");
2310 }
2311 }
2312 }
2313 Command::Varlink { .. } => {
2314 unreachable!("varlink is handled before opening a repository");
2316 }
2317 #[cfg(feature = "http")]
2318 Command::Fetch { url, name } => {
2319 let reporter: SharedReporter = IndicatifReporter::new().into_shared();
2320 let (digest, verity) = composefs_http::download(
2321 &url,
2322 &name,
2323 Arc::clone(&repo),
2324 composefs_http::DownloadOptions {
2325 progress: Some(reporter),
2326 },
2327 )
2328 .await?;
2329 println!("content {digest}");
2330 println!("verity {}", verity.to_hex());
2331 }
2332 Command::Mkcomposefs { .. } | Command::ComposefsInfo { .. } => {
2333 unreachable!("mkcomposefs/composefs-info are dispatched before opening a repository");
2335 }
2336 }
2337 Ok(())
2338}
2339
2340#[cfg(test)]
2341#[cfg(any(feature = "oci", feature = "http"))]
2342mod tests {
2343 use super::*;
2344 use composefs::progress::{ProgressEvent, ProgressUnit};
2345
2346 #[test]
2351 fn test_indicatif_reporter_valid_lifecycle() {
2352 let reporter = IndicatifReporter::new();
2353 reporter.report(ProgressEvent::Message("starting pull".into()));
2355 reporter.report(ProgressEvent::Started {
2357 id: "sha256:abc".into(),
2358 total: Some(1_000_000),
2359 unit: ProgressUnit::Bytes,
2360 });
2361 reporter.report(ProgressEvent::Progress {
2362 id: "sha256:abc".into(),
2363 fetched: 500_000,
2364 total: Some(1_000_000),
2365 });
2366 reporter.report(ProgressEvent::Done {
2367 id: "sha256:abc".into(),
2368 transferred: 1_000_000,
2369 });
2370 reporter.report(ProgressEvent::Started {
2372 id: "objects:stream".into(),
2373 total: Some(200),
2374 unit: ProgressUnit::Items,
2375 });
2376 reporter.report(ProgressEvent::Progress {
2377 id: "objects:stream".into(),
2378 fetched: 100,
2379 total: Some(200),
2380 });
2381 reporter.report(ProgressEvent::Done {
2382 id: "objects:stream".into(),
2383 transferred: 200,
2384 });
2385 reporter.report(ProgressEvent::Started {
2387 id: "sha256:cached".into(),
2388 total: None,
2389 unit: ProgressUnit::Bytes,
2390 });
2391 reporter.report(ProgressEvent::Skipped {
2392 id: "sha256:cached".into(),
2393 });
2394 }
2395
2396 #[test]
2402 fn test_indicatif_reporter_unknown_id_no_panic() {
2403 let reporter = IndicatifReporter::new();
2404 reporter.report(ProgressEvent::Progress {
2406 id: "ghost".into(),
2407 fetched: 42,
2408 total: None,
2409 });
2410 reporter.report(ProgressEvent::Done {
2412 id: "ghost".into(),
2413 transferred: 42,
2414 });
2415 reporter.report(ProgressEvent::Skipped { id: "ghost".into() });
2417 }
2418
2419 #[test]
2421 fn test_indicatif_reporter_spinner_lifecycle() {
2422 let reporter = IndicatifReporter::new();
2423 reporter.report(ProgressEvent::Started {
2425 id: "layer:unknown-size".into(),
2426 total: None,
2427 unit: ProgressUnit::Bytes,
2428 });
2429 reporter.report(ProgressEvent::Progress {
2430 id: "layer:unknown-size".into(),
2431 fetched: 1024,
2432 total: None,
2433 });
2434 reporter.report(ProgressEvent::Done {
2435 id: "layer:unknown-size".into(),
2436 transferred: 2048,
2437 });
2438 }
2439
2440 #[test]
2442 fn test_indicatif_reporter_multiple_concurrent_components() {
2443 let reporter = IndicatifReporter::new();
2444 reporter.report(ProgressEvent::Started {
2446 id: "layer:a".into(),
2447 total: Some(100),
2448 unit: ProgressUnit::Bytes,
2449 });
2450 reporter.report(ProgressEvent::Started {
2451 id: "layer:b".into(),
2452 total: Some(200),
2453 unit: ProgressUnit::Bytes,
2454 });
2455 reporter.report(ProgressEvent::Progress {
2457 id: "layer:a".into(),
2458 fetched: 50,
2459 total: Some(100),
2460 });
2461 reporter.report(ProgressEvent::Progress {
2462 id: "layer:b".into(),
2463 fetched: 100,
2464 total: Some(200),
2465 });
2466 reporter.report(ProgressEvent::Done {
2468 id: "layer:b".into(),
2469 transferred: 200,
2470 });
2471 reporter.report(ProgressEvent::Done {
2473 id: "layer:a".into(),
2474 transferred: 100,
2475 });
2476 }
2477}