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 #[clap(
345 long,
346 value_parser = clap::value_parser!(composefs::generic_tree::XattrFiltering),
347 default_value_t = composefs::generic_tree::XattrFiltering::AllowlistOnly
348 )]
349 xattrs: composefs::generic_tree::XattrFiltering,
350}
351
352#[cfg(feature = "oci")]
354#[derive(Debug, Parser)]
355struct OCIConfigOptions {
356 #[arg(add = ArgValueCompleter::new(complete_oci_tags_and_digests))]
358 config_name: OciReference,
359 config_verity: Option<String>,
361}
362
363#[cfg(feature = "oci")]
364#[derive(Debug, Subcommand)]
365enum OciCommand {
366 ImportLayer {
368 digest: composefs_oci::OciDigest,
370 name: Option<String>,
372 },
373 Dump {
379 #[clap(flatten)]
380 config_opts: OCIConfigFilesystemOptions,
381 },
382 Pull {
386 image: String,
388 name: Option<String>,
390 #[arg(long)]
392 bootable: bool,
393 #[arg(long, requires = "bootable")]
404 expected_digest: Option<String>,
405 #[arg(long, value_enum, default_value_t = LocalFetchCli::Disabled)]
408 local_fetch: LocalFetchCli,
409 },
410 Copy {
423 image: OciReference,
425 #[clap(long)]
427 from: PathBuf,
428 #[clap(long)]
430 name: Option<String>,
431 #[clap(long)]
433 zerocopy: bool,
434 },
435 #[clap(name = "images")]
437 ListImages {
438 #[clap(long)]
440 json: bool,
441 },
442 #[clap(name = "inspect")]
451 Inspect {
452 #[arg(add = ArgValueCompleter::new(complete_oci_tags_and_digests))]
454 image: OciReference,
455 #[clap(long, conflicts_with = "config")]
457 manifest: bool,
458 #[clap(long, conflicts_with = "manifest")]
460 config: bool,
461 },
462 Tag {
466 #[arg(add = ArgValueCompleter::new(complete_oci_digests))]
468 manifest_digest: composefs_oci::OciDigest,
469 name: String,
471 },
472 Untag {
474 #[arg(add = ArgValueCompleter::new(complete_oci_tags))]
476 name: String,
477 },
478 #[clap(name = "layer")]
483 LayerInspect {
484 layer: composefs_oci::OciDigest,
486 #[clap(long, conflicts_with = "json")]
488 dumpfile: bool,
489 #[clap(long, conflicts_with = "dumpfile")]
491 json: bool,
492 },
493 Mount {
495 #[arg(add = ArgValueCompleter::new(complete_oci_tags_and_digests))]
497 image: String,
498 #[arg(value_hint = clap::ValueHint::AnyPath)]
500 mountpoint: String,
501 #[arg(long)]
503 bootable: bool,
504 #[clap(flatten)]
505 mount_opts: MountOpts,
506 },
507 ComputeId {
513 #[clap(flatten)]
514 config_opts: OCIConfigFilesystemOptions,
515 },
516
517 PrepareBoot {
522 #[clap(flatten)]
523 config_opts: OCIConfigOptions,
524 #[clap(long, default_value = "/boot", value_hint = clap::ValueHint::DirPath)]
526 bootdir: PathBuf,
527 #[clap(long)]
529 entry_id: Option<String>,
530 #[clap(long)]
532 cmdline: Vec<String>,
533 },
534 Fsck {
540 #[arg(add = ArgValueCompleter::new(complete_oci_tags))]
542 image: Option<String>,
543 #[clap(long)]
545 json: bool,
546 },
547 Varlink {
553 #[clap(long, value_hint = clap::ValueHint::AnyPath)]
555 address: Option<PathBuf>,
556 },
557}
558
559#[cfg(feature = "ostree")]
560#[derive(Debug, Subcommand)]
561enum OstreeCommand {
562 PullLocal {
563 #[arg(value_hint = clap::ValueHint::DirPath)]
564 ostree_repo_path: PathBuf,
565 ostree_ref: String,
567 #[clap(long)]
568 base_name: Option<String>,
569 },
570 Pull {
571 #[arg(value_hint = clap::ValueHint::Url)]
572 ostree_repo_url: String,
573 ostree_ref: String,
575 #[clap(long)]
576 base_name: Option<String>,
577 #[clap(long)]
579 no_delta: bool,
580 },
581 Mount {
583 #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
585 commit: String,
586 #[arg(value_hint = clap::ValueHint::AnyPath)]
588 mountpoint: String,
589 #[clap(flatten)]
590 mount_opts: MountOpts,
591 },
592 Dump {
594 #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
596 commit_name: String,
597 },
598 ComputeId {
600 #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
602 commit_name: String,
603 },
604 Inspect {
606 #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
608 source: String,
609 #[clap(long)]
611 metadata: bool,
612 },
613 Tag {
617 #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
619 source: String,
620 name: String,
622 },
623 Untag {
625 #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
627 name: String,
628 },
629 Commit {
634 #[arg(add = ArgValueCompleter::new(complete_image_refs))]
636 image: String,
637 #[clap(long)]
639 reference: Option<String>,
640 #[clap(long, default_value = "")]
642 subject: String,
643 },
644 Export {
650 #[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
652 source: String,
653 #[arg(value_hint = clap::ValueHint::DirPath)]
655 ostree_repo_path: PathBuf,
656 #[clap(long)]
658 reference: Option<String>,
659 },
660 #[clap(name = "images")]
662 ListCommits,
663 ApplyDelta {
665 #[arg(value_hint = clap::ValueHint::FilePath)]
667 delta_path: PathBuf,
668 },
669 ListRefs {
671 #[arg(value_hint = clap::ValueHint::Url)]
673 ostree_repo_url: String,
674 #[clap(long)]
676 subset: Option<String>,
677 },
678}
679
680#[derive(Debug, Parser)]
682struct FsReadOptions {
683 #[arg(value_hint = clap::ValueHint::DirPath)]
685 path: PathBuf,
686 #[clap(long)]
688 bootable: bool,
689 #[clap(long)]
691 no_propagate_usr_to_root: bool,
692 #[clap(
697 long,
698 value_parser = clap::value_parser!(composefs::generic_tree::XattrFiltering),
699 default_value_t = composefs::generic_tree::XattrFiltering::AllowlistOnly
700 )]
701 xattrs: composefs::generic_tree::XattrFiltering,
702}
703
704#[derive(Debug, Parser)]
706struct MountOpts {
707 #[cfg(feature = "fuse")]
709 #[arg(long, value_enum, default_value_t)]
710 fuse: FuseMode,
711 #[cfg(feature = "fuse")]
713 #[arg(long)]
714 foreground: bool,
715 #[arg(long, requires = "workdir", value_hint = clap::ValueHint::DirPath)]
717 upperdir: Option<PathBuf>,
718 #[arg(long, requires = "upperdir", value_hint = clap::ValueHint::DirPath)]
720 workdir: Option<PathBuf>,
721 #[arg(long, requires = "upperdir")]
723 read_write: bool,
724}
725
726impl MountOpts {
727 fn to_mount_options(&self) -> Result<composefs::mount::MountOptions> {
728 get_mount_options(
729 self.upperdir.as_deref(),
730 self.workdir.as_deref(),
731 self.read_write,
732 )
733 }
734
735 fn mount_image<ObjectID: FsVerityHashValue>(
736 &self,
737 repo: &Arc<Repository<ObjectID>>,
738 image_name: &str,
739 mountpoint: &str,
740 ) -> Result<()> {
741 let mount_options = self.to_mount_options()?;
742
743 #[cfg(feature = "fuse")]
744 if let mode @ (MountMode::Fuse | MountMode::FuseOverlay) =
745 detect_mount_mode(self.fuse, self.upperdir.is_some())
746 {
747 return run_fuse_mount(
748 repo,
749 image_name,
750 mountpoint,
751 mode,
752 mount_options,
753 self.foreground,
754 );
755 }
756
757 repo.mount_at(image_name, mountpoint, &mount_options)?;
758 Ok(())
759 }
760}
761
762#[derive(Debug, Subcommand)]
763enum Command {
764 Init {
771 #[clap(long, value_parser = clap::value_parser!(Algorithm), default_value_t = Algorithm::SHA512)]
774 algorithm: Algorithm,
775 #[arg(value_hint = clap::ValueHint::DirPath)]
778 path: Option<PathBuf>,
779 #[clap(long)]
781 insecure: bool,
782 #[clap(long)]
787 reset_metadata: bool,
788 #[clap(long)]
794 ensure: bool,
795 #[clap(long)]
799 erofs_version: Option<ErofsVersion>,
800 },
801 Transaction,
804 Cat {
806 #[arg(add = ArgValueCompleter::new(complete_stream_refs))]
808 name: String,
809 },
810 GC {
812 #[clap(long, short = 'r')]
814 root: Vec<String>,
815 #[clap(long, short = 'n')]
817 dry_run: bool,
818 },
819 ImportImage { reference: String },
821 #[clap(name = "images", alias = "list-images")]
823 Images {
824 #[clap(long)]
826 json: bool,
827 #[clap(long)]
829 no_trunc: bool,
830 },
831 #[cfg(feature = "oci")]
833 Oci {
834 #[clap(subcommand)]
835 cmd: OciCommand,
836 },
837 #[cfg(feature = "ostree")]
838 Ostree {
839 #[clap(subcommand)]
840 cmd: OstreeCommand,
841 },
842 Mount {
844 #[arg(add = ArgValueCompleter::new(complete_image_refs))]
846 name: String,
847 #[arg(value_hint = clap::ValueHint::AnyPath)]
849 mountpoint: String,
850 #[clap(flatten)]
851 mount_opts: MountOpts,
852 },
853 CreateImage {
856 #[clap(flatten)]
857 fs_opts: FsReadOptions,
858 image_name: Option<String>,
860 },
861 ComputeId {
865 #[clap(flatten)]
866 fs_opts: FsReadOptions,
867 },
868 #[clap(name = "compute-karg")]
883 ComputeKarg {
884 #[arg(value_hint = clap::ValueHint::DirPath)]
886 path: PathBuf,
887 #[clap(long)]
889 no_propagate_usr_to_root: bool,
890 },
891 CreateDumpfile {
894 #[clap(flatten)]
895 fs_opts: FsReadOptions,
896 },
897 ImageObjects {
899 #[arg(add = ArgValueCompleter::new(complete_image_refs))]
901 name: String,
902 },
903 DumpFiles {
907 #[arg(add = ArgValueCompleter::new(complete_image_refs))]
909 image_name: String,
910 #[arg(value_hint = clap::ValueHint::AnyPath)]
912 files: Vec<PathBuf>,
913 #[clap(long)]
917 backing_path_only: bool,
918 },
919 Fsck {
925 #[clap(long)]
927 json: bool,
928 #[clap(long)]
931 metadata_only: bool,
932 },
933 #[cfg(feature = "http")]
934 Fetch {
935 #[arg(value_hint = clap::ValueHint::Url)]
936 url: String,
937 name: String,
938 },
939 Varlink {
945 #[clap(long, value_hint = clap::ValueHint::AnyPath)]
947 address: Option<PathBuf>,
948 },
949
950 #[clap(hide = true, name = "mkcomposefs")]
952 Mkcomposefs {
953 #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
955 args: Vec<std::ffi::OsString>,
956 },
957
958 #[clap(hide = true, name = "composefs-info")]
960 ComposefsInfo {
961 #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
963 args: Vec<std::ffi::OsString>,
964 },
965}
966
967pub async fn run_from_iter<I>(args: I) -> Result<()>
973where
974 I: IntoIterator,
975 I::Item: Into<OsString> + Clone,
976{
977 let args = App::parse_from(
978 std::iter::once(OsString::from("cfsctl")).chain(args.into_iter().map(Into::into)),
979 );
980
981 run_app(args).await
982}
983
984#[cfg(feature = "ostree")]
985fn print_pull_stats(stats: &composefs_ostree::PullStats) {
986 if stats.delta_parts_applied > 0 {
987 println!(
988 "objects {} metadata + {} files via {} delta parts",
989 stats.metadata_fetched, stats.files_fetched, stats.delta_parts_applied
990 );
991 } else {
992 println!(
993 "objects {} metadata + {} files fetched",
994 stats.metadata_fetched, stats.files_fetched
995 );
996 }
997}
998
999fn get_mount_options(
1000 upperdir: Option<&Path>,
1001 workdir: Option<&Path>,
1002 read_write: bool,
1003) -> Result<MountOptions> {
1004 let mut options = MountOptions::default();
1005 if let (Some(u), Some(w)) = (upperdir, workdir) {
1006 let upper_fd = rustix::fs::open(
1007 u,
1008 OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
1009 Mode::empty(),
1010 )
1011 .with_context(|| format!("Opening upperdir '{}'", u.display()))?;
1012 let work_fd = rustix::fs::open(
1013 w,
1014 OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
1015 Mode::empty(),
1016 )
1017 .with_context(|| format!("Opening workdir '{}'", w.display()))?;
1018 options.set_overlay(upper_fd, work_fd);
1019 }
1020 options.set_read_write(read_write);
1021 Ok(options)
1022}
1023
1024#[cfg(feature = "fuse")]
1025use fuse::{FuseMode, MountMode, detect_mount_mode, run_fuse_mount};
1026
1027#[cfg(feature = "oci")]
1028pub(crate) fn verity_opt<ObjectID>(opt: &Option<String>) -> Result<Option<ObjectID>>
1029where
1030 ObjectID: FsVerityHashValue,
1031{
1032 Ok(match opt {
1033 Some(value) => Some(FsVerityHashValue::from_hex(value)?),
1034 None => None,
1035 })
1036}
1037
1038pub(crate) fn default_repo_path() -> Result<PathBuf> {
1044 if rustix::process::getuid().is_root() {
1045 Ok(system_path())
1046 } else {
1047 user_path()
1048 }
1049}
1050
1051pub(crate) fn resolve_repo_path(args: &App) -> Result<PathBuf> {
1056 if let Some(path) = &args.repo {
1057 Ok(path.clone())
1058 } else if args.system {
1059 Ok(system_path())
1060 } else if args.user {
1061 user_path()
1062 } else {
1063 default_repo_path()
1064 }
1065}
1066
1067pub(crate) fn resolve_hash_type(
1079 repo_path: &Path,
1080 cli_hash: Option<HashType>,
1081 upgrade: bool,
1082) -> Result<HashType> {
1083 let repo_fd = rustix::fs::open(
1084 repo_path,
1085 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1086 Mode::empty(),
1087 )
1088 .with_context(|| format!("opening repository {}", repo_path.display()))?;
1089
1090 let algorithm = match read_repo_algorithm(&repo_fd)? {
1091 Some(alg) => alg,
1092 None if upgrade => {
1093 composefs::repository::infer_repo_algorithm(&repo_fd).with_context(|| {
1096 format!(
1097 "no {REPO_METADATA_FILENAME} in {}; tried to infer algorithm from objects",
1098 repo_path.display(),
1099 )
1100 })?
1101 }
1102 None => {
1103 anyhow::bail!(
1104 "{REPO_METADATA_FILENAME} not found in {}; \
1105 this repository must be initialized with `cfsctl init`",
1106 repo_path.display(),
1107 );
1108 }
1109 };
1110
1111 let detected = match algorithm {
1112 Algorithm::Sha256 { .. } => HashType::Sha256,
1113 Algorithm::Sha512 { .. } => HashType::Sha512,
1114 };
1115
1116 if let Some(explicit) = cli_hash
1118 && explicit != detected
1119 {
1120 anyhow::bail!(
1121 "repository is configured for {algorithm} (from {REPO_METADATA_FILENAME}) \
1122 but --hash {} was specified",
1123 match explicit {
1124 HashType::Sha256 => "sha256",
1125 HashType::Sha512 => "sha512",
1126 },
1127 );
1128 }
1129
1130 Ok(detected)
1131}
1132
1133pub async fn run_if_socket_activated() -> Result<bool> {
1151 if std::env::args_os().len() != 1 {
1155 return Ok(false);
1156 }
1157 let service = crate::varlink::CfsctlService::activated();
1158 match crate::varlink::try_activated_listener()? {
1159 Some(crate::varlink::ActivatedSocket::Connected(l)) => {
1160 crate::varlink::serve_activated(service, l).await?;
1161 Ok(true)
1162 }
1163 Some(crate::varlink::ActivatedSocket::Listening(listener)) => {
1164 crate::varlink::serve_on_listener(service, listener).await?;
1165 Ok(true)
1166 }
1167 None => Ok(false),
1168 }
1169}
1170
1171pub async fn run_app(args: App) -> Result<()> {
1173 if let Command::Mkcomposefs { args: extra } = args.cmd {
1175 return mkcomposefs::run_from_args(extra);
1176 }
1177 if let Command::ComposefsInfo { args: extra } = args.cmd {
1178 return composefs_info::run_from_args(extra);
1179 }
1180
1181 if let Command::Init {
1183 ref algorithm,
1184 ref path,
1185 insecure,
1186 reset_metadata,
1187 ensure,
1188 erofs_version: ref init_erofs_version,
1189 } = args.cmd
1190 {
1191 let erofs_version = init_erofs_version
1193 .or(args.erofs_version)
1194 .map(composefs::erofs::format::FormatVersion::from)
1195 .unwrap_or(composefs::erofs::format::FormatVersion::V1);
1196 return run_init(
1197 algorithm,
1198 path.as_deref(),
1199 insecure || args.insecure,
1200 reset_metadata,
1201 ensure,
1202 erofs_version,
1203 &args,
1204 );
1205 }
1206
1207 if let Command::Varlink { ref address } = args.cmd {
1213 let service = crate::varlink::CfsctlService::from_app(&args);
1214 return crate::varlink::serve(service, address.as_deref()).await;
1215 }
1216
1217 #[cfg(feature = "oci")]
1218 if let Command::Oci {
1219 cmd: OciCommand::Varlink { ref address },
1220 } = args.cmd
1221 {
1222 let service = crate::varlink::CfsctlService::from_app(&args);
1223 return crate::varlink::serve(service, address.as_deref()).await;
1224 }
1225
1226 if args.no_repo
1229 || matches!(
1230 args.cmd,
1231 Command::ComputeId { .. }
1232 | Command::ComputeKarg { .. }
1233 | Command::CreateDumpfile { .. }
1234 )
1235 {
1236 let effective_hash = if !args.no_repo {
1241 if let Ok(repo_path) = resolve_repo_path(&args) {
1242 resolve_hash_type(&repo_path, args.hash, !args.no_upgrade)
1243 .unwrap_or(args.hash.unwrap_or(HashType::Sha512))
1244 } else {
1245 args.hash.unwrap_or(HashType::Sha512)
1246 }
1247 } else {
1248 args.hash.unwrap_or(HashType::Sha512)
1249 };
1250 return match effective_hash {
1251 HashType::Sha256 => run_cmd_without_repo::<Sha256HashValue>(args).await,
1252 HashType::Sha512 => run_cmd_without_repo::<Sha512HashValue>(args).await,
1253 };
1254 }
1255
1256 let repo_path = resolve_repo_path(&args)?;
1257 let effective_hash = resolve_hash_type(&repo_path, args.hash, !args.no_upgrade)?;
1258
1259 match effective_hash {
1260 HashType::Sha256 => run_cmd_with_repo(open_repo::<Sha256HashValue>(&args)?, args).await,
1261 HashType::Sha512 => run_cmd_with_repo(open_repo::<Sha512HashValue>(&args)?, args).await,
1262 }
1263}
1264
1265fn run_init(
1267 algorithm: &Algorithm,
1268 path: Option<&Path>,
1269 insecure: bool,
1270 reset_metadata: bool,
1271 ensure: bool,
1272 erofs_version: composefs::erofs::format::FormatVersion,
1273 args: &App,
1274) -> Result<()> {
1275 let repo_path = if let Some(p) = path {
1276 p.to_path_buf()
1277 } else {
1278 resolve_repo_path(args)?
1279 };
1280
1281 if reset_metadata {
1282 composefs::repository::reset_metadata(&repo_path)?;
1283 }
1284
1285 if ensure {
1286 let formats = composefs::erofs::format::FormatConfig::single(erofs_version);
1287 let status =
1288 crate::varlink::run_ensure_repository(&repo_path, *algorithm, insecure, Some(formats))?;
1289 match status {
1290 composefs::repository::EnsureStatus::Created => {
1291 println!(
1292 "Initialized composefs repository at {}",
1293 repo_path.display()
1294 );
1295 println!(" algorithm: {algorithm}");
1296 if insecure {
1297 println!(" verity: not required (insecure)");
1298 } else {
1299 println!(" verity: required");
1300 }
1301 }
1302 composefs::repository::EnsureStatus::Opened => {
1303 println!(
1304 "Repository already initialized at {} (existing configuration preserved)",
1305 repo_path.display()
1306 );
1307 }
1308 composefs::repository::EnsureStatus::Upgraded => {
1309 println!("Upgraded legacy repository at {}", repo_path.display());
1310 }
1311 }
1312 return Ok(());
1313 }
1314
1315 if let Some(parent) = repo_path.parent() {
1317 std::fs::create_dir_all(parent)
1318 .with_context(|| format!("creating parent directories for {}", repo_path.display()))?;
1319 }
1320
1321 let config = {
1324 let mut c = RepositoryConfig::new(*algorithm);
1325 c.erofs_formats = composefs::erofs::format::FormatConfig::single(erofs_version);
1326 if insecure { c.set_insecure() } else { c }
1327 };
1328 let created = match algorithm {
1329 Algorithm::Sha256 { .. } => {
1330 Repository::<Sha256HashValue>::init_path(CWD, &repo_path, config)?.1
1331 }
1332 Algorithm::Sha512 { .. } => {
1333 Repository::<Sha512HashValue>::init_path(CWD, &repo_path, config)?.1
1334 }
1335 };
1336
1337 if created {
1338 println!(
1339 "Initialized composefs repository at {}",
1340 repo_path.display()
1341 );
1342 println!(" algorithm: {algorithm}");
1343 if insecure {
1344 println!(" verity: not required (insecure)");
1345 } else {
1346 println!(" verity: required");
1347 }
1348 } else {
1349 println!("Repository already initialized at {}", repo_path.display());
1350 }
1351
1352 Ok(())
1353}
1354
1355pub(crate) fn open_repo_at<ObjectID>(
1362 path: &Path,
1363 insecure: bool,
1364 require_verity: bool,
1365 no_upgrade: bool,
1366) -> Result<Repository<ObjectID>>
1367where
1368 ObjectID: FsVerityHashValue,
1369{
1370 let mut repo = if no_upgrade {
1371 Repository::open_path(CWD, path)?
1372 } else {
1373 let (repo, _upgraded) = Repository::open_upgrade(CWD, path)?;
1374 repo
1375 };
1376 if insecure {
1380 repo.set_insecure();
1381 }
1382 if require_verity {
1383 repo.require_verity()?;
1384 }
1385 Ok(repo)
1386}
1387
1388pub fn open_repo<ObjectID>(args: &App) -> Result<Repository<ObjectID>>
1390where
1391 ObjectID: FsVerityHashValue,
1392{
1393 let path = resolve_repo_path(args)?;
1394 let mut repo = open_repo_at(&path, args.insecure, args.require_verity, args.no_upgrade)?;
1395 if let Some(version) = args.erofs_version {
1398 repo.set_erofs_version(version.into());
1399 }
1400 Ok(repo)
1401}
1402
1403#[cfg(feature = "oci")]
1405pub async fn copy_image(
1406 conn_src: &mut zlink::tokio::unix::Connection,
1407 conn_dest: &mut zlink::tokio::unix::Connection,
1408 handle_src: u64,
1409 handle_dest: u64,
1410 image: &OciReference,
1411 name: Option<&str>,
1412 zerocopy: bool,
1413) -> Result<crate::varlink::layer_sync::FinalizeImageReply> {
1414 use crate::varlink::layer_sync::LayerRef;
1415 use crate::varlink::oci::OciError;
1416 use crate::varlink::proxy::{GetLayerParams, OciProxy};
1417 use anyhow::ensure;
1418 use zlink::futures_util::StreamExt as _;
1419
1420 let image_str = image.to_string();
1421 let inspect = conn_src
1422 .inspect(handle_src, &image_str)
1423 .await
1424 .context("zlink transport error calling Inspect")?
1425 .map_err(|e: OciError| anyhow::anyhow!("Inspect failed: {e:?}"))?;
1426
1427 ensure!(
1428 !inspect.manifest.is_empty(),
1429 "inspect returned empty manifest"
1430 );
1431 ensure!(!inspect.config.is_empty(), "inspect returned empty config");
1432
1433 let diff_ids_ordered = composefs_oci::extract_layer_ids(&inspect.manifest, &inspect.config)
1437 .context("extracting layer identifiers")?;
1438
1439 let mut layer_refs: Vec<LayerRef> = Vec::with_capacity(diff_ids_ordered.len());
1440
1441 for diff_id in &diff_ids_ordered {
1442 let has = conn_dest
1443 .has_layer(handle_dest, diff_id)
1444 .await
1445 .context("zlink transport error calling HasLayer")?
1446 .map_err(|e: OciError| anyhow::anyhow!("HasLayer failed: {e:?}"))?;
1447
1448 let layer_verity = if has.present {
1449 has.layer_verity
1450 .context("HasLayer returned present=true but no layer_verity")?
1451 } else {
1452 let get_params = GetLayerParams {
1453 diff_id: Some(diff_id.to_string()),
1454 storage: None,
1455 ..Default::default()
1456 };
1457 let mut get_stream = std::pin::pin!(
1458 conn_src
1459 .get_layer(handle_src, get_params)
1460 .await
1461 .context("zlink transport error calling GetLayer")?
1462 );
1463 let mut all_fds: Vec<std::os::fd::OwnedFd> = Vec::new();
1464 let mut get_reply = None;
1465 while let Some(item) = get_stream.next().await {
1466 let (result, fds) = item.context("GetLayer stream frame error")?;
1467 let reply =
1468 result.map_err(|e: OciError| anyhow::anyhow!("GetLayer failed: {e:?}"))?;
1469 get_reply = Some(reply);
1470 all_fds.extend(fds);
1471 }
1472 let get_reply = get_reply.context("GetLayer returned empty stream")?;
1473 let dir_count = get_reply.dir_count as usize;
1474
1475 let pipe_and_dirfds_len = 1 + dir_count;
1476 let lifetime_fds = all_fds.split_off(pipe_and_dirfds_len);
1477
1478 let put_reply = conn_dest
1479 .put_layer(handle_dest, diff_id, zerocopy, all_fds)
1480 .await
1481 .context("zlink transport error calling PutLayer")?
1482 .map_err(|e: OciError| anyhow::anyhow!("PutLayer failed: {e:?}"))?;
1483 drop(lifetime_fds);
1484
1485 put_reply.layer_verity
1486 };
1487
1488 layer_refs.push(LayerRef {
1489 diff_id: diff_id.clone(),
1490 layer_verity,
1491 });
1492 }
1493
1494 let finalize = conn_dest
1495 .finalize_image(
1496 handle_dest,
1497 &inspect.manifest,
1498 &inspect.config,
1499 layer_refs,
1500 name,
1501 )
1502 .await
1503 .context("zlink transport error calling FinalizeImage")?
1504 .map_err(|e: OciError| anyhow::anyhow!("FinalizeImage failed: {e:?}"))?;
1505
1506 Ok(finalize)
1507}
1508
1509#[cfg(feature = "oci")]
1511pub(crate) fn resolve_oci_image<ObjectID: FsVerityHashValue>(
1512 repo: &Repository<ObjectID>,
1513 reference: &OciReference,
1514) -> Result<composefs_oci::oci_image::OciImage<ObjectID>> {
1515 match reference {
1516 OciReference::Digest(digest) => {
1517 composefs_oci::oci_image::OciImage::open(repo, digest, None)
1518 }
1519 OciReference::Named(name) => composefs_oci::oci_image::OciImage::open_ref(repo, name),
1520 }
1521}
1522
1523#[cfg(feature = "oci")]
1528pub(crate) fn resolve_oci_config<ObjectID: FsVerityHashValue>(
1529 repo: &Repository<ObjectID>,
1530 reference: &OciReference,
1531 verity_override: Option<ObjectID>,
1532) -> Result<(composefs_oci::OciDigest, Option<ObjectID>)> {
1533 match reference {
1534 OciReference::Digest(digest) => Ok((digest.clone(), verity_override)),
1535 OciReference::Named(_) => {
1536 let img = resolve_oci_image(repo, reference)?;
1537 Ok((
1538 img.config_digest().clone(),
1539 Some(img.config_verity().clone()),
1540 ))
1541 }
1542 }
1543}
1544
1545#[cfg(feature = "oci")]
1546fn load_filesystem_from_oci_image<ObjectID: FsVerityHashValue>(
1547 repo: &Repository<ObjectID>,
1548 opts: OCIConfigFilesystemOptions,
1549) -> Result<FileSystem<RegularFile<ObjectID>>> {
1550 let verity = verity_opt(&opts.base_config.config_verity)?;
1551 let (config_digest, config_verity) =
1552 resolve_oci_config(repo, &opts.base_config.config_name, verity)?;
1553 let transform_opts = composefs_oci::OciTransformOptions {
1554 xattrs: opts.xattrs,
1555 };
1556 let mut fs = composefs_oci::image::create_filesystem(
1557 repo,
1558 &config_digest,
1559 config_verity.as_ref(),
1560 &transform_opts,
1561 )?;
1562 if opts.bootable {
1563 fs.transform_for_boot(repo)?;
1564 }
1565 Ok(fs)
1566}
1567
1568async fn load_filesystem_from_ondisk_fs<ObjectID: FsVerityHashValue>(
1569 fs_opts: &FsReadOptions,
1570 repo: Option<Arc<Repository<ObjectID>>>,
1571) -> Result<FileSystem<RegularFile<ObjectID>>> {
1572 let dirfd = rustix::fs::openat(
1575 CWD,
1576 ".",
1577 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1578 Mode::empty(),
1579 )?;
1580 let mut fs = if fs_opts.no_propagate_usr_to_root {
1581 composefs::fs::read_filesystem(dirfd, fs_opts.path.clone(), repo.clone()).await?
1582 } else {
1583 let transform_opts = composefs::generic_tree::OciTransformOptions {
1584 xattrs: fs_opts.xattrs,
1585 };
1586 composefs::fs::read_container_root(
1587 dirfd,
1588 fs_opts.path.clone(),
1589 repo.clone(),
1590 &transform_opts,
1591 )
1592 .await?
1593 };
1594 if fs_opts.bootable {
1595 if let Some(repo) = &repo {
1596 fs.transform_for_boot(repo)?;
1597 } else {
1598 let rootfd = rustix::fs::openat(
1599 CWD,
1600 &fs_opts.path,
1601 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1602 Mode::empty(),
1603 )?;
1604 fs.transform_for_boot_from_dir(rootfd)?;
1605 }
1606 }
1607 Ok(fs)
1608}
1609
1610pub fn dump_files<ObjectID: FsVerityHashValue>(
1617 repo: &Repository<ObjectID>,
1618 image_name: &str,
1619 files: &Vec<PathBuf>,
1620 backing_path_only: bool,
1621) -> Result<Vec<u8>> {
1622 let (img_fd, _) = repo.open_image(image_name)?;
1623
1624 let mut img_buf = Vec::new();
1625 std::fs::File::from(img_fd).read_to_end(&mut img_buf)?;
1626
1627 let fs = erofs_to_filesystem::<ObjectID>(&img_buf)?;
1628
1629 let mut out = Vec::new();
1630 let nlink_map = fs.nlinks();
1631
1632 for file_path in files {
1633 let (dir, file) = fs.root.split(file_path.as_os_str())?;
1634
1635 let (_, file) = dir
1636 .entries()
1637 .find(|ent| ent.0 == file)
1638 .ok_or_else(|| anyhow::anyhow!("{} not found", file_path.display()))?;
1639
1640 match &file {
1641 Inode::Directory(directory) => {
1642 if backing_path_only {
1643 anyhow::bail!("{} is a directory", file_path.display());
1644 }
1645
1646 dump_single_dir(&mut out, directory, &fs, &nlink_map, file_path.clone())?
1647 }
1648
1649 Inode::Leaf(leaf_id, _) => {
1650 use composefs::generic_tree::LeafContent::*;
1651 use composefs::tree::RegularFile::*;
1652
1653 if backing_path_only {
1654 let leaf = fs.leaf(*leaf_id);
1655 match &leaf.content {
1656 Regular(f) => match f {
1657 Inline(..) | Sparse(..) => {
1658 writeln!(&mut out, "{} inline", file_path.display())?;
1659 }
1660 External(id, _) | ExternalNoVerity(id, _) => {
1661 writeln!(
1662 &mut out,
1663 "{} {}",
1664 file_path.display(),
1665 id.to_object_pathname()
1666 )?;
1667 }
1668 },
1669 _ => {
1670 writeln!(&mut out, "{} inline", file_path.display())?;
1671 }
1672 }
1673
1674 continue;
1675 }
1676
1677 dump_single_file(&mut out, *leaf_id, &fs, &nlink_map, file_path.clone())?
1678 }
1679 };
1680 }
1681
1682 Ok(out)
1683}
1684
1685pub async fn run_cmd_without_repo<ObjectID: FsVerityHashValue>(args: App) -> Result<()> {
1687 let erofs_version = args
1688 .erofs_version
1689 .map(composefs::erofs::format::FormatVersion::from);
1690 match args.cmd {
1691 Command::ComputeId { fs_opts } => {
1692 let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
1693 let version = erofs_version.unwrap_or_default();
1694 let id = composefs::fsverity::compute_verity::<ObjectID>(
1695 &composefs::erofs::writer::mkfs_erofs_versioned(
1696 &composefs::erofs::writer::ValidatedFileSystem::new(fs)?,
1697 version,
1698 ),
1699 );
1700 println!("{}", id.to_hex());
1701 }
1702 Command::ComputeKarg {
1703 path,
1704 no_propagate_usr_to_root,
1705 } => {
1706 let fs_opts = FsReadOptions {
1707 path,
1708 bootable: true,
1709 no_propagate_usr_to_root,
1710 xattrs: composefs::generic_tree::XattrFiltering::AllowlistOnly,
1713 };
1714 let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
1715 let version = erofs_version.unwrap_or_default();
1716 let id = composefs::fsverity::compute_verity::<ObjectID>(
1717 &composefs::erofs::writer::mkfs_erofs_versioned(
1718 &composefs::erofs::writer::ValidatedFileSystem::new(fs)?,
1719 version,
1720 ),
1721 );
1722 let karg = match version {
1723 FormatVersion::V0 | FormatVersion::V1 => {
1724 ComposefsCmdline::new_v1(id, args.insecure)
1725 }
1726 FormatVersion::V2 => ComposefsCmdline::new_v2(id, args.insecure),
1727 };
1728 println!("{}", karg.to_cmdline_arg());
1729 }
1730 Command::CreateDumpfile { fs_opts } => {
1731 let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
1732 fs.print_dumpfile()?;
1733 }
1734 _ => {
1735 anyhow::bail!("--no-repo is only supported for compute-id and create-dumpfile");
1736 }
1737 }
1738 Ok(())
1739}
1740
1741pub async fn run_cmd_with_repo<ObjectID>(repo: Repository<ObjectID>, args: App) -> Result<()>
1743where
1744 ObjectID: FsVerityHashValue,
1745{
1746 let repo = Arc::new(repo);
1747 #[cfg(feature = "oci")]
1748 let dest_path = resolve_repo_path(&args)?;
1749 match args.cmd {
1750 Command::Init { .. } => {
1751 unreachable!("init is handled before opening a repository");
1753 }
1754 Command::Transaction => {
1755 loop {
1757 std::thread::park();
1758 }
1759 }
1760 Command::Cat { name } => {
1761 repo.merge_splitstream(&name, None, None, &mut std::io::stdout())?;
1762 }
1763 Command::ImportImage { reference } => {
1764 let image_id = repo.import_image(&reference, &mut std::io::stdin())?;
1765 println!("{}", image_id.to_id());
1766 }
1767 #[cfg(feature = "oci")]
1768 Command::Oci { cmd: oci_cmd } => match oci_cmd {
1769 OciCommand::ImportLayer { name, ref digest } => {
1770 let (object_id, _stats) = composefs_oci::import_layer(
1771 &repo,
1772 digest,
1773 name.as_deref(),
1774 tokio::io::BufReader::with_capacity(IO_BUF_CAPACITY, tokio::io::stdin()),
1775 )
1776 .await?;
1777 println!("{}", object_id.to_id());
1778 }
1779 OciCommand::Dump { config_opts } => {
1780 let fs = load_filesystem_from_oci_image(&repo, config_opts)?;
1781 fs.print_dumpfile()?;
1782 }
1783 OciCommand::Mount {
1784 ref image,
1785 ref mountpoint,
1786 bootable,
1787 ref mount_opts,
1788 } => {
1789 let img = if image.starts_with("sha256:") {
1790 let digest: composefs_oci::OciDigest =
1791 image.parse().context("Parsing manifest digest")?;
1792 composefs_oci::oci_image::OciImage::open(&repo, &digest, None)?
1793 } else {
1794 composefs_oci::oci_image::OciImage::open_ref(&repo, image)?
1795 };
1796 let erofs_id = if bootable {
1797 match img.boot_image_ref(repo.erofs_version()) {
1798 Some(id) => id,
1799 None => anyhow::bail!(
1800 "No boot EROFS image linked — try pulling with --bootable"
1801 ),
1802 }
1803 } else {
1804 match img.image_ref(repo.erofs_version()) {
1805 Some(id) => id,
1806 None => anyhow::bail!(
1807 "No composefs EROFS image linked — try re-pulling the image"
1808 ),
1809 }
1810 };
1811 mount_opts.mount_image(&repo, &erofs_id.to_hex(), mountpoint.as_str())?;
1812 }
1813 OciCommand::ComputeId { config_opts } => {
1814 let fs = load_filesystem_from_oci_image(&repo, config_opts)?;
1815 let id = fs.compute_image_id(repo.erofs_version());
1816 println!("{}", id.to_hex());
1817 }
1818 OciCommand::Pull {
1819 ref image,
1820 name,
1821 bootable,
1822 expected_digest,
1823 local_fetch,
1824 } => {
1825 let expected_digest = expected_digest
1828 .map(|hex| ObjectID::from_hex(&hex))
1829 .transpose()
1830 .context("Parsing --expected-digest")?;
1831
1832 let tag_name = name.as_deref().unwrap_or(image);
1834
1835 let reporter: SharedReporter = IndicatifReporter::new().into_shared();
1836 let opts = composefs_oci::PullOptions {
1837 local_fetch: local_fetch.into(),
1838 progress: Some(reporter),
1839 ..Default::default()
1840 };
1841
1842 let result = composefs_oci::pull(&repo, image, Some(tag_name), opts).await?;
1843
1844 println!("manifest {}", result.manifest_digest);
1845 println!("config {}", result.config_digest);
1846 println!("verity {}", result.manifest_verity.to_hex());
1847 println!("tagged {tag_name}");
1848 println!("objects {}", result.stats);
1849
1850 if let Some(expected) = expected_digest {
1851 assert!(
1856 bootable,
1857 "clap should have enforced --expected-digest requires --bootable"
1858 );
1859 match composefs_oci::find_matching_boot_image(
1860 &repo,
1861 &result.manifest_digest,
1862 &expected,
1863 )? {
1864 composefs_oci::BootImageMatch::Found {
1865 mode,
1866 version,
1867 digest,
1868 } => {
1869 println!(
1870 "Boot image: {} (xattr-mode={mode}, format-version={version:?})",
1871 digest.to_hex()
1872 );
1873 }
1874 composefs_oci::BootImageMatch::NotFound(tried) => {
1875 anyhow::bail!(
1876 "No boot image configuration matched expected digest \
1877 {} (tried {tried} mode/version combinations)",
1878 expected.to_hex()
1879 );
1880 }
1881 }
1882 } else if bootable {
1883 let image_verity = composefs_oci::generate_boot_image(
1884 &repo,
1885 &result.manifest_digest,
1886 &composefs_oci::OciTransformOptions::default(),
1887 )?;
1888 println!("Boot image: {}", image_verity.to_hex());
1889 }
1890 }
1891 OciCommand::Copy {
1892 ref image,
1893 ref from,
1894 ref name,
1895 zerocopy,
1896 } => {
1897 use crate::varlink::proxy::RepositoryProxy;
1898
1899 let src_hash = resolve_hash_type(from, args.hash, !args.no_upgrade)
1900 .with_context(|| format!("opening source repository {}", from.display()))?;
1901 let dest_hash = resolve_hash_type(&dest_path, args.hash, !args.no_upgrade)
1902 .with_context(|| {
1903 format!("opening destination repository {}", dest_path.display())
1904 })?;
1905
1906 if zerocopy && src_hash != dest_hash {
1907 anyhow::bail!(
1908 "--zerocopy requires matching hash algorithms; \
1909 source uses {src_hash:?} but destination uses {dest_hash:?}"
1910 );
1911 }
1912
1913 let from_str = from.to_str().context("source path is not valid UTF-8")?;
1914 let dest_str = dest_path
1915 .to_str()
1916 .context("destination path is not valid UTF-8")?;
1917
1918 let service_src = crate::varlink::CfsctlService::new();
1919 let service_dest = crate::varlink::CfsctlService::new();
1920
1921 let (mut conn_src, _srv_src) = crate::varlink::spawn_in_process(service_src)
1922 .context("spawning source in-process service")?;
1923 let (mut conn_dest, _srv_dest) = crate::varlink::spawn_in_process(service_dest)
1924 .context("spawning destination in-process service")?;
1925
1926 let handle_src = conn_src
1927 .open_repository(Some(from_str), None, None)
1928 .await
1929 .context("zlink transport error calling OpenRepository on source")?
1930 .map_err(|e| anyhow::anyhow!("OpenRepository failed on source: {e:?}"))?
1931 .handle;
1932
1933 let handle_dest = conn_dest
1934 .open_repository(Some(dest_str), None, None)
1935 .await
1936 .context("zlink transport error calling OpenRepository on destination")?
1937 .map_err(|e| anyhow::anyhow!("OpenRepository failed on destination: {e:?}"))?
1938 .handle;
1939
1940 let finalize_reply = copy_image(
1941 &mut conn_src,
1942 &mut conn_dest,
1943 handle_src,
1944 handle_dest,
1945 image,
1946 name.as_deref(),
1947 zerocopy,
1948 )
1949 .await?;
1950
1951 let tag_info = if let Some(n) = name {
1952 format!(", tagged as {n}")
1953 } else {
1954 String::new()
1955 };
1956 println!(
1957 "Copied image {image} from {} to destination repo{}",
1958 from.display(),
1959 tag_info
1960 );
1961 println!("Manifest digest: {}", finalize_reply.manifest_digest);
1962 println!("Manifest verity: {}", finalize_reply.manifest_verity);
1963 println!("Config digest: {}", finalize_reply.config_digest);
1964 println!("Config verity: {}", finalize_reply.config_verity);
1965 }
1966 OciCommand::ListImages { json } => {
1967 let images = composefs_oci::oci_image::list_images(&repo)?;
1968
1969 if json {
1970 let reply = crate::varlink::ListImagesReply {
1971 images: images
1972 .iter()
1973 .map(crate::varlink::ImageEntry::from)
1974 .collect(),
1975 };
1976 serde_json::to_writer_pretty(std::io::stdout().lock(), &reply)?;
1977 println!();
1978 } else if images.is_empty() {
1979 println!("No images found");
1980 } else {
1981 let mut table = Table::new();
1982 table.load_preset(UTF8_FULL);
1983 table.set_header(["NAME", "DIGEST", "ARCH", "LAYERS", "REFS"]);
1984
1985 for img in images {
1986 let digest_str: &str = img.manifest_digest.as_ref();
1987 let digest_short = digest_str.strip_prefix("sha256:").unwrap_or(digest_str);
1988 let digest_display = if digest_short.len() > 12 {
1989 &digest_short[..12]
1990 } else {
1991 digest_short
1992 };
1993 let arch = if img.architecture.is_empty() {
1994 "artifact"
1995 } else {
1996 &img.architecture
1997 };
1998 table.add_row([
1999 img.name.as_str(),
2000 digest_display,
2001 arch,
2002 &img.layer_count.to_string(),
2003 &img.referrer_count.to_string(),
2004 ]);
2005 }
2006 println!("{table}");
2007 }
2008 }
2009 OciCommand::Inspect {
2010 ref image,
2011 manifest,
2012 config,
2013 } => {
2014 let img = resolve_oci_image(&repo, image)?;
2015
2016 if manifest {
2017 let manifest_json = img.read_manifest_json(&repo)?;
2019 std::io::Write::write_all(&mut std::io::stdout(), &manifest_json)?;
2020 println!();
2021 } else if config {
2022 let config_json = img.read_config_json(&repo)?;
2024 std::io::Write::write_all(&mut std::io::stdout(), &config_json)?;
2025 println!();
2026 } else {
2027 let output = crate::varlink::OciInspectReply::from_image(&repo, &img)?;
2029 serde_json::to_writer_pretty(std::io::stdout().lock(), &output)?;
2030 println!();
2031 }
2032 }
2033 OciCommand::Tag {
2034 ref manifest_digest,
2035 ref name,
2036 } => {
2037 composefs_oci::oci_image::tag_image(&repo, manifest_digest, name)?;
2038 println!("Tagged {manifest_digest} as {name}");
2039 }
2040 OciCommand::Untag { ref name } => {
2041 composefs_oci::oci_image::untag_image(&repo, name)?;
2042 println!("Removed tag {name}");
2043 }
2044 OciCommand::LayerInspect {
2045 ref layer,
2046 dumpfile,
2047 json,
2048 } => {
2049 if json {
2050 let info = composefs_oci::layer_info(&repo, layer)?;
2051 serde_json::to_writer_pretty(std::io::stdout().lock(), &info)?;
2052 println!();
2053 } else if dumpfile {
2054 composefs_oci::layer_dumpfile(&repo, layer, &mut std::io::stdout())?;
2055 } else {
2056 let mut out = std::io::stdout().lock();
2058 if out.is_terminal() {
2059 anyhow::bail!(
2060 "Refusing to write tar data to terminal. \
2061 Redirect to a file, pipe to tar, or use --json for metadata."
2062 );
2063 }
2064 composefs_oci::layer_tar(&repo, layer, &mut out)?;
2065 }
2066 }
2067
2068 OciCommand::PrepareBoot {
2069 config_opts:
2070 OCIConfigOptions {
2071 ref config_name,
2072 ref config_verity,
2073 },
2074 ref bootdir,
2075 ref entry_id,
2076 ref cmdline,
2077 } => {
2078 let verity = verity_opt(config_verity)?;
2079 let (config_digest, config_verity) =
2080 resolve_oci_config(&repo, config_name, verity)?;
2081 let mut fs = composefs_oci::image::create_filesystem(
2082 &repo,
2083 &config_digest,
2084 config_verity.as_ref(),
2085 &composefs_oci::OciTransformOptions::default(),
2086 )?;
2087 let entries = fs.transform_for_boot(&repo)?;
2088 let ids = fs.commit_images(&repo, None)?;
2089 let fmt_config = repo.default_format_config();
2090 let id = ids
2092 .get(&FormatVersion::V1)
2093 .or_else(|| ids.get(&FormatVersion::V2))
2094 .ok_or_else(|| anyhow::anyhow!("commit_images produced no images"))?
2095 .clone();
2096
2097 let insecure = repo.is_insecure();
2098 let karg = if fmt_config.default == FormatVersion::V1
2099 && !fmt_config.extra.contains(&FormatVersion::V2)
2100 {
2101 ComposefsCmdline::new_v1(id, insecure)
2103 } else {
2104 ComposefsCmdline::new_v2(id, insecure)
2106 };
2107
2108 let Some(entry) = entries.into_iter().next() else {
2109 anyhow::bail!("No boot entries!");
2110 };
2111
2112 let cmdline_refs: Vec<&str> = cmdline.iter().map(String::as_str).collect();
2113 write_boot::write_boot_simple(
2114 &repo,
2115 entry,
2116 &karg,
2117 bootdir,
2118 None,
2119 entry_id.as_deref(),
2120 &cmdline_refs,
2121 )?;
2122
2123 let state = args
2124 .repo
2125 .as_ref()
2126 .map(|p: &PathBuf| p.parent().unwrap())
2127 .unwrap_or(Path::new("/sysroot"))
2128 .join("state/deploy")
2129 .join(karg.digest().to_hex());
2130
2131 create_dir_all(state.join("var"))?;
2132 create_dir_all(state.join("etc/upper"))?;
2133 create_dir_all(state.join("etc/work"))?;
2134 }
2135 OciCommand::Fsck { image, json } => {
2136 let result = if let Some(ref name) = image {
2137 composefs_oci::oci_fsck_image(&repo, name).await?
2138 } else {
2139 composefs_oci::oci_fsck(&repo).await?
2140 };
2141 if json {
2142 let output = crate::varlink::OciFsckReply::from(&result);
2143 serde_json::to_writer_pretty(std::io::stdout().lock(), &output)?;
2144 println!();
2145 } else {
2146 print!("{result}");
2147 if !result.is_ok() {
2148 anyhow::bail!("OCI integrity check failed");
2149 }
2150 }
2151 }
2152 OciCommand::Varlink { .. } => {
2153 unreachable!("oci varlink is handled before opening a repository");
2154 }
2155 },
2156 #[cfg(feature = "ostree")]
2157 Command::Ostree { cmd: ostree_cmd } => match ostree_cmd {
2158 OstreeCommand::PullLocal {
2159 ref ostree_repo_path,
2160 ref ostree_ref,
2161 base_name,
2162 } => {
2163 let ostree_repo =
2164 composefs_ostree::LocalRepo::open_path(&repo, CWD, ostree_repo_path)?;
2165 let reporter: SharedReporter = IndicatifReporter::new().into_shared();
2166 let opts = composefs_ostree::PullOptions {
2167 base_reference: base_name.as_deref(),
2168 progress: Some(reporter),
2169 ..Default::default()
2170 };
2171 let (verity, stats) =
2172 composefs_ostree::pull(&repo, ostree_repo, ostree_ref, opts).await?;
2173
2174 let image_id = composefs_ostree::get_image_ref(&repo, &stats.commit_id)?;
2175 println!("commit {}", stats.commit_id);
2176 println!("verity {}", verity.to_hex());
2177 println!("image {}", image_id.to_hex());
2178 if !composefs_ostree::is_commit_id(ostree_ref) {
2179 println!("tagged {ostree_ref}");
2180 }
2181 print_pull_stats(&stats);
2182 }
2183 OstreeCommand::Pull {
2184 ref ostree_repo_url,
2185 ref ostree_ref,
2186 base_name,
2187 no_delta,
2188 } => {
2189 let ostree_repo = composefs_ostree::RemoteRepo::new(&repo, ostree_repo_url)?;
2190 let reporter: SharedReporter = IndicatifReporter::new().into_shared();
2191 let opts = composefs_ostree::PullOptions {
2192 base_reference: base_name.as_deref(),
2193 progress: Some(reporter),
2194 disable_deltas: no_delta,
2195 };
2196 let (verity, stats) =
2197 composefs_ostree::pull(&repo, ostree_repo, ostree_ref, opts).await?;
2198
2199 let image_id = composefs_ostree::get_image_ref(&repo, &stats.commit_id)?;
2200 println!("commit {}", stats.commit_id);
2201 println!("verity {}", verity.to_hex());
2202 println!("image {}", image_id.to_hex());
2203 if !composefs_ostree::is_commit_id(ostree_ref) {
2204 println!("tagged {ostree_ref}");
2205 }
2206 print_pull_stats(&stats);
2207 }
2208 OstreeCommand::Mount {
2209 ref commit,
2210 ref mountpoint,
2211 ref mount_opts,
2212 } => {
2213 let image_id = composefs_ostree::get_image_ref(&repo, commit)?;
2214 mount_opts.mount_image(&repo, &image_id.to_hex(), mountpoint.as_str())?;
2215 }
2216 OstreeCommand::Dump { ref commit_name } => {
2217 let fs = composefs_ostree::create_filesystem(&repo, commit_name)?;
2218 fs.print_dumpfile()?;
2219 }
2220 OstreeCommand::ComputeId { ref commit_name } => {
2221 let image_id = composefs_ostree::ensure_ostree_erofs(&repo, commit_name)?;
2222 println!("{}", image_id.to_hex());
2223 }
2224 OstreeCommand::Inspect {
2225 ref source,
2226 metadata,
2227 } => {
2228 composefs_ostree::inspect(&repo, source, metadata)?;
2229 }
2230 OstreeCommand::Tag {
2231 ref source,
2232 ref name,
2233 } => {
2234 composefs_ostree::tag(&repo, source, name)?;
2235 println!("Tagged {source} as {name}");
2236 }
2237 OstreeCommand::Untag { ref name } => {
2238 composefs_ostree::untag(&repo, name)?;
2239 }
2240 OstreeCommand::Commit {
2241 ref image,
2242 ref reference,
2243 ref subject,
2244 } => {
2245 use std::time::{SystemTime, UNIX_EPOCH};
2246
2247 let (img_fd, _) = repo.open_image(image)?;
2248 let mut img_buf = Vec::new();
2249 std::fs::File::from(img_fd).read_to_end(&mut img_buf)?;
2250 let fs = composefs::erofs::reader::erofs_to_filesystem(&img_buf)?;
2251
2252 let timestamp = SystemTime::now()
2253 .duration_since(UNIX_EPOCH)
2254 .unwrap_or_default()
2255 .as_secs();
2256 let mut commit_meta = composefs_ostree::ostree::CommitMetadata::default()
2257 .subject(subject.as_str())
2258 .timestamp(timestamp);
2259 if let Some(ref_name) = reference {
2260 commit_meta = commit_meta.add_metadata(
2261 "ostree.ref-binding",
2262 composefs_ostree::ostree::MetadataValue::StringArray(vec![
2263 ref_name.clone(),
2264 ]),
2265 );
2266 }
2267
2268 let (verity, commit_id) = composefs_ostree::commit_filesystem(
2269 &repo,
2270 &fs,
2271 commit_meta,
2272 reference.as_deref(),
2273 )?;
2274 println!("commit {commit_id}");
2275 println!("verity {}", verity.to_hex());
2276 if let Some(ref_name) = reference {
2277 println!("tagged {ref_name}");
2278 }
2279 }
2280 OstreeCommand::Export {
2281 ref source,
2282 ref ostree_repo_path,
2283 ref reference,
2284 } => {
2285 let dest = composefs_ostree::LocalRepo::open_path(&repo, CWD, ostree_repo_path)?;
2286 let commit_id =
2287 composefs_ostree::export_commit(&repo, source, &dest, reference.as_deref())?;
2288 println!("commit {commit_id}");
2289 if let Some(ref_name) = reference {
2290 println!("tagged {ref_name}");
2291 }
2292 }
2293 OstreeCommand::ListCommits => {
2294 let commits = composefs_ostree::list_commits(&repo)?;
2295 if commits.is_empty() {
2296 println!("No ostree commits found");
2297 } else {
2298 let mut table = Table::new();
2299 table.load_preset(UTF8_FULL);
2300 table.set_header(["NAME", "COMMIT"]);
2301 for c in commits {
2302 table.add_row([c.name.as_str(), &c.commit_id]);
2303 }
2304 println!("{table}");
2305 }
2306 }
2307 OstreeCommand::ApplyDelta { ref delta_path } => {
2308 let (verity, stats) = composefs_ostree::apply_delta_offline(&repo, delta_path)?;
2309 let image_id = composefs_ostree::get_image_ref(&repo, &stats.commit_id)?;
2310 println!("commit {}", stats.commit_id);
2311 println!("verity {}", verity.to_hex());
2312 println!("image {}", image_id.to_hex());
2313 println!(
2314 "objects {} metadata + {} files applied",
2315 stats.metadata_fetched, stats.files_fetched
2316 );
2317 }
2318 OstreeCommand::ListRefs {
2319 ref ostree_repo_url,
2320 ref subset,
2321 } => {
2322 let mut ostree_repo = composefs_ostree::RemoteRepo::new(&repo, ostree_repo_url)?;
2323 if let Some(s) = subset {
2324 ostree_repo = ostree_repo.with_summary_subset(s);
2325 }
2326 let refs = ostree_repo.list_remote_refs().await?;
2327 if refs.is_empty() {
2328 println!("No refs found");
2329 } else {
2330 let mut table = Table::new();
2331 table.load_preset(UTF8_FULL);
2332 table.set_header(["REF", "COMMIT"]);
2333 for (name, checksum) in &refs {
2334 table.add_row([name.as_str(), &hex::encode(checksum)]);
2335 }
2336 println!("{table}");
2337 }
2338 }
2339 },
2340 Command::CreateImage {
2341 fs_opts,
2342 ref image_name,
2343 } => {
2344 let fs = load_filesystem_from_ondisk_fs(&fs_opts, Some(Arc::clone(&repo))).await?;
2345 let id = fs.commit_image(&repo, image_name.as_deref())?;
2346 println!("{}", id.to_id());
2347 }
2348 Command::ComputeId { .. }
2349 | Command::ComputeKarg { .. }
2350 | Command::CreateDumpfile { .. } => {
2351 unreachable!(
2353 "compute-id, compute-karg, and create-dumpfile are dispatched without a repo"
2354 );
2355 }
2356 Command::Mount {
2357 name,
2358 mountpoint,
2359 ref mount_opts,
2360 } => {
2361 mount_opts.mount_image(&repo, &name, &mountpoint)?;
2362 }
2363 Command::Images { json, no_trunc } => {
2364 let reply =
2365 varlink::run_list_image_refs(&repo).map_err(|e| anyhow::anyhow!("{e:?}"))?;
2366
2367 if json {
2368 serde_json::to_writer_pretty(std::io::stdout().lock(), &reply)?;
2369 println!();
2370 } else if reply.images.is_empty() {
2371 println!("No images found");
2372 } else {
2373 let mut table = Table::new();
2374 table.load_preset(UTF8_FULL);
2375 table.set_header(["NAME", "DIGEST"]);
2376
2377 for entry in &reply.images {
2378 let digest_display = if !no_trunc && entry.digest.len() > 12 {
2379 &entry.digest[..12]
2380 } else {
2381 &entry.digest
2382 };
2383 table.add_row([entry.name.as_str(), digest_display]);
2384 }
2385 println!("{table}");
2386 }
2387 }
2388 Command::ImageObjects { name } => {
2389 let objects = repo.objects_for_image(&name)?;
2390 for object in objects {
2391 println!("{}", object.to_id());
2392 }
2393 }
2394 Command::GC { root, dry_run } => {
2395 let roots: Vec<&str> = root.iter().map(|s| s.as_str()).collect();
2396 let result = if dry_run {
2397 repo.gc_dry_run(&roots)?
2398 } else {
2399 repo.gc(&roots)?
2400 };
2401 if dry_run {
2402 println!("Dry run (no files deleted):");
2403 }
2404 println!(
2405 "Objects: {} removed ({} bytes)",
2406 result.objects_removed, result.objects_bytes
2407 );
2408 if result.images_pruned > 0 || result.streams_pruned > 0 {
2409 println!(
2410 "Pruned symlinks: {} images, {} streams",
2411 result.images_pruned, result.streams_pruned
2412 );
2413 }
2414 }
2415 Command::DumpFiles {
2416 image_name,
2417 files,
2418 backing_path_only,
2419 } => {
2420 let out = dump_files(&repo, &image_name, &files, backing_path_only)?;
2421
2422 if !out.is_empty() {
2423 let out_str = std::str::from_utf8(&out).unwrap();
2424 print!("{}", out_str);
2425 }
2426 }
2427 Command::Fsck {
2428 json,
2429 metadata_only,
2430 } => {
2431 let result = if metadata_only {
2432 repo.fsck_metadata_only().await?
2433 } else {
2434 repo.fsck().await?
2435 };
2436 if json {
2437 let output = crate::varlink::FsckReply::from(&result);
2438 serde_json::to_writer_pretty(std::io::stdout().lock(), &output)?;
2439 println!();
2440 } else {
2441 print!("{result}");
2442 if !result.is_ok() {
2443 anyhow::bail!("repository integrity check failed");
2444 }
2445 }
2446 }
2447 Command::Varlink { .. } => {
2448 unreachable!("varlink is handled before opening a repository");
2450 }
2451 #[cfg(feature = "http")]
2452 Command::Fetch { url, name } => {
2453 let reporter: SharedReporter = IndicatifReporter::new().into_shared();
2454 let (digest, verity) = composefs_http::download(
2455 &url,
2456 &name,
2457 Arc::clone(&repo),
2458 composefs_http::DownloadOptions {
2459 progress: Some(reporter),
2460 },
2461 )
2462 .await?;
2463 println!("content {digest}");
2464 println!("verity {}", verity.to_hex());
2465 }
2466 Command::Mkcomposefs { .. } | Command::ComposefsInfo { .. } => {
2467 unreachable!("mkcomposefs/composefs-info are dispatched before opening a repository");
2469 }
2470 }
2471 Ok(())
2472}
2473
2474#[cfg(test)]
2475#[cfg(any(feature = "oci", feature = "http"))]
2476mod tests {
2477 use super::*;
2478 use composefs::progress::{ProgressEvent, ProgressUnit};
2479
2480 #[test]
2485 fn test_indicatif_reporter_valid_lifecycle() {
2486 let reporter = IndicatifReporter::new();
2487 reporter.report(ProgressEvent::Message("starting pull".into()));
2489 reporter.report(ProgressEvent::Started {
2491 id: "sha256:abc".into(),
2492 total: Some(1_000_000),
2493 unit: ProgressUnit::Bytes,
2494 });
2495 reporter.report(ProgressEvent::Progress {
2496 id: "sha256:abc".into(),
2497 fetched: 500_000,
2498 total: Some(1_000_000),
2499 });
2500 reporter.report(ProgressEvent::Done {
2501 id: "sha256:abc".into(),
2502 transferred: 1_000_000,
2503 });
2504 reporter.report(ProgressEvent::Started {
2506 id: "objects:stream".into(),
2507 total: Some(200),
2508 unit: ProgressUnit::Items,
2509 });
2510 reporter.report(ProgressEvent::Progress {
2511 id: "objects:stream".into(),
2512 fetched: 100,
2513 total: Some(200),
2514 });
2515 reporter.report(ProgressEvent::Done {
2516 id: "objects:stream".into(),
2517 transferred: 200,
2518 });
2519 reporter.report(ProgressEvent::Started {
2521 id: "sha256:cached".into(),
2522 total: None,
2523 unit: ProgressUnit::Bytes,
2524 });
2525 reporter.report(ProgressEvent::Skipped {
2526 id: "sha256:cached".into(),
2527 });
2528 }
2529
2530 #[test]
2536 fn test_indicatif_reporter_unknown_id_no_panic() {
2537 let reporter = IndicatifReporter::new();
2538 reporter.report(ProgressEvent::Progress {
2540 id: "ghost".into(),
2541 fetched: 42,
2542 total: None,
2543 });
2544 reporter.report(ProgressEvent::Done {
2546 id: "ghost".into(),
2547 transferred: 42,
2548 });
2549 reporter.report(ProgressEvent::Skipped { id: "ghost".into() });
2551 }
2552
2553 #[test]
2555 fn test_indicatif_reporter_spinner_lifecycle() {
2556 let reporter = IndicatifReporter::new();
2557 reporter.report(ProgressEvent::Started {
2559 id: "layer:unknown-size".into(),
2560 total: None,
2561 unit: ProgressUnit::Bytes,
2562 });
2563 reporter.report(ProgressEvent::Progress {
2564 id: "layer:unknown-size".into(),
2565 fetched: 1024,
2566 total: None,
2567 });
2568 reporter.report(ProgressEvent::Done {
2569 id: "layer:unknown-size".into(),
2570 transferred: 2048,
2571 });
2572 }
2573
2574 #[test]
2576 fn test_indicatif_reporter_multiple_concurrent_components() {
2577 let reporter = IndicatifReporter::new();
2578 reporter.report(ProgressEvent::Started {
2580 id: "layer:a".into(),
2581 total: Some(100),
2582 unit: ProgressUnit::Bytes,
2583 });
2584 reporter.report(ProgressEvent::Started {
2585 id: "layer:b".into(),
2586 total: Some(200),
2587 unit: ProgressUnit::Bytes,
2588 });
2589 reporter.report(ProgressEvent::Progress {
2591 id: "layer:a".into(),
2592 fetched: 50,
2593 total: Some(100),
2594 });
2595 reporter.report(ProgressEvent::Progress {
2596 id: "layer:b".into(),
2597 fetched: 100,
2598 total: Some(200),
2599 });
2600 reporter.report(ProgressEvent::Done {
2602 id: "layer:b".into(),
2603 transferred: 200,
2604 });
2605 reporter.report(ProgressEvent::Done {
2607 id: "layer:a".into(),
2608 transferred: 100,
2609 });
2610 }
2611}