From 60d5f1ba9093e6592977e0487228679b72c9c5c6 Mon Sep 17 00:00:00 2001 From: Alex Smolya Date: Sun, 6 Sep 2026 06:48:51 +0200 Subject: [PATCH 1/2] feat(cli): load rules database from file --- CHANGELOG.md | 1 + Cargo.lock | 1 + crates/diskern-cli/Cargo.toml | 3 + crates/diskern-cli/src/main.rs | 31 ++++++++- crates/diskern-cli/tests/rules_file.rs | 91 ++++++++++++++++++++++++++ 5 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 crates/diskern-cli/tests/rules_file.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 96bfe77..e9cd294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to Diskern are documented here. The format follows ### Added +- `diskern scan --rules ` to test scans with an external rules database - Cancel a running scan from the desktop app - `diskern scan` prints the findings themselves — grouped by verdict and category, with `--top` to cap each group and `--verdict` to filter diff --git a/Cargo.lock b/Cargo.lock index 62ce792..339c62b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,6 +849,7 @@ dependencies = [ "clap", "diskern-core", "serde_json", + "tempfile", ] [[package]] diff --git a/crates/diskern-cli/Cargo.toml b/crates/diskern-cli/Cargo.toml index dcbc1a0..897630f 100644 --- a/crates/diskern-cli/Cargo.toml +++ b/crates/diskern-cli/Cargo.toml @@ -16,3 +16,6 @@ diskern-core = { path = "../diskern-core" } anyhow.workspace = true serde_json.workspace = true clap = { version = "4", features = ["derive"] } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/diskern-cli/src/main.rs b/crates/diskern-cli/src/main.rs index b312433..54db867 100644 --- a/crates/diskern-cli/src/main.rs +++ b/crates/diskern-cli/src/main.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use clap::{Parser, Subcommand, ValueEnum}; use diskern_core::{report, rules::RulesDb, scanner, Category, Finding, Verdict}; use std::path::PathBuf; @@ -29,6 +29,9 @@ enum Command { /// Only show findings with this verdict #[arg(long, value_enum)] verdict: Option, + /// Load rules from a JSON file instead of the embedded database + #[arg(long, value_name = "FILE")] + rules: Option, }, } @@ -183,6 +186,21 @@ fn print_findings(findings: &[&Finding], top: usize) { } } +fn load_rules(path: Option<&std::path::Path>) -> Result { + let Some(path) = path else { + return Ok(RulesDb::embedded()); + }; + + let contents = std::fs::read(path).with_context(|| { + format!( + "could not read rules file '{}'; check that it exists and is readable", + path.display() + ) + })?; + serde_json::from_slice(&contents) + .with_context(|| format!("could not parse rules file '{}' as JSON", path.display())) +} + fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { @@ -191,19 +209,28 @@ fn main() -> Result<()> { json, top, verdict, + rules, } => { + let external_rules = rules.as_deref(); + let rules_db = load_rules(external_rules)?; let opts = scanner::ScanOptions { roots, ..Default::default() }; let progress = Arc::new(scanner::ScanProgress::default()); let entries = scanner::scan(&opts, progress)?; - let report = report::build(entries, &RulesDb::embedded()); + let report = report::build(entries, &rules_db); if json { + if let Some(path) = external_rules { + eprintln!("Using external rules database: {}", path.display()); + } println!("{}", serde_json::to_string_pretty(&report)?); } else { println!("Scanned {} files.", report.files_scanned); + if let Some(path) = external_rules { + println!("Rules: external database — {}", path.display()); + } println!( "Reclaimable: {} across {} findings and {} duplicate sets.", human_bytes(report.total_reclaimable), diff --git a/crates/diskern-cli/tests/rules_file.rs b/crates/diskern-cli/tests/rules_file.rs new file mode 100644 index 0000000..647795c --- /dev/null +++ b/crates/diskern-cli/tests/rules_file.rs @@ -0,0 +1,91 @@ +use serde_json::json; +use std::fs; +use std::process::Command; +use tempfile::tempdir; + +fn run_scan(root: &std::path::Path, rules: Option<&std::path::Path>) -> std::process::Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_diskern")); + command.arg("scan").arg(root).arg("--top").arg("0"); + if let Some(rules) = rules { + command.arg("--rules").arg(rules); + } + command.output().expect("diskern should start") +} + +fn write_rules(path: &std::path::Path, pattern: &str) { + let rules = json!({ + "version": 1, + "rules": [{ + "id": "test-rule", + "patterns": [pattern], + "category": "browser_cache", + "verdict": "safe", + "description": "Rule loaded from the test file." + }] + }); + fs::write(path, serde_json::to_vec(&rules).unwrap()).unwrap(); +} + +#[test] +fn scan_without_rules_uses_embedded_database() { + let root = tempdir().unwrap(); + let cache = root.path().join(".cache/google-chrome/Default/Cache"); + fs::create_dir_all(&cache).unwrap(); + fs::write(cache.join("entry"), b"cached").unwrap(); + + let output = run_scan(root.path(), None); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("matched rule chrome-cache"), "{stdout}"); +} + +#[test] +fn scan_accepts_and_uses_external_rules_file() { + let root = tempdir().unwrap(); + fs::write(root.path().join("sample.custom"), b"custom").unwrap(); + let rules = root.path().join("rules.json"); + write_rules(&rules, "**/*.custom"); + + let output = run_scan(root.path(), Some(&rules)); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Rules: external database"), "{stdout}"); + assert!(stdout.contains("matched rule test-rule"), "{stdout}"); + assert!( + stdout.contains("Rule loaded from the test file."), + "{stdout}" + ); +} + +#[test] +fn missing_rules_file_fails_clearly() { + let root = tempdir().unwrap(); + let missing = root.path().join("missing.json"); + let output = run_scan(root.path(), Some(&missing)); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("could not read rules file"), "{stderr}"); + assert!(stderr.contains("missing.json"), "{stderr}"); +} + +#[test] +fn malformed_rules_file_fails_clearly() { + let root = tempdir().unwrap(); + let rules = root.path().join("malformed.json"); + fs::write(&rules, b"{ not json }").unwrap(); + let output = run_scan(root.path(), Some(&rules)); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("could not parse rules file"), "{stderr}"); + assert!(stderr.contains("malformed.json"), "{stderr}"); +} From 4e3abb4a3f246d04c81d0b72f026dd6f4dc32303 Mon Sep 17 00:00:00 2001 From: Alex Smolya Date: Sun, 6 Sep 2026 19:48:37 +0200 Subject: [PATCH 2/2] fix(rules): validate external patterns and preserve protected rules --- crates/diskern-cli/README.md | 1 + crates/diskern-cli/src/main.rs | 11 +++- crates/diskern-cli/tests/rules_file.rs | 40 +++++++++++++ crates/diskern-core/src/rules.rs | 78 +++++++++++++++++++++++++- 4 files changed, 127 insertions(+), 3 deletions(-) diff --git a/crates/diskern-cli/README.md b/crates/diskern-cli/README.md index 62aeaed..d356cd7 100644 --- a/crates/diskern-cli/README.md +++ b/crates/diskern-cli/README.md @@ -53,6 +53,7 @@ nothing will offer to move it. | `--top N` | `5` | Findings shown per category; `0` shows every one. | | `--verdict` | all | `safe`, `review`, `risky` or `protected`. Duplicate sets have no verdict, so they are omitted when this is set. | | `--json` | off | Full report as JSON; the flags above don't apply. | +| `--rules ` | embedded | Load and validate an external rules database; embedded protected rules remain authoritative. | Scanning is always read-only — the CLI never modifies, moves, or deletes anything. diff --git a/crates/diskern-cli/src/main.rs b/crates/diskern-cli/src/main.rs index 54db867..86a9339 100644 --- a/crates/diskern-cli/src/main.rs +++ b/crates/diskern-cli/src/main.rs @@ -197,8 +197,15 @@ fn load_rules(path: Option<&std::path::Path>) -> Result { path.display() ) })?; - serde_json::from_slice(&contents) - .with_context(|| format!("could not parse rules file '{}' as JSON", path.display())) + let rules: RulesDb = serde_json::from_slice(&contents) + .with_context(|| format!("could not parse rules file '{}' as JSON", path.display()))?; + rules.validate().with_context(|| { + format!( + "could not validate rules file '{}'; every pattern must be a valid glob", + path.display() + ) + })?; + Ok(rules.with_embedded_protected_rules()) } fn main() -> Result<()> { diff --git a/crates/diskern-cli/tests/rules_file.rs b/crates/diskern-cli/tests/rules_file.rs index 647795c..a5c4f99 100644 --- a/crates/diskern-cli/tests/rules_file.rs +++ b/crates/diskern-cli/tests/rules_file.rs @@ -89,3 +89,43 @@ fn malformed_rules_file_fails_clearly() { assert!(stderr.contains("could not parse rules file"), "{stderr}"); assert!(stderr.contains("malformed.json"), "{stderr}"); } + +#[test] +fn invalid_glob_in_rules_file_fails_before_scan() { + let root = tempdir().unwrap(); + let rules = root.path().join("invalid-glob.json"); + write_rules(&rules, "["); + + let output = run_scan(root.path(), Some(&rules)); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("could not validate rules file"), "{stderr}"); + assert!(stderr.contains("invalid glob pattern"), "{stderr}"); + assert!(stderr.contains("test-rule"), "{stderr}"); +} + +#[test] +fn external_rules_cannot_shadow_embedded_protected_rules() { + let root = tempdir().unwrap(); + let protected_path = root.path().join("windows/installer/setup.msi"); + fs::create_dir_all(protected_path.parent().unwrap()).unwrap(); + fs::write(&protected_path, b"installer").unwrap(); + let rules = root.path().join("rules.json"); + write_rules(&rules, "**/*.msi"); + + let output = run_scan(root.path(), Some(&rules)); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("matched rule windows-installer-cache"), + "{stdout}" + ); + assert!(!stdout.contains("matched rule test-rule"), "{stdout}"); + assert!(stdout.contains("Protected — do not touch"), "{stdout}"); +} diff --git a/crates/diskern-core/src/rules.rs b/crates/diskern-core/src/rules.rs index 2cc81dc..ef4cd93 100644 --- a/crates/diskern-core/src/rules.rs +++ b/crates/diskern-core/src/rules.rs @@ -11,7 +11,7 @@ //! anchoring is the point: a plain substring test made `/tmp/` fire on //! `/home/user/tmp/tax-return.pdf`, which is user data, not scratch space. -use crate::{Category, Verdict}; +use crate::{Category, GenomeError, Result, Verdict}; use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder}; use serde::{Deserialize, Serialize}; use std::sync::OnceLock; @@ -75,6 +75,38 @@ impl RulesDb { } } + /// Reject malformed patterns before an externally supplied database is + /// used. `compile` remains tolerant for the embedded matcher path, but a + /// caller loading rules from outside the binary must not report success + /// for a rule that can never match. + pub fn validate(&self) -> Result<()> { + for rule in &self.rules { + for pattern in &rule.patterns { + build_glob(pattern).map_err(|error| { + GenomeError::Rules(format!( + "rule '{}' has invalid glob pattern '{}': {error}", + rule.id, pattern + )) + })?; + } + } + Ok(()) + } + + /// Put the embedded protected rules ahead of an externally supplied + /// database. First-match-wins makes this ordering a safety boundary: + /// custom rules may add coverage, but cannot shadow the system-critical + /// rules shipped with the application. + pub fn with_embedded_protected_rules(self) -> Self { + let mut rules = RulesDb::embedded() + .rules + .into_iter() + .filter(|rule| rule.verdict == Verdict::Protected) + .collect::>(); + rules.extend(self.rules); + Self::new(self.version, rules) + } + /// First matching rule wins; order in the db is priority order. /// Protected rules are listed first for exactly that reason. pub fn classify(&self, path: &std::path::Path) -> (Category, Verdict, Option<&Rule>) { @@ -222,6 +254,50 @@ mod tests { } } + #[test] + fn invalid_patterns_are_rejected_before_external_use() { + let db = RulesDb::new( + 1, + vec![Rule { + id: "broken".into(), + patterns: vec!["[".into()], + category: Category::Unknown, + verdict: Verdict::Review, + description: "invalid test rule".into(), + }], + ); + + let error = db + .validate() + .expect_err("invalid glob must fail validation"); + assert!(error.to_string().contains("broken")); + assert!(error.to_string().contains("invalid glob pattern")); + } + + #[test] + fn embedded_protected_rules_precede_external_rules() { + let external = RulesDb::new( + 1, + vec![Rule { + id: "unsafe-override".into(), + patterns: vec!["**/*.msi".into()], + category: Category::Installer, + verdict: Verdict::Safe, + description: "must not shadow protected rules".into(), + }], + ) + .with_embedded_protected_rules(); + + let (category, verdict, rule) = + external.classify(std::path::Path::new("/tmp/windows/installer/setup.msi")); + assert_eq!(category, Category::SystemCritical); + assert_eq!(verdict, Verdict::Protected); + assert_eq!( + rule.map(|rule| rule.id.as_str()), + Some("windows-installer-cache") + ); + } + /// Issue #41. Under substring matching every one of these matched a /// rule written for somewhere else on the disk, and `review` is an /// actionable verdict — the app offered to move them.