use serde_json::Value;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use tempfile::TempDir;
const AWS_KEY: &str = "AKIAQYLPMN5HFIQR7XYA";
const CUSTOM_TOKEN: &str = "demo_secret_ABCD1234";
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
fn write_detector(directory: &Path, id: &str) {
std::fs::create_dir_all(directory).expect("create detector directory");
let toml = r#"
[detector]
id = "__DETECTOR_ID__"
name = "Custom fixture"
service = "fixture"
severity = "high"
ml = { match_mode = "disabled", entropy_mode = "disabled", weight = 0.0, context_radius_lines = 0 }
match_confidence = { literal_prefix_weight = 0.35, context_anchor_weight = 0.20, entropy_weight = 0.20, high_entropy_partial_weight = 0.12, moderate_entropy_threshold = 3.0, moderate_entropy_weight = 0.05, low_entropy_penalty_floor = 2.0, low_entropy_min_match_length = 10, low_entropy_penalty_multiplier = 0.60, keyword_nearby_weight = 0.10, sensitive_file_weight = 0.10, companion_weight = 0.05, very_high_entropy_margin = 1.3, named_anchor_floor = 0.55, assignment_context_multiplier = 1.0, string_literal_context_multiplier = 0.9, unknown_context_multiplier = 0.8, documentation_context_multiplier = 0.3, comment_context_multiplier = 0.4, test_context_multiplier = 0.3, encrypted_context_multiplier = 0.05, soft_context_suppression_threshold = 0.5, encrypted_context_suppression_threshold = 0.8, post_match = { placeholder_multiplier = 0.05, minimum_byte_diversity = 0.1, low_diversity_multiplier = 0.1, maximum_repeat_ratio = 0.8, degenerate_run_min_length = 10, degenerate_repeat_multiplier = 0.1, fixture_path_multiplier = 0.5, ml_context_reapply_below = 0.95 } }
keywords = ["demo_secret_"]
[[detector.patterns]]
regex = "demo_secret_[A-Z0-9]{8}"
"#
.replace("__DETECTOR_ID__", id);
std::fs::write(directory.join("custom.toml"), toml).expect("write custom detector");
}
fn run_scan(root: &Path, extra: &[&str]) -> Output {
let fixture = root.join("planted.txt");
std::fs::write(
&fixture,
format!("AWS_ACCESS_KEY_ID={AWS_KEY}\ncustom={CUSTOM_TOKEN}\n"),
)
.expect("write scan fixture");
Command::new(binary())
.current_dir(root)
.args([
"scan",
"--daemon=off",
"--backend",
"cpu",
"--format",
"json-envelope",
"--no-entropy",
"--no-decode",
"--threads",
"1",
])
.args(extra)
.arg(&fixture)
.env("HOME", root)
.env("XDG_DATA_HOME", root.join("xdg-data"))
.env("XDG_CACHE_HOME", root.join("xdg-cache"))
.env_remove("KEYHOG_BACKEND")
.output()
.expect("run keyhog scan")
}
fn json(output: &Output) -> Value {
serde_json::from_slice(&output.stdout).unwrap_or_else(|error| {
panic!(
"parse JSON envelope: {error}; stdout={}; stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
})
}
fn finding_ids(report: &Value) -> Vec<&str> {
report["findings"]
.as_array()
.expect("findings array")
.iter()
.filter_map(|finding| finding["detector_id"].as_str())
.collect()
}
fn effective<'a>(report: &'a Value, field: &str) -> &'a str {
report["metadata"]["resolved_scan"]["effective"][field]
.as_str()
.unwrap_or_else(|| panic!("missing effective field {field}: {report}"))
}
/// Regression: an explicit custom directory preserves the historical replace default and never silently merges embedded rules.
#[test]
fn custom_directory_defaults_to_replace() {
let root = TempDir::new().expect("tempdir");
let custom = root.path().join("custom-detectors");
write_detector(&custom, "demo-only");
let output = run_scan(
root.path(),
&["--detectors", custom.to_str().expect("utf8 path")],
);
assert_eq!(
output.status.code(),
Some(1),
"stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let report = json(&output);
let ids = finding_ids(&report);
assert!(ids.contains(&"demo-only"), "report={report}");
assert!(
!ids.contains(&"aws-access-key"),
"replace must exclude embedded detectors: {report}"
);
assert_eq!(effective(&report, "detector_corpus_mode"), "replace");
assert_eq!(effective(&report, "detector_corpus_custom_count"), "1");
assert_eq!(effective(&report, "detector_corpus_embedded_count"), "0");
}
/// Regression: two replace scans with identical normalized detector specs must
/// retain the selected corpus schema in their reported evidence identity.
/// Otherwise a manifest-free schema-1 scan could reuse cache or autoroute
/// evidence produced under schema 3's typed evidence contract.
#[test]
fn reported_digest_distinguishes_legacy_and_current_custom_schema() {
let legacy_root = TempDir::new().expect("legacy tempdir");
let current_root = TempDir::new().expect("current tempdir");
let legacy = legacy_root.path().join("custom-detectors");
let current = current_root.path().join("custom-detectors");
write_detector(&legacy, "demo-only");
write_detector(¤t, "demo-only");
std::fs::write(
current.join("corpus.toml"),
format!(
"schema_version = {}\n",
keyhog_core::DETECTOR_CORPUS_SCHEMA_VERSION
),
)
.expect("write current corpus manifest");
let legacy_output = run_scan(
legacy_root.path(),
&["--detectors", legacy.to_str().expect("legacy path")],
);
let current_output = run_scan(
current_root.path(),
&["--detectors", current.to_str().expect("current path")],
);
assert_eq!(
legacy_output.status.code(),
Some(1),
"legacy stderr={}",
String::from_utf8_lossy(&legacy_output.stderr)
);
assert_eq!(
current_output.status.code(),
Some(1),
"current stderr={}",
String::from_utf8_lossy(¤t_output.stderr)
);
let legacy_report = json(&legacy_output);
let current_report = json(¤t_output);
let legacy_cached_output = run_scan(
legacy_root.path(),
&["--detectors", legacy.to_str().expect("legacy path")],
);
let current_cached_output = run_scan(
current_root.path(),
&["--detectors", current.to_str().expect("current path")],
);
let legacy_cached_report = json(&legacy_cached_output);
let current_cached_report = json(¤t_cached_output);
assert_eq!(
effective(&legacy_report, "detector_corpus_digest"),
effective(&legacy_cached_report, "detector_corpus_digest"),
"legacy schema identity must survive a detector parse-cache hit"
);
assert_eq!(
effective(¤t_report, "detector_corpus_digest"),
effective(¤t_cached_report, "detector_corpus_digest"),
"current schema identity must survive a detector parse-cache hit"
);
assert_eq!(effective(&legacy_report, "detector_corpus_mode"), "replace");
assert_eq!(
effective(¤t_report, "detector_corpus_mode"),
"replace"
);
assert_eq!(finding_ids(&legacy_report), finding_ids(¤t_report));
assert_ne!(
effective(&legacy_report, "detector_corpus_digest"),
effective(¤t_report, "detector_corpus_digest"),
"equal specs under schema 1 and schema 3 need distinct reported identities"
);
}
/// Regression: overlay is an explicit opt-in that reports both embedded and custom findings with effective-corpus identity.
#[test]
fn explicit_overlay_composes_and_reports_json_provenance() {
let root = TempDir::new().expect("tempdir");
let custom = root.path().join("custom-detectors");
write_detector(&custom, "demo-only");
let output = run_scan(
root.path(),
&[
"--detectors",
custom.to_str().expect("utf8 path"),
"--detectors-mode",
"overlay",
],
);
assert_eq!(
output.status.code(),
Some(1),
"stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let report = json(&output);
let ids = finding_ids(&report);
assert!(ids.contains(&"demo-only"), "report={report}");
assert!(
ids.contains(&"aws-access-key"),
"overlay must retain embedded detectors: {report}"
);
assert_eq!(effective(&report, "detector_corpus_mode"), "overlay");
assert!(effective(&report, "detector_corpus_source").starts_with("embedded+"));
let digest = effective(&report, "detector_corpus_digest");
assert_eq!(digest.len(), 64, "effective BLAKE3 digest: {digest}");
}
/// Regression: an overlay cannot shadow an embedded detector ID and must fail before scanning.
#[test]
fn overlay_rejects_embedded_id_collision() {
let root = TempDir::new().expect("tempdir");
let custom = root.path().join("custom-detectors");
write_detector(&custom, "aws-access-key");
let output = run_scan(
root.path(),
&[
"--detectors",
custom.to_str().expect("utf8 path"),
"--detectors-mode",
"overlay",
],
);
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("overlay collides") && stderr.contains("aws-access-key"),
"stderr={stderr}"
);
}
/// Regression: malformed custom TOML is a hard error in composition modes, never a fallback to embedded detectors.
#[test]
fn malformed_custom_corpus_fails_closed() {
let root = TempDir::new().expect("tempdir");
let custom = root.path().join("custom-detectors");
std::fs::create_dir_all(&custom).expect("create detector directory");
std::fs::write(custom.join("broken.toml"), "[detector\nid = ???")
.expect("write malformed detector");
let output = run_scan(
root.path(),
&[
"--detectors",
custom.to_str().expect("utf8 path"),
"--detectors-mode",
"overlay",
],
);
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("broken.toml") || stderr.contains("parse"),
"stderr={stderr}"
);
assert!(
output.stdout.is_empty(),
"failed scan must not emit a clean report"
);
}
/// Regression: omitting custom corpus options uses only embedded detectors and reports that provenance.
#[test]
fn default_scan_uses_embedded_corpus() {
let root = TempDir::new().expect("tempdir");
let output = run_scan(root.path(), &[]);
assert_eq!(
output.status.code(),
Some(1),
"stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let report = json(&output);
assert!(
finding_ids(&report).contains(&"aws-access-key"),
"report={report}"
);
assert_eq!(effective(&report, "detector_corpus_mode"), "embedded");
assert_eq!(effective(&report, "detector_corpus_source"), "embedded");
assert_eq!(effective(&report, "detector_corpus_custom_count"), "0");
}
/// Regression: a composition mode without an explicit custom path must not merge an auto-discovered directory.
#[test]
fn detector_mode_without_custom_path_is_rejected() {
let root = TempDir::new().expect("tempdir");
let output = run_scan(root.path(), &["--detectors-mode", "overlay"]);
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("--detectors-mode requires a custom corpus"),
"stderr={stderr}"
);
assert!(
output.stdout.is_empty(),
"ambiguous scan must not emit a report"
);
}
/// Regression: TOML supplies detector path and overlay mode, while an explicit CLI mode wins after that merge.
#[test]
fn toml_then_cli_mode_precedence_is_explicit() {
let root = TempDir::new().expect("tempdir");
let custom = root.path().join("custom-detectors");
write_detector(&custom, "demo-only");
std::fs::write(
root.path().join(".keyhog.toml"),
"detectors = \"custom-detectors\"\ndetectors_mode = \"overlay\"\n",
)
.expect("write config");
let from_toml = run_scan(root.path(), &[]);
assert_eq!(
from_toml.status.code(),
Some(1),
"stderr={}",
String::from_utf8_lossy(&from_toml.stderr)
);
let toml_report = json(&from_toml);
assert_eq!(effective(&toml_report, "detector_corpus_mode"), "overlay");
assert!(finding_ids(&toml_report).contains(&"aws-access-key"));
let cli_override = run_scan(root.path(), &["--detectors-mode", "replace"]);
assert_eq!(
cli_override.status.code(),
Some(1),
"stderr={}",
String::from_utf8_lossy(&cli_override.stderr)
);
let cli_report = json(&cli_override);
assert_eq!(effective(&cli_report, "detector_corpus_mode"), "replace");
assert!(finding_ids(&cli_report).contains(&"demo-only"));
assert!(!finding_ids(&cli_report).contains(&"aws-access-key"));
}