1pub use composefs;
19pub use composefs_boot;
20#[cfg(feature = "http")]
21pub use composefs_http;
22#[cfg(feature = "oci")]
23pub use composefs_oci;
24
25use std::{
26 ffi::OsString,
27 fs::create_dir_all,
28 io::IsTerminal,
29 path::{Path, PathBuf},
30 sync::Arc,
31};
32
33use anyhow::Result;
34use clap::{Parser, Subcommand, ValueEnum};
35use comfy_table::{presets::UTF8_FULL, Table};
36
37use rustix::fs::CWD;
38
39use composefs_boot::{write_boot, BootOps};
40
41use composefs::{
42 fsverity::{FsVerityHashValue, Sha256HashValue, Sha512HashValue},
43 repository::Repository,
44 shared_internals::IO_BUF_CAPACITY,
45};
46
47#[derive(Debug, Parser)]
49#[clap(name = "cfsctl", version)]
50pub struct App {
51 #[clap(long, group = "repopath")]
53 repo: Option<PathBuf>,
54 #[clap(long, group = "repopath")]
56 user: bool,
57 #[clap(long, group = "repopath")]
59 system: bool,
60
61 #[clap(long, value_enum, default_value_t = HashType::Sha512)]
63 pub hash: HashType,
64
65 #[clap(long)]
69 insecure: bool,
70
71 #[clap(subcommand)]
72 cmd: Command,
73}
74
75#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum, Default)]
76pub enum HashType {
78 Sha256,
80 #[default]
82 Sha512,
83}
84
85#[derive(Debug, Parser)]
87struct OCIConfigFilesystemOptions {
88 #[clap(flatten)]
89 base_config: OCIConfigOptions,
90 #[clap(long)]
92 bootable: bool,
93}
94
95#[derive(Debug, Parser)]
97struct OCIConfigOptions {
98 config_name: String,
100 config_verity: Option<String>,
102}
103
104#[cfg(feature = "oci")]
105#[derive(Debug, Subcommand)]
106enum OciCommand {
107 ImportLayer {
109 digest: String,
110 name: Option<String>,
111 },
112 LsLayer {
114 name: String,
116 },
117 Dump {
119 #[clap(flatten)]
120 config_opts: OCIConfigFilesystemOptions,
121 },
122 Pull {
124 image: String,
126 name: Option<String>,
128 },
129 #[clap(name = "images")]
131 ListImages {
132 #[clap(long)]
134 json: bool,
135 },
136 #[clap(name = "inspect")]
141 Inspect {
142 image: String,
144 #[clap(long, conflicts_with = "config")]
146 manifest: bool,
147 #[clap(long, conflicts_with = "manifest")]
149 config: bool,
150 },
151 Tag {
153 manifest_digest: String,
155 name: String,
157 },
158 Untag {
160 name: String,
162 },
163 #[clap(name = "layer")]
168 LayerInspect {
169 layer: String,
171 #[clap(long, conflicts_with = "json")]
173 dumpfile: bool,
174 #[clap(long, conflicts_with = "dumpfile")]
176 json: bool,
177 },
178 ComputeId {
180 #[clap(flatten)]
181 config_opts: OCIConfigFilesystemOptions,
182 },
183 CreateImage {
185 #[clap(flatten)]
186 config_opts: OCIConfigFilesystemOptions,
187 #[clap(long)]
189 image_name: Option<String>,
190 },
191 Seal {
194 #[clap(flatten)]
195 config_opts: OCIConfigOptions,
196 },
197 Mount {
200 name: String,
202 mountpoint: String,
204 },
205 PrepareBoot {
210 #[clap(flatten)]
211 config_opts: OCIConfigOptions,
212 #[clap(long, default_value = "/boot")]
214 bootdir: PathBuf,
215 #[clap(long)]
217 entry_id: Option<String>,
218 #[clap(long)]
220 cmdline: Vec<String>,
221 },
222}
223
224#[derive(Debug, Parser)]
226struct FsReadOptions {
227 path: PathBuf,
229 #[clap(long)]
231 bootable: bool,
232 #[clap(long)]
234 no_propagate_usr_to_root: bool,
235}
236
237#[derive(Debug, Subcommand)]
238enum Command {
239 Transaction,
242 Cat {
244 name: String,
246 },
247 GC {
249 #[clap(long, short = 'r')]
251 root: Vec<String>,
252 #[clap(long, short = 'n')]
254 dry_run: bool,
255 },
256 ImportImage { reference: String },
258 #[cfg(feature = "oci")]
260 Oci {
261 #[clap(subcommand)]
262 cmd: OciCommand,
263 },
264 Mount {
266 name: String,
268 mountpoint: String,
270 },
271 CreateImage {
274 #[clap(flatten)]
275 fs_opts: FsReadOptions,
276 image_name: Option<String>,
278 },
279 ComputeId {
282 #[clap(flatten)]
283 fs_opts: FsReadOptions,
284 },
285 CreateDumpfile {
288 #[clap(flatten)]
289 fs_opts: FsReadOptions,
290 },
291 ImageObjects {
293 name: String,
295 },
296 #[cfg(feature = "http")]
297 Fetch { url: String, name: String },
298}
299
300pub async fn run_from_iter<I>(args: I) -> Result<()>
306where
307 I: IntoIterator,
308 I::Item: Into<OsString> + Clone,
309{
310 let args = App::parse_from(
311 std::iter::once(OsString::from("cfsctl")).chain(args.into_iter().map(Into::into)),
312 );
313
314 match args.hash {
315 HashType::Sha256 => run_cmd_with_repo(open_repo::<Sha256HashValue>(&args)?, args).await,
316 HashType::Sha512 => run_cmd_with_repo(open_repo::<Sha512HashValue>(&args)?, args).await,
317 }
318}
319
320fn verity_opt<ObjectID>(opt: &Option<String>) -> Result<Option<ObjectID>>
321where
322 ObjectID: FsVerityHashValue,
323{
324 Ok(match opt {
325 Some(value) => Some(FsVerityHashValue::from_hex(value)?),
326 None => None,
327 })
328}
329
330pub fn open_repo<ObjectID>(args: &App) -> Result<Repository<ObjectID>>
332where
333 ObjectID: FsVerityHashValue,
334{
335 let mut repo = (if let Some(path) = &args.repo {
336 Repository::open_path(CWD, path)
337 } else if args.system {
338 Repository::open_system()
339 } else if args.user {
340 Repository::open_user()
341 } else if rustix::process::getuid().is_root() {
342 Repository::open_system()
343 } else {
344 Repository::open_user()
345 })?;
346
347 repo.set_insecure(args.insecure);
348
349 Ok(repo)
350}
351
352pub async fn run_cmd_with_repo<ObjectID>(repo: Repository<ObjectID>, args: App) -> Result<()>
354where
355 ObjectID: FsVerityHashValue,
356{
357 match args.cmd {
358 Command::Transaction => {
359 loop {
361 std::thread::park();
362 }
363 }
364 Command::Cat { name } => {
365 repo.merge_splitstream(&name, None, None, &mut std::io::stdout())?;
366 }
367 Command::ImportImage { reference } => {
368 let image_id = repo.import_image(&reference, &mut std::io::stdin())?;
369 println!("{}", image_id.to_id());
370 }
371 #[cfg(feature = "oci")]
372 Command::Oci { cmd: oci_cmd } => match oci_cmd {
373 OciCommand::ImportLayer { name, digest } => {
374 let repo = Arc::new(repo);
375 let (object_id, _stats) = composefs_oci::import_layer(
376 &repo,
377 &digest,
378 name.as_deref(),
379 tokio::io::BufReader::with_capacity(IO_BUF_CAPACITY, tokio::io::stdin()),
380 )
381 .await?;
382 println!("{}", object_id.to_id());
383 }
384 OciCommand::LsLayer { name } => {
385 composefs_oci::ls_layer(&repo, &name)?;
386 }
387 OciCommand::Dump {
388 config_opts:
389 OCIConfigFilesystemOptions {
390 base_config:
391 OCIConfigOptions {
392 ref config_name,
393 ref config_verity,
394 },
395 bootable,
396 },
397 } => {
398 let verity = verity_opt(config_verity)?;
399 let mut fs =
400 composefs_oci::image::create_filesystem(&repo, config_name, verity.as_ref())?;
401 if bootable {
402 fs.transform_for_boot(&repo)?;
403 }
404 fs.print_dumpfile()?;
405 }
406 OciCommand::ComputeId {
407 config_opts:
408 OCIConfigFilesystemOptions {
409 base_config:
410 OCIConfigOptions {
411 ref config_name,
412 ref config_verity,
413 },
414 bootable,
415 },
416 } => {
417 let verity = verity_opt(config_verity)?;
418 let mut fs =
419 composefs_oci::image::create_filesystem(&repo, config_name, verity.as_ref())?;
420 if bootable {
421 fs.transform_for_boot(&repo)?;
422 }
423 let id = fs.compute_image_id();
424 println!("{}", id.to_hex());
425 }
426 OciCommand::CreateImage {
427 config_opts:
428 OCIConfigFilesystemOptions {
429 base_config:
430 OCIConfigOptions {
431 ref config_name,
432 ref config_verity,
433 },
434 bootable,
435 },
436 ref image_name,
437 } => {
438 let verity = verity_opt(config_verity)?;
439 let mut fs =
440 composefs_oci::image::create_filesystem(&repo, config_name, verity.as_ref())?;
441 if bootable {
442 fs.transform_for_boot(&repo)?;
443 }
444 let image_id = fs.commit_image(&repo, image_name.as_deref())?;
445 println!("{}", image_id.to_id());
446 }
447 OciCommand::Pull { ref image, name } => {
448 let tag_name = name.as_deref().unwrap_or(image);
450 let (result, stats) =
451 composefs_oci::pull_image(&Arc::new(repo), image, Some(tag_name), None).await?;
452
453 println!("manifest {}", result.manifest_digest);
454 println!("config {}", result.config_digest);
455 println!("verity {}", result.manifest_verity.to_hex());
456 println!("tagged {tag_name}");
457 println!(
458 "objects {} copied, {} already present, {} bytes copied, {} bytes inlined",
459 stats.objects_copied,
460 stats.objects_already_present,
461 stats.bytes_copied,
462 stats.bytes_inlined,
463 );
464 }
465 OciCommand::ListImages { json } => {
466 let images = composefs_oci::oci_image::list_images(&repo)?;
467
468 if json {
469 println!("{}", serde_json::to_string_pretty(&images)?);
470 } else if images.is_empty() {
471 println!("No images found");
472 } else {
473 let mut table = Table::new();
474 table.load_preset(UTF8_FULL);
475 table.set_header(["NAME", "DIGEST", "ARCH", "SEALED", "LAYERS", "REFS"]);
476
477 for img in images {
478 let digest_short = img
479 .manifest_digest
480 .strip_prefix("sha256:")
481 .unwrap_or(&img.manifest_digest);
482 let digest_display = if digest_short.len() > 12 {
483 &digest_short[..12]
484 } else {
485 digest_short
486 };
487 let arch = if img.architecture.is_empty() {
488 "artifact"
489 } else {
490 &img.architecture
491 };
492 let sealed = if img.sealed { "yes" } else { "no" };
493 table.add_row([
494 img.name.as_str(),
495 digest_display,
496 arch,
497 sealed,
498 &img.layer_count.to_string(),
499 &img.referrer_count.to_string(),
500 ]);
501 }
502 println!("{table}");
503 }
504 }
505 OciCommand::Inspect {
506 ref image,
507 manifest,
508 config,
509 } => {
510 let img = if image.starts_with("sha256:") {
511 composefs_oci::oci_image::OciImage::open(&repo, image, None)?
512 } else {
513 composefs_oci::oci_image::OciImage::open_ref(&repo, image)?
514 };
515
516 if manifest {
517 let manifest_json = img.read_manifest_json(&repo)?;
519 std::io::Write::write_all(&mut std::io::stdout(), &manifest_json)?;
520 println!();
521 } else if config {
522 let config_json = img.read_config_json(&repo)?;
524 std::io::Write::write_all(&mut std::io::stdout(), &config_json)?;
525 println!();
526 } else {
527 let output = img.inspect_json(&repo)?;
529 println!("{}", serde_json::to_string_pretty(&output)?);
530 }
531 }
532 OciCommand::Tag {
533 ref manifest_digest,
534 ref name,
535 } => {
536 composefs_oci::oci_image::tag_image(&repo, manifest_digest, name)?;
537 println!("Tagged {manifest_digest} as {name}");
538 }
539 OciCommand::Untag { ref name } => {
540 composefs_oci::oci_image::untag_image(&repo, name)?;
541 println!("Removed tag {name}");
542 }
543 OciCommand::LayerInspect {
544 ref layer,
545 dumpfile,
546 json,
547 } => {
548 if json {
549 let info = composefs_oci::layer_info(&repo, layer)?;
550 println!("{}", serde_json::to_string_pretty(&info)?);
551 } else if dumpfile {
552 composefs_oci::layer_dumpfile(&repo, layer, &mut std::io::stdout())?;
553 } else {
554 let mut out = std::io::stdout().lock();
556 if out.is_terminal() {
557 anyhow::bail!(
558 "Refusing to write tar data to terminal. \
559 Redirect to a file, pipe to tar, or use --json for metadata."
560 );
561 }
562 composefs_oci::layer_tar(&repo, layer, &mut out)?;
563 }
564 }
565 OciCommand::Seal {
566 config_opts:
567 OCIConfigOptions {
568 ref config_name,
569 ref config_verity,
570 },
571 } => {
572 let verity = verity_opt(config_verity)?;
573 let (digest, verity) =
574 composefs_oci::seal(&Arc::new(repo), config_name, verity.as_ref())?;
575 println!("config {digest}");
576 println!("verity {}", verity.to_id());
577 }
578 OciCommand::Mount {
579 ref name,
580 ref mountpoint,
581 } => {
582 composefs_oci::mount(&repo, name, mountpoint, None)?;
583 }
584 OciCommand::PrepareBoot {
585 config_opts:
586 OCIConfigOptions {
587 ref config_name,
588 ref config_verity,
589 },
590 ref bootdir,
591 ref entry_id,
592 ref cmdline,
593 } => {
594 let verity = verity_opt(config_verity)?;
595 let mut fs =
596 composefs_oci::image::create_filesystem(&repo, config_name, verity.as_ref())?;
597 let entries = fs.transform_for_boot(&repo)?;
598 let id = fs.commit_image(&repo, None)?;
599
600 let Some(entry) = entries.into_iter().next() else {
601 anyhow::bail!("No boot entries!");
602 };
603
604 let cmdline_refs: Vec<&str> = cmdline.iter().map(String::as_str).collect();
605 write_boot::write_boot_simple(
606 &repo,
607 entry,
608 &id,
609 args.insecure,
610 bootdir,
611 None,
612 entry_id.as_deref(),
613 &cmdline_refs,
614 )?;
615
616 let state = args
617 .repo
618 .as_ref()
619 .map(|p: &PathBuf| p.parent().unwrap())
620 .unwrap_or(Path::new("/sysroot"))
621 .join("state/deploy")
622 .join(id.to_hex());
623
624 create_dir_all(state.join("var"))?;
625 create_dir_all(state.join("etc/upper"))?;
626 create_dir_all(state.join("etc/work"))?;
627 }
628 },
629 Command::ComputeId { fs_opts } => {
630 let mut fs = if fs_opts.no_propagate_usr_to_root {
631 composefs::fs::read_filesystem(CWD, &fs_opts.path, Some(&repo))?
632 } else {
633 composefs::fs::read_container_root(CWD, &fs_opts.path, Some(&repo))?
634 };
635 if fs_opts.bootable {
636 fs.transform_for_boot(&repo)?;
637 }
638 let id = fs.compute_image_id();
639 println!("{}", id.to_hex());
640 }
641 Command::CreateImage {
642 fs_opts,
643 ref image_name,
644 } => {
645 let mut fs = if fs_opts.no_propagate_usr_to_root {
646 composefs::fs::read_filesystem(CWD, &fs_opts.path, Some(&repo))?
647 } else {
648 composefs::fs::read_container_root(CWD, &fs_opts.path, Some(&repo))?
649 };
650 if fs_opts.bootable {
651 fs.transform_for_boot(&repo)?;
652 }
653 let id = fs.commit_image(&repo, image_name.as_deref())?;
654 println!("{}", id.to_id());
655 }
656 Command::CreateDumpfile { fs_opts } => {
657 let mut fs = if fs_opts.no_propagate_usr_to_root {
658 composefs::fs::read_filesystem(CWD, &fs_opts.path, Some(&repo))?
659 } else {
660 composefs::fs::read_container_root(CWD, &fs_opts.path, Some(&repo))?
661 };
662 if fs_opts.bootable {
663 fs.transform_for_boot(&repo)?;
664 }
665 fs.print_dumpfile()?;
666 }
667 Command::Mount { name, mountpoint } => {
668 repo.mount_at(&name, &mountpoint)?;
669 }
670 Command::ImageObjects { name } => {
671 let objects = repo.objects_for_image(&name)?;
672 for object in objects {
673 println!("{}", object.to_id());
674 }
675 }
676 Command::GC { root, dry_run } => {
677 let roots: Vec<&str> = root.iter().map(|s| s.as_str()).collect();
678 let result = if dry_run {
679 repo.gc_dry_run(&roots)?
680 } else {
681 repo.gc(&roots)?
682 };
683 if dry_run {
684 println!("Dry run (no files deleted):");
685 }
686 println!(
687 "Objects: {} removed ({} bytes)",
688 result.objects_removed, result.objects_bytes
689 );
690 if result.images_pruned > 0 || result.streams_pruned > 0 {
691 println!(
692 "Pruned symlinks: {} images, {} streams",
693 result.images_pruned, result.streams_pruned
694 );
695 }
696 }
697 #[cfg(feature = "http")]
698 Command::Fetch { url, name } => {
699 let (digest, verity) = composefs_http::download(&url, &name, Arc::new(repo)).await?;
700 println!("content {digest}");
701 println!("verity {}", verity.to_hex());
702 }
703 }
704 Ok(())
705}