//! Writes inventories as JSON, CSV or plain-text reports. Callers hold the //! tree's read lock (or own the tree exclusively). use crate::brand; use crate::inventory::{self as inv, Flags, Kind, NodeId, SizeMode, Tree, NO_NODE}; use crate::textutil; use std::io::{self, Write}; use std::time::UNIX_EPOCH; const FLAG_NAMES: [(Flags, &str); 12] = [ (inv::FLAG_HARDLINK_DUP, "hardlink-duplicate"), (inv::FLAG_MOUNT_POINT, "mount-point"), (inv::FLAG_VIRTUAL_FS, "virtual-fs-skipped"), (inv::FLAG_SKIPPED_FS, "other-fs-skipped"), (inv::FLAG_SPARSE, "sparse"), (inv::FLAG_ERROR, "error"), (inv::FLAG_INCOMPLETE, "incomplete"), (inv::FLAG_LOOP, "loop-skipped"), (inv::FLAG_FOLLOWED, "followed-symlink"), (inv::FLAG_ALLOC_UNKNOWN, "allocated-unknown"), (inv::FLAG_BROKEN_LINK, "broken-symlink"), (inv::FLAG_HARDLINKED, "hardlinked"), ]; /// Human-readable names for a node's flags. pub fn flag_list(f: Flags) -> Vec<&'static str> { FLAG_NAMES.iter().filter(|(b, _)| f & b != 0).map(|(_, n)| *n).collect() } fn kind_name(k: Kind) -> &'static str { ["file", "dir", "symlink", "other"][k as usize] } fn json_str(s: &str) -> String { serde_json::to_string(s).unwrap() } /// Writes the tree beneath root as nested JSON, down to max_depth levels /// (negative means unlimited). pub fn json(w: &mut dyn Write, t: &Tree, root: NodeId, max_depth: i64) -> io::Result<()> { let mut bw = io::BufWriter::new(w); let st = &t.stats; let errors: serde_json::Map = st .err_counts .iter() .enumerate() .filter(|(_, &c)| c > 0) .map(|(k, &c)| (inv::ErrKind::from_u8(k as u8).name().to_string(), c.into())) .collect(); let started = st.start.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos() as i64); let head = serde_json::json!({ "generator": format!("{} {}", brand::NAME, brand::VERSION), "root": t.path_string(root), "complete": !st.incomplete(), "cancelled": st.cancelled, "started": textutil::rfc3339_utc(started), "elapsed_s": st.elapsed().as_secs_f64(), "errors": errors, "excluded": st.excluded, "excludes": st.excludes, "hardlink_duplicates": st.hardlink_dups, }); let hb = serde_json::to_string(&head)?; bw.write_all(&hb.as_bytes()[..hb.len() - 1])?; bw.write_all(b",\"tree\":")?; write_node(&mut bw, t, root, max_depth)?; bw.write_all(b"}\n")?; bw.flush() } /// Streams one node; children are written recursively so memory stays /// proportional to depth, not tree size. fn write_node(bw: &mut impl Write, t: &Tree, id: NodeId, depth: i64) -> io::Result<()> { let n = t.node(id); write!(bw, "{{\"name\":{},\"type\":\"{}\",\"size\":{},\"allocated\":{}", json_str(&n.name_str()), kind_name(n.kind), n.tot_size, n.tot_alloc)?; for (k, v) in [("files", n.files), ("dirs", n.dirs), ("errors", n.errors)] { if v != 0 { write!(bw, ",\"{k}\":{v}")?; } } if n.mtime != 0 { write!(bw, ",\"modified\":\"{}\"", textutil::rfc3339_utc(n.mtime))?; } let flags = flag_list(n.flags); if !flags.is_empty() { write!(bw, ",\"flags\":{}", serde_json::to_string(&flags)?)?; } if n.first_child == NO_NODE || depth == 0 { return bw.write_all(b"}"); } bw.write_all(b",\"children\":[")?; for (i, c) in t.sorted_children(id, SizeMode::Allocated).into_iter().enumerate() { if i > 0 { bw.write_all(b",")?; } write_node(bw, t, c, depth - 1)?; } bw.write_all(b"]}") } fn csv_field(s: &str) -> String { if s.contains([',', '"', '\n', '\r']) || s.starts_with(' ') { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() } } /// Writes one row per node beneath root (depth-limited). Paths are /// sanitised so the output is safe to print to a terminal. pub fn csv(w: &mut dyn Write, t: &Tree, root: NodeId, max_depth: i64) -> io::Result<()> { let mut bw = io::BufWriter::new(w); bw.write_all(b"path,type,size,allocated,files,dirs,errors,modified,flags\n")?; fn rec(bw: &mut impl Write, t: &Tree, id: NodeId, path: &[u8], depth: i64) -> io::Result<()> { let n = t.node(id); let modified = if n.mtime != 0 { textutil::rfc3339_utc(n.mtime) } else { String::new() }; writeln!( bw, "{},{},{},{},{},{},{},{},{}", csv_field(&textutil::sanitize_bytes(path)), kind_name(n.kind), n.tot_size, n.tot_alloc, n.files, n.dirs, n.errors, modified, flag_list(n.flags).join("|") )?; if depth == 0 { return Ok(()); } for c in t.sorted_children(id, SizeMode::Allocated) { rec(bw, t, c, &crate::platform::join_path(path, &t.node(c).name), depth - 1)?; } Ok(()) } rec(&mut bw, t, root, &t.path_bytes(root), max_depth)?; bw.flush() } /// Writes "size % files path" rows for the given nodes. pub fn table(w: &mut dyn Write, t: &Tree, ids: &[NodeId], m: SizeMode) -> io::Result<()> { let total = t.node(t.root()).total(m); writeln!(w, "{:>12} {:>6} {:>12} PATH", "SIZE", "%", "FILES")?; for &id in ids { let n = t.node(id); let files = if n.is_dir() { textutil::count(n.files as i64) } else { String::new() }; writeln!( w, "{:>12} {:>6} {:>12} {}", textutil::size(n.total(m)), textutil::percent(n.total(m), total), files, textutil::sanitize_bytes(&t.path_bytes(id)) )?; } Ok(()) } /// Writes the scan summary, making incompleteness explicit. pub fn summary(w: &mut dyn Write, t: &Tree) -> io::Result<()> { let st = &t.stats; let r = t.node(t.root()); writeln!(w, "Root {}", textutil::sanitize_bytes(&st.root))?; writeln!(w, "Files {}", textutil::count(st.files + st.symlinks + st.others))?; writeln!(w, "Directories {}", textutil::count(st.dirs))?; writeln!(w, "Logical size {} ({} bytes)", textutil::size(r.tot_size), r.tot_size)?; writeln!(w, "Allocated size {} ({} bytes)", textutil::size(r.tot_alloc), r.tot_alloc)?; writeln!(w, "Elapsed {}", textutil::go_duration(st.elapsed()))?; for (k, &c) in st.err_counts.iter().enumerate() { if c > 0 { writeln!(w, "{:<15} {}", inv::ErrKind::from_u8(k as u8).name(), textutil::count(c))?; } } if st.excluded > 0 { writeln!(w, "Excluded {} entries ({})", textutil::count(st.excluded), textutil::sanitize(&st.excludes.join(", ")))?; } if st.skipped_mounts > 0 { writeln!(w, "Other FS skipped {}", textutil::count(st.skipped_mounts))?; } if st.virtual_skipped > 0 { writeln!(w, "Virtual FS skipped {}", textutil::count(st.virtual_skipped))?; } if st.hardlink_dups > 0 { writeln!(w, "Hard-link dups {} (counted once)", textutil::count(st.hardlink_dups))?; } if st.incomplete() { writeln!(w, "WARNING: totals are incomplete — some data could not be inspected.")?; } Ok(()) } #[cfg(test)] mod tests { use super::*; fn hostile_tree() -> Tree { let mut t = Tree::new(b"/r", Kind::Dir); let d = t.add(0, b"esc\x1b[2Jdir", Kind::Dir); let f = t.add(d, b"nl\nname\x07.txt", Kind::File); t.node_mut(f).size = 10; t.node_mut(f).alloc = 4096; t.stats.complete = true; t.recompute(); t } #[test] fn outputs_are_terminal_safe() { let t = hostile_tree(); for out in [ { let mut b = Vec::new(); json(&mut b, &t, 0, -1).unwrap(); b }, { let mut b = Vec::new(); csv(&mut b, &t, 0, -1).unwrap(); b }, { let mut b = Vec::new(); table(&mut b, &t, &[1, 2], SizeMode::Allocated).unwrap(); b }, ] { assert!(!out.iter().any(|&c| c == 0x1b || c == 0x07), "{}", String::from_utf8_lossy(&out)); } let mut b = Vec::new(); json(&mut b, &t, 0, -1).unwrap(); let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); assert_eq!(v["tree"]["children"][0]["children"][0]["size"], 10); } #[test] fn json_depth_limit() { let t = hostile_tree(); let mut b = Vec::new(); json(&mut b, &t, 0, 1).unwrap(); let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); assert!(v["tree"]["children"][0].get("children").is_none()); } }