use super::support;
use support::contracts::test_chunk as make_chunk;
use support::paths::detector_dir;
use std::collections::BTreeSet;
use std::sync::{Mutex, OnceLock};
use keyhog_scanner::CompiledScanner;
static COUNTER_LOCK: Mutex<()> = Mutex::new(());
const DETECTOR_IDS: &[&str] = &[
"asana-pat",
"deepl-api-key",
"stripe-secret-key",
"github-classic-pat",
"slack-bot-token",
"generic-password",
];
fn scanner() -> &'static CompiledScanner {
static SCANNER: OnceLock<CompiledScanner> = OnceLock::new();
SCANNER.get_or_init(|| {
let mut detectors = keyhog_core::load_detectors(&detector_dir()).expect("detectors load");
detectors.retain(|detector| DETECTOR_IDS.contains(&detector.id.as_str()));
for id in DETECTOR_IDS {
assert!(
detectors.iter().any(|detector| detector.id == *id),
"no-candidate gate detector subset missing shipped detector {id}"
);
}
CompiledScanner::compile(detectors).expect("scanner compile")
})
}
type FindingKey = (String, String, usize);
fn finding_keys(scanner: &CompiledScanner, text: &str, path: &str) -> BTreeSet<FindingKey> {
let chunk = make_chunk(text, path);
scanner.clear_fragment_cache();
scanner
.scan(&chunk)
.expect("phase-two zero-work test scan succeeds")
.iter()
.map(|m| {
(
m.detector_id.as_ref().to_string(),
m.credential.as_ref().to_string(),
m.location.offset,
)
})
.collect()
}
const NO_CANDIDATE_TEXT: &str =
"\n \t \n \n \t\t \n \n \n \n \n \t \n \n";
#[test]
fn no_candidate_chunk_does_zero_per_pattern_work() {
let _guard = COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let scanner = support::compile_full_detector_scanner();
scanner.clear_fragment_cache();
crate::engine::phase2_mark_stats_reset();
let keys = finding_keys(&scanner, NO_CANDIDATE_TEXT, "notes.rs");
assert!(
keys.is_empty(),
"no-candidate chunk must produce zero findings; got {keys:?}"
);
let snap = crate::engine::phase2_mark_stats();
let (calls, skips, work) = (snap.calls, snap.gate_skips, snap.perpattern_work);
assert!(
calls >= 1,
"expected mark_matches to be invoked on the direct scan path (calls={calls})"
);
assert_eq!(
work, 0,
"SWE-101 REGRESSION: the phase-2 prefilter did per-pattern work on a \
no-candidate chunk ({work} call(s) entered the expensive body). A fallback \
must NEVER eat runtime on a chunk with no candidate."
);
assert_eq!(
skips, calls,
"every mark_matches call on a no-candidate chunk must hit the combined-gate \
fast path (skips={skips} calls={calls}); a non-skip means the gate was \
absent or did not fire, the per-pattern body ran"
);
}
#[test]
fn always_active_finding_survives_the_gate() {
let _guard = COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let scanner = scanner();
crate::engine::phase2_mark_stats_reset();
let text = "asana = \"1/1234567890123456/0123456789abcdef0123456789abcdef\"\n";
let keys = finding_keys(&scanner, text, "asana.cfg");
let fired = keys.iter().any(|(det, _, _)| det == "asana-pat");
assert!(
fired,
"the always-active asana-pat finding must survive the no-candidate gate; got {keys:?}"
);
let snap = crate::engine::phase2_mark_stats();
let (calls, skips, work) = (snap.calls, snap.gate_skips, snap.perpattern_work);
assert!(calls >= 1, "mark_matches must run (calls={calls})");
assert_eq!(
skips + work,
calls,
"every mark_matches call must be exactly one of skip/work (skips={skips} \
work={work} calls={calls})"
);
}
#[test]
fn deepl_free_key_survives_production_scan() {
let _guard = COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let scanner = scanner();
let text = "7b3e5d8c-1a9f-4e2b-6c8d-3a5e9f1b7c4d:fx\n";
let keys = finding_keys(&scanner, text, "deepl.txt");
assert!(
keys.iter().any(|(det, credential, _)| {
det == "deepl-api-key" && credential == "7b3e5d8c-1a9f-4e2b-6c8d-3a5e9f1b7c4d:fx"
}),
"DeepL uuid:fx finding must survive production routing; got {keys:?}"
);
}
#[test]
fn boolean_admission_honors_homoglyph_ascii_skip() {
let _guard = COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let scanner = scanner();
let prefilter = scanner
.phase2_always_active_prefilter
.as_ref()
.expect("embedded detectors must build the always-active phase-2 prefilter");
let text = "stripe = \"rk_live_2S2FrlCUpmb2ou955jvUlPSH\"\n";
keyhog_scanner::testing::set_phase2_hs(&scanner, Some(false));
keyhog_scanner::testing::set_homoglyph_ascii_skip(&scanner, Some(false));
let fold_tuning = scanner.tuning().resolve();
let fold_path_admits =
prefilter.any_active_match(&scanner.phase2_patterns, text, &fold_tuning, true);
keyhog_scanner::testing::set_homoglyph_ascii_skip(&scanner, Some(true));
let skip_tuning = scanner.tuning().resolve();
let skip_path_admits =
prefilter.any_active_match(&scanner.phase2_patterns, text, &skip_tuning, true);
keyhog_scanner::testing::set_phase2_hs(&scanner, None);
keyhog_scanner::testing::set_homoglyph_ascii_skip(&scanner, None);
assert!(
fold_path_admits,
"control path must prove the ASCII text was admitted only by the folded homoglyph batch"
);
assert!(
!skip_path_admits,
"boolean no-hit admission must mirror mark_matches' proven ASCII homoglyph skip \
instead of over-admitting pure-ASCII chunks through generated variants"
);
}
#[test]
fn gate_is_recall_neutral_findings_byte_identical() {
let _guard = COUNTER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let scanner = scanner();
let corpus = "\
# config with a mix of real secrets and ordinary lines
db_host = \"localhost\"
db_port = 5432
asana = \"1/1234567890123456/0123456789abcdef0123456789abcdef:0123456789abcdef0123456789abcdef\"
note = \"this line is just prose with nothing sensitive at all\"
github = \"ghp_0123456789abcdefghijklmnopqrstuvwxyzAB\"
timeout_seconds = 30
slack = \"xoxb-0000000000-0000000000-abcdefghijklmnopqrstuvwx\"
comment = \"the quick brown fox jumps over the lazy dog repeatedly\"
";
keyhog_scanner::testing::set_no_candidate_gate(&scanner, Some(true));
let on = finding_keys(&scanner, corpus, "mixed.cfg");
keyhog_scanner::testing::set_no_candidate_gate(&scanner, Some(false));
let off = finding_keys(&scanner, corpus, "mixed.cfg");
keyhog_scanner::testing::set_no_candidate_gate(&scanner, None);
assert_eq!(
on, off,
"SWE-101 combined no-candidate gate changed the finding set (recall/precision \
regression). gate-on={on:?}\n gate-off={off:?}"
);
assert!(
!on.is_empty(),
"test corpus must surface at least one finding; got none"
);
}