composefs/
dumpfile.rs

1//! Reading and writing composefs dumpfile format.
2//!
3//! This module provides functionality to serialize filesystem trees into
4//! the composefs dumpfile text format (writing), and to convert parsed
5//! dumpfile entries back into tree structures (reading).
6//!
7//! The module handles file metadata, extended attributes, and hardlink tracking.
8
9use std::{
10    cell::RefCell,
11    collections::{BTreeMap, HashMap},
12    ffi::{OsStr, OsString},
13    fmt,
14    io::{BufWriter, Write},
15    os::unix::ffi::OsStrExt,
16    path::{Path, PathBuf},
17    rc::Rc,
18};
19
20use anyhow::{Context, Result};
21use rustix::fs::FileType;
22
23use crate::{
24    dumpfile_parse::{Entry, Item},
25    fsverity::FsVerityHashValue,
26    tree::{Directory, FileSystem, Inode, Leaf, LeafContent, RegularFile, Stat},
27};
28
29fn write_empty(writer: &mut impl fmt::Write) -> fmt::Result {
30    writer.write_str("-")
31}
32
33fn write_escaped(writer: &mut impl fmt::Write, bytes: &[u8]) -> fmt::Result {
34    if bytes.is_empty() {
35        return write_empty(writer);
36    }
37
38    for c in bytes {
39        let c = *c;
40
41        if c < b'!' || c == b'=' || c == b'\\' || c > b'~' {
42            write!(writer, "\\x{c:02x}")?;
43        } else {
44            writer.write_char(c as char)?;
45        }
46    }
47
48    Ok(())
49}
50
51#[allow(clippy::too_many_arguments)]
52fn write_entry(
53    writer: &mut impl fmt::Write,
54    path: &Path,
55    stat: &Stat,
56    ifmt: FileType,
57    size: u64,
58    nlink: usize,
59    rdev: u64,
60    payload: impl AsRef<OsStr>,
61    content: &[u8],
62    digest: Option<&str>,
63) -> fmt::Result {
64    let mode = stat.st_mode | ifmt.as_raw_mode();
65    let uid = stat.st_uid;
66    let gid = stat.st_gid;
67    let mtim_sec = stat.st_mtim_sec;
68
69    write_escaped(writer, path.as_os_str().as_bytes())?;
70    write!(
71        writer,
72        " {size} {mode:o} {nlink} {uid} {gid} {rdev} {mtim_sec}.0 "
73    )?;
74    write_escaped(writer, payload.as_ref().as_bytes())?;
75    write!(writer, " ")?;
76    write_escaped(writer, content)?;
77    write!(writer, " ")?;
78    if let Some(id) = digest {
79        write!(writer, "{id}")?;
80    } else {
81        write_empty(writer)?;
82    }
83
84    for (key, value) in &*stat.xattrs.borrow() {
85        write!(writer, " ")?;
86        write_escaped(writer, key.as_bytes())?;
87        write!(writer, "=")?;
88        write_escaped(writer, value)?;
89    }
90
91    Ok(())
92}
93
94/// Writes a directory entry to the dumpfile format.
95///
96/// Writes the metadata for a directory including path, permissions, ownership,
97/// timestamps, and extended attributes.
98pub fn write_directory(
99    writer: &mut impl fmt::Write,
100    path: &Path,
101    stat: &Stat,
102    nlink: usize,
103) -> fmt::Result {
104    write_entry(
105        writer,
106        path,
107        stat,
108        FileType::Directory,
109        0,
110        nlink,
111        0,
112        "",
113        &[],
114        None,
115    )
116}
117
118/// Writes a leaf node (non-directory) entry to the dumpfile format.
119///
120/// Handles all types of leaf nodes including regular files (inline and external),
121/// device files, symlinks, sockets, and FIFOs.
122pub fn write_leaf(
123    writer: &mut impl fmt::Write,
124    path: &Path,
125    stat: &Stat,
126    content: &LeafContent<impl FsVerityHashValue>,
127    nlink: usize,
128) -> fmt::Result {
129    match content {
130        LeafContent::Regular(RegularFile::Inline(ref data)) => write_entry(
131            writer,
132            path,
133            stat,
134            FileType::RegularFile,
135            data.len() as u64,
136            nlink,
137            0,
138            "",
139            data,
140            None,
141        ),
142        LeafContent::Regular(RegularFile::External(id, size)) => write_entry(
143            writer,
144            path,
145            stat,
146            FileType::RegularFile,
147            *size,
148            nlink,
149            0,
150            id.to_object_pathname(),
151            &[],
152            Some(&id.to_hex()),
153        ),
154        LeafContent::BlockDevice(rdev) => write_entry(
155            writer,
156            path,
157            stat,
158            FileType::BlockDevice,
159            0,
160            nlink,
161            *rdev,
162            "",
163            &[],
164            None,
165        ),
166        LeafContent::CharacterDevice(rdev) => write_entry(
167            writer,
168            path,
169            stat,
170            FileType::CharacterDevice,
171            0,
172            nlink,
173            *rdev,
174            "",
175            &[],
176            None,
177        ),
178        LeafContent::Fifo => write_entry(
179            writer,
180            path,
181            stat,
182            FileType::Fifo,
183            0,
184            nlink,
185            0,
186            "",
187            &[],
188            None,
189        ),
190        LeafContent::Socket => write_entry(
191            writer,
192            path,
193            stat,
194            FileType::Socket,
195            0,
196            nlink,
197            0,
198            "",
199            &[],
200            None,
201        ),
202        LeafContent::Symlink(ref target) => write_entry(
203            writer,
204            path,
205            stat,
206            FileType::Symlink,
207            target.as_bytes().len() as u64,
208            nlink,
209            0,
210            target,
211            &[],
212            None,
213        ),
214    }
215}
216
217/// Writes a hardlink entry to the dumpfile format.
218///
219/// Creates a special entry that links the given path to an existing target path
220/// that was already written to the dumpfile.
221pub fn write_hardlink(writer: &mut impl fmt::Write, path: &Path, target: &OsStr) -> fmt::Result {
222    write_escaped(writer, path.as_os_str().as_bytes())?;
223    write!(writer, " 0 @120000 - - - - 0.0 ")?;
224    write_escaped(writer, target.as_bytes())?;
225    write!(writer, " - -")?;
226    Ok(())
227}
228
229struct DumpfileWriter<'a, W: Write, ObjectID: FsVerityHashValue> {
230    hardlinks: HashMap<*const Leaf<ObjectID>, OsString>,
231    writer: &'a mut W,
232}
233
234fn writeln_fmt(writer: &mut impl Write, f: impl Fn(&mut String) -> fmt::Result) -> Result<()> {
235    let mut tmp = String::with_capacity(256);
236    f(&mut tmp)?;
237    Ok(writeln!(writer, "{tmp}")?)
238}
239
240impl<'a, W: Write, ObjectID: FsVerityHashValue> DumpfileWriter<'a, W, ObjectID> {
241    fn new(writer: &'a mut W) -> Self {
242        Self {
243            hardlinks: HashMap::new(),
244            writer,
245        }
246    }
247
248    fn write_dir(&mut self, path: &mut PathBuf, dir: &Directory<ObjectID>) -> Result<()> {
249        // nlink is 2 + number of subdirectories
250        // this is also true for the root dir since '..' is another self-ref
251        let nlink = dir.inodes().fold(2, |count, inode| {
252            count + {
253                match inode {
254                    Inode::Directory(..) => 1,
255                    _ => 0,
256                }
257            }
258        });
259
260        writeln_fmt(self.writer, |fmt| {
261            write_directory(fmt, path, &dir.stat, nlink)
262        })?;
263
264        for (name, inode) in dir.sorted_entries() {
265            path.push(name);
266
267            match inode {
268                Inode::Directory(ref dir) => {
269                    self.write_dir(path, dir)?;
270                }
271                Inode::Leaf(ref leaf) => {
272                    self.write_leaf(path, leaf)?;
273                }
274            }
275
276            path.pop();
277        }
278        Ok(())
279    }
280
281    fn write_leaf(&mut self, path: &Path, leaf: &Rc<Leaf<ObjectID>>) -> Result<()> {
282        let nlink = Rc::strong_count(leaf);
283
284        if nlink > 1 {
285            // This is a hardlink.  We need to handle that specially.
286            let ptr = Rc::as_ptr(leaf);
287            if let Some(target) = self.hardlinks.get(&ptr) {
288                return writeln_fmt(self.writer, |fmt| write_hardlink(fmt, path, target));
289            }
290
291            // @path gets modified all the time, so take a copy
292            self.hardlinks.insert(ptr, OsString::from(&path));
293        }
294
295        writeln_fmt(self.writer, |fmt| {
296            write_leaf(fmt, path, &leaf.stat, &leaf.content, nlink)
297        })
298    }
299}
300
301/// Writes a complete filesystem tree to the composefs dumpfile format.
302///
303/// Serializes the entire filesystem structure including all directories, files,
304/// metadata, and handles hardlink tracking automatically.
305pub fn write_dumpfile(
306    writer: &mut impl Write,
307    fs: &FileSystem<impl FsVerityHashValue>,
308) -> Result<()> {
309    // default pipe capacity on Linux is 16 pages (65536 bytes), but
310    // sometimes the BufWriter will write more than its capacity...
311    let mut buffer = BufWriter::with_capacity(32768, writer);
312    let mut dfw = DumpfileWriter::new(&mut buffer);
313    let mut path = PathBuf::from("/");
314
315    dfw.write_dir(&mut path, &fs.root)?;
316    buffer.flush()?;
317
318    Ok(())
319}
320
321// Reading: Converting dumpfile entries to tree structures
322
323/// Convert a dumpfile Entry into tree structures and insert into a FileSystem.
324pub fn add_entry_to_filesystem<ObjectID: FsVerityHashValue>(
325    fs: &mut FileSystem<ObjectID>,
326    entry: Entry<'_>,
327    hardlinks: &mut HashMap<PathBuf, Rc<Leaf<ObjectID>>>,
328) -> Result<()> {
329    let path = entry.path.as_ref();
330
331    // Handle root directory specially
332    if path == Path::new("/") {
333        let stat = entry_to_stat(&entry);
334        fs.set_root_stat(stat);
335        return Ok(());
336    }
337
338    // Split the path into directory and filename
339    let parent = path.parent().unwrap_or_else(|| Path::new("/"));
340    let filename = path
341        .file_name()
342        .ok_or_else(|| anyhow::anyhow!("Path has no filename: {:?}", path))?;
343
344    // Get or create parent directory
345    let parent_dir = if parent == Path::new("/") {
346        &mut fs.root
347    } else {
348        fs.root
349            .get_directory_mut(parent.as_os_str())
350            .with_context(|| format!("Parent directory not found: {:?}", parent))?
351    };
352
353    // Convert the entry to an inode
354    let inode = match entry.item {
355        Item::Directory { .. } => {
356            let stat = entry_to_stat(&entry);
357            Inode::Directory(Box::new(Directory::new(stat)))
358        }
359        Item::Hardlink { ref target } => {
360            // Look up the target in our hardlinks map and clone the Rc
361            let target_leaf = hardlinks
362                .get(target.as_ref())
363                .ok_or_else(|| anyhow::anyhow!("Hardlink target not found: {:?}", target))?
364                .clone();
365            Inode::Leaf(target_leaf)
366        }
367        Item::RegularInline { ref content, .. } => {
368            let stat = entry_to_stat(&entry);
369            let data: Box<[u8]> = match content {
370                std::borrow::Cow::Borrowed(d) => Box::from(*d),
371                std::borrow::Cow::Owned(d) => d.clone().into_boxed_slice(),
372            };
373            let content = LeafContent::Regular(RegularFile::Inline(data));
374            Inode::Leaf(Rc::new(Leaf { stat, content }))
375        }
376        Item::Regular {
377            size,
378            ref fsverity_digest,
379            ..
380        } => {
381            let stat = entry_to_stat(&entry);
382            let digest = fsverity_digest
383                .as_ref()
384                .ok_or_else(|| anyhow::anyhow!("External file missing fsverity digest"))?;
385            let object_id = ObjectID::from_hex(digest)?;
386            let content = LeafContent::Regular(RegularFile::External(object_id, size));
387            Inode::Leaf(Rc::new(Leaf { stat, content }))
388        }
389        Item::Device { rdev, .. } => {
390            let stat = entry_to_stat(&entry);
391            // S_IFMT = 0o170000, S_IFBLK = 0o60000, S_IFCHR = 0o20000
392            let content = if entry.mode & 0o170000 == 0o60000 {
393                LeafContent::BlockDevice(rdev)
394            } else {
395                LeafContent::CharacterDevice(rdev)
396            };
397            Inode::Leaf(Rc::new(Leaf { stat, content }))
398        }
399        Item::Symlink { ref target, .. } => {
400            let stat = entry_to_stat(&entry);
401            let target_os: Box<OsStr> = match target {
402                std::borrow::Cow::Borrowed(t) => Box::from(t.as_os_str()),
403                std::borrow::Cow::Owned(t) => Box::from(t.as_os_str()),
404            };
405            let content = LeafContent::Symlink(target_os);
406            Inode::Leaf(Rc::new(Leaf { stat, content }))
407        }
408        Item::Fifo { .. } => {
409            let stat = entry_to_stat(&entry);
410            let content = LeafContent::Fifo;
411            Inode::Leaf(Rc::new(Leaf { stat, content }))
412        }
413    };
414
415    // Store Leafs in the hardlinks map for future hardlink lookups
416    if let Inode::Leaf(ref leaf) = inode {
417        hardlinks.insert(path.to_path_buf(), leaf.clone());
418    }
419
420    parent_dir.insert(filename, inode);
421    Ok(())
422}
423
424/// Convert a dumpfile Entry's metadata into a tree Stat structure.
425fn entry_to_stat(entry: &Entry<'_>) -> Stat {
426    let mut xattrs = BTreeMap::new();
427    for xattr in &entry.xattrs {
428        let key: Box<OsStr> = match &xattr.key {
429            std::borrow::Cow::Borrowed(k) => Box::from(*k),
430            std::borrow::Cow::Owned(k) => Box::from(k.as_os_str()),
431        };
432        let value: Box<[u8]> = match &xattr.value {
433            std::borrow::Cow::Borrowed(v) => Box::from(*v),
434            std::borrow::Cow::Owned(v) => v.clone().into_boxed_slice(),
435        };
436        xattrs.insert(key, value);
437    }
438
439    Stat {
440        st_mode: entry.mode & 0o7777, // Keep only permission bits
441        st_uid: entry.uid,
442        st_gid: entry.gid,
443        st_mtim_sec: entry.mtime.sec as i64,
444        xattrs: RefCell::new(xattrs),
445    }
446}
447
448/// Parse a dumpfile string and build a complete FileSystem.
449pub fn dumpfile_to_filesystem<ObjectID: FsVerityHashValue>(
450    dumpfile: &str,
451) -> Result<FileSystem<ObjectID>> {
452    let mut fs = FileSystem::default();
453    let mut hardlinks = HashMap::new();
454
455    for line in dumpfile.lines() {
456        if line.trim().is_empty() {
457            continue;
458        }
459        let entry = Entry::parse(line)
460            .with_context(|| format!("Failed to parse dumpfile line: {}", line))?;
461        add_entry_to_filesystem(&mut fs, entry, &mut hardlinks)?;
462    }
463
464    fs.ensure_root_stat();
465    Ok(fs)
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use crate::fsverity::Sha256HashValue;
472
473    const SIMPLE_DUMP: &str = r#"/ 4096 40755 2 0 0 0 1000.0 - - -
474/empty_file 0 100644 1 0 0 0 1000.0 - - -
475/small_file 5 100644 1 0 0 0 1000.0 - hello -
476/symlink 7 120777 1 0 0 0 1000.0 /target - -
477"#;
478
479    #[test]
480    fn test_simple_dumpfile_conversion() -> Result<()> {
481        let fs = dumpfile_to_filesystem::<Sha256HashValue>(SIMPLE_DUMP)?;
482
483        // Check root exists
484        assert!(fs.have_root_stat);
485
486        // Check files exist
487        assert!(fs.root.lookup(OsStr::new("empty_file")).is_some());
488        assert!(fs.root.lookup(OsStr::new("small_file")).is_some());
489        assert!(fs.root.lookup(OsStr::new("symlink")).is_some());
490
491        // Check inline file content
492        let small_file = fs.root.get_file(OsStr::new("small_file"))?;
493        if let RegularFile::Inline(data) = small_file {
494            assert_eq!(&**data, b"hello");
495        } else {
496            panic!("Expected inline file");
497        }
498
499        Ok(())
500    }
501
502    #[test]
503    fn test_hardlinks() -> Result<()> {
504        let dumpfile = r#"/ 4096 40755 2 0 0 0 1000.0 - - -
505/original 11 100644 2 0 0 0 1000.0 - hello_world -
506/hardlink1 0 @120000 2 0 0 0 0.0 /original - -
507/dir1 4096 40755 2 0 0 0 1000.0 - - -
508/dir1/hardlink2 0 @120000 2 0 0 0 0.0 /original - -
509"#;
510
511        let fs = dumpfile_to_filesystem::<Sha256HashValue>(dumpfile)?;
512
513        // Get the original file
514        let original = fs.root.lookup(OsStr::new("original")).unwrap();
515        let hardlink1 = fs.root.lookup(OsStr::new("hardlink1")).unwrap();
516
517        // Get hardlink2 from dir1
518        let dir1 = fs.root.get_directory(OsStr::new("dir1"))?;
519        let hardlink2 = dir1.lookup(OsStr::new("hardlink2")).unwrap();
520
521        // All three should be Leaf inodes
522        let original_leaf = match original {
523            Inode::Leaf(ref l) => l,
524            _ => panic!("Expected Leaf inode"),
525        };
526        let hardlink1_leaf = match hardlink1 {
527            Inode::Leaf(ref l) => l,
528            _ => panic!("Expected Leaf inode"),
529        };
530        let hardlink2_leaf = match hardlink2 {
531            Inode::Leaf(ref l) => l,
532            _ => panic!("Expected Leaf inode"),
533        };
534
535        // They should all point to the same Rc (same pointer)
536        assert!(Rc::ptr_eq(original_leaf, hardlink1_leaf));
537        assert!(Rc::ptr_eq(original_leaf, hardlink2_leaf));
538
539        // Verify the strong count is 3 (original + 2 hardlinks)
540        assert_eq!(Rc::strong_count(original_leaf), 3);
541
542        // Verify content
543        if let LeafContent::Regular(RegularFile::Inline(data)) = &original_leaf.content {
544            assert_eq!(&**data, b"hello_world");
545        } else {
546            panic!("Expected inline regular file");
547        }
548
549        Ok(())
550    }
551}