Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ All notable changes to Diskern are documented here. The format follows

### Added

- `diskern scan --rules <file>` to test scans with an external rules database
Comment thread
Muawiya-contact marked this conversation as resolved.
- 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
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/diskern-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions crates/diskern-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` | 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.
Expand Down
38 changes: 36 additions & 2 deletions crates/diskern-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -29,6 +29,9 @@ enum Command {
/// Only show findings with this verdict
#[arg(long, value_enum)]
verdict: Option<VerdictFilter>,
/// Load rules from a JSON file instead of the embedded database
#[arg(long, value_name = "FILE")]
rules: Option<PathBuf>,
},
}

Expand Down Expand Up @@ -183,6 +186,28 @@ fn print_findings(findings: &[&Finding], top: usize) {
}
}

fn load_rules(path: Option<&std::path::Path>) -> Result<RulesDb> {
Comment thread
Muawiya-contact marked this conversation as resolved.
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()
)
})?;
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<()> {
let cli = Cli::parse();
match cli.command {
Expand All @@ -191,19 +216,28 @@ fn main() -> Result<()> {
json,
top,
verdict,
rules,
} => {
let external_rules = rules.as_deref();
Comment thread
Muawiya-contact marked this conversation as resolved.
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),
Expand Down
131 changes: 131 additions & 0 deletions crates/diskern-cli/tests/rules_file.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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}");
}

#[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}");
}
78 changes: 77 additions & 1 deletion crates/diskern-core/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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::<Vec<_>>();
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>) {
Expand Down Expand Up @@ -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.
Expand Down
Loading