223 lines
8.4 KiB
Rust
223 lines
8.4 KiB
Rust
//! 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<String, serde_json::Value> = 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());
|
|
}
|
|
}
|