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::{ensure, 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.
449///
450/// The dumpfile must start with a root directory entry (`/`) which provides
451/// the root metadata. Returns an error if no root entry is found.
452pub fn dumpfile_to_filesystem<ObjectID: FsVerityHashValue>(
453    dumpfile: &str,
454) -> Result<FileSystem<ObjectID>> {
455    let mut lines = dumpfile.lines().peekable();
456    let mut hardlinks = HashMap::new();
457
458    // Find the first non-empty line which must be the root entry
459    let root_stat = loop {
460        match lines.next() {
461            Some(line) if line.trim().is_empty() => continue,
462            Some(line) => {
463                let entry = Entry::parse(line)
464                    .with_context(|| format!("Failed to parse dumpfile line: {line}"))?;
465                ensure!(
466                    entry.path.as_ref() == Path::new("/"),
467                    "Dumpfile must start with root directory entry, found: {:?}",
468                    entry.path
469                );
470                break entry_to_stat(&entry);
471            }
472            None => anyhow::bail!("Dumpfile is empty, expected root directory entry"),
473        }
474    };
475
476    let mut fs = FileSystem::new(root_stat);
477
478    // Process remaining entries
479    for line in lines {
480        if line.trim().is_empty() {
481            continue;
482        }
483        let entry =
484            Entry::parse(line).with_context(|| format!("Failed to parse dumpfile line: {line}"))?;
485        add_entry_to_filesystem(&mut fs, entry, &mut hardlinks)?;
486    }
487
488    Ok(fs)
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::fsverity::Sha256HashValue;
495
496    const SIMPLE_DUMP: &str = r#"/ 4096 40755 2 0 0 0 1000.0 - - -
497/empty_file 0 100644 1 0 0 0 1000.0 - - -
498/small_file 5 100644 1 0 0 0 1000.0 - hello -
499/symlink 7 120777 1 0 0 0 1000.0 /target - -
500"#;
501
502    #[test]
503    fn test_simple_dumpfile_conversion() -> Result<()> {
504        let fs = dumpfile_to_filesystem::<Sha256HashValue>(SIMPLE_DUMP)?;
505
506        // Check files exist
507        assert!(fs.root.lookup(OsStr::new("empty_file")).is_some());
508        assert!(fs.root.lookup(OsStr::new("small_file")).is_some());
509        assert!(fs.root.lookup(OsStr::new("symlink")).is_some());
510
511        // Check inline file content
512        let small_file = fs.root.get_file(OsStr::new("small_file"))?;
513        if let RegularFile::Inline(data) = small_file {
514            assert_eq!(&**data, b"hello");
515        } else {
516            panic!("Expected inline file");
517        }
518
519        Ok(())
520    }
521
522    #[test]
523    fn test_hardlinks() -> Result<()> {
524        let dumpfile = r#"/ 4096 40755 2 0 0 0 1000.0 - - -
525/original 11 100644 2 0 0 0 1000.0 - hello_world -
526/hardlink1 0 @120000 2 0 0 0 0.0 /original - -
527/dir1 4096 40755 2 0 0 0 1000.0 - - -
528/dir1/hardlink2 0 @120000 2 0 0 0 0.0 /original - -
529"#;
530
531        let fs = dumpfile_to_filesystem::<Sha256HashValue>(dumpfile)?;
532
533        // Get the original file
534        let original = fs.root.lookup(OsStr::new("original")).unwrap();
535        let hardlink1 = fs.root.lookup(OsStr::new("hardlink1")).unwrap();
536
537        // Get hardlink2 from dir1
538        let dir1 = fs.root.get_directory(OsStr::new("dir1"))?;
539        let hardlink2 = dir1.lookup(OsStr::new("hardlink2")).unwrap();
540
541        // All three should be Leaf inodes
542        let original_leaf = match original {
543            Inode::Leaf(ref l) => l,
544            _ => panic!("Expected Leaf inode"),
545        };
546        let hardlink1_leaf = match hardlink1 {
547            Inode::Leaf(ref l) => l,
548            _ => panic!("Expected Leaf inode"),
549        };
550        let hardlink2_leaf = match hardlink2 {
551            Inode::Leaf(ref l) => l,
552            _ => panic!("Expected Leaf inode"),
553        };
554
555        // They should all point to the same Rc (same pointer)
556        assert!(Rc::ptr_eq(original_leaf, hardlink1_leaf));
557        assert!(Rc::ptr_eq(original_leaf, hardlink2_leaf));
558
559        // Verify the strong count is 3 (original + 2 hardlinks)
560        assert_eq!(Rc::strong_count(original_leaf), 3);
561
562        // Verify content
563        if let LeafContent::Regular(RegularFile::Inline(data)) = &original_leaf.content {
564            assert_eq!(&**data, b"hello_world");
565        } else {
566            panic!("Expected inline regular file");
567        }
568
569        Ok(())
570    }
571}