use keyhog_core::{Chunk, ChunkMetadata, DetectorSpec, PatternSpec, Severity};
use keyhog_scanner::{CompiledScanner, ScanError};
fn detector(regex: &str, group: Option<usize>) -> DetectorSpec {
DetectorSpec {
tests: Vec::new(),
id: "test-detector".into(),
name: "Test Detector".into(),
service: "test".into(),
severity: Severity::High,
patterns: vec![PatternSpec {
regex: regex.into(),
description: None,
group,
required_literals: Vec::new(),
client_safe: false,
weak_anchor: false,
structural_password_slot: false,
}],
companions: vec![],
verify: None,
keywords: vec!["secret".into()],
min_confidence: None,
..keyhog_scanner::testing::named_detector_fixture_defaults()
}
}
fn compiles(regex: &str, group: Option<usize>) -> bool {
CompiledScanner::compile(vec![detector(regex, group)]).is_ok()
}
fn compile_err(regex: &str, group: Option<usize>) -> ScanError {
CompiledScanner::compile(vec![detector(regex, group)])
.err()
.expect("expected compile to fail for an out-of-range capture group")
}
#[test]
fn accepts_no_declared_group() {
assert!(compiles(r"AKIA[0-9A-Z]{16}", None));
}
#[test]
fn accepts_group_zero_whole_match() {
assert!(compiles(r"AKIA[0-9A-Z]{16}", Some(0)));
}
#[test]
fn accepts_group_one_on_a_one_group_regex() {
assert!(compiles(r"secret=(\w+)", Some(1)));
}
#[test]
fn accepts_group_two_on_a_two_group_regex() {
assert!(compiles(r"(\w+)=(\w+)", Some(2)));
}
#[test]
fn accepts_group_at_the_exact_upper_edge() {
assert!(compiles(r"(\w+)-(\w+)", Some(2)));
}
#[test]
fn accepts_named_capture_group() {
assert!(compiles(r"secret=(?P<value>\w+)", Some(1)));
}
#[test]
fn accepts_group_one_after_a_leading_non_capturing_group() {
assert!(compiles(r"(?:secret|token)=(\w+)", Some(1)));
}
#[test]
fn rejects_group_two_on_a_one_group_regex() {
assert!(!compiles(r"secret=(\w+)", Some(2)));
}
#[test]
fn rejects_group_one_on_a_zero_group_regex() {
assert!(!compiles(r"AKIA[0-9A-Z]{16}", Some(1)));
}
#[test]
fn rejects_a_far_out_of_range_group() {
assert!(!compiles(r"(\w+)=(\w+)", Some(5)));
}
#[test]
fn rejects_group_one_past_the_upper_edge() {
assert!(!compiles(r"(\w+)-(\w+)", Some(3)));
}
#[test]
fn rejects_group_equal_to_captures_len() {
assert!(!compiles(r"secret=(\w+)", Some(2)));
}
#[test]
fn rejects_group_when_only_non_capturing_groups_are_present() {
assert!(!compiles(r"(?:secret)(?:token)[0-9]{10}", Some(1)));
}
#[test]
fn error_is_a_corpus_quality_gate_configuration_error() {
let err = compile_err(r"secret=(\w+)", Some(2));
assert!(matches!(err, ScanError::Config(_)));
}
#[test]
fn error_carries_the_declared_group_and_explicit_capture_count() {
let error = compile_err(r"secret=(\w+)", Some(2)).to_string();
assert!(
error.contains("capture group 2 is out of range"),
"declared group must be reported verbatim: {error}"
);
assert!(
error.contains("regex has 1 capture groups (valid group indexes are 0..1)"),
"explicit capture count and valid indexes must be reported: {error}"
);
}
#[test]
fn error_message_names_the_detector_id() {
let err = compile_err(r"secret=(\w+)", Some(2));
assert!(
err.to_string().contains("test-detector"),
"error must name the offending detector: {err}"
);
}
#[test]
fn error_message_includes_a_fix_hint() {
let err = compile_err(r"secret=(\w+)", Some(2));
assert!(
err.to_string().contains("Fix:"),
"error must include a Fix hint: {err}"
);
}
#[test]
fn multi_pattern_detector_reports_the_offending_pattern_index() {
let det = DetectorSpec {
tests: Vec::new(),
id: "multi".into(),
name: "Multi".into(),
service: "test".into(),
severity: Severity::High,
patterns: vec![
PatternSpec {
regex: r"secret=(\w+)".into(),
description: None,
group: Some(1),
required_literals: Vec::new(),
client_safe: false,
weak_anchor: false,
structural_password_slot: false,
},
PatternSpec {
regex: r"token=(\w+)".into(),
description: None,
group: Some(2),
required_literals: Vec::new(),
client_safe: false,
weak_anchor: false,
structural_password_slot: false,
},
],
companions: vec![],
verify: None,
keywords: vec!["secret".into()],
min_confidence: None,
..keyhog_scanner::testing::named_detector_fixture_defaults()
};
let error = CompiledScanner::compile(vec![det])
.err()
.expect("second pattern must fail capture validation")
.to_string();
assert!(
error.contains("pattern 1 capture group 2 is out of range"),
"error must identify the second pattern and invalid group: {error}"
);
}
#[test]
fn embedded_corpus_still_compiles_after_group_validation() {
let detectors =
keyhog_core::load_embedded_detectors_or_fail().expect("embedded corpus must load");
assert!(
CompiledScanner::compile(detectors).is_ok(),
"the group-bound validation must not reject any embedded detector"
);
}
#[test]
fn in_bounds_group_captures_the_group_value_not_the_whole_match() {
let value = "Qz7mWp2RkLx9Vn4Bt6Hs8";
let scanner = CompiledScanner::compile(vec![detector(
r"(?i)secret\s*=\s*([A-Za-z0-9]{20,})",
Some(1),
)])
.expect("valid detector compiles");
let chunk = Chunk {
data: format!("secret = {value}\n").into(),
metadata: ChunkMetadata {
path: Some("config.env".into()),
..Default::default()
},
};
let matches = scanner
.scan(&chunk)
.expect("capture-group behavior scan should succeed");
assert!(
matches.iter().any(|m| m.credential.as_ref() == value),
"expected credential {value:?}, got {:?}",
matches
.iter()
.map(|m| m.credential.as_ref())
.collect::<Vec<_>>()
);
assert!(
!matches
.iter()
.any(|m| m.credential.as_ref().contains("secret =")),
"credential must not include the keyword/separator (whole-match capture)"
);
}