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