1use 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#[derive(Parser, Debug)]
54#[command(name = "mkcomposefs", version, about)]
55struct Args {
56 #[arg(long)]
60 from_file: bool,
61
62 #[arg(long)]
64 print_digest: bool,
65
66 #[arg(long)]
70 print_digest_only: bool,
71
72 #[arg(long)]
74 use_epoch: bool,
75
76 #[arg(long)]
78 skip_devices: bool,
79
80 #[arg(long)]
82 skip_xattrs: bool,
83
84 #[arg(long)]
86 user_xattrs: bool,
87
88 #[arg(long, default_value = "0")]
90 min_version: u32,
91
92 #[arg(long, default_value = "1")]
94 max_version: u32,
95
96 #[arg(long)]
103 digest_store: Option<PathBuf>,
104
105 #[arg(long)]
109 hardlinks: bool,
110
111 #[arg(long)]
113 threads: Option<usize>,
114
115 source: PathBuf,
117
118 image: Option<PathBuf>,
122}
123
124pub fn run() -> Result<()> {
126 let args = Args::parse();
127 run_with_args(args)
128}
129
130pub 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 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 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 let format_version = if args.min_version >= 1 {
176 FormatVersion::V1
177 } else {
178 FormatVersion::V0
179 };
180
181 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 if args.from_file && args.digest_store.is_some() {
196 eprintln!("warning: --digest-store is ignored when --from-file is specified");
197 }
198
199 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(&mut fs, &args)?;
217
218 let image = mkfs_erofs_versioned(&ValidatedFileSystem::new(fs)?, format_version);
220
221 if !args.print_digest_only {
223 let image_path = args.image.as_ref().unwrap();
224 write_image(image_path, &image)?;
225 }
226
227 if args.print_digest || args.print_digest_only {
229 let digest = compute_fsverity_digest(&image);
230 println!("{digest}");
231 }
232
233 Ok(())
234}
235
236fn read_dumpfile(args: &Args) -> Result<composefs::tree::FileSystem<Sha256HashValue>> {
238 let content = if args.source.as_os_str() == "-" {
239 let stdin = io::stdin();
241 let mut content = String::new();
242 stdin.lock().read_to_string(&mut content)?;
243 content
244 } else {
245 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
257fn 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 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 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 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 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
334fn 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
352fn compute_fsverity_digest(image: &[u8]) -> String {
354 let digest: Sha256HashValue = compute_verity(image);
355 digest.to_hex()
356}
357
358fn apply_transformations(fs: &mut FileSystem<Sha256HashValue>, args: &Args) -> Result<()> {
360 if args.skip_xattrs {
362 fs.filter_xattrs(|_| false);
364 } else if args.user_xattrs {
365 fs.filter_xattrs(|name| name.as_encoded_bytes().starts_with(b"user."));
367 }
368
369 if args.use_epoch {
371 set_all_mtimes_to_epoch(fs);
372 }
373
374 if args.skip_devices {
376 remove_device_nodes(fs);
377 }
378
379 Ok(())
380}
381
382fn 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
390fn 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 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 for name in subdir_names {
412 if let Ok(subdir) = dir.get_directory_mut(&name) {
413 process_dir(subdir, leaves);
414 }
415 }
416
417 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 for name in devices_to_remove {
435 dir.remove(&name);
436 }
437 }
438
439 let FileSystem { root, leaves, .. } = fs;
441 process_dir(root, leaves);
442
443 fs.compact();
446}