From 3138ebd1a319a580551f16f65b6677084a9c8d00 Mon Sep 17 00:00:00 2001 From: eunwoo song Date: Mon, 7 Sep 2026 03:17:21 +0900 Subject: [PATCH 1/2] fix(cli): reject invalid scan roots Validate root paths before the scanner starts so a typo cannot look like a successful empty disk report. Keep the check in the CLI because inaccessible descendants remain a recoverable scanner concern. Signed-off-by: eunwoo song --- CHANGELOG.md | 2 ++ crates/diskern-cli/src/main.rs | 38 ++++++++++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96bfe77..0ecd1d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,8 @@ All notable changes to Diskern are documented here. The format follows ### Fixed +- `diskern scan` now rejects missing and nonexistent roots instead of + reporting a successful empty scan - Restoring a quarantined file across filesystems no longer fails with `EXDEV` - Restore refuses when something is already at the original path, rather diff --git a/crates/diskern-cli/src/main.rs b/crates/diskern-cli/src/main.rs index b312433..2b5bd4d 100644 --- a/crates/diskern-cli/src/main.rs +++ b/crates/diskern-cli/src/main.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{bail, Result}; use clap::{Parser, Subcommand, ValueEnum}; use diskern_core::{report, rules::RulesDb, scanner, Category, Finding, Verdict}; use std::path::PathBuf; @@ -19,6 +19,7 @@ enum Command { /// Read-only scan: find duplicates, caches, and reclaimable space. Scan { /// Directories to scan + #[arg(required = true)] roots: Vec, /// Emit full JSON report instead of a summary #[arg(long)] @@ -183,6 +184,15 @@ fn print_findings(findings: &[&Finding], top: usize) { } } +fn validate_roots(roots: &[PathBuf]) -> Result<()> { + for root in roots { + if !root.exists() { + bail!("scan root does not exist: {}", root.display()); + } + } + Ok(()) +} + fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { @@ -192,6 +202,7 @@ fn main() -> Result<()> { top, verdict, } => { + validate_roots(&roots)?; let opts = scanner::ScanOptions { roots, ..Default::default() @@ -259,7 +270,30 @@ fn main() -> Result<()> { #[cfg(test)] mod tests { - use super::human_bytes; + use super::{human_bytes, validate_roots, Cli}; + use clap::{error::ErrorKind, Parser}; + use std::path::PathBuf; + + #[test] + fn scan_requires_at_least_one_root() { + let error = match Cli::try_parse_from(["diskern", "scan"]) { + Ok(_) => panic!("scan without roots should be rejected"), + Err(error) => error, + }; + + assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); + } + + #[test] + fn nonexistent_scan_root_is_rejected_before_scanning() { + let missing = PathBuf::from("diskern-test-root-that-does-not-exist"); + let error = validate_roots(std::slice::from_ref(&missing)).unwrap_err(); + + assert_eq!( + error.to_string(), + format!("scan root does not exist: {}", missing.display()) + ); + } #[test] fn scales_to_a_readable_unit() { From c65f04c83fee4b9143e515dbf61ed20824955578 Mon Sep 17 00:00:00 2001 From: eunwoo song Date: Wed, 9 Sep 2026 08:21:59 +0900 Subject: [PATCH 2/2] fix(cli): preserve scan root context --- crates/diskern-cli/src/main.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/diskern-cli/src/main.rs b/crates/diskern-cli/src/main.rs index 264f1e8..7bb1b51 100644 --- a/crates/diskern-cli/src/main.rs +++ b/crates/diskern-cli/src/main.rs @@ -220,7 +220,10 @@ fn load_rules(path: Option<&std::path::Path>) -> Result { fn validate_roots(roots: &[PathBuf]) -> Result<()> { for root in roots { - if !root.try_exists()? { + if !root + .try_exists() + .with_context(|| format!("could not check scan root '{}'", root.display()))? + { bail!("scan root does not exist: {}", root.display()); } } @@ -341,6 +344,21 @@ mod tests { ); } + #[test] + fn unreadable_scan_root_error_names_the_root() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("file"); + std::fs::write(&file, b"not a directory").unwrap(); + let invalid_root = file.join("child"); + + let error = validate_roots(std::slice::from_ref(&invalid_root)).unwrap_err(); + + assert_eq!( + error.to_string(), + format!("could not check scan root '{}'", invalid_root.display()) + ); + } + #[test] fn plural_returns_empty_only_for_singular() { assert_eq!(plural(0), "s");