Harden scanner accounting and expose controlled scan reports

This commit is contained in:
2026-10-09 18:28:48 -04:00
parent 9c8d1d43fd
commit 8b177e21a0
9 changed files with 839 additions and 179 deletions
+52
View File
@@ -45,3 +45,55 @@ fn test_json_output() {
.stdout(predicate::str::contains("\"entry_type\":\"File\""))
.stdout(predicate::str::contains("\"path\":"));
}
#[test]
fn missing_path_fails_without_a_success_report() {
let dir = TempDir::new().unwrap();
Command::new(env!("CARGO_BIN_EXE_sized"))
.arg(dir.path().join("missing"))
.args(["--format", "json"])
.assert()
.failure()
.stdout(predicate::str::is_empty())
.stderr(predicate::str::contains("cannot scan"));
}
#[cfg(unix)]
#[test]
fn partial_scan_has_nonzero_status_and_explicit_json_flag() {
use std::{fs, os::unix::fs::PermissionsExt};
let dir = TempDir::new().unwrap();
let blocked = dir.path().join("blocked");
fs::create_dir(&blocked).unwrap();
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).unwrap();
let result = std::panic::catch_unwind(|| {
assert!(
fs::read_dir(&blocked).is_err(),
"permission tests require an unprivileged user"
);
Command::new(env!("CARGO_BIN_EXE_sized"))
.arg(dir.path())
.args(["--format", "json"])
.assert()
.failure()
.stdout(predicate::str::contains("\"complete\":false"))
.stderr(predicate::str::contains("incomplete scan"));
});
fs::set_permissions(blocked, fs::Permissions::from_mode(0o700)).unwrap();
result.unwrap();
}
#[cfg(unix)]
#[test]
fn dangling_symlink_is_a_valid_scan_target() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let path = dir.path().join("dangling");
symlink(dir.path().join("missing"), &path).unwrap();
Command::new(env!("CARGO_BIN_EXE_sized"))
.arg(path)
.args(["--format", "json"])
.assert()
.success()
.stdout(predicate::str::contains("\"complete\":true"));
}
+254
View File
@@ -0,0 +1,254 @@
#![cfg(unix)]
use sized::{build_tree, scan_tree, EntryType, ScanControl, ScanError, ScanOptions};
use std::{
fs,
io::Write,
os::unix::fs::{symlink, MetadataExt, PermissionsExt},
};
use tempfile::tempdir;
fn scan(path: &std::path::Path) -> sized::ScanReport {
scan_tree(path, &ScanOptions::default(), &ScanControl::default()).unwrap()
}
#[test]
fn hard_links_count_allocation_once_but_keep_apparent_sizes() {
let dir = tempdir().unwrap();
let original = dir.path().join("a-original");
fs::write(&original, [9; 65536]).unwrap();
fs::hard_link(&original, dir.path().join("z-alias")).unwrap();
let expected =
fs::metadata(&original).unwrap().blocks() + fs::metadata(dir.path()).unwrap().blocks();
for threads in [1, 4] {
let report = scan_tree(
dir.path(),
&ScanOptions {
threads: Some(threads),
..ScanOptions::default()
},
&ScanControl::default(),
)
.unwrap();
assert_eq!(report.root.blocks, expected);
assert_eq!(report.root.children[0].size_bytes, 65536);
assert_eq!(report.root.children[1].size_bytes, 65536);
assert!(!report.root.children[0].hard_link_duplicate);
assert!(report.root.children[1].hard_link_duplicate);
assert_eq!(report.root.children[1].blocks, 0);
assert_eq!(report.entries_scanned, 3);
assert!(report.root.complete);
}
}
#[test]
fn ignored_hard_link_does_not_steal_filtered_allocation() {
let dir = tempdir().unwrap();
fs::write(dir.path().join(".ignore"), "a-ignored\n").unwrap();
let original = dir.path().join("a-ignored");
fs::write(&original, [9; 65536]).unwrap();
fs::hard_link(&original, dir.path().join("z-included")).unwrap();
let report = scan_tree(
dir.path(),
&ScanOptions {
compare_ignore: true,
..ScanOptions::default()
},
&ScanControl::default(),
)
.unwrap();
let expected = fs::metadata(&original).unwrap().blocks()
+ fs::metadata(dir.path()).unwrap().blocks()
+ fs::metadata(dir.path().join(".ignore")).unwrap().blocks();
assert_eq!(report.root.blocks, expected);
assert_eq!(report.root.blocks_filtered, expected);
assert_eq!(report.root.children.last().unwrap().blocks, 0);
assert!(report.root.children.last().unwrap().blocks_filtered > 0);
}
#[test]
fn hidden_files_are_counted_without_ignore_filtering() {
let dir = tempdir().unwrap();
fs::write(dir.path().join(".hidden"), b"hidden").unwrap();
fs::write(dir.path().join(".ignore"), "build\n").unwrap();
fs::write(dir.path().join("build"), b"build").unwrap();
assert_eq!(scan(dir.path()).root.children.len(), 3);
let report = scan_tree(
dir.path(),
&ScanOptions {
respect_ignore: true,
..ScanOptions::default()
},
&ScanControl::default(),
)
.unwrap();
assert_eq!(report.root.children.len(), 2);
assert!(report
.root
.children
.iter()
.any(|n| n.path.ends_with(".hidden")));
}
#[test]
fn ignore_rules_are_inherited_by_nested_directories() {
let dir = tempdir().unwrap();
fs::write(dir.path().join(".ignore"), "*.tmp\n").unwrap();
let nested = dir.path().join("nested");
fs::create_dir(&nested).unwrap();
fs::write(nested.join("skip.tmp"), b"ignored").unwrap();
fs::write(nested.join("keep.bin"), b"included").unwrap();
let report = scan_tree(
dir.path(),
&ScanOptions {
respect_ignore: true,
..ScanOptions::default()
},
&ScanControl::default(),
)
.unwrap();
let children = &report
.root
.children
.iter()
.find(|n| n.path == nested)
.unwrap()
.children;
assert_eq!(children.len(), 1);
assert!(children[0].path.ends_with("keep.bin"));
let compared = scan_tree(
dir.path(),
&ScanOptions {
compare_ignore: true,
..ScanOptions::default()
},
&ScanControl::default(),
)
.unwrap();
assert_eq!(compared.root.size_bytes_filtered, report.root.size_bytes);
assert_eq!(compared.root.blocks_filtered, report.root.blocks);
}
#[test]
fn sparse_file_reports_blocks_and_logical_length_separately() {
let dir = tempdir().unwrap();
let path = dir.path().join("sparse");
let mut file = fs::File::create(&path).unwrap();
file.write_all(&[7; 4096]).unwrap();
file.set_len(128 * 1024 * 1024).unwrap();
file.sync_all().unwrap();
let node = scan(&path).root;
assert_eq!(node.size_bytes, 128 * 1024 * 1024);
assert_eq!(node.blocks, fs::metadata(path).unwrap().blocks());
assert!(node.blocks * 512 < node.size_bytes);
}
#[test]
fn symlinks_including_loop_and_dangling_link_are_leaves() {
let dir = tempdir().unwrap();
symlink(dir.path(), dir.path().join("loop")).unwrap();
symlink(dir.path().join("missing"), dir.path().join("dangling")).unwrap();
let report = scan(dir.path());
assert_eq!(report.root.children.len(), 2);
for node in report.root.children {
assert!(node.symlink);
assert!(node.children.is_empty());
assert!(node.complete);
}
}
#[test]
fn missing_root_returns_error_and_legacy_helper_marks_unavailable() {
let dir = tempdir().unwrap();
let path = dir.path().join("vanished");
assert!(
matches!(scan_tree(&path, &ScanOptions::default(), &ScanControl::default()),
Err(ScanError::Root { source, .. }) if source.kind() == std::io::ErrorKind::NotFound)
);
let node = build_tree(&path, false, false);
assert_eq!(node.entry_type, EntryType::Unknown);
assert!(!node.accessible);
assert!(!node.complete);
}
#[test]
fn permission_error_propagates_incomplete_status_to_root() {
let dir = tempdir().unwrap();
let blocked = dir.path().join("blocked");
fs::create_dir(&blocked).unwrap();
fs::write(blocked.join("unreadable"), b"data").unwrap();
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).unwrap();
let result = std::panic::catch_unwind(|| {
assert!(
fs::read_dir(&blocked).is_err(),
"permission tests require an unprivileged user"
);
let report = scan(dir.path());
assert!(report.root.accessible);
assert!(!report.root.complete);
assert!(!report.root.children[0].accessible);
assert_eq!(report.issues.len(), 1);
assert_eq!(report.issues[0].path, blocked);
assert_eq!(report.issues[0].kind, std::io::ErrorKind::PermissionDenied);
});
fs::set_permissions(blocked, fs::Permissions::from_mode(0o700)).unwrap();
result.unwrap();
}
#[test]
fn cancelled_scan_never_returns_completed_tree() {
let dir = tempdir().unwrap();
let control = ScanControl::default();
control.cancel();
assert!(matches!(
scan_tree(dir.path(), &ScanOptions::default(), &control),
Err(ScanError::Cancelled)
));
assert_eq!(control.entries_scanned(), 0);
}
#[test]
fn cancellation_and_progress_work_during_scan() {
let dir = tempdir().unwrap();
for i in 0..2000 {
fs::write(dir.path().join(format!("file-{i}")), b"x").unwrap();
}
let control = ScanControl::default();
let watcher = control.clone();
let cancel = std::thread::spawn(move || {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while watcher.entries_scanned() < 10 {
assert!(
std::time::Instant::now() < deadline,
"scan made no progress"
);
std::thread::yield_now();
}
watcher.cancel();
});
let result = scan_tree(
dir.path(),
&ScanOptions {
threads: Some(1),
..ScanOptions::default()
},
&control,
);
cancel.join().unwrap();
assert!(matches!(result, Err(ScanError::Cancelled)));
assert!(control.entries_scanned() >= 10);
}
#[test]
fn special_file_is_not_treated_as_directory() {
let dir = tempdir().unwrap();
let path = dir.path().join("fifo");
assert!(std::process::Command::new("mkfifo")
.arg(&path)
.status()
.unwrap()
.success());
let node = scan(&path).root;
assert_eq!(node.entry_type, EntryType::Special);
assert!(node.children.is_empty());
assert!(node.complete);
}