use std::path::PathBuf;
use std::process::{Command, Output};
fn explain(detector_id: &str) -> Output {
let detectors = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("detectors");
Command::new(env!("CARGO_BIN_EXE_keyhog"))
.args(["explain", detector_id, "--detectors"])
.arg(detectors)
.output()
.unwrap_or_else(|error| panic!("run keyhog explain {detector_id}: {error}"))
}
fn detector_bpe_ceiling(detector_id: &str) -> f64 {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("detectors")
.join(format!("{detector_id}.toml"));
let source = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
let document: toml::Value =
toml::from_str(&source).unwrap_or_else(|error| panic!("parse {}: {error}", path.display()));
document["detector"]["bpe_max_bytes_per_token"]
.as_float()
.unwrap_or_else(|| {
panic!(
"{} must declare detector.bpe_max_bytes_per_token",
path.display()
)
})
}
#[test]
fn explain_generic_secret_prints_detector_owned_entropy_and_bpe_policy() {
let output = explain("generic-secret");
assert_eq!(
output.status.code(),
Some(0),
"explain failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let bpe_ceiling = detector_bpe_ceiling("generic-secret");
for expected in [
"Declared detector policy:".to_string(),
"kind: phase2-generic".to_string(),
"entropy_high: 4.5 bits/byte".to_string(),
"entropy_low: 3 bits/byte".to_string(),
"plausibility:".to_string(),
"symbolic_entropy_floor: 3.5 bits/byte".to_string(),
"second_half_entropy_floor: 2.5 bits/byte".to_string(),
"mixed_alnum_min_len: 20 bytes".to_string(),
"isolated_mixed_entropy_floor: 3.65 bits/byte".to_string(),
"isolated_symbolic_min_len: 18 bytes".to_string(),
"isolated_symbolic_min_symbols: 2".to_string(),
"isolated_symbolic_requires_non_underscore: true".to_string(),
"isolated_alpha_only_min_symbols: 3".to_string(),
"isolated_alpha_only_min_alpha_ratio: 0.5".to_string(),
"min_alnum_ratio: 0.5".to_string(),
"source_type_name_max_len: 40 bytes".to_string(),
"source_type_name_min_uppercase: 2".to_string(),
"url_path_high_entropy_min_len: 41 bytes".to_string(),
"isolated_colon_left_min_len: 20 bytes".to_string(),
"isolated_colon_right_min_len: 16 bytes".to_string(),
"leading_slash_base64_entropy_floor: 4.8 bits/byte".to_string(),
"keyword_free_operator_margin: +1 bits/byte over the resolved Tier-A entropy threshold"
.to_string(),
"reject_source_symbol_identifiers: true".to_string(),
format!("bpe_max_bytes_per_token: {bpe_ceiling} UTF-8 bytes/token"),
"max_len: 512 bytes".to_string(),
"canonical_hex_key_material: lengths=[32, 48] keywords=[secret, private_key, signing_secret] suffixes=[key, secret] excluded_keywords=[license_key]".to_string(),
"canonical_hex_key_material: lengths=[64] keywords=[private_key, signing_secret]"
.to_string(),
"entropy_floor: 2.8 bits/byte through 24 bytes".to_string(),
"entropy_fallback: class=generic id=entropy-generic name=\"Generic High-Entropy Secret\" service=generic".to_string(),
"declared policy owner: [detector] in the loaded detector TOML".to_string(),
"unset optional fields: field defaults or scan policy resolve at scan time; use `config --effective` for scan-fallback/scan-override".to_string(),
] {
assert!(
stdout.contains(&expected),
"explain output is missing {expected:?}:\n{stdout}"
);
}
}
#[test]
fn explain_generic_api_key_prints_transport_and_direct_hex_policy() {
let output = explain("generic-api-key");
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
for expected in [
"decoded_hex_key_material_lengths: 32, 48",
"canonical_hex_key_material: lengths=[32, 48] keywords=[api_key, access_key, secret_key, client_secret, x-api-key, auth_key, signing_key, encryption_key, master_key, session_key, hmac_secret, hmac_seed] suffixes=[key, secret] excluded_keywords=[license_key]",
"canonical_hex_key_material: lengths=[64]",
"entropy_fallback: class=api-key id=entropy-api-key",
] {
assert!(
stdout.contains(expected),
"generic API-key explanation is missing {expected:?}:\n{stdout}"
);
}
}
#[test]
fn explain_distinguishes_declared_detector_policy_from_resolved_scan_fallback() {
let output = explain("123formbuilder-api-key");
assert_eq!(
output.status.code(),
Some(0),
"explain failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout
.contains("ml: match_mode=lift entropy_mode=disabled weight=1 context_radius_lines=5")
&& stdout.contains("declared policy owner: [detector] in the loaded detector TOML"),
"explain must identify the explicit detector-local policy and its owner:\n{stdout}"
);
assert!(
stdout.contains(
"unset optional fields: field defaults or scan policy resolve at scan time; use `config --effective` for scan-fallback/scan-override"
),
"explain must state when values are resolved by scan policy:\n{stdout}"
);
assert!(
!stdout.contains("scan defaults apply"),
"the ambiguous retired label must not hide runtime precedence:\n{stdout}"
);
}
#[test]
fn explain_password_reports_explicit_bpe_disablement() {
let output = explain("generic-password");
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("bpe_enabled: false"),
"password policy must expose explicit BPE disablement:\n{stdout}"
);
assert!(
!stdout.contains("bpe_max_bytes_per_token:"),
"disabled policy must not retain a magic BPE ceiling:\n{stdout}"
);
}
#[test]
fn explain_prints_pattern_required_literals_with_detector_toml_ownership() {
for (detector_id, literal) in [("deepl-api-key", ":fx"), ("url-credentials", "://")] {
let output = explain(detector_id);
assert_eq!(
output.status.code(),
Some(0),
"explain failed for {detector_id}: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(&format!("required_literals [detector TOML]: {literal}")),
"{detector_id} must expose its detector-owned routing literal:\n{stdout}"
);
}
}
#[test]
fn every_generic_entropy_owner_exposes_complete_toml_policy() {
for (detector_id, class) in [
("generic-api-key", "api-key"),
("generic-keyword-secret", "token"),
("generic-password", "password"),
("generic-secret", "generic"),
] {
let output = explain(detector_id);
assert_eq!(
output.status.code(),
Some(0),
"explain failed for {detector_id}: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
for field in [
"entropy_high:",
"entropy_low:",
"entropy_very_high:",
"plausibility:",
"mixed_alnum_floor:",
"symbolic_entropy_floor:",
"second_half_entropy_floor:",
"mixed_alnum_min_len:",
"isolated_mixed_entropy_floor:",
"isolated_symbolic_min_len:",
"isolated_symbolic_min_symbols:",
"isolated_symbolic_requires_non_underscore:",
"isolated_alpha_only_min_symbols:",
"isolated_alpha_only_min_alpha_ratio:",
"min_alnum_ratio:",
"source_type_name_max_len:",
"source_type_name_min_uppercase:",
"url_path_high_entropy_min_len:",
"isolated_colon_left_min_len:",
"isolated_colon_right_min_len:",
"leading_slash_base64_entropy_floor:",
"reject_repeated_blocks:",
"allow_alphabetic_credential:",
"reject_program_identifiers:",
"reject_source_symbol_identifiers:",
"reject_dash_segmented_alnum:",
"entropy_policy_priority:",
] {
assert!(
stdout.contains(field),
"{detector_id} must expose TOML-owned {field} in explain output:\n{stdout}"
);
}
assert!(
stdout.contains("declared policy owner: [detector] in the loaded detector TOML"),
"{detector_id} must identify the detector TOML as policy owner:\n{stdout}"
);
assert!(
stdout.contains(&format!("entropy_fallback: class={class}")),
"{detector_id} must expose its typed entropy-fallback class:\n{stdout}"
);
}
}