Harden scanner accounting and expose controlled scan reports
This commit is contained in:
+59
-168
@@ -6,15 +6,17 @@ use colored::*;
|
||||
use comfy_table::presets::UTF8_FULL;
|
||||
use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table};
|
||||
use csv::WriterBuilder;
|
||||
use ignore::WalkBuilder;
|
||||
use rayon::prelude::*;
|
||||
use serde::Serialize;
|
||||
use std::cmp::Ordering;
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
|
||||
pub mod scan;
|
||||
pub use scan::{
|
||||
scan_tree, EntryType, FileIdentity, Node, ScanControl, ScanError, ScanIssue, ScanOptions,
|
||||
ScanReport,
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
#[command(disable_version_flag = true)]
|
||||
@@ -61,6 +63,10 @@ pub struct Args {
|
||||
#[arg(short = 'j', long = "threads")]
|
||||
pub threads: Option<usize>,
|
||||
|
||||
/// Stay on the target's filesystem instead of descending into other mounts
|
||||
#[arg(short = 'x', long)]
|
||||
pub one_file_system: bool,
|
||||
|
||||
/// Generate shell completions
|
||||
#[arg(long, value_enum)]
|
||||
pub completions: Option<Shell>,
|
||||
@@ -147,40 +153,14 @@ impl FromStr for SortDirection {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Debug)]
|
||||
pub struct Node {
|
||||
pub path: PathBuf,
|
||||
pub size_bytes: u64,
|
||||
pub blocks: u64,
|
||||
pub size_bytes_filtered: u64,
|
||||
pub blocks_filtered: u64,
|
||||
pub entry_type: EntryType,
|
||||
pub accessible: bool,
|
||||
#[serde(skip)]
|
||||
pub children: Vec<Node>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum EntryType {
|
||||
File,
|
||||
Dir,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for EntryType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
EntryType::File => write!(f, "File"),
|
||||
EntryType::Dir => write!(f, "Dir"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(args: Args, mut writer: &mut dyn Write) {
|
||||
/// Returns whether the scan completed without missing entries. The caller
|
||||
/// chooses the exit status; partial reports remain available to the user.
|
||||
pub fn run(args: Args, mut writer: &mut dyn Write) -> bool {
|
||||
if let Some(shell) = args.completions {
|
||||
let mut cmd = Args::command();
|
||||
let name = cmd.get_name().to_string();
|
||||
generate(shell, &mut cmd, name, &mut std::io::stdout());
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(out_dir) = args.generate_man_page {
|
||||
@@ -193,23 +173,11 @@ pub fn run(args: Args, mut writer: &mut dyn Write) {
|
||||
let file_path = out_dir.join("sized.1");
|
||||
std::fs::write(&file_path, buffer).expect("Failed to write man page");
|
||||
println!("Man page generated at {}", file_path.display());
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(threads) = args.threads {
|
||||
rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(threads)
|
||||
.build_global()
|
||||
.ok(); // Ignore error if initialized called multiple times (e.g. in tests)
|
||||
return true;
|
||||
}
|
||||
|
||||
let target_path = args.path.clone();
|
||||
|
||||
if !target_path.exists() {
|
||||
eprintln!("Error: Path '{}' does not exist.", target_path.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let min_bytes = if let Some(size_str) = &args.min_size {
|
||||
match Byte::parse_str(size_str, true) {
|
||||
Ok(byte) => byte.as_u64(),
|
||||
@@ -234,134 +202,55 @@ pub fn run(args: Args, mut writer: &mut dyn Write) {
|
||||
}
|
||||
}
|
||||
|
||||
let root_node = build_tree(&target_path, args.ignore, args.compare);
|
||||
let report = match scan_tree(
|
||||
&target_path,
|
||||
&ScanOptions {
|
||||
respect_ignore: args.ignore,
|
||||
compare_ignore: args.compare,
|
||||
stay_on_filesystem: args.one_file_system,
|
||||
threads: args.threads,
|
||||
},
|
||||
&ScanControl::default(),
|
||||
) {
|
||||
Ok(report) => report,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
for issue in &report.issues {
|
||||
eprintln!(
|
||||
"Warning: incomplete scan at '{}': {}",
|
||||
issue.path.display(),
|
||||
issue.message
|
||||
);
|
||||
}
|
||||
let root_node = report.root;
|
||||
|
||||
if root_node.size_bytes < min_bytes {
|
||||
if args.format == OutputFormat::Text {
|
||||
writeln!(writer, "Root directory is smaller than minimum size.").ok();
|
||||
}
|
||||
return;
|
||||
return root_node.complete;
|
||||
}
|
||||
|
||||
process_node_recursive(&mut writer, &root_node, 0, &args, min_bytes, &sort_criteria);
|
||||
root_node.complete
|
||||
}
|
||||
|
||||
pub fn build_tree(path: &Path, ignore: bool, compare: bool) -> Node {
|
||||
let metadata = path.symlink_metadata();
|
||||
|
||||
if let Ok(meta) = metadata {
|
||||
// Treat symlinks as files (nodes) but do not recurse
|
||||
if meta.is_file() || meta.is_symlink() {
|
||||
return Node {
|
||||
path: path.to_path_buf(),
|
||||
size_bytes: meta.len(),
|
||||
blocks: meta.blocks(),
|
||||
size_bytes_filtered: meta.len(), // Single file is its own filtered size for now
|
||||
blocks_filtered: meta.blocks(),
|
||||
entry_type: EntryType::File,
|
||||
accessible: true,
|
||||
children: vec![],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we can read the directory (handle permissions)
|
||||
if let Err(e) = std::fs::read_dir(path) {
|
||||
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
// Return an "empty" directory node marked as inaccessible
|
||||
let meta = path.symlink_metadata().ok();
|
||||
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
|
||||
let blocks = meta.as_ref().map(|m| m.blocks()).unwrap_or(0);
|
||||
return Node {
|
||||
path: path.to_path_buf(),
|
||||
size_bytes: size,
|
||||
blocks,
|
||||
size_bytes_filtered: size,
|
||||
blocks_filtered: blocks,
|
||||
entry_type: EntryType::Dir,
|
||||
accessible: false,
|
||||
children: vec![],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Total walker (always everything if compare is true, else respects 'ignore' arg)
|
||||
let total_ignore = if compare { false } else { ignore };
|
||||
let walker_total = WalkBuilder::new(path)
|
||||
.standard_filters(false)
|
||||
.hidden(false)
|
||||
.git_ignore(total_ignore)
|
||||
.ignore(total_ignore)
|
||||
.max_depth(Some(1))
|
||||
.build();
|
||||
|
||||
let child_paths_total: Vec<PathBuf> = walker_total
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path() != path)
|
||||
.map(|e| e.path().to_path_buf())
|
||||
.collect();
|
||||
|
||||
// Filtered set (only if compare is true)
|
||||
let non_ignored_set: std::collections::HashSet<PathBuf> = if compare {
|
||||
let walker_filtered = WalkBuilder::new(path)
|
||||
.standard_filters(false)
|
||||
.hidden(false)
|
||||
.git_ignore(true)
|
||||
.ignore(true)
|
||||
.max_depth(Some(1))
|
||||
.build();
|
||||
|
||||
walker_filtered
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path() != path)
|
||||
.map(|e| e.path().to_path_buf())
|
||||
.collect()
|
||||
} else {
|
||||
std::collections::HashSet::new()
|
||||
};
|
||||
|
||||
let children: Vec<Node> = child_paths_total
|
||||
.par_iter()
|
||||
.map(|p| build_tree(p, ignore, compare))
|
||||
.collect();
|
||||
|
||||
let mut size_bytes = 0;
|
||||
let mut blocks = 0;
|
||||
let mut size_bytes_filtered = 0;
|
||||
let mut blocks_filtered = 0;
|
||||
|
||||
for child in &children {
|
||||
size_bytes += child.size_bytes;
|
||||
blocks += child.blocks;
|
||||
|
||||
if compare {
|
||||
if non_ignored_set.contains(&child.path) {
|
||||
size_bytes_filtered += child.size_bytes_filtered;
|
||||
blocks_filtered += child.blocks_filtered;
|
||||
}
|
||||
} else {
|
||||
size_bytes_filtered += child.size_bytes_filtered;
|
||||
blocks_filtered += child.blocks_filtered;
|
||||
}
|
||||
}
|
||||
|
||||
let (self_size, self_blocks) = path
|
||||
.symlink_metadata()
|
||||
.map(|m| (m.len(), m.blocks()))
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
Node {
|
||||
path: path.to_path_buf(),
|
||||
size_bytes: size_bytes + self_size,
|
||||
blocks: blocks + self_blocks,
|
||||
size_bytes_filtered: size_bytes_filtered + self_size, // self is always part of self
|
||||
blocks_filtered: blocks_filtered + self_blocks,
|
||||
entry_type: EntryType::Dir,
|
||||
accessible: true,
|
||||
children,
|
||||
}
|
||||
// Compatibility helper; callers needing diagnostics should use scan_tree.
|
||||
scan_tree(
|
||||
path,
|
||||
&ScanOptions {
|
||||
respect_ignore: ignore,
|
||||
compare_ignore: compare,
|
||||
..ScanOptions::default()
|
||||
},
|
||||
&ScanControl::default(),
|
||||
)
|
||||
.map(|report| report.root)
|
||||
.unwrap_or_else(|_| Node::unavailable(path))
|
||||
}
|
||||
|
||||
fn process_node_recursive(
|
||||
@@ -372,8 +261,6 @@ fn process_node_recursive(
|
||||
min_bytes: u64,
|
||||
sort_criteria: &[(SortColumn, SortDirection)],
|
||||
) {
|
||||
if node.children.is_empty() && node.entry_type == EntryType::Dir {}
|
||||
|
||||
let mut display_children: Vec<&Node> = node
|
||||
.children
|
||||
.iter()
|
||||
@@ -406,7 +293,7 @@ fn process_node_recursive(
|
||||
|
||||
print_output(
|
||||
writer,
|
||||
&node,
|
||||
node,
|
||||
&display_children,
|
||||
args,
|
||||
&relative_path,
|
||||
@@ -735,7 +622,7 @@ fn print_text(
|
||||
Cell::new("N/A")
|
||||
.fg(Color::Red)
|
||||
.set_alignment(CellAlignment::Right),
|
||||
Cell::new("Access Denied")
|
||||
Cell::new("Unavailable")
|
||||
.fg(Color::Red)
|
||||
.set_alignment(CellAlignment::Right),
|
||||
];
|
||||
@@ -794,6 +681,9 @@ fn print_json(writer: &mut dyn Write, parent_node: &Node, children: &[&Node], ar
|
||||
"path": &c.path,
|
||||
"entry_type": c.entry_type.to_string(),
|
||||
"accessible": c.accessible,
|
||||
"complete": c.complete,
|
||||
"hard_link_duplicate": c.hard_link_duplicate,
|
||||
"skipped_mount": c.skipped_mount,
|
||||
"disk_usage": c.blocks * 512,
|
||||
"blocks": c.blocks,
|
||||
});
|
||||
@@ -831,6 +721,7 @@ fn print_json(writer: &mut dyn Write, parent_node: &Node, children: &[&Node], ar
|
||||
"total_blocks": parent_node.blocks,
|
||||
"entries": entries_view,
|
||||
"accessible": parent_node.accessible,
|
||||
"complete": parent_node.complete,
|
||||
});
|
||||
|
||||
if args.apparent {
|
||||
|
||||
Reference in New Issue
Block a user