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 composefs_info;
26pub mod mkcomposefs;
27pub mod varlink;
29
30#[cfg(any(feature = "oci", feature = "http"))]
31use std::collections::HashMap;
32use std::io::Read;
33use std::path::Path;
34#[cfg(any(feature = "oci", feature = "http"))]
35use std::sync::Mutex;
36use std::{ffi::OsString, path::PathBuf};
37
38#[cfg(feature = "oci")]
39use std::{fs::create_dir_all, io::IsTerminal};
40
41use std::sync::Arc;
42
43use anyhow::{Context as _, Result};
44use clap::{Parser, Subcommand, ValueEnum};
45#[cfg(any(feature = "oci", feature = "ostree"))]
46use comfy_table::{Table, presets::UTF8_FULL};
47#[cfg(any(feature = "oci", feature = "http"))]
48use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
49use rustix::fs::{CWD, Mode, OFlags};
50
51#[cfg(any(feature = "oci", feature = "http"))]
52use composefs::progress::{
53 ComponentId, ProgressEvent, ProgressReporter, ProgressUnit, SharedReporter,
54};
55use composefs_boot::BootOps;
56use composefs_boot::cmdline::ComposefsCmdline;
57#[cfg(feature = "oci")]
58use composefs_boot::write_boot;
59
60use composefs::erofs::format::FormatVersion;
61#[cfg(feature = "oci")]
62use composefs::shared_internals::IO_BUF_CAPACITY;
63use composefs::{
64 dumpfile::{dump_single_dir, dump_single_file},
65 erofs::reader::erofs_to_filesystem,
66 fsverity::{Algorithm, FsVerityHashValue, Sha256HashValue, Sha512HashValue},
67 generic_tree::{FileSystem, Inode},
68 mount::MountOptions,
69 repository::{
70 REPO_METADATA_FILENAME, Repository, RepositoryConfig, read_repo_algorithm, system_path,
71 user_path,
72 },
73 tree::RegularFile,
74};
75
76#[cfg(any(feature = "oci", feature = "http"))]
82struct IndicatifReporter {
83 multi: MultiProgress,
84 bars: Mutex<HashMap<ComponentId, ProgressBar>>,
85}
86
87#[cfg(any(feature = "oci", feature = "http"))]
88impl IndicatifReporter {
89 fn new() -> Self {
90 IndicatifReporter {
91 multi: MultiProgress::new(),
92 bars: Mutex::new(HashMap::new()),
93 }
94 }
95
96 fn into_shared(self) -> SharedReporter {
98 Arc::new(self)
99 }
100}
101
102#[cfg(any(feature = "oci", feature = "http"))]
103impl std::fmt::Debug for IndicatifReporter {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 f.debug_struct("IndicatifReporter").finish_non_exhaustive()
106 }
107}
108
109#[cfg(any(feature = "oci", feature = "http"))]
110impl ProgressReporter for IndicatifReporter {
111 fn report(&self, event: ProgressEvent) {
112 match event {
113 ProgressEvent::Started { id, total, unit } => {
114 let bar = if let Some(total) = total {
115 self.multi.add(ProgressBar::new(total))
116 } else {
117 self.multi.add(ProgressBar::new_spinner())
118 };
119 let style = match unit {
120 ProgressUnit::Bytes => ProgressStyle::with_template(
121 "[eta {eta}] {bar:40.cyan/blue} {decimal_bytes:>7}/{decimal_total_bytes:7} {msg}",
122 ),
123 ProgressUnit::Items => ProgressStyle::with_template(
124 "[eta {eta}] {bar:40.cyan/blue} {pos:>7}/{len:7} objects {msg}",
125 ),
126 _ => ProgressStyle::with_template(
128 "[eta {eta}] {bar:40.cyan/blue} {pos}/{len} {msg}",
129 ),
130 };
131 bar.set_style(
132 style
133 .unwrap_or_else(|_| ProgressStyle::default_bar())
134 .progress_chars("##-"),
135 );
136 bar.set_message(id.to_string());
137 self.bars.lock().unwrap().insert(id, bar);
138 }
139 ProgressEvent::Progress { id, fetched, .. } => {
140 if let Some(bar) = self.bars.lock().unwrap().get(&id) {
141 bar.set_position(fetched);
142 }
143 }
144 ProgressEvent::Done { id, .. } => {
145 if let Some(bar) = self.bars.lock().unwrap().remove(&id) {
146 bar.finish_and_clear();
147 }
148 }
149 ProgressEvent::Skipped { id } => {
150 if let Some(bar) = self.bars.lock().unwrap().remove(&id) {
151 bar.finish_with_message("skipped");
152 }
153 }
154 ProgressEvent::Message(msg) => {
155 let _ = self.multi.println(msg);
156 }
157 _ => {}
160 }
161 }
162}
163
164#[derive(Debug, Parser)]
166#[clap(name = "cfsctl", version)]
167pub struct App {
168 #[clap(long, group = "repopath")]
170 repo: Option<PathBuf>,
171 #[clap(long, group = "repopath")]
173 user: bool,
174 #[clap(long, group = "repopath")]
176 system: bool,
177
178 #[clap(long, value_enum)]
181 pub hash: Option<HashType>,
182
183 #[clap(long, value_enum)]
186 pub erofs_version: Option<ErofsVersion>,
187
188 #[clap(long, hide = true)]
192 insecure: bool,
193
194 #[clap(long)]
196 require_verity: bool,
197
198 #[clap(long)]
202 no_upgrade: bool,
203
204 #[clap(long)]
207 pub no_repo: bool,
208
209 #[clap(subcommand)]
210 cmd: Command,
211}
212
213#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum)]
215pub enum HashType {
216 Sha256,
218 Sha512,
220}
221
222#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum)]
224pub enum ErofsVersion {
225 #[clap(name = "0")]
227 V0,
228 #[clap(name = "1")]
230 V1,
231 #[clap(name = "2")]
233 V2,
234}
235
236impl From<ErofsVersion> for composefs::erofs::format::FormatVersion {
237 fn from(v: ErofsVersion) -> Self {
238 match v {
239 ErofsVersion::V0 => Self::V0,
240 ErofsVersion::V1 => Self::V1,
241 ErofsVersion::V2 => Self::V2,
242 }
243 }
244}
245
246#[cfg(feature = "oci")]
265#[derive(Debug, Clone)]
266pub(crate) enum OciReference {
267 Digest(composefs_oci::OciDigest),
269 Named(String),
272}
273
274#[cfg(feature = "oci")]
275impl std::str::FromStr for OciReference {
276 type Err = anyhow::Error;
277
278 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
279 if let Some(digest_str) = s.strip_prefix('@') {
280 let digest: composefs_oci::OciDigest =
281 digest_str.parse().context("Invalid OCI digest after '@'")?;
282 Ok(Self::Digest(digest))
283 } else {
284 Ok(Self::Named(s.to_owned()))
285 }
286 }
287}
288
289#[cfg(feature = "oci")]
290impl std::fmt::Display for OciReference {
291 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292 match self {
293 Self::Digest(d) => write!(f, "@{d}"),
294 Self::Named(n) => write!(f, "{n}"),
295 }
296 }
297}
298
299#[cfg(feature = "oci")]
301#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
302enum LocalFetchCli {
303 #[default]
305 Disabled,
306 Auto,
308 Zerocopy,
310}
311
312#[cfg(feature = "oci")]
313impl From<LocalFetchCli> for composefs_oci::LocalFetchOpt {
314 fn from(cli: LocalFetchCli) -> Self {
315 match cli {
316 LocalFetchCli::Disabled => Self::Disabled,
317 LocalFetchCli::Auto => Self::IfPossible,
318 LocalFetchCli::Zerocopy => Self::ZeroCopy,
319 }
320 }
321}
322
323#[cfg(feature = "oci")]
325#[derive(Debug, Parser)]
326struct OCIConfigFilesystemOptions {
327 #[clap(flatten)]
328 base_config: OCIConfigOptions,
329 #[clap(long)]
331 bootable: bool,
332}
333
334#[cfg(feature = "oci")]
336#[derive(Debug, Parser)]
337struct OCIConfigOptions {
338 config_name: OciReference,
340 config_verity: Option<String>,
342}
343
344#[cfg(feature = "oci")]
345#[derive(Debug, Subcommand)]
346enum OciCommand {
347 ImportLayer {
349 digest: composefs_oci::OciDigest,
351 name: Option<String>,
353 },
354 Dump {
360 #[clap(flatten)]
361 config_opts: OCIConfigFilesystemOptions,
362 },
363 Pull {
367 image: String,
369 name: Option<String>,
371 #[arg(long)]
373 bootable: bool,
374 #[arg(long, value_enum, default_value_t = LocalFetchCli::Disabled)]
377 local_fetch: LocalFetchCli,
378 },
379 #[clap(name = "images")]
381 ListImages {
382 #[clap(long)]
384 json: bool,
385 },
386 #[clap(name = "inspect")]
395 Inspect {
396 image: OciReference,
398 #[clap(long, conflicts_with = "config")]
400 manifest: bool,
401 #[clap(long, conflicts_with = "manifest")]
403 config: bool,
404 },
405 Tag {
409 manifest_digest: composefs_oci::OciDigest,
411 name: String,
413 },
414 Untag {
416 name: String,
418 },
419 #[clap(name = "layer")]
424 LayerInspect {
425 layer: composefs_oci::OciDigest,
427 #[clap(long, conflicts_with = "json")]
429 dumpfile: bool,
430 #[clap(long, conflicts_with = "dumpfile")]
432 json: bool,
433 },
434 Mount {
436 image: String,
438 mountpoint: String,
440 #[arg(long)]
442 bootable: bool,
443 #[arg(long, requires = "workdir")]
445 upperdir: Option<PathBuf>,
446 #[arg(long, requires = "upperdir")]
448 workdir: Option<PathBuf>,
449 #[arg(long, requires = "upperdir")]
451 read_write: bool,
452 },
453 ComputeId {
459 #[clap(flatten)]
460 config_opts: OCIConfigFilesystemOptions,
461 },
462
463 PrepareBoot {
468 #[clap(flatten)]
469 config_opts: OCIConfigOptions,
470 #[clap(long, default_value = "/boot")]
472 bootdir: PathBuf,
473 #[clap(long)]
475 entry_id: Option<String>,
476 #[clap(long)]
478 cmdline: Vec<String>,
479 },
480 Fsck {
486 image: Option<String>,
488 #[clap(long)]
490 json: bool,
491 },
492 Varlink {
498 #[clap(long)]
500 address: Option<PathBuf>,
501 },
502}
503
504#[cfg(feature = "ostree")]
505#[derive(Debug, Subcommand)]
506enum OstreeCommand {
507 PullLocal {
508 ostree_repo_path: PathBuf,
509 ostree_ref: String,
511 #[clap(long)]
512 base_name: Option<String>,
513 },
514 Pull {
515 ostree_repo_url: String,
516 ostree_ref: String,
518 #[clap(long)]
519 base_name: Option<String>,
520 },
521 Mount {
523 commit: String,
525 mountpoint: String,
527 #[arg(long, requires = "workdir")]
529 upperdir: Option<PathBuf>,
530 #[arg(long, requires = "upperdir")]
532 workdir: Option<PathBuf>,
533 #[arg(long, requires = "upperdir")]
535 read_write: bool,
536 },
537 Dump {
539 commit_name: String,
541 },
542 ComputeId {
544 commit_name: String,
546 },
547 Inspect {
549 source: String,
551 #[clap(long)]
553 metadata: bool,
554 },
555 Tag {
559 source: String,
561 name: String,
563 },
564 Untag {
566 name: String,
568 },
569 #[clap(name = "images")]
571 ListCommits,
572}
573
574#[derive(Debug, Parser)]
576struct FsReadOptions {
577 path: PathBuf,
579 #[clap(long)]
581 bootable: bool,
582 #[clap(long)]
584 no_propagate_usr_to_root: bool,
585}
586
587#[derive(Debug, Subcommand)]
588enum Command {
589 Init {
596 #[clap(long, value_parser = clap::value_parser!(Algorithm), default_value = "fsverity-sha512-12")]
599 algorithm: Algorithm,
600 path: Option<PathBuf>,
603 #[clap(long)]
605 insecure: bool,
606 #[clap(long)]
611 reset_metadata: bool,
612 #[clap(long)]
616 erofs_version: Option<ErofsVersion>,
617 },
618 Transaction,
621 Cat {
623 name: String,
625 },
626 GC {
628 #[clap(long, short = 'r')]
630 root: Vec<String>,
631 #[clap(long, short = 'n')]
633 dry_run: bool,
634 },
635 ImportImage { reference: String },
637 #[cfg(feature = "oci")]
639 Oci {
640 #[clap(subcommand)]
641 cmd: OciCommand,
642 },
643 #[cfg(feature = "ostree")]
644 Ostree {
645 #[clap(subcommand)]
646 cmd: OstreeCommand,
647 },
648 Mount {
650 name: String,
652 mountpoint: String,
654 #[arg(long, requires = "workdir")]
656 upperdir: Option<PathBuf>,
657 #[arg(long, requires = "upperdir")]
659 workdir: Option<PathBuf>,
660 #[arg(long, requires = "upperdir")]
662 read_write: bool,
663 },
664 CreateImage {
667 #[clap(flatten)]
668 fs_opts: FsReadOptions,
669 image_name: Option<String>,
671 },
672 ComputeId {
676 #[clap(flatten)]
677 fs_opts: FsReadOptions,
678 },
679 #[clap(name = "compute-karg")]
694 ComputeKarg {
695 path: PathBuf,
697 #[clap(long)]
699 no_propagate_usr_to_root: bool,
700 },
701 CreateDumpfile {
704 #[clap(flatten)]
705 fs_opts: FsReadOptions,
706 },
707 ImageObjects {
709 name: String,
711 },
712 DumpFiles {
716 image_name: String,
718 files: Vec<PathBuf>,
720 #[clap(long)]
724 backing_path_only: bool,
725 },
726 Fsck {
732 #[clap(long)]
734 json: bool,
735 #[clap(long)]
738 metadata_only: bool,
739 },
740 #[cfg(feature = "http")]
741 Fetch { url: String, name: String },
742 Varlink {
748 #[clap(long)]
750 address: Option<PathBuf>,
751 },
752
753 #[clap(hide = true, name = "mkcomposefs")]
755 Mkcomposefs {
756 #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
758 args: Vec<std::ffi::OsString>,
759 },
760
761 #[clap(hide = true, name = "composefs-info")]
763 ComposefsInfo {
764 #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
766 args: Vec<std::ffi::OsString>,
767 },
768}
769
770pub async fn run_from_iter<I>(args: I) -> Result<()>
776where
777 I: IntoIterator,
778 I::Item: Into<OsString> + Clone,
779{
780 let args = App::parse_from(
781 std::iter::once(OsString::from("cfsctl")).chain(args.into_iter().map(Into::into)),
782 );
783
784 run_app(args).await
785}
786
787fn get_mount_options(
788 upperdir: Option<&Path>,
789 workdir: Option<&Path>,
790 read_write: bool,
791) -> Result<MountOptions> {
792 let mut options = MountOptions::default();
793 if let (Some(u), Some(w)) = (upperdir, workdir) {
794 let upper_fd = rustix::fs::open(
795 u,
796 OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
797 Mode::empty(),
798 )
799 .with_context(|| format!("Opening upperdir '{}'", u.display()))?;
800 let work_fd = rustix::fs::open(
801 w,
802 OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
803 Mode::empty(),
804 )
805 .with_context(|| format!("Opening workdir '{}'", w.display()))?;
806 options.set_overlay(upper_fd, work_fd);
807 }
808 options.set_read_write(read_write);
809 Ok(options)
810}
811
812#[cfg(feature = "oci")]
813pub(crate) fn verity_opt<ObjectID>(opt: &Option<String>) -> Result<Option<ObjectID>>
814where
815 ObjectID: FsVerityHashValue,
816{
817 Ok(match opt {
818 Some(value) => Some(FsVerityHashValue::from_hex(value)?),
819 None => None,
820 })
821}
822
823pub(crate) fn default_repo_path() -> Result<PathBuf> {
829 if rustix::process::getuid().is_root() {
830 Ok(system_path())
831 } else {
832 user_path()
833 }
834}
835
836pub(crate) fn resolve_repo_path(args: &App) -> Result<PathBuf> {
841 if let Some(path) = &args.repo {
842 Ok(path.clone())
843 } else if args.system {
844 Ok(system_path())
845 } else if args.user {
846 user_path()
847 } else {
848 default_repo_path()
849 }
850}
851
852pub(crate) fn resolve_hash_type(
864 repo_path: &Path,
865 cli_hash: Option<HashType>,
866 upgrade: bool,
867) -> Result<HashType> {
868 let repo_fd = rustix::fs::open(
869 repo_path,
870 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
871 Mode::empty(),
872 )
873 .with_context(|| format!("opening repository {}", repo_path.display()))?;
874
875 let algorithm = match read_repo_algorithm(&repo_fd)? {
876 Some(alg) => alg,
877 None if upgrade => {
878 composefs::repository::infer_repo_algorithm(&repo_fd).with_context(|| {
881 format!(
882 "no {REPO_METADATA_FILENAME} in {}; tried to infer algorithm from objects",
883 repo_path.display(),
884 )
885 })?
886 }
887 None => {
888 anyhow::bail!(
889 "{REPO_METADATA_FILENAME} not found in {}; \
890 this repository must be initialized with `cfsctl init`",
891 repo_path.display(),
892 );
893 }
894 };
895
896 let detected = match algorithm {
897 Algorithm::Sha256 { .. } => HashType::Sha256,
898 Algorithm::Sha512 { .. } => HashType::Sha512,
899 };
900
901 if let Some(explicit) = cli_hash
903 && explicit != detected
904 {
905 anyhow::bail!(
906 "repository is configured for {algorithm} (from {REPO_METADATA_FILENAME}) \
907 but --hash {} was specified",
908 match explicit {
909 HashType::Sha256 => "sha256",
910 HashType::Sha512 => "sha512",
911 },
912 );
913 }
914
915 Ok(detected)
916}
917
918pub async fn run_if_socket_activated() -> Result<bool> {
936 if std::env::args_os().len() != 1 {
940 return Ok(false);
941 }
942 let Some(listener) = crate::varlink::try_activated_listener()? else {
943 return Ok(false);
944 };
945 let service = crate::varlink::CfsctlService::activated();
946 crate::varlink::serve_activated(service, listener).await?;
947 Ok(true)
948}
949
950pub async fn run_app(args: App) -> Result<()> {
952 if let Command::Mkcomposefs { args: extra } = args.cmd {
954 return mkcomposefs::run_from_args(extra);
955 }
956 if let Command::ComposefsInfo { args: extra } = args.cmd {
957 return composefs_info::run_from_args(extra);
958 }
959
960 if let Command::Init {
962 ref algorithm,
963 ref path,
964 insecure,
965 reset_metadata,
966 erofs_version: ref init_erofs_version,
967 } = args.cmd
968 {
969 let erofs_version = init_erofs_version
971 .or(args.erofs_version)
972 .map(composefs::erofs::format::FormatVersion::from)
973 .unwrap_or(composefs::erofs::format::FormatVersion::V2);
974 return run_init(
975 algorithm,
976 path.as_deref(),
977 insecure || args.insecure,
978 reset_metadata,
979 erofs_version,
980 &args,
981 );
982 }
983
984 if let Command::Varlink { ref address } = args.cmd {
990 let service = crate::varlink::CfsctlService::from_app(&args);
991 return crate::varlink::serve(service, address.as_deref()).await;
992 }
993
994 #[cfg(feature = "oci")]
995 if let Command::Oci {
996 cmd: OciCommand::Varlink { ref address },
997 } = args.cmd
998 {
999 let service = crate::varlink::CfsctlService::from_app(&args);
1000 return crate::varlink::serve(service, address.as_deref()).await;
1001 }
1002
1003 if args.no_repo
1006 || matches!(
1007 args.cmd,
1008 Command::ComputeId { .. }
1009 | Command::ComputeKarg { .. }
1010 | Command::CreateDumpfile { .. }
1011 )
1012 {
1013 let effective_hash = if !args.no_repo {
1018 if let Ok(repo_path) = resolve_repo_path(&args) {
1019 resolve_hash_type(&repo_path, args.hash, !args.no_upgrade)
1020 .unwrap_or(args.hash.unwrap_or(HashType::Sha512))
1021 } else {
1022 args.hash.unwrap_or(HashType::Sha512)
1023 }
1024 } else {
1025 args.hash.unwrap_or(HashType::Sha512)
1026 };
1027 return match effective_hash {
1028 HashType::Sha256 => run_cmd_without_repo::<Sha256HashValue>(args).await,
1029 HashType::Sha512 => run_cmd_without_repo::<Sha512HashValue>(args).await,
1030 };
1031 }
1032
1033 let repo_path = resolve_repo_path(&args)?;
1034 let effective_hash = resolve_hash_type(&repo_path, args.hash, !args.no_upgrade)?;
1035
1036 match effective_hash {
1037 HashType::Sha256 => run_cmd_with_repo(open_repo::<Sha256HashValue>(&args)?, args).await,
1038 HashType::Sha512 => run_cmd_with_repo(open_repo::<Sha512HashValue>(&args)?, args).await,
1039 }
1040}
1041
1042fn run_init(
1044 algorithm: &Algorithm,
1045 path: Option<&Path>,
1046 insecure: bool,
1047 reset_metadata: bool,
1048 erofs_version: composefs::erofs::format::FormatVersion,
1049 args: &App,
1050) -> Result<()> {
1051 let repo_path = if let Some(p) = path {
1052 p.to_path_buf()
1053 } else {
1054 resolve_repo_path(args)?
1055 };
1056
1057 if reset_metadata {
1058 composefs::repository::reset_metadata(&repo_path)?;
1059 }
1060
1061 if let Some(parent) = repo_path.parent() {
1063 std::fs::create_dir_all(parent)
1064 .with_context(|| format!("creating parent directories for {}", repo_path.display()))?;
1065 }
1066
1067 let config = {
1070 let mut c = RepositoryConfig::new(*algorithm);
1071 c.erofs_formats = composefs::erofs::format::FormatConfig::single(erofs_version);
1072 if insecure { c.set_insecure() } else { c }
1073 };
1074 let created = match algorithm {
1075 Algorithm::Sha256 { .. } => {
1076 Repository::<Sha256HashValue>::init_path(CWD, &repo_path, config)?.1
1077 }
1078 Algorithm::Sha512 { .. } => {
1079 Repository::<Sha512HashValue>::init_path(CWD, &repo_path, config)?.1
1080 }
1081 };
1082
1083 if created {
1084 println!(
1085 "Initialized composefs repository at {}",
1086 repo_path.display()
1087 );
1088 println!(" algorithm: {algorithm}");
1089 if insecure {
1090 println!(" verity: not required (insecure)");
1091 } else {
1092 println!(" verity: required");
1093 }
1094 } else {
1095 println!("Repository already initialized at {}", repo_path.display());
1096 }
1097
1098 Ok(())
1099}
1100
1101pub(crate) fn open_repo_at<ObjectID>(
1108 path: &Path,
1109 insecure: bool,
1110 require_verity: bool,
1111 no_upgrade: bool,
1112) -> Result<Repository<ObjectID>>
1113where
1114 ObjectID: FsVerityHashValue,
1115{
1116 let mut repo = if no_upgrade {
1117 Repository::open_path(CWD, path)?
1118 } else {
1119 let (repo, _upgraded) = Repository::open_upgrade(CWD, path)?;
1120 repo
1121 };
1122 if insecure {
1126 repo.set_insecure();
1127 }
1128 if require_verity {
1129 repo.require_verity()?;
1130 }
1131 Ok(repo)
1132}
1133
1134pub fn open_repo<ObjectID>(args: &App) -> Result<Repository<ObjectID>>
1136where
1137 ObjectID: FsVerityHashValue,
1138{
1139 let path = resolve_repo_path(args)?;
1140 let mut repo = open_repo_at(&path, args.insecure, args.require_verity, args.no_upgrade)?;
1141 if let Some(version) = args.erofs_version {
1144 repo.set_erofs_version(version.into());
1145 }
1146 Ok(repo)
1147}
1148
1149#[cfg(feature = "oci")]
1151pub(crate) fn resolve_oci_image<ObjectID: FsVerityHashValue>(
1152 repo: &Repository<ObjectID>,
1153 reference: &OciReference,
1154) -> Result<composefs_oci::oci_image::OciImage<ObjectID>> {
1155 match reference {
1156 OciReference::Digest(digest) => {
1157 composefs_oci::oci_image::OciImage::open(repo, digest, None)
1158 }
1159 OciReference::Named(name) => composefs_oci::oci_image::OciImage::open_ref(repo, name),
1160 }
1161}
1162
1163#[cfg(feature = "oci")]
1168pub(crate) fn resolve_oci_config<ObjectID: FsVerityHashValue>(
1169 repo: &Repository<ObjectID>,
1170 reference: &OciReference,
1171 verity_override: Option<ObjectID>,
1172) -> Result<(composefs_oci::OciDigest, Option<ObjectID>)> {
1173 match reference {
1174 OciReference::Digest(digest) => Ok((digest.clone(), verity_override)),
1175 OciReference::Named(_) => {
1176 let img = resolve_oci_image(repo, reference)?;
1177 Ok((
1178 img.config_digest().clone(),
1179 Some(img.config_verity().clone()),
1180 ))
1181 }
1182 }
1183}
1184
1185#[cfg(feature = "oci")]
1186fn load_filesystem_from_oci_image<ObjectID: FsVerityHashValue>(
1187 repo: &Repository<ObjectID>,
1188 opts: OCIConfigFilesystemOptions,
1189) -> Result<FileSystem<RegularFile<ObjectID>>> {
1190 let verity = verity_opt(&opts.base_config.config_verity)?;
1191 let (config_digest, config_verity) =
1192 resolve_oci_config(repo, &opts.base_config.config_name, verity)?;
1193 let mut fs =
1194 composefs_oci::image::create_filesystem(repo, &config_digest, config_verity.as_ref())?;
1195 if opts.bootable {
1196 fs.transform_for_boot(repo)?;
1197 }
1198 Ok(fs)
1199}
1200
1201async fn load_filesystem_from_ondisk_fs<ObjectID: FsVerityHashValue>(
1202 fs_opts: &FsReadOptions,
1203 repo: Option<Arc<Repository<ObjectID>>>,
1204) -> Result<FileSystem<RegularFile<ObjectID>>> {
1205 let dirfd = rustix::fs::openat(
1208 CWD,
1209 ".",
1210 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1211 Mode::empty(),
1212 )?;
1213 let mut fs = if fs_opts.no_propagate_usr_to_root {
1214 composefs::fs::read_filesystem(dirfd, fs_opts.path.clone(), repo.clone()).await?
1215 } else {
1216 composefs::fs::read_container_root(dirfd, fs_opts.path.clone(), repo.clone()).await?
1217 };
1218 if fs_opts.bootable {
1219 if let Some(repo) = &repo {
1220 fs.transform_for_boot(repo)?;
1221 } else {
1222 let rootfd = rustix::fs::openat(
1223 CWD,
1224 &fs_opts.path,
1225 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1226 Mode::empty(),
1227 )?;
1228 fs.transform_for_boot_from_dir(rootfd)?;
1229 }
1230 }
1231 Ok(fs)
1232}
1233
1234fn dump_file_impl(
1235 fs: FileSystem<RegularFile<impl FsVerityHashValue>>,
1236 files: &Vec<PathBuf>,
1237 backing_path_only: bool,
1238) -> Result<()> {
1239 let mut out = Vec::new();
1240 let nlink_map = fs.nlinks();
1241
1242 for file_path in files {
1243 let (dir, file) = fs.root.split(file_path.as_os_str())?;
1244
1245 let (_, file) = dir
1246 .entries()
1247 .find(|ent| ent.0 == file)
1248 .ok_or_else(|| anyhow::anyhow!("{} not found", file_path.display()))?;
1249
1250 match &file {
1251 Inode::Directory(directory) => {
1252 if backing_path_only {
1253 anyhow::bail!("{} is a directory", file_path.display());
1254 }
1255
1256 dump_single_dir(&mut out, directory, &fs, &nlink_map, file_path.clone())?
1257 }
1258
1259 Inode::Leaf(leaf_id, _) => {
1260 use composefs::generic_tree::LeafContent::*;
1261 use composefs::tree::RegularFile::*;
1262
1263 if backing_path_only {
1264 let leaf = fs.leaf(*leaf_id);
1265 match &leaf.content {
1266 Regular(f) => match f {
1267 Inline(..) => println!("{} inline", file_path.display()),
1268 External(id, _) => {
1269 println!("{} {}", file_path.display(), id.to_object_pathname());
1270 }
1271 },
1272 _ => {
1273 println!("{} inline", file_path.display())
1274 }
1275 }
1276
1277 continue;
1278 }
1279
1280 dump_single_file(&mut out, *leaf_id, &fs, &nlink_map, file_path.clone())?
1281 }
1282 };
1283 }
1284
1285 if !out.is_empty() {
1286 let out_str = std::str::from_utf8(&out).unwrap();
1287 println!("{}", out_str);
1288 }
1289
1290 Ok(())
1291}
1292
1293pub async fn run_cmd_without_repo<ObjectID: FsVerityHashValue>(args: App) -> Result<()> {
1295 let erofs_version = args
1296 .erofs_version
1297 .map(composefs::erofs::format::FormatVersion::from);
1298 match args.cmd {
1299 Command::ComputeId { fs_opts } => {
1300 let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
1301 let version = erofs_version.unwrap_or_default();
1302 let id = composefs::fsverity::compute_verity::<ObjectID>(
1303 &composefs::erofs::writer::mkfs_erofs_versioned(
1304 &composefs::erofs::writer::ValidatedFileSystem::new(fs)?,
1305 version,
1306 ),
1307 );
1308 println!("{}", id.to_hex());
1309 }
1310 Command::ComputeKarg {
1311 path,
1312 no_propagate_usr_to_root,
1313 } => {
1314 let fs_opts = FsReadOptions {
1315 path,
1316 bootable: true,
1317 no_propagate_usr_to_root,
1318 };
1319 let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
1320 let version = erofs_version.unwrap_or_default();
1321 let id = composefs::fsverity::compute_verity::<ObjectID>(
1322 &composefs::erofs::writer::mkfs_erofs_versioned(
1323 &composefs::erofs::writer::ValidatedFileSystem::new(fs)?,
1324 version,
1325 ),
1326 );
1327 let karg = match version {
1328 FormatVersion::V0 | FormatVersion::V1 => {
1329 ComposefsCmdline::new_v1(id, args.insecure)
1330 }
1331 FormatVersion::V2 => ComposefsCmdline::new_v2(id, args.insecure),
1332 };
1333 println!("{}", karg.to_cmdline_arg());
1334 }
1335 Command::CreateDumpfile { fs_opts } => {
1336 let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
1337 fs.print_dumpfile()?;
1338 }
1339 _ => {
1340 anyhow::bail!("--no-repo is only supported for compute-id and create-dumpfile");
1341 }
1342 }
1343 Ok(())
1344}
1345
1346pub async fn run_cmd_with_repo<ObjectID>(repo: Repository<ObjectID>, args: App) -> Result<()>
1348where
1349 ObjectID: FsVerityHashValue,
1350{
1351 let repo = Arc::new(repo);
1352 match args.cmd {
1353 Command::Init { .. } => {
1354 unreachable!("init is handled before opening a repository");
1356 }
1357 Command::Transaction => {
1358 loop {
1360 std::thread::park();
1361 }
1362 }
1363 Command::Cat { name } => {
1364 repo.merge_splitstream(&name, None, None, &mut std::io::stdout())?;
1365 }
1366 Command::ImportImage { reference } => {
1367 let image_id = repo.import_image(&reference, &mut std::io::stdin())?;
1368 println!("{}", image_id.to_id());
1369 }
1370 #[cfg(feature = "oci")]
1371 Command::Oci { cmd: oci_cmd } => match oci_cmd {
1372 OciCommand::ImportLayer { name, ref digest } => {
1373 let (object_id, _stats) = composefs_oci::import_layer(
1374 &repo,
1375 digest,
1376 name.as_deref(),
1377 tokio::io::BufReader::with_capacity(IO_BUF_CAPACITY, tokio::io::stdin()),
1378 )
1379 .await?;
1380 println!("{}", object_id.to_id());
1381 }
1382 OciCommand::Dump { config_opts } => {
1383 let fs = load_filesystem_from_oci_image(&repo, config_opts)?;
1384 fs.print_dumpfile()?;
1385 }
1386 OciCommand::Mount {
1387 ref image,
1388 ref mountpoint,
1389 bootable,
1390 ref upperdir,
1391 ref workdir,
1392 read_write,
1393 } => {
1394 let mount_options =
1395 get_mount_options(upperdir.as_deref(), workdir.as_deref(), read_write)?;
1396 let img = if image.starts_with("sha256:") {
1397 let digest: composefs_oci::OciDigest =
1398 image.parse().context("Parsing manifest digest")?;
1399 composefs_oci::oci_image::OciImage::open(&repo, &digest, None)?
1400 } else {
1401 composefs_oci::oci_image::OciImage::open_ref(&repo, image)?
1402 };
1403 let erofs_id = if bootable {
1404 match img.boot_image_ref(repo.erofs_version()) {
1405 Some(id) => id,
1406 None => anyhow::bail!(
1407 "No boot EROFS image linked — try pulling with --bootable"
1408 ),
1409 }
1410 } else {
1411 match img.image_ref(repo.erofs_version()) {
1412 Some(id) => id,
1413 None => anyhow::bail!(
1414 "No composefs EROFS image linked — try re-pulling the image"
1415 ),
1416 }
1417 };
1418 repo.mount_at(&erofs_id.to_hex(), mountpoint.as_str(), &mount_options)?;
1419 }
1420 OciCommand::ComputeId { config_opts } => {
1421 let fs = load_filesystem_from_oci_image(&repo, config_opts)?;
1422 let id = fs.compute_image_id(repo.erofs_version());
1423 println!("{}", id.to_hex());
1424 }
1425 OciCommand::Pull {
1426 ref image,
1427 name,
1428 bootable,
1429 local_fetch,
1430 } => {
1431 let tag_name = name.as_deref().unwrap_or(image);
1433
1434 let reporter: SharedReporter = IndicatifReporter::new().into_shared();
1435 let opts = composefs_oci::PullOptions {
1436 local_fetch: local_fetch.into(),
1437 progress: Some(reporter),
1438 ..Default::default()
1439 };
1440
1441 let result = composefs_oci::pull(&repo, image, Some(tag_name), opts).await?;
1442
1443 println!("manifest {}", result.manifest_digest);
1444 println!("config {}", result.config_digest);
1445 println!("verity {}", result.manifest_verity.to_hex());
1446 println!("tagged {tag_name}");
1447 println!("objects {}", result.stats);
1448
1449 if bootable {
1450 let image_verity =
1451 composefs_oci::generate_boot_image(&repo, &result.manifest_digest)?;
1452 println!("Boot image: {}", image_verity.to_hex());
1453 }
1454 }
1455 OciCommand::ListImages { json } => {
1456 let images = composefs_oci::oci_image::list_images(&repo)?;
1457
1458 if json {
1459 let reply = crate::varlink::ListImagesReply {
1460 images: images
1461 .iter()
1462 .map(crate::varlink::ImageEntry::from)
1463 .collect(),
1464 };
1465 serde_json::to_writer_pretty(std::io::stdout().lock(), &reply)?;
1466 println!();
1467 } else if images.is_empty() {
1468 println!("No images found");
1469 } else {
1470 let mut table = Table::new();
1471 table.load_preset(UTF8_FULL);
1472 table.set_header(["NAME", "DIGEST", "ARCH", "LAYERS", "REFS"]);
1473
1474 for img in images {
1475 let digest_str: &str = img.manifest_digest.as_ref();
1476 let digest_short = digest_str.strip_prefix("sha256:").unwrap_or(digest_str);
1477 let digest_display = if digest_short.len() > 12 {
1478 &digest_short[..12]
1479 } else {
1480 digest_short
1481 };
1482 let arch = if img.architecture.is_empty() {
1483 "artifact"
1484 } else {
1485 &img.architecture
1486 };
1487 table.add_row([
1488 img.name.as_str(),
1489 digest_display,
1490 arch,
1491 &img.layer_count.to_string(),
1492 &img.referrer_count.to_string(),
1493 ]);
1494 }
1495 println!("{table}");
1496 }
1497 }
1498 OciCommand::Inspect {
1499 ref image,
1500 manifest,
1501 config,
1502 } => {
1503 let img = resolve_oci_image(&repo, image)?;
1504
1505 if manifest {
1506 let manifest_json = img.read_manifest_json(&repo)?;
1508 std::io::Write::write_all(&mut std::io::stdout(), &manifest_json)?;
1509 println!();
1510 } else if config {
1511 let config_json = img.read_config_json(&repo)?;
1513 std::io::Write::write_all(&mut std::io::stdout(), &config_json)?;
1514 println!();
1515 } else {
1516 let output = crate::varlink::OciInspectReply::from_image(&repo, &img)?;
1518 serde_json::to_writer_pretty(std::io::stdout().lock(), &output)?;
1519 println!();
1520 }
1521 }
1522 OciCommand::Tag {
1523 ref manifest_digest,
1524 ref name,
1525 } => {
1526 composefs_oci::oci_image::tag_image(&repo, manifest_digest, name)?;
1527 println!("Tagged {manifest_digest} as {name}");
1528 }
1529 OciCommand::Untag { ref name } => {
1530 composefs_oci::oci_image::untag_image(&repo, name)?;
1531 println!("Removed tag {name}");
1532 }
1533 OciCommand::LayerInspect {
1534 ref layer,
1535 dumpfile,
1536 json,
1537 } => {
1538 if json {
1539 let info = composefs_oci::layer_info(&repo, layer)?;
1540 serde_json::to_writer_pretty(std::io::stdout().lock(), &info)?;
1541 println!();
1542 } else if dumpfile {
1543 composefs_oci::layer_dumpfile(&repo, layer, &mut std::io::stdout())?;
1544 } else {
1545 let mut out = std::io::stdout().lock();
1547 if out.is_terminal() {
1548 anyhow::bail!(
1549 "Refusing to write tar data to terminal. \
1550 Redirect to a file, pipe to tar, or use --json for metadata."
1551 );
1552 }
1553 composefs_oci::layer_tar(&repo, layer, &mut out)?;
1554 }
1555 }
1556
1557 OciCommand::PrepareBoot {
1558 config_opts:
1559 OCIConfigOptions {
1560 ref config_name,
1561 ref config_verity,
1562 },
1563 ref bootdir,
1564 ref entry_id,
1565 ref cmdline,
1566 } => {
1567 let verity = verity_opt(config_verity)?;
1568 let (config_digest, config_verity) =
1569 resolve_oci_config(&repo, config_name, verity)?;
1570 let mut fs = composefs_oci::image::create_filesystem(
1571 &repo,
1572 &config_digest,
1573 config_verity.as_ref(),
1574 )?;
1575 let entries = fs.transform_for_boot(&repo)?;
1576 let ids = fs.commit_images(&repo, None)?;
1577 let fmt_config = repo.default_format_config();
1578 let id = ids
1580 .get(&FormatVersion::V1)
1581 .or_else(|| ids.get(&FormatVersion::V2))
1582 .ok_or_else(|| anyhow::anyhow!("commit_images produced no images"))?
1583 .clone();
1584
1585 let insecure = repo.is_insecure();
1586 let karg = if fmt_config.default == FormatVersion::V1
1587 && !fmt_config.extra.contains(&FormatVersion::V2)
1588 {
1589 ComposefsCmdline::new_v1(id, insecure)
1591 } else {
1592 ComposefsCmdline::new_v2(id, insecure)
1594 };
1595
1596 let Some(entry) = entries.into_iter().next() else {
1597 anyhow::bail!("No boot entries!");
1598 };
1599
1600 let cmdline_refs: Vec<&str> = cmdline.iter().map(String::as_str).collect();
1601 write_boot::write_boot_simple(
1602 &repo,
1603 entry,
1604 &karg,
1605 bootdir,
1606 None,
1607 entry_id.as_deref(),
1608 &cmdline_refs,
1609 )?;
1610
1611 let state = args
1612 .repo
1613 .as_ref()
1614 .map(|p: &PathBuf| p.parent().unwrap())
1615 .unwrap_or(Path::new("/sysroot"))
1616 .join("state/deploy")
1617 .join(karg.digest().to_hex());
1618
1619 create_dir_all(state.join("var"))?;
1620 create_dir_all(state.join("etc/upper"))?;
1621 create_dir_all(state.join("etc/work"))?;
1622 }
1623 OciCommand::Fsck { image, json } => {
1624 let result = if let Some(ref name) = image {
1625 composefs_oci::oci_fsck_image(&repo, name).await?
1626 } else {
1627 composefs_oci::oci_fsck(&repo).await?
1628 };
1629 if json {
1630 let output = crate::varlink::OciFsckReply::from(&result);
1631 serde_json::to_writer_pretty(std::io::stdout().lock(), &output)?;
1632 println!();
1633 } else {
1634 print!("{result}");
1635 if !result.is_ok() {
1636 anyhow::bail!("OCI integrity check failed");
1637 }
1638 }
1639 }
1640 OciCommand::Varlink { .. } => {
1641 unreachable!("oci varlink is handled before opening a repository");
1642 }
1643 },
1644 #[cfg(feature = "ostree")]
1645 Command::Ostree { cmd: ostree_cmd } => match ostree_cmd {
1646 OstreeCommand::PullLocal {
1647 ref ostree_repo_path,
1648 ref ostree_ref,
1649 base_name,
1650 } => {
1651 eprintln!("Fetching {ostree_ref}");
1652 let (verity, stats) = composefs_ostree::pull_local(
1653 &repo,
1654 ostree_repo_path,
1655 ostree_ref,
1656 base_name.as_deref(),
1657 )
1658 .await?;
1659
1660 let image_id = composefs_ostree::get_image_ref(&repo, &stats.commit_id)?;
1661 println!("commit {}", stats.commit_id);
1662 println!("verity {}", verity.to_hex());
1663 println!("image {}", image_id.to_hex());
1664 if !composefs_ostree::is_commit_id(ostree_ref) {
1665 println!("tagged {ostree_ref}");
1666 }
1667 println!(
1668 "objects {} metadata + {} files fetched",
1669 stats.metadata_fetched, stats.files_fetched
1670 );
1671 }
1672 OstreeCommand::Pull {
1673 ref ostree_repo_url,
1674 ref ostree_ref,
1675 base_name,
1676 } => {
1677 eprintln!("Fetching {ostree_ref}");
1678 let (verity, stats) = composefs_ostree::pull(
1679 &repo,
1680 ostree_repo_url,
1681 ostree_ref,
1682 base_name.as_deref(),
1683 )
1684 .await?;
1685
1686 let image_id = composefs_ostree::get_image_ref(&repo, &stats.commit_id)?;
1687 println!("commit {}", stats.commit_id);
1688 println!("verity {}", verity.to_hex());
1689 println!("image {}", image_id.to_hex());
1690 if !composefs_ostree::is_commit_id(ostree_ref) {
1691 println!("tagged {ostree_ref}");
1692 }
1693 println!(
1694 "objects {} metadata + {} files fetched",
1695 stats.metadata_fetched, stats.files_fetched
1696 );
1697 }
1698 OstreeCommand::Mount {
1699 ref commit,
1700 ref mountpoint,
1701 ref upperdir,
1702 ref workdir,
1703 read_write,
1704 } => {
1705 let mount_options =
1706 get_mount_options(upperdir.as_deref(), workdir.as_deref(), read_write)?;
1707 let image_id = composefs_ostree::get_image_ref(&repo, commit)?;
1708 repo.mount_at(&image_id.to_hex(), mountpoint.as_str(), &mount_options)?;
1709 }
1710 OstreeCommand::Dump { ref commit_name } => {
1711 let fs = composefs_ostree::create_filesystem(&repo, commit_name)?;
1712 fs.print_dumpfile()?;
1713 }
1714 OstreeCommand::ComputeId { ref commit_name } => {
1715 let image_id = composefs_ostree::ensure_ostree_erofs(&repo, commit_name)?;
1716 println!("{}", image_id.to_hex());
1717 }
1718 OstreeCommand::Inspect {
1719 ref source,
1720 metadata,
1721 } => {
1722 composefs_ostree::inspect(&repo, source, metadata)?;
1723 }
1724 OstreeCommand::Tag {
1725 ref source,
1726 ref name,
1727 } => {
1728 composefs_ostree::tag(&repo, source, name)?;
1729 println!("Tagged {source} as {name}");
1730 }
1731 OstreeCommand::Untag { ref name } => {
1732 composefs_ostree::untag(&repo, name)?;
1733 }
1734 OstreeCommand::ListCommits => {
1735 let commits = composefs_ostree::list_commits(&repo)?;
1736 if commits.is_empty() {
1737 println!("No ostree commits found");
1738 } else {
1739 let mut table = Table::new();
1740 table.load_preset(UTF8_FULL);
1741 table.set_header(["NAME", "COMMIT"]);
1742 for c in commits {
1743 table.add_row([c.name.as_str(), &c.commit_id]);
1744 }
1745 println!("{table}");
1746 }
1747 }
1748 },
1749 Command::CreateImage {
1750 fs_opts,
1751 ref image_name,
1752 } => {
1753 let fs = load_filesystem_from_ondisk_fs(&fs_opts, Some(Arc::clone(&repo))).await?;
1754 let id = fs.commit_image(&repo, image_name.as_deref())?;
1755 println!("{}", id.to_id());
1756 }
1757 Command::ComputeId { .. }
1758 | Command::ComputeKarg { .. }
1759 | Command::CreateDumpfile { .. } => {
1760 unreachable!(
1762 "compute-id, compute-karg, and create-dumpfile are dispatched without a repo"
1763 );
1764 }
1765 Command::Mount {
1766 name,
1767 mountpoint,
1768 ref upperdir,
1769 ref workdir,
1770 read_write,
1771 } => {
1772 let mount_options =
1773 get_mount_options(upperdir.as_deref(), workdir.as_deref(), read_write)?;
1774 repo.mount_at(&name, &mountpoint, &mount_options)?;
1775 }
1776 Command::ImageObjects { name } => {
1777 let objects = repo.objects_for_image(&name)?;
1778 for object in objects {
1779 println!("{}", object.to_id());
1780 }
1781 }
1782 Command::GC { root, dry_run } => {
1783 let roots: Vec<&str> = root.iter().map(|s| s.as_str()).collect();
1784 let result = if dry_run {
1785 repo.gc_dry_run(&roots)?
1786 } else {
1787 repo.gc(&roots)?
1788 };
1789 if dry_run {
1790 println!("Dry run (no files deleted):");
1791 }
1792 println!(
1793 "Objects: {} removed ({} bytes)",
1794 result.objects_removed, result.objects_bytes
1795 );
1796 if result.images_pruned > 0 || result.streams_pruned > 0 {
1797 println!(
1798 "Pruned symlinks: {} images, {} streams",
1799 result.images_pruned, result.streams_pruned
1800 );
1801 }
1802 }
1803 Command::DumpFiles {
1804 image_name,
1805 files,
1806 backing_path_only,
1807 } => {
1808 let (img_fd, _) = repo.open_image(&image_name)?;
1809
1810 let mut img_buf = Vec::new();
1811 std::fs::File::from(img_fd).read_to_end(&mut img_buf)?;
1812
1813 dump_file_impl(
1814 erofs_to_filesystem::<ObjectID>(&img_buf)?,
1815 &files,
1816 backing_path_only,
1817 )?;
1818 }
1819 Command::Fsck {
1820 json,
1821 metadata_only,
1822 } => {
1823 let result = if metadata_only {
1824 repo.fsck_metadata_only().await?
1825 } else {
1826 repo.fsck().await?
1827 };
1828 if json {
1829 let output = crate::varlink::FsckReply::from(&result);
1830 serde_json::to_writer_pretty(std::io::stdout().lock(), &output)?;
1831 println!();
1832 } else {
1833 print!("{result}");
1834 if !result.is_ok() {
1835 anyhow::bail!("repository integrity check failed");
1836 }
1837 }
1838 }
1839 Command::Varlink { .. } => {
1840 unreachable!("varlink is handled before opening a repository");
1842 }
1843 #[cfg(feature = "http")]
1844 Command::Fetch { url, name } => {
1845 let reporter: SharedReporter = IndicatifReporter::new().into_shared();
1846 let (digest, verity) = composefs_http::download(
1847 &url,
1848 &name,
1849 Arc::clone(&repo),
1850 composefs_http::DownloadOptions {
1851 progress: Some(reporter),
1852 },
1853 )
1854 .await?;
1855 println!("content {digest}");
1856 println!("verity {}", verity.to_hex());
1857 }
1858 Command::Mkcomposefs { .. } | Command::ComposefsInfo { .. } => {
1859 unreachable!("mkcomposefs/composefs-info are dispatched before opening a repository");
1861 }
1862 }
1863 Ok(())
1864}
1865
1866#[cfg(test)]
1867#[cfg(any(feature = "oci", feature = "http"))]
1868mod tests {
1869 use super::*;
1870 use composefs::progress::{ProgressEvent, ProgressUnit};
1871
1872 #[test]
1877 fn test_indicatif_reporter_valid_lifecycle() {
1878 let reporter = IndicatifReporter::new();
1879 reporter.report(ProgressEvent::Message("starting pull".into()));
1881 reporter.report(ProgressEvent::Started {
1883 id: "sha256:abc".into(),
1884 total: Some(1_000_000),
1885 unit: ProgressUnit::Bytes,
1886 });
1887 reporter.report(ProgressEvent::Progress {
1888 id: "sha256:abc".into(),
1889 fetched: 500_000,
1890 total: Some(1_000_000),
1891 });
1892 reporter.report(ProgressEvent::Done {
1893 id: "sha256:abc".into(),
1894 transferred: 1_000_000,
1895 });
1896 reporter.report(ProgressEvent::Started {
1898 id: "objects:stream".into(),
1899 total: Some(200),
1900 unit: ProgressUnit::Items,
1901 });
1902 reporter.report(ProgressEvent::Progress {
1903 id: "objects:stream".into(),
1904 fetched: 100,
1905 total: Some(200),
1906 });
1907 reporter.report(ProgressEvent::Done {
1908 id: "objects:stream".into(),
1909 transferred: 200,
1910 });
1911 reporter.report(ProgressEvent::Started {
1913 id: "sha256:cached".into(),
1914 total: None,
1915 unit: ProgressUnit::Bytes,
1916 });
1917 reporter.report(ProgressEvent::Skipped {
1918 id: "sha256:cached".into(),
1919 });
1920 }
1921
1922 #[test]
1928 fn test_indicatif_reporter_unknown_id_no_panic() {
1929 let reporter = IndicatifReporter::new();
1930 reporter.report(ProgressEvent::Progress {
1932 id: "ghost".into(),
1933 fetched: 42,
1934 total: None,
1935 });
1936 reporter.report(ProgressEvent::Done {
1938 id: "ghost".into(),
1939 transferred: 42,
1940 });
1941 reporter.report(ProgressEvent::Skipped { id: "ghost".into() });
1943 }
1944
1945 #[test]
1947 fn test_indicatif_reporter_spinner_lifecycle() {
1948 let reporter = IndicatifReporter::new();
1949 reporter.report(ProgressEvent::Started {
1951 id: "layer:unknown-size".into(),
1952 total: None,
1953 unit: ProgressUnit::Bytes,
1954 });
1955 reporter.report(ProgressEvent::Progress {
1956 id: "layer:unknown-size".into(),
1957 fetched: 1024,
1958 total: None,
1959 });
1960 reporter.report(ProgressEvent::Done {
1961 id: "layer:unknown-size".into(),
1962 transferred: 2048,
1963 });
1964 }
1965
1966 #[test]
1968 fn test_indicatif_reporter_multiple_concurrent_components() {
1969 let reporter = IndicatifReporter::new();
1970 reporter.report(ProgressEvent::Started {
1972 id: "layer:a".into(),
1973 total: Some(100),
1974 unit: ProgressUnit::Bytes,
1975 });
1976 reporter.report(ProgressEvent::Started {
1977 id: "layer:b".into(),
1978 total: Some(200),
1979 unit: ProgressUnit::Bytes,
1980 });
1981 reporter.report(ProgressEvent::Progress {
1983 id: "layer:a".into(),
1984 fetched: 50,
1985 total: Some(100),
1986 });
1987 reporter.report(ProgressEvent::Progress {
1988 id: "layer:b".into(),
1989 fetched: 100,
1990 total: Some(200),
1991 });
1992 reporter.report(ProgressEvent::Done {
1994 id: "layer:b".into(),
1995 transferred: 200,
1996 });
1997 reporter.report(ProgressEvent::Done {
1999 id: "layer:a".into(),
2000 transferred: 100,
2001 });
2002 }
2003}