Skip to main content

composefs_ctl/
mkcomposefs.rs

1//! mkcomposefs - Create composefs images from directories or dumpfiles.
2//!
3//! This is a Rust reimplementation of the C mkcomposefs tool, providing
4//! compatible command-line interface and output format.
5//!
6//! ## Compatibility status
7//!
8//! See <https://github.com/composefs/composefs/discussions/423> for context.
9//!
10//! Implemented and tested (byte-for-byte match with C mkcomposefs):
11//! - `--from-file`, `--print-digest`, `--print-digest-only`
12//! - `--skip-devices`, `--skip-xattrs`, `--user-xattrs`
13//! - `--min-version` / `--max-version` (V1 compact inodes, BFS ordering, whiteout table)
14//! - `--digest-store` (C-compatible flat `XX/digest` layout via [`FlatDigestStore`])
15//! - `--threads` (controls tokio worker threads and verity-computation concurrency)
16//! - Source from directory or dumpfile, output to file or stdout
17//!
18//! Bit-for-bit compatible with the C mkcomposefs by default; use `--hardlinks`
19//! to enable host hard-link deduplication (shared EROFS inodes, i_nlink > 1).
20
21use std::{
22    ffi::OsString,
23    fs::File,
24    io::{self, BufReader, IsTerminal, Read, Write},
25    path::{Path, PathBuf},
26    sync::Arc,
27    thread::available_parallelism,
28};
29
30use anyhow::{Context, Result, bail};
31use clap::Parser;
32use rustix::fs::CWD;
33use tokio::sync::Semaphore;
34
35use composefs::{
36    dumpfile::dumpfile_to_filesystem,
37    erofs::{
38        format::FormatVersion,
39        writer::{ValidatedFileSystem, mkfs_erofs_versioned},
40    },
41    fs::{
42        FlatDigestStore, HardlinkBehavior, ObjectStore, ReadFilesystemOpts,
43        read_filesystem_with_opts,
44    },
45    fsverity::{FsVerityHashValue, Sha256HashValue, compute_verity},
46    tree::FileSystem,
47};
48
49/// Create a composefs image from a source directory or dumpfile.
50///
51/// Composefs uses EROFS image files for metadata and separate content-addressed
52/// backing directories for regular file data.
53#[derive(Parser, Debug)]
54#[command(name = "mkcomposefs", version, about)]
55struct Args {
56    /// Treat SOURCE as a dumpfile in composefs-dump(5) format.
57    ///
58    /// If SOURCE is `-`, reads from stdin.
59    #[arg(long)]
60    from_file: bool,
61
62    /// Print the fsverity digest of the image after writing.
63    #[arg(long)]
64    print_digest: bool,
65
66    /// Print the fsverity digest without writing the image.
67    ///
68    /// When set, IMAGE must be omitted.
69    #[arg(long)]
70    print_digest_only: bool,
71
72    /// Set modification time to zero (Unix epoch) for all files.
73    #[arg(long)]
74    use_epoch: bool,
75
76    /// Exclude device nodes from the image.
77    #[arg(long)]
78    skip_devices: bool,
79
80    /// Exclude all extended attributes.
81    #[arg(long)]
82    skip_xattrs: bool,
83
84    /// Only include xattrs with the `user.` prefix.
85    #[arg(long)]
86    user_xattrs: bool,
87
88    /// Minimum image format version to use (0 or 1).
89    #[arg(long, default_value = "0")]
90    min_version: u32,
91
92    /// Maximum image format version (for auto-upgrade).
93    #[arg(long, default_value = "1")]
94    max_version: u32,
95
96    /// Copy regular file content to the given object store directory.
97    ///
98    /// Files are stored by their fsverity digest using the same flat layout
99    /// as C mkcomposefs: `XX/DIGEST` where XX is the first byte of the digest.
100    /// The directory is created if it doesn't exist. The layout is compatible
101    /// with digest stores written by the C mkcomposefs tool.
102    #[arg(long)]
103    digest_store: Option<PathBuf>,
104
105    /// Deduplicate hard-linked files on the source filesystem into shared
106    /// EROFS inodes. Without this flag the output is bit-for-bit compatible
107    /// with the C mkcomposefs tool.
108    #[arg(long)]
109    hardlinks: bool,
110
111    /// Number of threads to use for digest calculation and file copying.
112    #[arg(long)]
113    threads: Option<usize>,
114
115    /// The source directory or dumpfile.
116    source: PathBuf,
117
118    /// The output image path (use `-` for stdout).
119    ///
120    /// Must be omitted when using --print-digest-only.
121    image: Option<PathBuf>,
122}
123
124/// Entry point for the mkcomposefs multi-call mode.
125pub fn run() -> Result<()> {
126    let args = Args::parse();
127    run_with_args(args)
128}
129
130/// Entry point when invoked as a hidden `cfsctl mkcomposefs` subcommand.
131///
132/// `extra_args` contains everything after the `mkcomposefs` token as captured
133/// by clap's trailing-var-arg mechanism.  A synthetic `argv[0]` is prepended
134/// so that `Args::parse_from` produces the same help text / error messages as
135/// the standalone binary.
136pub fn run_from_args(extra_args: Vec<std::ffi::OsString>) -> Result<()> {
137    let mut argv = vec![std::ffi::OsString::from("mkcomposefs")];
138    argv.extend(extra_args);
139    let args = Args::parse_from(argv);
140    run_with_args(args)
141}
142
143fn run_with_args(args: Args) -> Result<()> {
144    // Validate arguments
145    if args.print_digest_only && args.image.is_some() {
146        bail!("IMAGE must be omitted when using --print-digest-only");
147    }
148
149    if !args.print_digest_only && args.image.is_none() {
150        bail!("IMAGE is required (or use --print-digest-only)");
151    }
152
153    if args.min_version > args.max_version {
154        bail!(
155            "Invalid version range: --min-version ({}) must not exceed --max-version ({})",
156            args.min_version,
157            args.max_version
158        );
159    }
160
161    // The C composefs tool supports only versions 0 and 1 (LCFS_VERSION_MAX=1).
162    // Both use the same C-compatible on-disk format (compact inodes, BFS, whiteout
163    // table).  The difference is in the `composefs_version` EROFS header field:
164    //   - version 0 (default): starts at 0, auto-upgrades to 1 if a user-visible
165    //     whiteout device (chr 0,0) is encountered.
166    //   - version 1: always writes composefs_version=1 regardless of content.
167    // Versions > 1 are not defined by the C tool and are rejected below.
168    if args.min_version > 1 {
169        bail!(
170            "--min-version {} is not supported; the maximum C-compatible version is 1",
171            args.min_version
172        );
173    }
174    // Map C --min-version to our FormatVersion: 0 → V0 (auto-detect), 1 → V1 (always 1).
175    let format_version = if args.min_version >= 1 {
176        FormatVersion::V1
177    } else {
178        FormatVersion::V0
179    };
180
181    // Open or create the digest store if specified.
182    // Always uses the C-compatible flat layout (XX/DIGEST) so that the store
183    // is interchangeable with the one written by C mkcomposefs.
184    let store: Option<Arc<dyn ObjectStore<Sha256HashValue>>> =
185        if let Some(store_path) = &args.digest_store {
186            let n = args
187                .threads
188                .unwrap_or_else(|| available_parallelism().map(|n| n.get()).unwrap_or(4));
189            Some(Arc::new(FlatDigestStore::open(store_path, n, true)?))
190        } else {
191            None
192        };
193
194    // Warn if --digest-store is combined with --from-file (store is unused in that case)
195    if args.from_file && args.digest_store.is_some() {
196        eprintln!("warning: --digest-store is ignored when --from-file is specified");
197    }
198
199    // Read input
200    let mut fs = if args.from_file {
201        read_dumpfile(&args)?
202    } else {
203        read_directory(
204            &args.source,
205            store,
206            args.threads,
207            if args.hardlinks {
208                HardlinkBehavior::Tracked
209            } else {
210                HardlinkBehavior::Break
211            },
212        )?
213    };
214
215    // Apply transformations based on flags
216    apply_transformations(&mut fs, &args)?;
217
218    // Generate EROFS image
219    let image = mkfs_erofs_versioned(&ValidatedFileSystem::new(fs)?, format_version);
220
221    // Write image (skipped when only the digest is requested)
222    if !args.print_digest_only {
223        let image_path = args.image.as_ref().unwrap();
224        write_image(image_path, &image)?;
225    }
226
227    // Print digest when requested either way
228    if args.print_digest || args.print_digest_only {
229        let digest = compute_fsverity_digest(&image);
230        println!("{digest}");
231    }
232
233    Ok(())
234}
235
236/// Read and parse a dumpfile from the given source.
237fn read_dumpfile(args: &Args) -> Result<composefs::tree::FileSystem<Sha256HashValue>> {
238    let content = if args.source.as_os_str() == "-" {
239        // Read from stdin
240        let stdin = io::stdin();
241        let mut content = String::new();
242        stdin.lock().read_to_string(&mut content)?;
243        content
244    } else {
245        // Read from file
246        let file = File::open(&args.source)
247            .with_context(|| format!("Failed to open dumpfile: {:?}", args.source))?;
248        let mut reader = BufReader::new(file);
249        let mut content = String::new();
250        reader.read_to_string(&mut content)?;
251        content
252    };
253
254    dumpfile_to_filesystem(&content).context("Failed to parse dumpfile")
255}
256
257/// Read a filesystem tree from a directory path.
258///
259/// If a store is provided, large file contents are copied there and
260/// referenced by digest. The store must implement [`ObjectStore`].
261///
262/// The `threads` argument controls both the tokio worker thread count and the
263/// semaphore used to limit concurrent verity computations. `Some(1)` uses a
264/// single-threaded runtime; `None` or `Some(n > 1)` uses the multi-threaded
265/// scheduler.
266fn read_directory(
267    path: &Path,
268    store: Option<Arc<dyn ObjectStore<Sha256HashValue>>>,
269    threads: Option<usize>,
270    hardlinks: HardlinkBehavior,
271) -> Result<FileSystem<Sha256HashValue>> {
272    use rustix::fs::{Mode, OFlags};
273
274    // Verify the path exists and is a directory
275    let metadata = std::fs::metadata(path)
276        .with_context(|| format!("Failed to access source directory: {path:?}"))?;
277
278    if !metadata.is_dir() {
279        bail!("Source path is not a directory: {path:?}");
280    }
281
282    // Open a dirfd for the current directory (required by the async API)
283    let dirfd = rustix::fs::openat(
284        CWD,
285        ".",
286        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
287        Mode::empty(),
288    )
289    .context("Failed to open current directory")?;
290
291    // Build a tokio runtime appropriate for the requested thread count.
292    // --threads 1 → current_thread (no extra OS threads, minimal overhead).
293    // --threads N → multi_thread with exactly N worker threads.
294    // (default)  → multi_thread with the tokio default (one per logical CPU).
295    let rt = match threads {
296        Some(1) => tokio::runtime::Builder::new_current_thread()
297            .enable_all()
298            .build()
299            .context("Failed to create single-threaded tokio runtime")?,
300        Some(n) => tokio::runtime::Builder::new_multi_thread()
301            .worker_threads(n)
302            .enable_all()
303            .build()
304            .context("Failed to create multi-threaded tokio runtime")?,
305        None => tokio::runtime::Builder::new_multi_thread()
306            .enable_all()
307            .build()
308            .context("Failed to create multi-threaded tokio runtime")?,
309    };
310
311    let path = path.to_path_buf();
312
313    // When a store is present its semaphore is already configured; when there
314    // is no store we build the semaphore ourselves so the requested thread
315    // count is honoured.
316    let semaphore = if store.is_none() {
317        let n = threads.unwrap_or_else(|| available_parallelism().map(|n| n.get()).unwrap_or(4));
318        Some(Arc::new(Semaphore::new(n)))
319    } else {
320        None
321    };
322    rt.block_on(read_filesystem_with_opts(
323        dirfd,
324        path,
325        ReadFilesystemOpts {
326            store,
327            semaphore,
328            hardlinks,
329        },
330    ))
331    .context("Failed to read directory tree")
332}
333
334/// Write the image to the specified path (or stdout if `-`).
335fn write_image(path: &PathBuf, image: &[u8]) -> Result<()> {
336    if path.as_os_str() == "-" {
337        let stdout = io::stdout();
338        if stdout.is_terminal() {
339            bail!(
340                "Refusing to write binary image to terminal. Redirect stdout or use a file path."
341            );
342        }
343        stdout.lock().write_all(image)?;
344    } else {
345        let mut file =
346            File::create(path).with_context(|| format!("Failed to create image file: {path:?}"))?;
347        file.write_all(image)?;
348    }
349    Ok(())
350}
351
352/// Compute the fsverity digest of the image.
353fn compute_fsverity_digest(image: &[u8]) -> String {
354    let digest: Sha256HashValue = compute_verity(image);
355    digest.to_hex()
356}
357
358/// Apply filesystem transformations based on command-line flags.
359fn apply_transformations(fs: &mut FileSystem<Sha256HashValue>, args: &Args) -> Result<()> {
360    // Handle xattr filtering
361    if args.skip_xattrs {
362        // Remove all xattrs
363        fs.filter_xattrs(|_| false);
364    } else if args.user_xattrs {
365        // Keep only user.* xattrs
366        fs.filter_xattrs(|name| name.as_encoded_bytes().starts_with(b"user."));
367    }
368
369    // Handle --use-epoch (set all mtimes to 0)
370    if args.use_epoch {
371        set_all_mtimes_to_epoch(fs);
372    }
373
374    // Handle --skip-devices (remove device nodes)
375    if args.skip_devices {
376        remove_device_nodes(fs);
377    }
378
379    Ok(())
380}
381
382/// Set all modification times in the filesystem to Unix epoch (0).
383fn set_all_mtimes_to_epoch(fs: &mut FileSystem<Sha256HashValue>) {
384    fs.for_each_stat_mut(|stat| {
385        stat.st_mtim_sec = 0;
386        stat.st_mtim_nsec = 0;
387    });
388}
389
390/// Remove all device nodes (block and character devices) from the filesystem.
391fn remove_device_nodes(fs: &mut FileSystem<Sha256HashValue>) {
392    use composefs::generic_tree::{Inode, LeafContent};
393
394    type Leaf = composefs::generic_tree::Leaf<composefs::tree::RegularFile<Sha256HashValue>>;
395    type Dir = composefs::generic_tree::Directory<composefs::tree::RegularFile<Sha256HashValue>>;
396
397    fn process_dir(dir: &mut Dir, leaves: &[Leaf]) {
398        // First, collect names of subdirectories to process
399        let subdir_names: Vec<OsString> = dir
400            .entries()
401            .filter_map(|(name, inode)| {
402                if matches!(inode, Inode::Directory(_)) {
403                    Some(name.to_os_string())
404                } else {
405                    None
406                }
407            })
408            .collect();
409
410        // Recursively process subdirectories
411        for name in subdir_names {
412            if let Ok(subdir) = dir.get_directory_mut(&name) {
413                process_dir(subdir, leaves);
414            }
415        }
416
417        // Collect names of device nodes to remove
418        let devices_to_remove: Vec<OsString> = dir
419            .entries()
420            .filter_map(|(name, inode)| {
421                if let Inode::Leaf(leaf_id, _) = inode
422                    && matches!(
423                        leaves[leaf_id.0].content,
424                        LeafContent::BlockDevice(_) | LeafContent::CharacterDevice(_)
425                    )
426                {
427                    return Some(name.to_os_string());
428                }
429                None
430            })
431            .collect();
432
433        // Remove device nodes
434        for name in devices_to_remove {
435            dir.remove(&name);
436        }
437    }
438
439    // Split struct field borrows: Rust allows borrowing different fields simultaneously.
440    let FileSystem { root, leaves, .. } = fs;
441    process_dir(root, leaves);
442
443    // Compact the leaves table to remove entries now unreferenced after
444    // device-node removal.  Without this, fs.fsck() would report orphaned leaves.
445    fs.compact();
446}