Skip to main content

gravityfile_scan/
ncdu.rs

1//! The ncdu JSON dump format (<https://dev.yorhel.nl/ncdu/jsonfmt>), as
2//! written by `ncdu -o` and `gdu -o`.
3//!
4//! A dump is `[1, 2, {metadata}, directory]`, where a directory is an array of
5//! its own info object followed by its children: nested arrays for
6//! directories, objects for everything else. Each entry carries its own
7//! apparent size (`asize`) and disk usage (`dsize`); readers aggregate them.
8//! Names are raw bytes, not necessarily UTF-8, so both directions work on
9//! bytes rather than through a JSON library. Both are iterative and stream, so
10//! neither nesting depth nor dump size is limited by the stack or by holding
11//! the document in memory.
12
13use std::collections::HashSet;
14use std::io::{self, BufRead, Write};
15use std::path::{Path, PathBuf};
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17
18use gravityfile_core::{
19    FileNode, FileTree, InodeInfo, NodeKind, ScanConfig, ScanWarning, Timestamps, TreeStats,
20    WarningKind,
21};
22
23use crate::snapshot::{ImportOptions, SnapshotError};
24
25/// Longest name the format allows.
26const MAX_NAME: usize = 32 * 1024;
27
28/// Longest string accepted in fields this reader ignores.
29const SKIPPED_STRING: usize = 1 << 20;
30
31// ---------------------------------------------------------------------------
32// Writing
33// ---------------------------------------------------------------------------
34
35/// Write `tree` as an ncdu dump, format version 1.2.
36///
37/// Entries whose path appears among the tree's warnings are marked
38/// `read_error`. Hard-linked files carry `ino`, `dev` and `hlnkc`, so readers
39/// count them once.
40pub fn write(tree: &FileTree, out: &mut dyn Write) -> io::Result<()> {
41    let timestamp = tree
42        .scanned_at
43        .duration_since(UNIX_EPOCH)
44        .map_or(0, |elapsed| elapsed.as_secs());
45    writeln!(
46        out,
47        "[1,2,{{\"progname\":\"gravityfile\",\"progver\":\"{}\",\"timestamp\":{timestamp}}},",
48        env!("CARGO_PKG_VERSION")
49    )?;
50    let failed: HashSet<&Path> = tree
51        .warnings
52        .iter()
53        .filter(|warning| warning.kind != WarningKind::BrokenSymlink)
54        .map(|warning| warning.path.as_path())
55        .collect();
56    let mut path = tree.root_path.clone();
57    out.write_all(b"[")?;
58    write_info(
59        out,
60        &tree.root,
61        tree.root_path.as_os_str(),
62        failed.contains(path.as_path()),
63    )?;
64    let mut stack = vec![tree.root.children.iter()];
65    while let Some(children) = stack.last_mut() {
66        let Some(node) = children.next() else {
67            out.write_all(b"]")?;
68            stack.pop();
69            path.pop();
70            continue;
71        };
72        out.write_all(b",\n")?;
73        path.push(node.file_name());
74        let failed = failed.contains(path.as_path());
75        if node.is_dir() {
76            out.write_all(b"[")?;
77            write_info(out, node, node.file_name(), failed)?;
78            stack.push(node.children.iter());
79        } else {
80            write_info(out, node, node.file_name(), failed)?;
81            path.pop();
82        }
83    }
84    out.write_all(b"]\n")?;
85    out.flush()
86}
87
88fn write_info(
89    out: &mut dyn Write,
90    node: &FileNode,
91    name: &std::ffi::OsStr,
92    read_error: bool,
93) -> io::Result<()> {
94    out.write_all(b"{\"name\":")?;
95    write_bytes(out, name.as_encoded_bytes())?;
96    write!(
97        out,
98        ",\"asize\":{},\"dsize\":{}",
99        node.len,
100        node.blocks.saturating_mul(512)
101    )?;
102    if let Some(identity) = node.inode {
103        write!(
104            out,
105            ",\"dev\":{},\"ino\":{},\"hlnkc\":true",
106            identity.device, identity.inode
107        )?;
108    }
109    if matches!(node.kind, NodeKind::Symlink { .. } | NodeKind::Other) {
110        out.write_all(b",\"notreg\":true")?;
111    }
112    if read_error {
113        out.write_all(b",\"read_error\":true")?;
114    }
115    if let Ok(modified) = node.timestamps.modified.duration_since(UNIX_EPOCH) {
116        write!(out, ",\"mtime\":{}", modified.as_secs())?;
117    }
118    out.write_all(b"}")
119}
120
121/// A JSON string of raw bytes: quotes, backslashes and control bytes are
122/// escaped; everything else, including bytes that are not UTF-8, is written
123/// as is, as ncdu does.
124fn write_bytes(out: &mut dyn Write, bytes: &[u8]) -> io::Result<()> {
125    out.write_all(b"\"")?;
126    let mut start = 0;
127    for (index, &byte) in bytes.iter().enumerate() {
128        if byte == b'"' || byte == b'\\' || byte < 0x20 {
129            out.write_all(&bytes[start..index])?;
130            match byte {
131                b'"' => out.write_all(b"\\\"")?,
132                b'\\' => out.write_all(b"\\\\")?,
133                b'\n' => out.write_all(b"\\n")?,
134                b'\t' => out.write_all(b"\\t")?,
135                _ => write!(out, "\\u{byte:04x}")?,
136            }
137            start = index + 1;
138        }
139    }
140    out.write_all(&bytes[start..])?;
141    out.write_all(b"\"")
142}
143
144// ---------------------------------------------------------------------------
145// Reading
146// ---------------------------------------------------------------------------
147
148/// Read an ncdu dump into a tree measured as `options` asks.
149pub fn read(input: impl BufRead, options: &ImportOptions) -> Result<FileTree, SnapshotError> {
150    let mut parser = Parser {
151        input,
152        offset: 0,
153        entries: 0,
154        max_entries: options.max_entries,
155    };
156    parser.expect(b'[')?;
157    let major = parser.integer()?;
158    if major != 1 {
159        return Err(parser.error(format!("unsupported ncdu dump version {major}")));
160    }
161    parser.expect(b',')?;
162    parser.integer()?;
163    parser.expect(b',')?;
164    let scanned_at = parser.metadata()?;
165    parser.expect(b',')?;
166    let (root, root_path, warnings) = parser.tree()?;
167    parser.expect(b']')?;
168    if parser.peek()?.is_some() {
169        return Err(parser.error("data after the end of the dump".into()));
170    }
171
172    let mut config = ScanConfig::new(&root_path);
173    config.apparent_size = options.apparent;
174    config.count_hard_links = options.count_hard_links;
175    let mut tree = FileTree::new(
176        root,
177        root_path,
178        config,
179        TreeStats::new(),
180        Duration::ZERO,
181        warnings,
182    );
183    tree.root.update_counts();
184    tree.remeasure(options.apparent, options.count_hard_links);
185    if let Some(scanned_at) = scanned_at {
186        tree.scanned_at = scanned_at;
187    }
188    Ok(tree)
189}
190
191/// What an info object says about one entry.
192#[derive(Default)]
193struct Info {
194    name: Option<Vec<u8>>,
195    asize: u64,
196    dsize: u64,
197    dev: Option<u64>,
198    ino: Option<u64>,
199    nlink: u64,
200    hlnkc: bool,
201    notreg: bool,
202    read_error: bool,
203    excluded: bool,
204    mtime: Option<u64>,
205    mode: Option<u64>,
206}
207
208struct Parser<R> {
209    input: R,
210    offset: u64,
211    entries: u64,
212    max_entries: Option<u64>,
213}
214
215/// A directory whose entries are still being read.
216struct Open {
217    node: FileNode,
218    dev: u64,
219}
220
221impl<R: BufRead> Parser<R> {
222    fn error(&self, message: String) -> SnapshotError {
223        SnapshotError::Syntax {
224            offset: self.offset,
225            message,
226        }
227    }
228
229    fn peek_raw(&mut self) -> Result<Option<u8>, SnapshotError> {
230        Ok(self.input.fill_buf()?.first().copied())
231    }
232
233    fn bump(&mut self) {
234        self.input.consume(1);
235        self.offset += 1;
236    }
237
238    /// The next significant byte, without consuming it.
239    fn peek(&mut self) -> Result<Option<u8>, SnapshotError> {
240        while let Some(byte) = self.peek_raw()? {
241            if !byte.is_ascii_whitespace() {
242                return Ok(Some(byte));
243            }
244            self.bump();
245        }
246        Ok(None)
247    }
248
249    fn next(&mut self) -> Result<u8, SnapshotError> {
250        match self.peek()? {
251            Some(byte) => {
252                self.bump();
253                Ok(byte)
254            }
255            None => Err(self.error("unexpected end of dump".into())),
256        }
257    }
258
259    fn expect(&mut self, wanted: u8) -> Result<(), SnapshotError> {
260        let found = self.next()?;
261        if found == wanted {
262            Ok(())
263        } else {
264            Err(self.error(format!(
265                "expected '{}', found '{}'",
266                wanted as char,
267                found.escape_ascii()
268            )))
269        }
270    }
271
272    /// The raw text of a number.
273    fn number_text(&mut self) -> Result<String, SnapshotError> {
274        self.peek()?;
275        let mut text = String::new();
276        while let Some(byte) = self.peek_raw()? {
277            if !(byte.is_ascii_digit() || matches!(byte, b'-' | b'+' | b'.' | b'e' | b'E')) {
278                break;
279            }
280            if text.len() >= 64 {
281                return Err(self.error("number too long".into()));
282            }
283            text.push(byte as char);
284            self.bump();
285        }
286        if text.is_empty() {
287            return Err(self.error("expected a number".into()));
288        }
289        Ok(text)
290    }
291
292    fn integer(&mut self) -> Result<u64, SnapshotError> {
293        let text = self.number_text()?;
294        text.parse()
295            .map_err(|_| self.error(format!("expected a non-negative integer, found {text}")))
296    }
297
298    /// Integral part of a non-negative number, which may be fractional.
299    fn whole_number(&mut self) -> Result<u64, SnapshotError> {
300        let text = self.number_text()?;
301        if let Ok(value) = text.parse::<u64>() {
302            return Ok(value);
303        }
304        match text.parse::<f64>() {
305            Ok(value) if value.is_finite() && value >= 0.0 && value < u64::MAX as f64 => {
306                Ok(value as u64)
307            }
308            _ => Err(self.error(format!("expected a non-negative number, found {text}"))),
309        }
310    }
311
312    fn boolean(&mut self) -> Result<bool, SnapshotError> {
313        match self.next()? {
314            b't' => self.literal(b"rue").map(|()| true),
315            b'f' => self.literal(b"alse").map(|()| false),
316            b'n' => self.literal(b"ull").map(|()| false),
317            other => Err(self.error(format!(
318                "expected a boolean, found '{}'",
319                other.escape_ascii()
320            ))),
321        }
322    }
323
324    fn literal(&mut self, rest: &[u8]) -> Result<(), SnapshotError> {
325        for &wanted in rest {
326            match self.peek_raw()? {
327                Some(byte) if byte == wanted => self.bump(),
328                _ => return Err(self.error("invalid literal".into())),
329            }
330        }
331        Ok(())
332    }
333
334    /// A string's bytes, with escapes decoded and raw bytes kept as they are.
335    fn string(&mut self, limit: usize) -> Result<Vec<u8>, SnapshotError> {
336        self.expect(b'"')?;
337        let mut bytes = Vec::new();
338        loop {
339            let Some(byte) = self.peek_raw()? else {
340                return Err(self.error("unterminated string".into()));
341            };
342            self.bump();
343            match byte {
344                b'"' => return Ok(bytes),
345                b'\\' => {
346                    let Some(escape) = self.peek_raw()? else {
347                        return Err(self.error("unterminated string".into()));
348                    };
349                    self.bump();
350                    match escape {
351                        b'"' | b'\\' | b'/' => bytes.push(escape),
352                        b'b' => bytes.push(0x08),
353                        b'f' => bytes.push(0x0c),
354                        b'n' => bytes.push(b'\n'),
355                        b'r' => bytes.push(b'\r'),
356                        b't' => bytes.push(b'\t'),
357                        b'u' => {
358                            let character = self.unicode_escape()?;
359                            let mut buffer = [0; 4];
360                            bytes.extend_from_slice(character.encode_utf8(&mut buffer).as_bytes());
361                        }
362                        other => {
363                            return Err(
364                                self.error(format!("invalid escape '\\{}'", other.escape_ascii()))
365                            );
366                        }
367                    }
368                }
369                _ => bytes.push(byte),
370            }
371            if bytes.len() > limit {
372                return Err(self.error(format!("string longer than {limit} bytes")));
373            }
374        }
375    }
376
377    fn hex4(&mut self) -> Result<u32, SnapshotError> {
378        let mut value = 0;
379        for _ in 0..4 {
380            let digit = self
381                .peek_raw()?
382                .and_then(|byte| (byte as char).to_digit(16))
383                .ok_or_else(|| self.error("invalid \\u escape".into()))?;
384            self.bump();
385            value = value * 16 + digit;
386        }
387        Ok(value)
388    }
389
390    /// The character of a `\u` escape, joining surrogate pairs. Lone
391    /// surrogates cannot be represented and become U+FFFD.
392    fn unicode_escape(&mut self) -> Result<char, SnapshotError> {
393        let first = self.hex4()?;
394        if (0xd800..0xdc00).contains(&first) && self.peek_raw()? == Some(b'\\') {
395            self.bump();
396            if self.peek_raw()? != Some(b'u') {
397                return Err(self.error("invalid surrogate pair".into()));
398            }
399            self.bump();
400            let second = self.hex4()?;
401            if (0xdc00..0xe000).contains(&second) {
402                let code = 0x10000 + ((first - 0xd800) << 10) + (second - 0xdc00);
403                return Ok(char::from_u32(code).unwrap_or(char::REPLACEMENT_CHARACTER));
404            }
405            return Ok(char::REPLACEMENT_CHARACTER);
406        }
407        Ok(char::from_u32(first).unwrap_or(char::REPLACEMENT_CHARACTER))
408    }
409
410    /// Skip any value, iteratively.
411    fn skip_value(&mut self) -> Result<(), SnapshotError> {
412        // The closing byte of each open container.
413        let mut open = Vec::new();
414        loop {
415            match self.peek()? {
416                Some(byte @ (b'[' | b'{')) => {
417                    self.bump();
418                    let close = if byte == b'[' { b']' } else { b'}' };
419                    if self.peek()? == Some(close) {
420                        self.bump();
421                    } else {
422                        open.push(close);
423                        if close == b'}' {
424                            self.key()?;
425                        }
426                        continue;
427                    }
428                }
429                Some(b'"') => {
430                    self.string(SKIPPED_STRING)?;
431                }
432                Some(b't' | b'f' | b'n') => {
433                    self.boolean()?;
434                }
435                Some(_) => {
436                    self.number_text()?;
437                }
438                None => return Err(self.error("unexpected end of dump".into())),
439            }
440            // After a value: close containers, or move on to the next value.
441            loop {
442                let Some(&close) = open.last() else {
443                    return Ok(());
444                };
445                match self.next()? {
446                    b',' => {
447                        if close == b'}' {
448                            self.key()?;
449                        }
450                        break;
451                    }
452                    byte if byte == close => {
453                        open.pop();
454                    }
455                    other => {
456                        return Err(self.error(format!("unexpected '{}'", other.escape_ascii())));
457                    }
458                }
459            }
460        }
461    }
462
463    /// An object key and its colon.
464    fn key(&mut self) -> Result<(), SnapshotError> {
465        self.string(SKIPPED_STRING)?;
466        self.expect(b':')
467    }
468
469    /// The metadata object; returns its timestamp.
470    fn metadata(&mut self) -> Result<Option<SystemTime>, SnapshotError> {
471        self.expect(b'{')?;
472        let mut timestamp = None;
473        if self.peek()? == Some(b'}') {
474            self.bump();
475            return Ok(None);
476        }
477        loop {
478            let key = self.string(256)?;
479            self.expect(b':')?;
480            if key == b"timestamp" {
481                timestamp = since_epoch(self.whole_number()?);
482            } else {
483                self.skip_value()?;
484            }
485            match self.next()? {
486                b',' => continue,
487                b'}' => return Ok(timestamp),
488                other => return Err(self.error(format!("unexpected '{}'", other.escape_ascii()))),
489            }
490        }
491    }
492
493    fn info(&mut self) -> Result<Info, SnapshotError> {
494        self.expect(b'{')?;
495        let mut info = Info {
496            nlink: 1,
497            ..Info::default()
498        };
499        if self.peek()? == Some(b'}') {
500            self.bump();
501            return Ok(info);
502        }
503        loop {
504            let key = self.string(256)?;
505            self.expect(b':')?;
506            match key.as_slice() {
507                b"name" => info.name = Some(self.string(MAX_NAME)?),
508                b"asize" => info.asize = self.integer()?,
509                b"dsize" => info.dsize = self.integer()?,
510                b"dev" => info.dev = Some(self.integer()?),
511                b"ino" => info.ino = Some(self.integer()?),
512                b"nlink" => info.nlink = self.integer()?,
513                b"hlnkc" => info.hlnkc = self.boolean()?,
514                b"notreg" => info.notreg = self.boolean()?,
515                b"read_error" => info.read_error = self.boolean()?,
516                b"mtime" => info.mtime = Some(self.whole_number()?),
517                b"mode" => info.mode = Some(self.integer()?),
518                b"excluded" => {
519                    info.excluded = if self.peek()? == Some(b'"') {
520                        self.string(256)?;
521                        true
522                    } else {
523                        self.boolean()?
524                    }
525                }
526                _ => self.skip_value()?,
527            }
528            match self.next()? {
529                b',' => continue,
530                b'}' => return Ok(info),
531                other => return Err(self.error(format!("unexpected '{}'", other.escape_ascii()))),
532            }
533        }
534    }
535
536    /// A node for `info`, inside a directory on device `dev`.
537    fn node(&self, info: &Info, directory: bool, dev: u64) -> Result<FileNode, SnapshotError> {
538        let name = info
539            .name
540            .as_deref()
541            .ok_or_else(|| self.error("entry without a name".into()))?;
542        let modified = info.mtime.and_then(since_epoch).unwrap_or(UNIX_EPOCH);
543        let timestamps = Timestamps::with_modified(modified);
544        let mut node = if directory {
545            FileNode::new_directory("", timestamps)
546        } else {
547            let executable = info.mode.is_some_and(|mode| mode & 0o111 != 0);
548            let mut node = FileNode::new_file("", 0, 0, timestamps, executable);
549            if info.notreg {
550                node.kind = NodeKind::Other;
551            }
552            node
553        };
554        node.set_file_name(&bytes_to_os(name));
555        node.len = info.asize;
556        node.blocks = info.dsize.div_ceil(512);
557        if !directory
558            && (info.hlnkc || info.nlink > 1)
559            && let Some(inode) = info.ino
560        {
561            node.inode = Some(InodeInfo::new(inode, info.dev.unwrap_or(dev)));
562        }
563        Ok(node)
564    }
565
566    fn count_entry(&mut self) -> Result<(), SnapshotError> {
567        if let Some(limit) = self.max_entries
568            && self.entries >= limit
569        {
570            return Err(SnapshotError::EntryLimitExceeded { limit });
571        }
572        self.entries += 1;
573        Ok(())
574    }
575
576    /// The root directory array and everything in it.
577    fn tree(&mut self) -> Result<(FileNode, PathBuf, Vec<ScanWarning>), SnapshotError> {
578        let mut warnings = Vec::new();
579        self.expect(b'[')?;
580        let info = self.info()?;
581        let root_path = PathBuf::from(bytes_to_os(info.name.as_deref().unwrap_or_default()));
582        if root_path.as_os_str().is_empty() {
583            return Err(self.error("the root directory has no name".into()));
584        }
585        let dev = info.dev.unwrap_or(0);
586        let mut root = self.node(&info, true, dev)?;
587        root.set_file_name(root_path.file_name().unwrap_or(root_path.as_os_str()));
588        if info.read_error {
589            warnings.push(read_error(&root_path));
590        }
591        let mut path = root_path.clone();
592        let mut stack = vec![Open { node: root, dev }];
593        loop {
594            match self.next()? {
595                b',' => {}
596                b']' => {
597                    let finished = stack.pop().expect("open directory");
598                    let Some(parent) = stack.last_mut() else {
599                        return Ok((finished.node, root_path, warnings));
600                    };
601                    parent.node.children.push(finished.node);
602                    path.pop();
603                    continue;
604                }
605                other => return Err(self.error(format!("unexpected '{}'", other.escape_ascii()))),
606            }
607            let parent_dev = stack.last().expect("open directory").dev;
608            self.count_entry()?;
609            if self.peek()? == Some(b'[') {
610                self.bump();
611                let info = self.info()?;
612                let node = self.node(&info, true, parent_dev)?;
613                path.push(node.file_name());
614                if info.read_error {
615                    warnings.push(read_error(&path));
616                }
617                let dev = info.dev.unwrap_or(parent_dev);
618                if info.excluded {
619                    // Keep the structure consistent: read and drop its contents.
620                    self.skip_directory_rest()?;
621                    path.pop();
622                    continue;
623                }
624                stack.push(Open { node, dev });
625            } else {
626                let info = self.info()?;
627                if info.excluded {
628                    continue;
629                }
630                let node = self.node(&info, false, parent_dev)?;
631                if info.read_error {
632                    warnings.push(read_error(&path.join(node.file_name())));
633                }
634                stack
635                    .last_mut()
636                    .expect("open directory")
637                    .node
638                    .children
639                    .push(node);
640            }
641        }
642    }
643
644    /// Skip the remaining entries of a directory array whose info object was
645    /// just read.
646    fn skip_directory_rest(&mut self) -> Result<(), SnapshotError> {
647        loop {
648            match self.next()? {
649                b',' => self.skip_value()?,
650                b']' => return Ok(()),
651                other => return Err(self.error(format!("unexpected '{}'", other.escape_ascii()))),
652            }
653        }
654    }
655}
656
657fn read_error(path: &Path) -> ScanWarning {
658    ScanWarning::new(
659        path,
660        WarningKind::ReadError,
661        "could not be read when the dump was made",
662    )
663}
664
665/// The time `secs` after the epoch, or `None` past what the platform can
666/// represent: a corrupt timestamp is unknown, not a crash.
667fn since_epoch(secs: u64) -> Option<SystemTime> {
668    UNIX_EPOCH.checked_add(Duration::from_secs(secs))
669}
670
671#[cfg(unix)]
672fn bytes_to_os(bytes: &[u8]) -> std::ffi::OsString {
673    use std::os::unix::ffi::OsStringExt;
674    std::ffi::OsString::from_vec(bytes.to_vec())
675}
676
677#[cfg(not(unix))]
678fn bytes_to_os(bytes: &[u8]) -> std::ffi::OsString {
679    String::from_utf8_lossy(bytes).into_owned().into()
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685
686    fn import(dump: &str, apparent: bool) -> FileTree {
687        read(
688            dump.as_bytes(),
689            &ImportOptions {
690                apparent,
691                ..ImportOptions::default()
692            },
693        )
694        .unwrap()
695    }
696
697    fn child<'a>(node: &'a FileNode, name: &str) -> &'a FileNode {
698        node.children
699            .iter()
700            .find(|child| child.name == name)
701            .unwrap_or_else(|| panic!("{name} missing"))
702    }
703
704    const SAMPLE: &str = r#"[1,2,{"progname":"ncdu","progver":"2.4","timestamp":1700000000},
705        [{"name":"/data","asize":4096,"dsize":4096,"dev":12},
706         {"name":"a.bin","asize":1000,"dsize":4096,"mtime":1600000000.5},
707         [{"name":"sub","asize":4096,"dsize":4096},
708          {"name":"linked","asize":500,"dsize":4096,"ino":7,"nlink":2,"hlnkc":true},
709          {"name":"fifo","notreg":true},
710          {"name":"skipped","asize":99999,"excluded":"pattern"},
711          [{"name":"gone","excluded":"otherfs"},{"name":"inside","asize":5}]],
712         {"name":"linked-too","asize":500,"dsize":4096,"ino":7,"nlink":2,"hlnkc":true},
713         {"name":"bad","read_error":true,"future":{"nested":[1,{"x":[]}]}}]]"#;
714
715    #[test]
716    fn reads_sizes_hard_links_and_exclusions() {
717        let tree = import(SAMPLE, false);
718        assert_eq!(tree.root_path, Path::new("/data"));
719        assert_eq!(tree.root.name, "data");
720        assert_eq!(
721            tree.root.size,
722            4096 * 4,
723            "root, a.bin, sub, one copy of linked"
724        );
725        assert_eq!(child(&tree.root, "a.bin").size, 4096);
726        let sub = child(&tree.root, "sub");
727        assert!(matches!(child(sub, "fifo").kind, NodeKind::Other));
728        assert!(
729            sub.children
730                .iter()
731                .all(|node| node.name != "skipped" && node.name != "gone")
732        );
733        assert_eq!(
734            tree.stats.total_files, 4,
735            "a.bin, both links and bad; the fifo is not a regular file"
736        );
737        assert_eq!(
738            tree.warnings
739                .iter()
740                .map(|w| w.path.clone())
741                .collect::<Vec<_>>(),
742            [PathBuf::from("/data/bad")]
743        );
744        assert_eq!(
745            tree.scanned_at,
746            UNIX_EPOCH + Duration::from_secs(1_700_000_000)
747        );
748        assert_eq!(
749            child(&tree.root, "a.bin").timestamps.modified,
750            UNIX_EPOCH + Duration::from_secs(1_600_000_000)
751        );
752
753        let tree = import(SAMPLE, true);
754        assert_eq!(tree.root.size, 4096 + 1000 + 4096 + 500);
755    }
756
757    #[test]
758    fn writing_and_reading_back_preserves_the_tree() {
759        let original = import(SAMPLE, false);
760        let mut dump = Vec::new();
761        write(&original, &mut dump).unwrap();
762        let restored = read(dump.as_slice(), &ImportOptions::default()).unwrap();
763        assert_eq!(restored.root.size, original.root.size);
764        assert_eq!(restored.stats.total_files, original.stats.total_files);
765        assert_eq!(restored.root_path, original.root_path);
766        assert_eq!(restored.warnings.len(), 1);
767        let restored_sub = child(&restored.root, "sub");
768        assert_eq!(restored_sub.size, child(&original.root, "sub").size);
769        assert!(matches!(child(restored_sub, "fifo").kind, NodeKind::Other));
770    }
771
772    #[cfg(unix)]
773    #[test]
774    fn names_round_trip_as_raw_bytes() {
775        use std::os::unix::ffi::OsStrExt;
776        let mut tree = import(SAMPLE, false);
777        let name = std::ffi::OsStr::from_bytes(b"bad-\xff \"quoted\" back\\slash\nline");
778        tree.root.children[0].set_file_name(name);
779        let mut dump = Vec::new();
780        write(&tree, &mut dump).unwrap();
781        assert!(dump.windows(5).any(|window| window == b"bad-\xff"));
782        let restored = read(dump.as_slice(), &ImportOptions::default()).unwrap();
783        assert!(
784            restored
785                .root
786                .children
787                .iter()
788                .any(|node| node.file_name() == name)
789        );
790    }
791
792    #[test]
793    fn decodes_escapes_and_surrogate_pairs() {
794        let dump = r#"[1,2,{},[{"name":"/r"},{"name":"\u00e9\ud83d\ude00\t\/","asize":1}]]"#;
795        let tree = import(dump, true);
796        assert_eq!(tree.root.children[0].name, "é😀\t/");
797    }
798
799    #[test]
800    fn deep_dumps_parse_on_a_small_stack() {
801        std::thread::Builder::new()
802            .stack_size(128 * 1024)
803            .spawn(|| {
804                let depth = 50_000;
805                let mut dump = String::from(r#"[1,2,{},[{"name":"/r"}"#);
806                for _ in 0..depth {
807                    dump.push_str(r#",[{"name":"d","dsize":512}"#);
808                }
809                dump.push_str(&"]".repeat(depth + 2));
810                let tree = import(&dump, false);
811                assert_eq!(tree.root.size, 512 * depth as u64);
812                assert_eq!(tree.stats.total_dirs, depth as u64 + 1);
813            })
814            .unwrap()
815            .join()
816            .unwrap();
817    }
818
819    #[test]
820    fn malformed_dumps_are_errors_not_panics() {
821        for dump in [
822            "",
823            "[",
824            "[2,0,{},[{\"name\":\"/r\"}]]",
825            "[1,2,{},[{\"name\":\"/r\"}]",
826            "[1,2,{},[{\"name\":\"/r\"},{\"asize\":1}]]",
827            "[1,2,{},[{\"name\":\"/r\"},{\"name\":\"x\",\"asize\":-1}]]",
828            "[1,2,{},[{\"name\":\"/r\"},{\"name\":\"x\",\"asize\":1e400}]]",
829            "[1,2,{},[{\"name\":\"/r\"},{\"name\":\"\\u12\"}]]",
830            "[1,2,{},[{\"name\":\"/r\"}]] trailing",
831            "[1,2,{},[{}]]",
832        ] {
833            assert!(
834                read(dump.as_bytes(), &ImportOptions::default()).is_err(),
835                "{dump:?}"
836            );
837        }
838        let limited = ImportOptions {
839            max_entries: Some(2),
840            ..ImportOptions::default()
841        };
842        assert!(matches!(
843            read(SAMPLE.as_bytes(), &limited),
844            Err(SnapshotError::EntryLimitExceeded { limit: 2 })
845        ));
846    }
847
848    /// Found by fuzzing: a time past what `SystemTime` holds panicked.
849    #[test]
850    fn times_out_of_range_are_unknown() {
851        let max = u64::MAX;
852        let dump = format!(
853            r#"[1,2,{{"timestamp":{max}}},[{{"name":"/r"}},{{"name":"x","mtime":{max}}}]]"#
854        );
855        let tree = import(&dump, true);
856        assert_eq!(tree.root.children[0].timestamps.modified, UNIX_EPOCH);
857    }
858}