Skip to main content

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    collections::{BTreeMap, HashMap},
11    ffi::{OsStr, OsString},
12    fmt,
13    io::{BufWriter, Write},
14    os::unix::ffi::OsStrExt,
15    path::{Path, PathBuf},
16};
17
18use anyhow::{Context, Result, ensure};
19use fn_error_context::context;
20use rustix::fs::FileType;
21
22use crate::{
23    dumpfile_parse::{Entry, Item},
24    fsverity::FsVerityHashValue,
25    generic_tree::LeafId,
26    tree::{Directory, FileSystem, Inode, LeafContent, RegularFile, Stat},
27};
28
29fn write_empty(writer: &mut impl fmt::Write) -> fmt::Result {
30    writer.write_str("-")
31}
32
33/// Escape a byte slice for a space-delimited dumpfile field.
34///
35/// This corresponds to `print_escaped_optional` in the C composefs
36/// `composefs-info.c`, combining `ESCAPE_STANDARD | ESCAPE_LONE_DASH`.
37/// Empty values map to `-` (the "none" sentinel), and a bare `-` is
38/// hex-escaped so it is not confused with the sentinel.
39///
40/// Not appropriate for xattr values — use [`write_escaped_raw`] instead.
41fn write_escaped(writer: &mut impl fmt::Write, bytes: &[u8]) -> fmt::Result {
42    if bytes.is_empty() {
43        return write_empty(writer);
44    }
45
46    // Matches C ESCAPE_LONE_DASH: a bare `-` must be escaped because
47    // the parser uses `-` as the sentinel for "empty/none".
48    if bytes == b"-" {
49        return writer.write_str("\\x2d");
50    }
51
52    write_escaped_raw(writer, bytes, EscapeEquals::No)
53}
54
55/// Whether to escape `=` as `\x3d`.
56///
57/// The C composefs implementation only escapes `=` in xattr key/value
58/// fields where it separates the key from the value.  In other fields
59/// (paths, content, payload) `=` is a normal graphic character.
60#[derive(Clone, Copy)]
61enum EscapeEquals {
62    /// Escape `=` — used for xattr key/value fields.
63    Yes,
64    /// Do not escape `=` — used for paths, content, and payload fields.
65    No,
66}
67
68/// Escape a byte slice without the `-` sentinel logic.
69///
70/// This corresponds to `print_escaped` in the C composefs
71/// `composefs-info.c`.  Used for xattr values where `-` and empty are
72/// valid literal values, not sentinels.
73///
74/// The `escape_eq` parameter controls whether `=` is escaped (only
75/// needed in xattr key/value fields where `=` is a separator).
76fn write_escaped_raw(
77    writer: &mut impl fmt::Write,
78    bytes: &[u8],
79    escape_eq: EscapeEquals,
80) -> fmt::Result {
81    for c in bytes {
82        let c = *c;
83
84        // Named escapes matching the C composefs implementation.
85        match c {
86            b'\\' => writer.write_str("\\\\")?,
87            b'\n' => writer.write_str("\\n")?,
88            b'\r' => writer.write_str("\\r")?,
89            b'\t' => writer.write_str("\\t")?,
90            b'=' if matches!(escape_eq, EscapeEquals::Yes) => write!(writer, "\\x{c:02x}")?,
91            // Hex-escape non-graphic characters (outside 0x21..=0x7E in POSIX locale).
92            c if !(b'!'..=b'~').contains(&c) => write!(writer, "\\x{c:02x}")?,
93            c => writer.write_char(c as char)?,
94        }
95    }
96
97    Ok(())
98}
99
100#[allow(clippy::too_many_arguments)]
101fn write_entry(
102    writer: &mut impl fmt::Write,
103    path: &Path,
104    stat: &Stat,
105    ifmt: FileType,
106    size: u64,
107    nlink: usize,
108    rdev: u64,
109    payload: impl AsRef<OsStr>,
110    content: &[u8],
111    digest: Option<&str>,
112) -> fmt::Result {
113    let mode = stat.st_mode | ifmt.as_raw_mode();
114    let uid = stat.st_uid;
115    let gid = stat.st_gid;
116    let mtim_sec = stat.st_mtim_sec;
117    let mtim_nsec = stat.st_mtim_nsec;
118
119    write_escaped(writer, path.as_os_str().as_bytes())?;
120    write!(
121        writer,
122        " {size} {mode:o} {nlink} {uid} {gid} {rdev} {mtim_sec}.{mtim_nsec} "
123    )?;
124    write_escaped(writer, payload.as_ref().as_bytes())?;
125    write!(writer, " ")?;
126    write_escaped(writer, content)?;
127    write!(writer, " ")?;
128    if let Some(id) = digest {
129        write!(writer, "{id}")?;
130    } else {
131        write_empty(writer)?;
132    }
133
134    for (key, value) in &stat.xattrs {
135        write!(writer, " ")?;
136        write_escaped_raw(writer, key.as_bytes(), EscapeEquals::Yes)?;
137        write!(writer, "=")?;
138        // Xattr values don't use the `-` sentinel — they're always present
139        // when the key=value pair exists, and empty or `-` are valid values.
140        write_escaped_raw(writer, value, EscapeEquals::Yes)?;
141    }
142
143    Ok(())
144}
145
146/// Writes a directory entry to the dumpfile format.
147///
148/// Writes the metadata for a directory including path, permissions, ownership,
149/// timestamps, and extended attributes.
150pub fn write_directory(
151    writer: &mut impl fmt::Write,
152    path: &Path,
153    stat: &Stat,
154    nlink: usize,
155) -> fmt::Result {
156    write_entry(
157        writer,
158        path,
159        stat,
160        FileType::Directory,
161        0,
162        nlink,
163        0,
164        "",
165        &[],
166        None,
167    )
168}
169
170/// Writes a leaf node (non-directory) entry to the dumpfile format.
171///
172/// Handles all types of leaf nodes including regular files (inline and external),
173/// device files, symlinks, sockets, and FIFOs.
174pub fn write_leaf(
175    writer: &mut impl fmt::Write,
176    path: &Path,
177    stat: &Stat,
178    content: &LeafContent<impl FsVerityHashValue>,
179    nlink: usize,
180) -> fmt::Result {
181    match content {
182        LeafContent::Regular(RegularFile::Inline(data)) => write_entry(
183            writer,
184            path,
185            stat,
186            FileType::RegularFile,
187            data.len() as u64,
188            nlink,
189            0,
190            "",
191            data,
192            None,
193        ),
194        LeafContent::Regular(RegularFile::External(id, size)) => write_entry(
195            writer,
196            path,
197            stat,
198            FileType::RegularFile,
199            *size,
200            nlink,
201            0,
202            id.to_object_pathname(),
203            &[],
204            Some(&id.to_hex()),
205        ),
206        LeafContent::BlockDevice(rdev) => write_entry(
207            writer,
208            path,
209            stat,
210            FileType::BlockDevice,
211            0,
212            nlink,
213            *rdev,
214            "",
215            &[],
216            None,
217        ),
218        LeafContent::CharacterDevice(rdev) => write_entry(
219            writer,
220            path,
221            stat,
222            FileType::CharacterDevice,
223            0,
224            nlink,
225            *rdev,
226            "",
227            &[],
228            None,
229        ),
230        LeafContent::Fifo => write_entry(
231            writer,
232            path,
233            stat,
234            FileType::Fifo,
235            0,
236            nlink,
237            0,
238            "",
239            &[],
240            None,
241        ),
242        LeafContent::Socket => write_entry(
243            writer,
244            path,
245            stat,
246            FileType::Socket,
247            0,
248            nlink,
249            0,
250            "",
251            &[],
252            None,
253        ),
254        LeafContent::Symlink(target) => write_entry(
255            writer,
256            path,
257            stat,
258            FileType::Symlink,
259            target.as_bytes().len() as u64,
260            nlink,
261            0,
262            target,
263            &[],
264            None,
265        ),
266    }
267}
268
269/// Writes a hardlink entry to the dumpfile format.
270///
271/// Creates a special entry that links the given path to an existing target path
272/// that was already written to the dumpfile.  The nlink/uid/gid/rdev/mtime
273/// fields are written as `-` (ignored); both the C and Rust parsers detect
274/// the `@` hardlink prefix on the mode field and skip parsing the remaining
275/// numeric fields.
276pub fn write_hardlink(writer: &mut impl fmt::Write, path: &Path, target: &OsStr) -> fmt::Result {
277    write_escaped(writer, path.as_os_str().as_bytes())?;
278    write!(writer, " 0 @120000 - - - - 0.0 ")?;
279    write_escaped(writer, target.as_bytes())?;
280    write!(writer, " - -")?;
281    Ok(())
282}
283
284struct DumpfileWriter<'a, W: Write, ObjectID: FsVerityHashValue> {
285    hardlinks: HashMap<LeafId, OsString>,
286    fs: &'a FileSystem<ObjectID>,
287    nlink_map: &'a [u32],
288    writer: &'a mut W,
289}
290
291#[context("Writing formatted line to dumpfile")]
292fn writeln_fmt(writer: &mut impl Write, f: impl Fn(&mut String) -> fmt::Result) -> Result<()> {
293    let mut tmp = String::with_capacity(256);
294    f(&mut tmp)?;
295    Ok(writeln!(writer, "{tmp}")?)
296}
297
298impl<'a, W: Write, ObjectID: FsVerityHashValue> DumpfileWriter<'a, W, ObjectID> {
299    fn new(writer: &'a mut W, fs: &'a FileSystem<ObjectID>, nlink_map: &'a [u32]) -> Self {
300        Self {
301            hardlinks: HashMap::new(),
302            fs,
303            nlink_map,
304            writer,
305        }
306    }
307
308    #[context("Writing directory to dumpfile: {}", path.display())]
309    fn write_dir(&mut self, path: &mut PathBuf, dir: &Directory<ObjectID>) -> Result<()> {
310        // nlink is 2 + number of subdirectories
311        // this is also true for the root dir since '..' is another self-ref
312        let nlink = dir.inodes().fold(2, |count, inode| {
313            count + {
314                match inode {
315                    Inode::Directory(..) => 1,
316                    _ => 0,
317                }
318            }
319        });
320
321        writeln_fmt(self.writer, |fmt| {
322            write_directory(fmt, path, &dir.stat, nlink)
323        })?;
324
325        for (name, inode) in dir.sorted_entries() {
326            path.push(name);
327
328            match inode {
329                Inode::Directory(dir) => {
330                    self.write_dir(path, dir)?;
331                }
332                Inode::Leaf(leaf_id, _) => {
333                    self.write_leaf(path, *leaf_id)?;
334                }
335            }
336
337            path.pop();
338        }
339        Ok(())
340    }
341
342    #[context("Writing leaf to dumpfile: {}", path.display())]
343    fn write_leaf(&mut self, path: &Path, leaf_id: LeafId) -> Result<()> {
344        let nlink = self.nlink_map[leaf_id.0] as usize;
345
346        if nlink > 1 {
347            // This is a hardlink.  We need to handle that specially.
348            if let Some(target) = self.hardlinks.get(&leaf_id) {
349                return writeln_fmt(self.writer, |fmt| write_hardlink(fmt, path, target));
350            }
351
352            // @path gets modified all the time, so take a copy
353            self.hardlinks.insert(leaf_id, OsString::from(&path));
354        }
355
356        let leaf = self.fs.leaf(leaf_id);
357        writeln_fmt(self.writer, |fmt| {
358            write_leaf(fmt, path, &leaf.stat, &leaf.content, nlink)
359        })
360    }
361}
362
363/// Writes a complete filesystem tree to the composefs dumpfile format.
364///
365/// Serializes the entire filesystem structure including all directories, files,
366/// metadata, and handles hardlink tracking automatically.
367pub fn write_dumpfile(
368    writer: &mut impl Write,
369    fs: &FileSystem<impl FsVerityHashValue>,
370) -> Result<()> {
371    let nlink_map = fs.nlinks();
372    let path = PathBuf::from("/");
373    dump_single_dir(writer, &fs.root, fs, &nlink_map, path)
374}
375
376/// Write a single dir
377pub fn dump_single_dir<ObjectID: FsVerityHashValue>(
378    writer: &mut impl Write,
379    dir: &Directory<ObjectID>,
380    fs: &FileSystem<ObjectID>,
381    nlink_map: &[u32],
382    mut path: PathBuf,
383) -> Result<()> {
384    // default pipe capacity on Linux is 16 pages (65536 bytes), but
385    // sometimes the BufWriter will write more than its capacity...
386    let mut buffer = BufWriter::with_capacity(32768, writer);
387    let mut dfw = DumpfileWriter::new(&mut buffer, fs, nlink_map);
388
389    dfw.write_dir(&mut path, dir)?;
390    buffer.flush()?;
391
392    Ok(())
393}
394
395/// Write a single file
396pub fn dump_single_file<ObjectID: FsVerityHashValue>(
397    writer: &mut impl Write,
398    leaf_id: LeafId,
399    fs: &FileSystem<ObjectID>,
400    nlink_map: &[u32],
401    path: PathBuf,
402) -> Result<()> {
403    // default pipe capacity on Linux is 16 pages (65536 bytes), but
404    // sometimes the BufWriter will write more than its capacity...
405    let mut buffer = BufWriter::with_capacity(32768, writer);
406    let mut dfw = DumpfileWriter::new(&mut buffer, fs, nlink_map);
407
408    dfw.write_leaf(&path, leaf_id)?;
409    buffer.flush()?;
410
411    Ok(())
412}
413
414// Reading: Converting dumpfile entries to tree structures
415
416/// Convert a dumpfile Entry into tree structures and insert into a FileSystem.
417pub fn add_entry_to_filesystem<ObjectID: FsVerityHashValue>(
418    fs: &mut FileSystem<ObjectID>,
419    entry: Entry<'_>,
420    hardlinks: &mut HashMap<PathBuf, LeafId>,
421) -> Result<()> {
422    let path = entry.path.as_ref();
423
424    // Handle root directory specially
425    if path == Path::new("/") {
426        let stat = entry_to_stat(&entry)?;
427        fs.set_root_stat(stat);
428        return Ok(());
429    }
430
431    // Split the path into directory and filename
432    let parent = path.parent().unwrap_or_else(|| Path::new("/"));
433    let filename = path
434        .file_name()
435        .ok_or_else(|| anyhow::anyhow!("Path has no filename: {path:?}"))?;
436
437    // Helper to push a leaf into the filesystem and return a LeafId
438    let push_leaf = |fs: &mut FileSystem<ObjectID>, stat, content| fs.push_leaf(stat, content);
439
440    // Convert the entry to an inode
441    let inode = match entry.item {
442        Item::Directory { .. } => {
443            let stat = entry_to_stat(&entry)?;
444            Inode::Directory(Box::new(Directory::new(stat)))
445        }
446        Item::Hardlink { ref target } => {
447            // Look up the target in our hardlinks map and reuse the LeafId
448            let existing_id = *hardlinks
449                .get(target.as_ref())
450                .ok_or_else(|| anyhow::anyhow!("Hardlink target not found: {target:?}"))?;
451            Inode::leaf(existing_id)
452        }
453        Item::RegularInline { ref content, .. } => {
454            let stat = entry_to_stat(&entry)?;
455            let data: Box<[u8]> = match content {
456                std::borrow::Cow::Borrowed(d) => Box::from(*d),
457                std::borrow::Cow::Owned(d) => d.clone().into_boxed_slice(),
458            };
459            let content = LeafContent::Regular(RegularFile::Inline(data));
460            let id = push_leaf(fs, stat, content);
461            Inode::leaf(id)
462        }
463        Item::Regular {
464            size,
465            ref fsverity_digest,
466            ..
467        } => {
468            let stat = entry_to_stat(&entry)?;
469            let digest = fsverity_digest
470                .as_ref()
471                .ok_or_else(|| anyhow::anyhow!("External file missing fsverity digest"))?;
472            let object_id = ObjectID::from_hex(digest)?;
473            let content = LeafContent::Regular(RegularFile::External(object_id, size));
474            let id = push_leaf(fs, stat, content);
475            Inode::leaf(id)
476        }
477        Item::Device { rdev, nlink } => {
478            // S_IFMT = 0o170000, S_IFBLK = 0o60000, S_IFCHR = 0o20000
479            let is_chardev = entry.mode & 0o170000 != 0o60000;
480            // A whiteout is a character device with rdev=0; hardlinked whiteouts
481            // are invalid because composefs cannot represent them correctly.
482            if is_chardev && rdev == 0 && nlink > 1 {
483                anyhow::bail!(
484                    "invalid dumpfile: whiteout entry {:?} has nlink > 1",
485                    entry.path
486                );
487            }
488            let stat = entry_to_stat(&entry)?;
489            let content = if !is_chardev {
490                LeafContent::BlockDevice(rdev)
491            } else {
492                LeafContent::CharacterDevice(rdev)
493            };
494            let id = push_leaf(fs, stat, content);
495            Inode::leaf(id)
496        }
497        Item::Symlink { ref target, .. } => {
498            let stat = entry_to_stat(&entry)?;
499            let target_os: Box<OsStr> = match target {
500                std::borrow::Cow::Borrowed(t) => Box::from(t.as_os_str()),
501                std::borrow::Cow::Owned(t) => Box::from(t.as_os_str()),
502            };
503            let content = LeafContent::Symlink(target_os);
504            let id = push_leaf(fs, stat, content);
505            Inode::leaf(id)
506        }
507        Item::Fifo { .. } => {
508            let stat = entry_to_stat(&entry)?;
509            let content = LeafContent::Fifo;
510            let id = push_leaf(fs, stat, content);
511            Inode::leaf(id)
512        }
513        Item::Socket { .. } => {
514            let stat = entry_to_stat(&entry)?;
515            let content = LeafContent::Socket;
516            let id = push_leaf(fs, stat, content);
517            Inode::leaf(id)
518        }
519    };
520
521    // Store LeafIds in the hardlinks map for future hardlink lookups
522    if let Inode::Leaf(id, _) = inode {
523        hardlinks.insert(path.to_path_buf(), id);
524    }
525
526    // We need to get the parent_dir after pushing leaves (borrow checker)
527    let parent_dir = if parent == Path::new("/") {
528        &mut fs.root
529    } else {
530        fs.root
531            .get_directory_mut(parent.as_os_str())
532            .with_context(|| format!("Parent directory not found: {parent:?}"))?
533    };
534
535    parent_dir.insert(filename, inode);
536    Ok(())
537}
538
539/// Convert a dumpfile Entry's metadata into a tree Stat structure.
540fn entry_to_stat(entry: &Entry<'_>) -> Result<Stat> {
541    let mut xattrs = BTreeMap::new();
542    for xattr in &entry.xattrs {
543        let key: Box<OsStr> = match &xattr.key {
544            std::borrow::Cow::Borrowed(k) => Box::from(*k),
545            std::borrow::Cow::Owned(k) => Box::from(k.as_os_str()),
546        };
547        let value: Box<[u8]> = match &xattr.value {
548            std::borrow::Cow::Borrowed(v) => Box::from(*v),
549            std::borrow::Cow::Owned(v) => v.clone().into_boxed_slice(),
550        };
551        xattrs.insert(key, value);
552    }
553
554    let nsec = entry.mtime.nsec;
555    if nsec >= 1_000_000_000 {
556        anyhow::bail!("Invalid mtime nanoseconds: {nsec} (must be < 1_000_000_000)");
557    }
558
559    Ok(Stat {
560        st_mode: entry.mode & 0o7777, // Keep only permission bits
561        st_uid: entry.uid,
562        st_gid: entry.gid,
563        st_mtim_sec: entry.mtime.sec as i64,
564        st_mtim_nsec: nsec as u32,
565        xattrs,
566    })
567}
568
569/// Parse a dumpfile string and build a complete FileSystem.
570///
571/// The dumpfile must start with a root directory entry (`/`) which provides
572/// the root metadata. Returns an error if no root entry is found.
573pub fn dumpfile_to_filesystem<ObjectID: FsVerityHashValue>(
574    dumpfile: &str,
575) -> Result<FileSystem<ObjectID>> {
576    let mut lines = dumpfile.lines().peekable();
577    let mut hardlinks = HashMap::new();
578
579    // Find the first non-empty line which must be the root entry
580    let root_stat = loop {
581        match lines.next() {
582            Some(line) if line.trim().is_empty() => continue,
583            Some(line) => {
584                let entry = Entry::parse(line)
585                    .with_context(|| format!("Failed to parse dumpfile line: {line}"))?;
586                ensure!(
587                    entry.path.as_ref() == Path::new("/"),
588                    "Dumpfile must start with root directory entry, found: {:?}",
589                    entry.path
590                );
591                break entry_to_stat(&entry)?;
592            }
593            None => anyhow::bail!("Dumpfile is empty, expected root directory entry"),
594        }
595    };
596
597    let mut fs = FileSystem::new(root_stat);
598
599    // Process remaining entries
600    for line in lines {
601        if line.trim().is_empty() {
602            continue;
603        }
604        let entry =
605            Entry::parse(line).with_context(|| format!("Failed to parse dumpfile line: {line}"))?;
606        add_entry_to_filesystem(&mut fs, entry, &mut hardlinks)?;
607    }
608
609    debug_assert!(
610        fs.fsck().is_ok(),
611        "dumpfile parsing produced invalid filesystem"
612    );
613    Ok(fs)
614}
615
616/// Parse a composefs dumpfile string and validate the resulting filesystem
617/// for EROFS serialization.
618///
619/// Combines [`dumpfile_to_filesystem`] with [`ValidatedFileSystem::new`].
620/// Returns an error if the dumpfile is malformed or if the resulting
621/// filesystem violates EROFS invariants (e.g. hardlinked whiteouts).
622pub fn dumpfile_to_validated_filesystem<ObjectID: FsVerityHashValue>(
623    dumpfile: &str,
624) -> anyhow::Result<crate::erofs::writer::ValidatedFileSystem<ObjectID>> {
625    let fs = dumpfile_to_filesystem(dumpfile)?;
626    crate::erofs::writer::ValidatedFileSystem::new(fs)
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use crate::fsverity::Sha256HashValue;
633
634    const SIMPLE_DUMP: &str = r#"/ 0 40755 2 0 0 0 1000.0 - - -
635/empty_file 0 100644 1 0 0 0 1000.0 - - -
636/small_file 5 100644 1 0 0 0 1000.0 - hello -
637/symlink 7 120777 1 0 0 0 1000.0 /target - -
638"#;
639
640    #[test]
641    fn test_simple_dumpfile_conversion() -> Result<()> {
642        let fs = dumpfile_to_filesystem::<Sha256HashValue>(SIMPLE_DUMP)?;
643
644        // Check files exist
645        assert!(fs.root.lookup(OsStr::new("empty_file")).is_some());
646        assert!(fs.root.lookup(OsStr::new("small_file")).is_some());
647        assert!(fs.root.lookup(OsStr::new("symlink")).is_some());
648
649        // Check inline file content
650        let small_file = fs.as_dir().get_file(OsStr::new("small_file"))?;
651        if let RegularFile::Inline(data) = small_file {
652            assert_eq!(&**data, b"hello");
653        } else {
654            panic!("Expected inline file");
655        }
656
657        Ok(())
658    }
659
660    #[test]
661    fn test_hardlinks() -> Result<()> {
662        // The nlink/uid/gid/rdev fields on hardlink lines use `-` here,
663        // matching the C composefs writer convention.  The parser must
664        // accept these without trying to parse them as integers.
665        let dumpfile = r#"/ 0 40755 2 0 0 0 1000.0 - - -
666/original 11 100644 2 0 0 0 1000.0 - hello_world -
667/hardlink1 0 @120000 - - - - 0.0 /original - -
668/dir1 0 40755 2 0 0 0 1000.0 - - -
669/dir1/hardlink2 0 @120000 - - - - 0.0 /original - -
670"#;
671
672        let fs = dumpfile_to_filesystem::<Sha256HashValue>(dumpfile)?;
673
674        // Get the original file
675        let original = fs.root.lookup(OsStr::new("original")).unwrap();
676        let hardlink1 = fs.root.lookup(OsStr::new("hardlink1")).unwrap();
677
678        // Get hardlink2 from dir1
679        let dir1 = fs.root.get_directory(OsStr::new("dir1"))?;
680        let hardlink2 = dir1.lookup(OsStr::new("hardlink2")).unwrap();
681
682        // All three should be Leaf inodes with the same LeafId
683        let original_id = match original {
684            Inode::Leaf(id, _) => *id,
685            _ => panic!("Expected Leaf inode"),
686        };
687        let hardlink1_id = match hardlink1 {
688            Inode::Leaf(id, _) => *id,
689            _ => panic!("Expected Leaf inode"),
690        };
691        let hardlink2_id = match hardlink2 {
692            Inode::Leaf(id, _) => *id,
693            _ => panic!("Expected Leaf inode"),
694        };
695
696        // They should all share the same LeafId
697        assert_eq!(original_id, hardlink1_id);
698        assert_eq!(original_id, hardlink2_id);
699
700        // Verify nlink count is 3 (original + 2 hardlinks)
701        assert_eq!(fs.nlinks()[original_id.0], 3);
702
703        // Verify content
704        if let LeafContent::Regular(RegularFile::Inline(data)) = &fs.leaf(original_id).content {
705            assert_eq!(&**data, b"hello_world");
706        } else {
707            panic!("Expected inline regular file");
708        }
709
710        Ok(())
711    }
712
713    /// Verify that a symlink whose target is literally "-" survives a
714    /// write → parse → write round-trip.  Previously `write_escaped`
715    /// did not escape a bare "-", so the parser treated it as "none".
716    #[test]
717    fn test_symlink_target_dash_round_trip() -> Result<()> {
718        let dumpfile = "/ 0 40755 2 0 0 0 0.0 - - -\n\
719                         /link 1 120777 1 0 0 0 0.0 \\x2d - -\n";
720        let fs = dumpfile_to_filesystem::<Sha256HashValue>(dumpfile)?;
721        let link = fs.root.lookup(OsStr::new("link")).unwrap();
722        match link {
723            Inode::Leaf(id, _) => match &fs.leaf(*id).content {
724                LeafContent::Symlink(target) => assert_eq!(target.as_ref(), OsStr::new("-")),
725                other => panic!("expected symlink, got {other:?}"),
726            },
727            _ => panic!("expected leaf"),
728        }
729
730        // Re-serialize and verify it round-trips
731        let mut out = Vec::new();
732        write_dumpfile(&mut out, &fs)?;
733        let out_str = std::str::from_utf8(&out).unwrap();
734        let fs2 = dumpfile_to_filesystem::<Sha256HashValue>(out_str)?;
735        let mut out2 = Vec::new();
736        write_dumpfile(&mut out2, &fs2)?;
737        assert_eq!(out, out2);
738        Ok(())
739    }
740
741    /// Verify that xattrs with empty values and with a value of "-"
742    /// both survive a round-trip.  Previously `write_escaped` used
743    /// the "-" sentinel for empty bytes, which the xattr parser does
744    /// not treat specially.
745    #[test]
746    fn test_xattr_empty_and_dash_values_round_trip() -> Result<()> {
747        let mut xattrs = BTreeMap::new();
748        xattrs.insert(
749            Box::from(OsStr::new("user.empty")),
750            Vec::new().into_boxed_slice(),
751        );
752        xattrs.insert(
753            Box::from(OsStr::new("user.dash")),
754            vec![b'-'].into_boxed_slice(),
755        );
756
757        let mut fs = FileSystem::<Sha256HashValue>::new(Stat {
758            st_mode: 0o755,
759            st_uid: 0,
760            st_gid: 0,
761            st_mtim_sec: 0,
762            st_mtim_nsec: 0,
763            xattrs: BTreeMap::new(),
764        });
765        let leaf_id = fs.push_leaf(
766            Stat {
767                st_mode: 0o644,
768                st_uid: 0,
769                st_gid: 0,
770                st_mtim_sec: 0,
771                st_mtim_nsec: 0,
772                xattrs,
773            },
774            LeafContent::Regular(RegularFile::Inline(b"test".to_vec().into())),
775        );
776        fs.root.insert(OsStr::new("f"), Inode::leaf(leaf_id));
777
778        let mut out = Vec::new();
779        write_dumpfile(&mut out, &fs)?;
780        let out_str = std::str::from_utf8(&out).unwrap();
781        let fs2 = dumpfile_to_filesystem::<Sha256HashValue>(out_str)?;
782        let mut out2 = Vec::new();
783        write_dumpfile(&mut out2, &fs2)?;
784        assert_eq!(out, out2, "xattr round-trip mismatch:\n{out_str}");
785        Ok(())
786    }
787
788    /// Verify that write_dumpfile → dumpfile_to_filesystem round-trips
789    /// hardlinks correctly.
790    #[test]
791    fn test_hardlink_write_round_trip() -> Result<()> {
792        let stat = || Stat {
793            st_mode: 0o644,
794            st_uid: 0,
795            st_gid: 0,
796            st_mtim_sec: 0,
797            st_mtim_nsec: 0,
798            xattrs: BTreeMap::new(),
799        };
800
801        let mut fs = FileSystem::<Sha256HashValue>::new(Stat {
802            st_mode: 0o755,
803            ..stat()
804        });
805        let leaf_id = fs.push_leaf(
806            stat(),
807            LeafContent::Regular(RegularFile::Inline(b"data".to_vec().into())),
808        );
809        // Insert original + hardlink (same LeafId)
810        fs.root.insert(OsStr::new("original"), Inode::leaf(leaf_id));
811        fs.root.insert(OsStr::new("link"), Inode::leaf(leaf_id));
812
813        let mut out = Vec::new();
814        write_dumpfile(&mut out, &fs)?;
815        let out_str = std::str::from_utf8(&out).unwrap();
816
817        let fs2 = dumpfile_to_filesystem::<Sha256HashValue>(out_str)?;
818
819        // Verify the hardlink is preserved (same LeafId)
820        let orig = fs2.root.lookup(OsStr::new("original")).unwrap();
821        let link = fs2.root.lookup(OsStr::new("link")).unwrap();
822        match (orig, link) {
823            (Inode::Leaf(a, _), Inode::Leaf(b, _)) => assert_eq!(a, b),
824            _ => panic!("expected both to be leaves"),
825        }
826
827        // And re-serialization is stable
828        let mut out2 = Vec::new();
829        write_dumpfile(&mut out2, &fs2)?;
830        assert_eq!(out, out2);
831        Ok(())
832    }
833
834    /// A whiteout (chardev, rdev=0) with nlink > 1 must be rejected.
835    #[test]
836    fn test_hardlinked_whiteout_rejected() {
837        // /foo 0 20000 2 0 0 0 0.0 - - -
838        //       ^size  ^mode ^nlink ^uid ^gid ^rdev ^mtime ^payload ^digest ^xattrs
839        // mode 20000 = S_IFCHR (character device), rdev=0 → whiteout, nlink=2
840        let dumpfile = "/ 0 40755 2 0 0 0 0.0 - - -\n\
841                         /foo 0 20000 2 0 0 0 0.0 - - -\n";
842        let result = dumpfile_to_filesystem::<Sha256HashValue>(dumpfile);
843        let err = result.expect_err("hardlinked whiteout must be rejected");
844        let msg = format!("{err:#}");
845        assert!(
846            msg.contains("nlink"),
847            "error should mention nlink, got: {msg}"
848        );
849    }
850
851    /// Helper to escape bytes through write_escaped and return the result.
852    fn escaped(bytes: &[u8]) -> String {
853        let mut out = String::new();
854        write_escaped(&mut out, bytes).unwrap();
855        out
856    }
857
858    /// Helper to escape bytes through write_escaped_raw with the given mode.
859    fn escaped_raw(bytes: &[u8], eq: EscapeEquals) -> String {
860        let mut out = String::new();
861        write_escaped_raw(&mut out, bytes, eq).unwrap();
862        out
863    }
864
865    #[test]
866    fn test_named_escapes() {
867        // These must use named escapes matching C composefs, not \xHH.
868        assert_eq!(escaped_raw(b"\\", EscapeEquals::No), "\\\\");
869        assert_eq!(escaped_raw(b"\n", EscapeEquals::No), "\\n");
870        assert_eq!(escaped_raw(b"\r", EscapeEquals::No), "\\r");
871        assert_eq!(escaped_raw(b"\t", EscapeEquals::No), "\\t");
872
873        // Mixed: named escapes interspersed with literals
874        assert_eq!(escaped_raw(b"a\nb", EscapeEquals::No), "a\\nb");
875        assert_eq!(escaped_raw(b"\t\n\\", EscapeEquals::No), "\\t\\n\\\\");
876    }
877
878    #[test]
879    fn test_non_graphic_hex_escapes() {
880        // Characters outside 0x21..=0x7E get \xHH
881        assert_eq!(escaped_raw(b"\x00", EscapeEquals::No), "\\x00");
882        assert_eq!(escaped_raw(b"\x1f", EscapeEquals::No), "\\x1f");
883        assert_eq!(escaped_raw(b" ", EscapeEquals::No), "\\x20"); // space = 0x20 < '!'
884        assert_eq!(escaped_raw(b"\x7f", EscapeEquals::No), "\\x7f");
885        assert_eq!(escaped_raw(b"\xff", EscapeEquals::No), "\\xff");
886    }
887
888    #[test]
889    fn test_equals_escaping_context() {
890        // '=' is literal in normal fields (paths, content, payload)
891        assert_eq!(escaped_raw(b"a=b", EscapeEquals::No), "a=b");
892        assert_eq!(escaped(b"key=val"), "key=val");
893
894        // '=' is escaped in xattr key/value fields
895        assert_eq!(escaped_raw(b"a=b", EscapeEquals::Yes), "a\\x3db");
896        assert_eq!(
897            escaped_raw(b"overlay.redirect=/foo", EscapeEquals::Yes),
898            "overlay.redirect\\x3d/foo"
899        );
900    }
901
902    #[test]
903    fn test_escaped_sentinels() {
904        // Empty → "-"
905        assert_eq!(escaped(b""), "-");
906        // Lone dash → "\x2d"
907        assert_eq!(escaped(b"-"), "\\x2d");
908        // Dash in context is fine
909        assert_eq!(escaped(b"a-b"), "a-b");
910    }
911
912    #[test]
913    fn test_graphic_chars_literal() {
914        // All printable graphic ASCII (0x21..=0x7E) except '\' should be literal
915        assert_eq!(escaped_raw(b"!", EscapeEquals::No), "!");
916        assert_eq!(escaped_raw(b"~", EscapeEquals::No), "~");
917        assert_eq!(escaped_raw(b"abc/def.txt", EscapeEquals::No), "abc/def.txt");
918    }
919
920    mod proptest_tests {
921        use super::*;
922        use crate::fsverity::Sha512HashValue;
923        use crate::test::proptest_strategies::{build_filesystem, filesystem_spec};
924        use proptest::prelude::*;
925
926        /// Serialize filesystem to dumpfile bytes, returning None if the
927        /// output contains non-UTF-8 data (binary filenames) which the
928        /// text-based dumpfile parser cannot round-trip.
929        fn dumpfile_bytes<ObjectID: FsVerityHashValue>(
930            fs: &FileSystem<ObjectID>,
931        ) -> Option<Vec<u8>> {
932            let mut bytes = Vec::new();
933            write_dumpfile(&mut bytes, fs).unwrap();
934            // dumpfile_to_filesystem requires &str, so reject non-UTF-8
935            std::str::from_utf8(&bytes).ok()?;
936            Some(bytes)
937        }
938
939        fn round_trip_dumpfile<ObjectID: FsVerityHashValue>(orig_bytes: &[u8]) {
940            let orig_str = std::str::from_utf8(orig_bytes).unwrap();
941            let fs_rt = dumpfile_to_filesystem::<ObjectID>(orig_str).unwrap();
942
943            let mut rt_bytes = Vec::new();
944            write_dumpfile(&mut rt_bytes, &fs_rt).unwrap();
945
946            assert_eq!(orig_bytes, &rt_bytes);
947        }
948
949        proptest! {
950            #![proptest_config(ProptestConfig::with_cases(64))]
951
952            #[test]
953            fn test_dumpfile_round_trip_sha256(spec in filesystem_spec()) {
954                let fs = build_filesystem::<Sha256HashValue>(spec);
955                let bytes = dumpfile_bytes(&fs);
956                prop_assume!(bytes.is_some(), "dumpfile can't round-trip binary names");
957                round_trip_dumpfile::<Sha256HashValue>(&bytes.unwrap());
958            }
959
960            #[test]
961            fn test_dumpfile_round_trip_sha512(spec in filesystem_spec()) {
962                let fs = build_filesystem::<Sha512HashValue>(spec);
963                let bytes = dumpfile_bytes(&fs);
964                prop_assume!(bytes.is_some(), "dumpfile can't round-trip binary names");
965                round_trip_dumpfile::<Sha512HashValue>(&bytes.unwrap());
966            }
967        }
968    }
969}