use std::sync::Arc;
#[cfg(feature = "parallel")]
use rayon::prelude::*;
use crate::{
RuleMetadata,
candidate_detection::detect_sensitive_candidates,
compiled_rule::{CompiledRuleMetadata, CompiledRuleSet},
finding::Finding,
location::Location,
scan_entry::ScanEntry,
scan_report::ScanReport,
scan_results::ScanResults,
scanner_builder::ScannerBuilder,
validators::dispatch::validate_candidate,
};
#[derive(Debug, Clone)]
pub struct Scanner {
rules: Arc<CompiledRuleSet>,
}
#[derive(Debug, Copy, Clone)]
struct AcceptedCandidate<'a> {
metadata: &'a CompiledRuleMetadata,
start: usize,
end: usize,
confidence: crate::Confidence,
}
#[cfg(test)]
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
struct ScanDiagnostics {
raw_candidates: usize,
accepted_candidates: usize,
normalized_candidates: usize,
findings: usize,
}
#[cfg(test)]
impl ScanDiagnostics {
const fn rejected_candidates(self) -> usize {
self.raw_candidates - self.accepted_candidates
}
const fn collapsed_candidates(self) -> usize {
self.accepted_candidates - self.normalized_candidates
}
}
impl Scanner {
pub(crate) fn new(rules: Arc<CompiledRuleSet>) -> Self {
Self { rules }
}
#[must_use]
pub const fn builder() -> ScannerBuilder {
ScannerBuilder::new()
}
#[must_use]
pub fn rules_count(&self) -> usize {
self.rules.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
#[must_use]
pub fn scan<'a, K, I>(&self, inputs: I) -> ScanResults<K>
where
I: IntoIterator<Item = (K, &'a str)>,
{
ScanResults::new(
inputs
.into_iter()
.map(|(key, source)| ScanEntry::new(key, source.len(), self.scan_source(source)))
.collect(),
)
}
#[cfg(feature = "parallel")]
#[must_use]
pub fn parallel_scan<'a, K, I>(&self, inputs: I) -> ScanResults<K>
where
K: Send,
I: IntoIterator<Item = (K, &'a str)>,
{
let inputs = inputs.into_iter().collect::<Vec<_>>();
ScanResults::new(
inputs
.into_par_iter()
.map(|(key, source)| ScanEntry::new(key, source.len(), self.scan_source(source)))
.collect(),
)
}
fn scan_source(&self, source: &str) -> ScanReport {
let mut raw_candidates = Vec::new();
self.rules.scan(source, &mut raw_candidates);
let mut accepted = Vec::with_capacity(raw_candidates.len());
for candidate in raw_candidates {
let metadata = self.rules.metadata(candidate.rule_index());
let Some(validation) = validate_candidate(
metadata.validator(),
source,
candidate.start()..candidate.end(),
metadata.confidence(),
) else {
continue;
};
accepted.push(AcceptedCandidate {
metadata,
start: candidate.start(),
end: candidate.end(),
confidence: validation.confidence(),
});
}
normalize_candidates(&mut accepted);
let mut findings = Vec::with_capacity(accepted.len());
let mut cursor = 0;
let mut line = 1;
let mut column = 1;
for candidate in accepted {
advance_position(source, &mut cursor, candidate.start, &mut line, &mut column);
let mut location = Location::from_span(candidate.start, candidate.end);
location.set_position(line, column);
findings.push(Finding::new(
candidate.metadata.id().clone(),
location,
candidate.metadata.severity(),
candidate.confidence,
candidate.metadata.remediation(),
));
}
let mut candidates = detect_sensitive_candidates(source);
candidates.retain(|candidate| {
findings
.iter()
.all(|finding| !spans_overlap(candidate.location(), finding.location()))
});
ScanReport::new_with_candidates(findings, candidates)
}
#[cfg(test)]
fn diagnostics(&self, source: &str) -> ScanDiagnostics {
let mut raw_candidates = Vec::new();
self.rules.scan(source, &mut raw_candidates);
let raw_count = raw_candidates.len();
let mut accepted = Vec::with_capacity(raw_count);
for candidate in raw_candidates {
let metadata = self.rules.metadata(candidate.rule_index());
let Some(validation) = validate_candidate(
metadata.validator(),
source,
candidate.start()..candidate.end(),
metadata.confidence(),
) else {
continue;
};
accepted.push(AcceptedCandidate {
metadata,
start: candidate.start(),
end: candidate.end(),
confidence: validation.confidence(),
});
}
let accepted_count = accepted.len();
normalize_candidates(&mut accepted);
let normalized_count = accepted.len();
ScanDiagnostics {
raw_candidates: raw_count,
accepted_candidates: accepted_count,
normalized_candidates: normalized_count,
findings: normalized_count,
}
}
pub fn rule_metadata(&self) -> impl ExactSizeIterator<Item = RuleMetadata<'_>> + '_ {
self.rules.public_metadata()
}
}
impl Default for Scanner {
fn default() -> Self {
crate::builtins::current_scanner()
}
}
fn normalize_candidates(candidates: &mut Vec<AcceptedCandidate<'_>>) {
candidates.sort_unstable_by(|left, right| {
left.start
.cmp(&right.start)
.then_with(|| left.end.cmp(&right.end))
.then_with(|| right.metadata.priority().cmp(&left.metadata.priority()))
.then_with(|| right.confidence.cmp(&left.confidence))
.then_with(|| right.metadata.severity().cmp(&left.metadata.severity()))
.then_with(|| {
left.metadata
.id()
.as_str()
.cmp(right.metadata.id().as_str())
})
});
let mut current_span = None;
let mut highest_priority = 0;
candidates.retain(|candidate| {
let span = (candidate.start, candidate.end);
if current_span != Some(span) {
current_span = Some(span);
highest_priority = candidate.metadata.priority();
return true;
}
candidate.metadata.priority() == highest_priority
});
}
fn spans_overlap(left: &Location, right: &Location) -> bool {
left.start() < right.end() && right.start() < left.end()
}
fn advance_position(
source: &str,
cursor: &mut usize,
target: usize,
line: &mut usize,
column: &mut usize,
) {
debug_assert!(target >= *cursor);
debug_assert!(source.is_char_boundary(*cursor));
debug_assert!(source.is_char_boundary(target));
for character in source[*cursor..target].chars() {
if character == '\n' {
*line += 1;
*column = 1;
} else {
*column += 1;
}
}
*cursor = target;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Confidence, Rule, Severity, validators::dispatch::ValidatorKind};
const DENSE_DIAGNOSTIC_SIZE: usize = 64 * 1_024;
fn repeat_to_size(block: &str, size: usize) -> String {
let mut source = String::with_capacity(size + block.len());
while source.len() < size {
source.push_str(block);
}
source.truncate(size);
source
}
fn dense_diagnostic_source() -> String {
const BLOCK: &str = concat!(
"GITHUB_TOKEN=ghp_AbCdEf0123456789_AbCdEf0123456789\n",
"STRIPE_SECRET_KEY=sk_live_AbCdEf0123456789_AbCdEf0123456789\n",
"AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n",
"POSTGRES_PASSWORD=CorrectHorseBatteryStaple!\n",
);
repeat_to_size(BLOCK, DENSE_DIAGNOSTIC_SIZE)
}
#[test]
fn empty_builder_has_no_rules_or_findings() {
let scanner = Scanner::builder().build().unwrap();
assert!(scanner.is_empty());
assert_eq!(scanner.rules_count(), 0);
assert!(scanner.scan_source("anything").findings().is_empty());
}
#[test]
fn default_scanner_contains_builtin_rules() {
let scanner = Scanner::default();
assert!(!scanner.is_empty());
assert!(scanner.rules_count() > 0);
}
#[test]
fn location_columns_count_unicode_scalars() {
let scanner = Scanner::builder()
.rule(Rule::literal("token", "secret", Severity::High))
.build()
.expect("scanner should compile");
let report = scanner.scan_source("😀 secret");
let location = report.findings()[0].location();
assert_eq!(location.start(), 5);
assert_eq!(location.end(), 11);
assert_eq!(location.line(), 1);
assert_eq!(location.column(), 3);
}
#[test]
fn specialized_validator_rejects_invalid_candidate() {
let scanner = Scanner::builder()
.rule(
Rule::prefix("github", "ghp_", Severity::Critical)
.with_validator(ValidatorKind::GitHub),
)
.build()
.expect("scanner should compile");
let report = scanner.scan_source("GITHUB_TOKEN=ghp_your_token_here");
assert!(report.is_empty());
}
#[test]
fn specialized_validator_accepts_and_overrides_confidence() {
let token = "ghp_AbCdEf0123456789_AbCdEf0123456789";
let scanner = Scanner::builder()
.rule(
Rule::prefix("github", "ghp_", Severity::Critical)
.with_validator(ValidatorKind::GitHub),
)
.build()
.expect("scanner should compile");
let report = scanner.scan_source(&format!("GITHUB_TOKEN={token}"));
assert_eq!(report.len(), 1);
assert_eq!(report.findings()[0].confidence(), Confidence::High);
}
#[test]
fn unvalidated_custom_rule_preserves_existing_behavior() {
let scanner = Scanner::builder()
.rule(Rule::literal("custom", "custom-value", Severity::Medium))
.build()
.expect("scanner should compile");
assert_eq!(scanner.scan_source("custom-value").len(), 1);
}
#[test]
fn distinct_rules_with_identical_spans_are_preserved() {
let scanner = Scanner::builder()
.rule(Rule::literal("first", "secret", Severity::High))
.rule(Rule::literal("second", "secret", Severity::High))
.build()
.expect("scanner should compile");
let report = scanner.scan_source("secret");
assert_eq!(report.len(), 2);
assert_eq!(report.findings()[0].rule_id().as_str(), "first");
assert_eq!(report.findings()[1].rule_id().as_str(), "second");
}
#[test]
fn provider_specific_rule_wins_exact_span_collision() {
let token = "ghp_AbCdEf0123456789_AbCdEf0123456789";
let scanner = Scanner::builder()
.rule(Rule::prefix("generic", "ghp_", Severity::Critical))
.rule(
Rule::prefix("github", "ghp_", Severity::Critical)
.with_validator(ValidatorKind::GitHub),
)
.build()
.expect("scanner should compile");
let report = scanner.scan_source(token);
assert_eq!(report.len(), 1);
assert_eq!(report.findings()[0].rule_id().as_str(), "github");
}
#[test]
fn partially_overlapping_spans_are_preserved() {
let scanner = Scanner::builder()
.rule(Rule::literal("whole", "secret-value", Severity::High))
.rule(Rule::literal("part", "secret", Severity::Medium))
.build()
.expect("scanner should compile");
let report = scanner.scan_source("secret-value");
assert_eq!(report.len(), 2);
}
#[test]
fn emits_ambiguous_candidates_separately_from_findings() {
let scanner = Scanner::default();
let report = scanner.scan_source("ABCD-EFGH-IJKL-MNOP");
assert!(report.findings().is_empty());
assert_eq!(report.candidate_len(), 1);
assert!(report.needs_review());
assert_eq!(
report.candidates()[0].kind(),
crate::SensitiveCandidateKind::RecoveryLikeCode
);
}
#[test]
fn finding_suppresses_overlapping_ambiguous_candidate() {
let scanner = Scanner::builder()
.rule(Rule::literal(
"known-recovery-code",
"ABCD-EFGH-IJKL-MNOP",
Severity::Critical,
))
.build()
.expect("scanner should compile");
let report = scanner.scan_source("ABCD-EFGH-IJKL-MNOP");
assert_eq!(report.findings().len(), 1);
assert!(report.candidates().is_empty());
}
#[test]
fn dense_fixture_diagnostics() {
let scanner = Scanner::default();
let source = dense_diagnostic_source();
let diagnostics = scanner.diagnostics(&source);
let report = scanner.scan_source(&source);
println!(
"dense diagnostics: bytes={}, raw={}, accepted={}, rejected={}, normalized={}, collapsed={}, findings={}",
source.len(),
diagnostics.raw_candidates,
diagnostics.accepted_candidates,
diagnostics.rejected_candidates(),
diagnostics.normalized_candidates,
diagnostics.collapsed_candidates(),
diagnostics.findings,
);
assert_eq!(diagnostics.findings, report.len());
assert!(diagnostics.raw_candidates >= diagnostics.accepted_candidates);
assert!(diagnostics.accepted_candidates >= diagnostics.normalized_candidates);
assert!(diagnostics.findings > 0);
}
#[test]
fn exposes_metadata_for_compiled_builtin_rules() {
let scanner = Scanner::default();
let metadata = scanner.rule_metadata().collect::<Vec<_>>();
assert_eq!(scanner.rules_count(), metadata.len());
assert!(!metadata.is_empty());
assert!(metadata.iter().all(|metadata| !metadata.id().is_empty()));
}
#[test]
fn exposes_metadata_for_custom_rules() {
let scanner = Scanner::builder()
.rules([
crate::Rule::literal("literal", "secret", crate::Severity::High),
crate::Rule::pattern(
"pattern",
r#"token_[A-Za-z0-9]+"#,
crate::Severity::Critical,
)
.expect("pattern should compile"),
])
.build()
.expect("scanner should build");
let metadata = scanner.rule_metadata().collect::<Vec<_>>();
assert_eq!(metadata.len(), 2);
assert_eq!(metadata[0].id(), "literal");
assert_eq!(metadata[0].kind(), crate::RuleKind::Literal);
assert_eq!(metadata[1].id(), "pattern");
assert_eq!(metadata[1].kind(), crate::RuleKind::Pattern);
}
}