1use 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
33fn write_escaped(writer: &mut impl fmt::Write, bytes: &[u8]) -> fmt::Result {
42 if bytes.is_empty() {
43 return write_empty(writer);
44 }
45
46 if bytes == b"-" {
49 return writer.write_str("\\x2d");
50 }
51
52 write_escaped_raw(writer, bytes, EscapeEquals::No)
53}
54
55#[derive(Clone, Copy)]
61enum EscapeEquals {
62 Yes,
64 No,
66}
67
68fn 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 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 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 write_escaped_raw(writer, value, EscapeEquals::Yes)?;
141 }
142
143 Ok(())
144}
145
146pub 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
170pub 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
269pub 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 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 if let Some(target) = self.hardlinks.get(&leaf_id) {
349 return writeln_fmt(self.writer, |fmt| write_hardlink(fmt, path, target));
350 }
351
352 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
363pub 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
376pub 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 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
395pub 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 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
414pub 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 if path == Path::new("/") {
426 let stat = entry_to_stat(&entry)?;
427 fs.set_root_stat(stat);
428 return Ok(());
429 }
430
431 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 let push_leaf = |fs: &mut FileSystem<ObjectID>, stat, content| fs.push_leaf(stat, content);
439
440 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 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 let is_chardev = entry.mode & 0o170000 != 0o60000;
480 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 if let Inode::Leaf(id, _) = inode {
523 hardlinks.insert(path.to_path_buf(), id);
524 }
525
526 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
539fn 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, 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
569pub 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 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 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
616pub 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 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 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 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 let original = fs.root.lookup(OsStr::new("original")).unwrap();
676 let hardlink1 = fs.root.lookup(OsStr::new("hardlink1")).unwrap();
677
678 let dir1 = fs.root.get_directory(OsStr::new("dir1"))?;
680 let hardlink2 = dir1.lookup(OsStr::new("hardlink2")).unwrap();
681
682 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 assert_eq!(original_id, hardlink1_id);
698 assert_eq!(original_id, hardlink2_id);
699
700 assert_eq!(fs.nlinks()[original_id.0], 3);
702
703 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 #[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 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 #[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 #[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 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 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 let mut out2 = Vec::new();
829 write_dumpfile(&mut out2, &fs2)?;
830 assert_eq!(out, out2);
831 Ok(())
832 }
833
834 #[test]
836 fn test_hardlinked_whiteout_rejected() {
837 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 fn escaped(bytes: &[u8]) -> String {
853 let mut out = String::new();
854 write_escaped(&mut out, bytes).unwrap();
855 out
856 }
857
858 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 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 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 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"); 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 assert_eq!(escaped_raw(b"a=b", EscapeEquals::No), "a=b");
892 assert_eq!(escaped(b"key=val"), "key=val");
893
894 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 assert_eq!(escaped(b""), "-");
906 assert_eq!(escaped(b"-"), "\\x2d");
908 assert_eq!(escaped(b"a-b"), "a-b");
910 }
911
912 #[test]
913 fn test_graphic_chars_literal() {
914 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 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 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}