Skip to main content

composefs_ctl/
composefs_info.rs

1//! composefs-info - Query information from composefs images.
2//!
3//! This is a Rust reimplementation of the C composefs-info tool, providing
4//! commands to inspect EROFS images, list objects, and compute fs-verity digests.
5//!
6//! ## Compatibility status
7//!
8//! Implemented subcommands:
9//! - `ls` — lists files with type suffixes, skips whiteout entries
10//! - `dump` — outputs composefs-dump(5) text format (image → tree → dumpfile)
11//! - `objects` — lists all backing file object paths (XX/XXXX...)
12//! - `missing-objects` — lists objects not present in `--basedir`
13//! - `measure-file` — computes fs-verity digest of files
14//!
15//! Compatibility notes:
16//! - `measure-file` tries `FS_IOC_MEASURE_VERITY` first; falls back to
17//!   in-process computation when the kernel reports verity is absent or the
18//!   filesystem doesn't support it, matching the C `lcfs_fd_get_fsverity()`
19//!   behaviour.
20
21use std::collections::HashSet;
22use std::io::Write;
23use std::path::Path;
24use std::{fs::File, io::Read, path::PathBuf};
25
26use anyhow::{Context, Result};
27use clap::{Parser, Subcommand};
28
29use composefs::{
30    dumpfile::write_dumpfile,
31    erofs::reader::erofs_to_filesystem,
32    fsverity::{FsVerityHashValue, Sha256HashValue, measure_verity_with_fallback},
33    generic_tree::{Inode, LeafContent, LeafId},
34    tree::{FileSystem, RegularFile},
35};
36
37/// Query information from composefs images.
38#[derive(Parser, Debug)]
39#[command(
40    name = "composefs-info",
41    version,
42    about = "Query information from composefs images"
43)]
44struct Cli {
45    /// The subcommand to run.
46    #[command(subcommand)]
47    command: Command,
48}
49
50/// Available subcommands.
51#[derive(Subcommand, Debug)]
52enum Command {
53    /// Simple listing of files and directories in the image.
54    Ls {
55        /// Filter entries at the root level by name (can be specified multiple times).
56        #[arg(long = "filter", action = clap::ArgAction::Append)]
57        filter: Vec<String>,
58        /// Composefs image files to inspect.
59        images: Vec<PathBuf>,
60    },
61
62    /// Full dump in composefs-dump(5) format.
63    Dump {
64        /// Filter entries at the root level by name (can be specified multiple times).
65        #[arg(long = "filter", action = clap::ArgAction::Append)]
66        filter: Vec<String>,
67        /// Composefs image files to dump.
68        images: Vec<PathBuf>,
69    },
70
71    /// List all backing file object paths.
72    Objects {
73        /// Composefs image files to inspect.
74        images: Vec<PathBuf>,
75    },
76
77    /// List backing files not present in basedir.
78    MissingObjects {
79        /// Base directory for object lookups.
80        #[arg(long = "basedir", required = true)]
81        basedir: PathBuf,
82        /// Composefs image files to inspect.
83        images: Vec<PathBuf>,
84    },
85
86    /// Print the fs-verity digest of files.
87    MeasureFile {
88        /// Files to measure.
89        files: Vec<PathBuf>,
90    },
91}
92
93/// Entry point for the composefs-info multi-call mode.
94pub fn run() -> Result<()> {
95    let cli = Cli::parse();
96    run_with_cli(cli)
97}
98
99/// Entry point when invoked as a hidden `cfsctl composefs-info` subcommand.
100///
101/// `extra_args` contains everything after the `composefs-info` token as
102/// captured by clap's trailing-var-arg mechanism.  A synthetic `argv[0]` is
103/// prepended so that `Cli::parse_from` produces the same help text / error
104/// messages as the standalone binary.
105pub fn run_from_args(extra_args: Vec<std::ffi::OsString>) -> Result<()> {
106    let mut argv = vec![std::ffi::OsString::from("composefs-info")];
107    argv.extend(extra_args);
108    let cli = Cli::parse_from(argv);
109    run_with_cli(cli)
110}
111
112fn run_with_cli(cli: Cli) -> Result<()> {
113    match &cli.command {
114        Command::Ls { filter, images } => cmd_ls(filter, images),
115        Command::Dump { filter, images } => cmd_dump(filter, images),
116        Command::Objects { images } => cmd_objects(images),
117        Command::MissingObjects { basedir, images } => cmd_missing_objects(basedir, images),
118        Command::MeasureFile { files } => cmd_measure_file(files),
119    }
120}
121
122/// Print escaped path (matches C implementation behavior).
123fn print_escaped<W: Write>(out: &mut W, s: &[u8]) -> std::io::Result<()> {
124    for &c in s {
125        match c {
126            b'\\' => write!(out, "\\\\")?,
127            b'\n' => write!(out, "\\n")?,
128            b'\r' => write!(out, "\\r")?,
129            b'\t' => write!(out, "\\t")?,
130            // Non-printable or non-ASCII characters are hex-escaped
131            c if !c.is_ascii_graphic() && c != b' ' => write!(out, "\\x{c:02x}")?,
132            c => out.write_all(&[c])?,
133        }
134    }
135    Ok(())
136}
137
138/// Walk and print entries: directory line first, then recurse into children.
139fn ls_print<W: Write>(
140    out: &mut W,
141    fs: &FileSystem<Sha256HashValue>,
142    dir: &composefs::tree::Directory<Sha256HashValue>,
143    path: &[u8],
144    seen_leaf_ids: &mut HashSet<LeafId>,
145    filter: Option<&[String]>,
146) -> Result<()> {
147    for (name, child) in dir.sorted_entries() {
148        let name_bytes = name.as_encoded_bytes();
149
150        if let Some(filter) = filter
151            && !filter.is_empty()
152        {
153            let name_str = name.to_string_lossy();
154            if !filter.iter().any(|f| f == name_str.as_ref()) {
155                continue;
156            }
157        }
158
159        let mut child_path = path.to_vec();
160        child_path.push(b'/');
161        child_path.extend_from_slice(name_bytes);
162
163        match child {
164            Inode::Directory(child_dir) => {
165                // Print the directory entry with trailing slash.
166                print_escaped(out, &child_path)?;
167                write!(out, "/\t")?;
168                writeln!(out)?;
169                // Recurse into the directory.
170                ls_print(out, fs, child_dir, &child_path, seen_leaf_ids, None)?;
171            }
172            Inode::Leaf(leaf_id, _) => {
173                let leaf = fs.leaf(*leaf_id);
174
175                print_escaped(out, &child_path)?;
176
177                match &leaf.content {
178                    LeafContent::Regular(regular) => {
179                        let is_hardlink = !seen_leaf_ids.insert(*leaf_id);
180                        if !is_hardlink && let RegularFile::External(id, _) = regular {
181                            write!(out, "\t@ ")?;
182                            print_escaped(out, id.to_object_pathname().as_bytes())?;
183                        }
184                    }
185                    LeafContent::Symlink(target) => {
186                        write!(out, "\t-> ")?;
187                        print_escaped(out, target.as_encoded_bytes())?;
188                    }
189                    _ => {}
190                }
191
192                writeln!(out)?;
193            }
194        }
195    }
196    Ok(())
197}
198
199/// List files and directories in the image.
200fn cmd_ls(filter: &[String], images: &[PathBuf]) -> Result<()> {
201    let stdout = std::io::stdout();
202    let mut out = stdout.lock();
203
204    for image_path in images {
205        let image_data = read_image(image_path)?;
206        let fs = erofs_to_filesystem::<Sha256HashValue>(&image_data)
207            .with_context(|| format!("Failed to parse image: {image_path:?}"))?;
208
209        let mut seen_leaf_ids = HashSet::new();
210        ls_print(
211            &mut out,
212            &fs,
213            &fs.root,
214            b"",
215            &mut seen_leaf_ids,
216            Some(filter),
217        )?;
218    }
219
220    Ok(())
221}
222
223/// Dump the image in composefs-dump(5) text format.
224///
225/// This matches the C composefs-info dump output: the EROFS image is parsed
226/// back into a filesystem tree which is then serialized as a dumpfile.
227fn cmd_dump(_filter: &[String], images: &[PathBuf]) -> Result<()> {
228    let stdout = std::io::stdout();
229    let mut out = stdout.lock();
230
231    for image_path in images {
232        let image_data = read_image(image_path)?;
233        let fs = erofs_to_filesystem::<Sha256HashValue>(&image_data)
234            .with_context(|| format!("Failed to parse image: {image_path:?}"))?;
235        write_dumpfile(&mut out, &fs)
236            .with_context(|| format!("Failed to dump image: {image_path:?}"))?;
237    }
238
239    Ok(())
240}
241
242/// Collect all external object IDs from a parsed filesystem.
243///
244/// Iterates the leaves table directly — each `RegularFile::External` entry
245/// is a unique content-addressed object.  Because `erofs_to_filesystem`
246/// deduplicates hard-linked inodes into a single leaf, each object appears
247/// exactly once even if it is referenced by multiple paths.
248fn collect_objects_from_fs(fs: &FileSystem<Sha256HashValue>) -> HashSet<Sha256HashValue> {
249    fs.leaves
250        .iter()
251        .filter_map(|leaf| match &leaf.content {
252            LeafContent::Regular(RegularFile::External(id, _)) => Some(id.clone()),
253            _ => None,
254        })
255        .collect()
256}
257
258/// List all object paths from the images.
259fn cmd_objects(images: &[PathBuf]) -> Result<()> {
260    for image_path in images {
261        let image_data = read_image(image_path)?;
262        let fs = erofs_to_filesystem::<Sha256HashValue>(&image_data)
263            .with_context(|| format!("Failed to parse image: {image_path:?}"))?;
264
265        let mut objects: Vec<Sha256HashValue> = collect_objects_from_fs(&fs).into_iter().collect();
266        objects.sort_by_key(|id| id.to_hex());
267
268        for obj in objects {
269            println!("{}", obj.to_object_pathname());
270        }
271    }
272    Ok(())
273}
274
275/// List objects not present in basedir.
276fn cmd_missing_objects(basedir: &Path, images: &[PathBuf]) -> Result<()> {
277    let mut all_objects: HashSet<Sha256HashValue> = HashSet::new();
278
279    for image_path in images {
280        let image_data = read_image(image_path)?;
281        let fs = erofs_to_filesystem::<Sha256HashValue>(&image_data)
282            .with_context(|| format!("Failed to parse image: {image_path:?}"))?;
283        all_objects.extend(collect_objects_from_fs(&fs));
284    }
285
286    let mut missing: Vec<Sha256HashValue> = all_objects
287        .into_iter()
288        .filter(|obj| !basedir.join(obj.to_object_pathname()).exists())
289        .collect();
290
291    missing.sort_by_key(|a| a.to_hex());
292
293    for obj in missing {
294        println!("{}", obj.to_object_pathname());
295    }
296
297    Ok(())
298}
299
300/// Compute and print the fs-verity digest of each file.
301fn cmd_measure_file(files: &[PathBuf]) -> Result<()> {
302    for path in files {
303        let file = File::open(path).with_context(|| format!("Failed to open file: {path:?}"))?;
304        let digest = measure_verity_with_fallback::<Sha256HashValue>(file)
305            .with_context(|| format!("Failed to measure verity for {path:?}"))?;
306        println!("{}", digest.to_hex());
307    }
308    Ok(())
309}
310
311/// Read an entire image file into memory.
312fn read_image(path: &PathBuf) -> Result<Vec<u8>> {
313    let mut file = File::open(path).with_context(|| format!("Failed to open image: {path:?}"))?;
314    let mut data = Vec::new();
315    file.read_to_end(&mut data)
316        .with_context(|| format!("Failed to read image: {path:?}"))?;
317    Ok(data)
318}