use super::*;
use keyhog_core::DetectorKind;
use std::collections::BTreeSet;
const RECALL_CRITICAL: &[&str] = &[
"secret",
"password",
"passwd",
"pwd",
"pass",
"token",
"webhook_url",
"webhook-url",
"webhook.url",
"apikey",
"api_key",
"api-key",
"api.key",
"auth",
"authorization",
"auth_token",
"auth-token",
"auth.token",
"auth_key",
"auth-key",
"auth.key",
"credential",
"private_key",
"private-key",
"private.key",
"signing_key",
"signing-key",
"signing.key",
"encryption_key",
"encryption-key",
"encryption.key",
"access_key",
"access-key",
"access.key",
"client_secret",
"client-secret",
"client.secret",
"app_secret",
"app-secret",
"app.secret",
"master_key",
"master-key",
"master.key",
"license_key",
"license-key",
"license.key",
];
fn phase2_generic(keywords: &[&str]) -> DetectorSpec {
DetectorSpec {
id: "generic-test".to_string(),
name: "Generic Test".to_string(),
service: "generic".to_string(),
kind: DetectorKind::Phase2Generic,
keywords: keywords.iter().map(|k| k.to_string()).collect(),
..keyhog_scanner::testing::named_detector_fixture_defaults()
}
}
#[test]
fn recall_critical_set_is_exactly_46_unique_entries() {
assert_eq!(RECALL_CRITICAL.len(), 46);
let unique: BTreeSet<&&str> = RECALL_CRITICAL.iter().collect();
assert_eq!(unique.len(), 46);
}
#[test]
fn derived_vocab_is_a_superset_of_the_recall_critical_46() {
let derived: BTreeSet<&str> = assignment_keywords().iter().map(String::as_str).collect();
let missing: Vec<&str> = RECALL_CRITICAL
.iter()
.copied()
.filter(|kw| !derived.contains(kw))
.collect();
assert!(
missing.is_empty(),
"derived vocab dropped recall-critical keywords: {missing:?}"
);
assert!(assignment_keywords().len() >= RECALL_CRITICAL.len());
}
#[test]
fn extra_derived_keywords_beyond_the_46_are_the_expected_detector_declarations() {
let derived: BTreeSet<&str> = assignment_keywords().iter().map(String::as_str).collect();
for extra in ["secret_key", "access_token", "signing_secret", "passphrase"] {
assert!(derived.contains(extra), "expected extra keyword {extra}");
assert!(
!RECALL_CRITICAL.contains(&extra),
"{extra} should be an EXTRA, not part of the 46"
);
}
}
#[test]
fn accessor_equals_a_fresh_derivation_from_the_embedded_corpus() {
let detectors = keyhog_core::load_embedded_detectors_or_fail().expect("corpus loads");
let fresh = derive_assignment_keywords(&detectors).expect("derivation succeeds");
assert_eq!(fresh.as_slice(), assignment_keywords());
}
#[test]
fn the_legacy_assignment_keywords_toml_is_gone() {
let legacy = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../rules/assignment_keywords.toml");
assert!(
!legacy.exists(),
"rules/assignment_keywords.toml was re-created; the vocabulary has exactly one \
home (the generic phase-2 detector specs)"
);
}
#[test]
fn derive_lowercases_detector_keywords() {
let out = derive_assignment_keywords(&[phase2_generic(&["API_KEY"])]).unwrap();
assert!(out.iter().any(|k| k == "api_key"));
assert!(out.iter().any(|k| k == "api-key"));
assert!(out.iter().any(|k| k == "api.key"));
assert!(!out.iter().any(|k| k == "API_KEY"));
}
#[test]
fn derive_expands_each_compound_into_all_three_separator_spellings() {
let out = derive_assignment_keywords(&[phase2_generic(&["foo_bar"])]).unwrap();
let set: BTreeSet<&str> = out.iter().map(String::as_str).collect();
assert!(set.contains("foo_bar"));
assert!(set.contains("foo-bar"));
assert!(set.contains("foo.bar"));
}
#[test]
fn derive_emits_a_separatorless_keyword_verbatim_once() {
let out = derive_assignment_keywords(&[phase2_generic(&["secret"])]).unwrap();
let occurrences = out.iter().filter(|k| k.as_str() == "secret").count();
assert_eq!(occurrences, 1);
assert!(!out.iter().any(|k| k.contains('-') || k.contains('.')));
}
#[test]
fn derive_includes_non_generic_service_with_explicit_entropy_ownership() {
let mut aws = phase2_generic(&["aws_secret_marker"]);
aws.service = "aws".to_string();
let out = derive_assignment_keywords(&[aws, phase2_generic(&["secret"])]).unwrap();
assert!(out.iter().any(|k| k == "aws_secret_marker"));
}
#[test]
fn derive_excludes_regex_detectors_without_entropy_ownership() {
let mut regex_generic = phase2_generic(&["regex_only_marker"]);
regex_generic.kind = DetectorKind::Regex;
let out = derive_assignment_keywords(&[regex_generic, phase2_generic(&["secret"])]).unwrap();
assert!(!out.iter().any(|k| k == "regex_only_marker"));
}
#[test]
fn derive_includes_regex_detectors_with_entropy_ownership() {
let mut regex_generic = phase2_generic(&["regex_owner_marker"]);
regex_generic.kind = DetectorKind::Regex;
regex_generic.entropy_policy_priority = Some(50);
let out = derive_assignment_keywords(&[regex_generic]).unwrap();
assert!(out.iter().any(|k| k == "regex_owner_marker"));
}
#[test]
fn derive_dedups_a_keyword_shared_across_detectors() {
let out = derive_assignment_keywords(&[
phase2_generic(&["secret", "token"]),
phase2_generic(&["secret", "credential"]),
])
.unwrap();
assert_eq!(out.iter().filter(|k| k.as_str() == "secret").count(), 1);
}
#[test]
fn derive_carries_the_pass_stem_from_its_real_detector_owner() {
let with = derive_assignment_keywords(&[phase2_generic(&["secret", "pass"])]).unwrap();
assert!(with.iter().any(|k| k == "pass"));
let without = derive_assignment_keywords(&[phase2_generic(&["secret"])]).unwrap();
assert!(!without.iter().any(|k| k == "pass"));
let corpus = keyhog_core::load_embedded_detectors_or_fail().expect("embedded corpus");
let shipped = derive_assignment_keywords(&corpus).unwrap();
assert!(
shipped.iter().any(|k| k == "pass"),
"generic-keyword-secret lost its `pass` keyword; *_PASS= recall would silently drop"
);
}
#[test]
fn derive_fails_closed_when_no_generic_entropy_owner_is_present() {
assert!(derive_assignment_keywords(&[]).is_err());
let mut regex_generic = phase2_generic(&["password"]);
regex_generic.kind = DetectorKind::Regex;
let err = derive_assignment_keywords(&[regex_generic]).unwrap_err();
assert!(err.contains("entropy-policy owner"), "got: {err}");
}
#[test]
fn separator_spellings_expands_a_single_separator_in_underscore_hyphen_dot_order() {
assert_eq!(
separator_spellings("api_key"),
vec!["api_key", "api-key", "api.key"]
);
}
#[test]
fn separator_spellings_substitutes_every_separator_uniformly() {
assert_eq!(
separator_spellings("x-api-key"),
vec!["x_api_key", "x-api-key", "x.api.key"]
);
}
#[test]
fn separator_spellings_returns_a_separatorless_keyword_unchanged() {
assert_eq!(separator_spellings("secret"), vec!["secret"]);
}
#[test]
fn all_keywords_are_lowercase() {
for keyword in assignment_keywords() {
assert_eq!(
keyword,
&keyword.to_ascii_lowercase(),
"not lowercase: {keyword}"
);
}
}
#[test]
fn all_keywords_are_ascii() {
for keyword in assignment_keywords() {
assert!(keyword.is_ascii(), "non-ascii keyword: {keyword}");
}
}
#[test]
fn keywords_are_nonempty() {
assert!(!assignment_keywords().is_empty());
for keyword in assignment_keywords() {
assert!(!keyword.is_empty());
}
}
#[test]
fn no_duplicate_keywords() {
let mut seen = BTreeSet::new();
for keyword in assignment_keywords() {
assert!(seen.insert(keyword), "duplicate keyword {keyword}");
}
}
#[test]
fn compound_keys_ship_all_three_separator_spellings() {
let set: BTreeSet<&str> = assignment_keywords().iter().map(String::as_str).collect();
for (u, h, d) in [
("api_key", "api-key", "api.key"),
("private_key", "private-key", "private.key"),
("client_secret", "client-secret", "client.secret"),
("master_key", "master-key", "master.key"),
("license_key", "license-key", "license.key"),
] {
assert!(set.contains(u), "missing {u}");
assert!(set.contains(h), "missing {h}");
assert!(set.contains(d), "missing {d}");
}
}
#[test]
fn bare_pass_keyword_is_present() {
assert!(assignment_keywords().iter().any(|k| k == "pass"));
}
#[test]
fn bare_key_stem_is_not_in_the_base_list() {
assert!(
!assignment_keywords().iter().any(|k| k == "key"),
"the bare `key` stem must stay a consumer-side addition, not the base list"
);
}
#[test]
fn ac_built_from_derived_vocab_matches_case_insensitively() {
let ac = aho_corasick::AhoCorasick::builder()
.ascii_case_insensitive(true)
.build(assignment_keywords().iter().map(String::as_str))
.expect("AC builds from the derived keywords");
assert!(
ac.find(b"SECRET=hunter2").is_some(),
"case-insensitive secret"
);
assert!(ac.find(b"api_key: xyz").is_some(), "underscore spelling");
assert!(ac.find(b"API.KEY=xyz").is_some(), "dotted + upper spelling");
assert!(
ac.find(b"just some random text").is_none(),
"a non-keyword line must not trigger the prefilter"
);
}