use crate::category::Category;
use crate::error::{Result, SanitizeError};
use crate::store::MappingStore;
use aho_corasick::AhoCorasick;
use regex::bytes::{Regex, RegexBuilder, RegexSet, RegexSetBuilder};
use serde::Serialize;
use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::sync::Arc;
const DEFAULT_CHUNK_SIZE: usize = 1024 * 1024;
const DEFAULT_OVERLAP_SIZE: usize = 4096;
const REGEX_SIZE_LIMIT: usize = 1 << 20;
const REGEX_DFA_SIZE_LIMIT: usize = 1 << 20;
const REGEX_SET_SIZE_CAP: usize = 256 * 1024 * 1024;
const DEFAULT_MAX_PATTERNS: usize = 10_000;
const OVERLONG_MARKER: &[u8] = b"__SANITIZED_OVERLONG__";
const OVERLONG_LABEL: &str = "overlong-redacted";
pub const KV_LABEL_SUFFIX: &str = "_kv";
pub const MIN_DISCOVERED_LITERAL_LEN: usize = 4;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ScanConfig {
pub chunk_size: usize,
pub overlap_size: usize,
}
impl Default for ScanConfig {
fn default() -> Self {
Self {
chunk_size: DEFAULT_CHUNK_SIZE,
overlap_size: DEFAULT_OVERLAP_SIZE,
}
}
}
impl ScanConfig {
#[must_use]
pub fn new(chunk_size: usize, overlap_size: usize) -> Self {
Self {
chunk_size,
overlap_size,
}
}
pub fn validate(&self) -> Result<()> {
if self.chunk_size == 0 {
return Err(SanitizeError::InvalidConfig(
"chunk_size must be > 0".into(),
));
}
if self.overlap_size >= self.chunk_size {
return Err(SanitizeError::InvalidConfig(
"overlap_size must be < chunk_size".into(),
));
}
Ok(())
}
}
#[inline]
fn compile_err(e: impl std::fmt::Display) -> SanitizeError {
SanitizeError::PatternCompileError(e.to_string())
}
pub struct ScanPattern {
regex: Regex,
category: Category,
label: String,
literal: Option<String>,
pub min_length: usize,
pub max_length: usize,
word_boundary: bool,
}
impl std::fmt::Debug for ScanPattern {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ScanPattern")
.field("pattern", &self.regex.as_str())
.field("category", &self.category)
.field("label", &self.label)
.field("literal", &self.literal.as_deref())
.field("min_length", &self.min_length)
.field("max_length", &self.max_length)
.field("word_boundary", &self.word_boundary)
.finish()
}
}
impl Clone for ScanPattern {
fn clone(&self) -> Self {
Self {
regex: self.regex.clone(),
category: self.category.clone(),
label: self.label.clone(),
literal: self.literal.clone(),
min_length: self.min_length,
max_length: self.max_length,
word_boundary: self.word_boundary,
}
}
}
impl ScanPattern {
pub fn from_regex(pattern: &str, category: Category, label: impl Into<String>) -> Result<Self> {
let regex = RegexBuilder::new(pattern)
.size_limit(REGEX_SIZE_LIMIT)
.dfa_size_limit(REGEX_DFA_SIZE_LIMIT)
.build()
.map_err(compile_err)?;
Ok(Self {
regex,
category,
label: label.into(),
literal: None,
min_length: 0,
max_length: usize::MAX,
word_boundary: false,
})
}
#[must_use]
pub fn with_length_bounds(mut self, min: usize, max: usize) -> Self {
self.min_length = min;
self.max_length = max;
self
}
pub fn from_literal(
literal: &str,
category: Category,
label: impl Into<String>,
) -> Result<Self> {
let escaped = regex::escape(literal);
let regex = RegexBuilder::new(&escaped)
.size_limit(REGEX_SIZE_LIMIT)
.dfa_size_limit(REGEX_DFA_SIZE_LIMIT)
.build()
.map_err(compile_err)?;
let word_boundary = matches!(category, Category::Name);
Ok(Self {
regex,
category,
label: label.into(),
min_length: literal.len(),
max_length: usize::MAX,
literal: Some(literal.to_owned()),
word_boundary,
})
}
#[must_use]
pub fn category(&self) -> &Category {
&self.category
}
#[must_use]
pub fn label(&self) -> &str {
&self.label
}
#[must_use]
pub fn regex_pattern(&self) -> &str {
self.regex.as_str()
}
}
#[inline]
fn word_bounded(window: &[u8], start: usize, end: usize) -> bool {
let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
let before_ok = start == 0 || !is_word(window[start - 1]);
let after_ok = end >= window.len() || !is_word(window[end]);
before_ok && after_ok
}
#[derive(Debug, Clone, Copy)]
struct RawMatch {
start: usize,
end: usize,
pattern_idx: usize,
capture: Option<(usize, usize)>,
}
struct ScanScratch {
all_matches: Vec<RawMatch>,
selected: Vec<RawMatch>,
output: Vec<u8>,
pattern_counts: Vec<u64>,
}
struct WindowOutcome {
commit_point: usize,
overlong: bool,
}
impl ScanScratch {
fn new(pattern_count: usize, chunk_size: usize, overlap_size: usize) -> Self {
Self {
all_matches: Vec::with_capacity(64),
selected: Vec::with_capacity(64),
output: Vec::with_capacity(chunk_size + overlap_size),
pattern_counts: vec![0u64; pattern_count],
}
}
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct MatchLocation {
pub line: u64,
pub byte_offset: u64,
pub pattern: String,
}
#[derive(Debug, Clone, Default, PartialEq)]
#[non_exhaustive]
pub struct ScanStats {
pub bytes_processed: u64,
pub bytes_output: u64,
pub matches_found: u64,
pub replacements_applied: u64,
pub pattern_counts: HashMap<String, u64>,
}
#[derive(Debug, Clone, Default, Eq, PartialEq)]
#[non_exhaustive]
pub struct ScanProgress {
pub bytes_processed: u64,
pub bytes_output: u64,
pub total_bytes: Option<u64>,
pub matches_found: u64,
pub replacements_applied: u64,
}
pub struct StreamScanner {
patterns: Vec<ScanPattern>,
regex_set: RegexSet,
regex_indices: Vec<usize>,
aho_corasick: Option<AhoCorasick>,
literal_indices: Vec<usize>,
store: Arc<MappingStore>,
config: ScanConfig,
}
#[must_use = "use allow_patterns to build an AllowlistMatcher; check warnings for skipped patterns"]
#[non_exhaustive]
pub struct SecretsLoadResult {
pub scanner: StreamScanner,
pub warnings: Vec<(usize, SanitizeError)>,
pub allow_patterns: Vec<String>,
}
impl StreamScanner {
pub fn new(
patterns: Vec<ScanPattern>,
store: Arc<MappingStore>,
config: ScanConfig,
) -> Result<Self> {
Self::new_with_max_patterns(patterns, store, config, DEFAULT_MAX_PATTERNS)
}
pub fn new_with_max_patterns(
patterns: Vec<ScanPattern>,
store: Arc<MappingStore>,
config: ScanConfig,
max_patterns: usize,
) -> Result<Self> {
config.validate()?;
if patterns.len() > max_patterns {
return Err(SanitizeError::InvalidConfig(format!(
"pattern count ({}) exceeds maximum allowed ({}) — \
RegexSet memory scales linearly with pattern count",
patterns.len(),
max_patterns
)));
}
let mut literal_bytes: Vec<Vec<u8>> = Vec::new();
let mut literal_indices: Vec<usize> = Vec::new();
let mut regex_strs: Vec<&str> = Vec::new();
let mut regex_indices: Vec<usize> = Vec::new();
for (i, pattern) in patterns.iter().enumerate() {
if let Some(lit) = &pattern.literal {
literal_bytes.push(lit.as_bytes().to_vec());
literal_indices.push(i);
} else {
regex_strs.push(pattern.regex_pattern());
regex_indices.push(i);
}
}
let aho_corasick = if literal_bytes.is_empty() {
None
} else {
Some(AhoCorasick::new(&literal_bytes).map_err(compile_err)?)
};
let regex_set = if regex_strs.is_empty() {
RegexSetBuilder::new(Vec::<&str>::new())
.size_limit(REGEX_SIZE_LIMIT)
.dfa_size_limit(REGEX_DFA_SIZE_LIMIT)
.build()
.map_err(compile_err)?
} else {
RegexSetBuilder::new(®ex_strs)
.size_limit((REGEX_SIZE_LIMIT * regex_strs.len().max(1)).min(REGEX_SET_SIZE_CAP))
.dfa_size_limit(
(REGEX_DFA_SIZE_LIMIT * regex_strs.len().max(1)).min(REGEX_SET_SIZE_CAP),
)
.build()
.map_err(compile_err)?
};
Ok(Self {
patterns,
regex_set,
regex_indices,
aho_corasick,
literal_indices,
store,
config,
})
}
pub fn with_extra_literals(&self, extra: Vec<ScanPattern>) -> Result<Self> {
let mut patterns = self.patterns.clone();
patterns.extend(extra);
Self::new(patterns, Arc::clone(&self.store), self.config.clone())
}
pub fn for_structured_pass(&self, extra: Vec<ScanPattern>) -> Result<Self> {
let mut patterns: Vec<ScanPattern> = self
.patterns
.iter()
.filter(|p| !p.label.ends_with(KV_LABEL_SUFFIX))
.cloned()
.collect();
patterns.extend(extra);
Self::new(patterns, Arc::clone(&self.store), self.config.clone())
}
pub fn scan_reader<R: Read, W: Write>(&self, reader: R, writer: W) -> Result<ScanStats> {
self.scan_reader_with_callbacks(reader, writer, None, |_| {}, |_| {})
}
pub fn scan_reader_with_progress<R: Read, W: Write, F>(
&self,
reader: R,
writer: W,
total_bytes: Option<u64>,
on_progress: F,
) -> Result<ScanStats>
where
F: FnMut(&ScanProgress),
{
self.scan_reader_with_callbacks(reader, writer, total_bytes, on_progress, |_| {})
}
pub fn scan_reader_with_callbacks<R: Read, W: Write, F, M>(
&self,
mut reader: R,
mut writer: W,
total_bytes: Option<u64>,
mut on_progress: F,
mut on_match: M,
) -> Result<ScanStats>
where
F: FnMut(&ScanProgress),
M: FnMut(MatchLocation),
{
let mut stats = ScanStats::default();
let mut carry: Vec<u8> = Vec::new();
let mut read_buf = vec![0u8; self.config.chunk_size];
let mut window: Vec<u8> =
Vec::with_capacity(self.config.chunk_size + self.config.overlap_size);
let mut scratch = ScanScratch::new(
self.patterns.len(),
self.config.chunk_size,
self.config.overlap_size,
);
let mut window_file_offset: u64 = 0;
let mut newlines_before_window: u64 = 0;
let mut consuming_overlong = false;
loop {
let bytes_read = read_fully(&mut reader, &mut read_buf)?;
let is_eof = bytes_read < read_buf.len();
stats.bytes_processed += bytes_read as u64;
if bytes_read == 0 && carry.is_empty() {
break;
}
window.clear();
window.extend_from_slice(&carry);
window.extend_from_slice(&read_buf[..bytes_read]);
if window.is_empty() {
break;
}
if consuming_overlong {
if let Some(pos) = window.iter().position(u8::is_ascii_whitespace) {
consuming_overlong = false;
window_file_offset += pos as u64;
window.drain(..pos);
if window.is_empty() {
if is_eof {
break;
}
carry.clear();
continue;
}
} else {
window_file_offset += window.len() as u64;
carry.clear();
if is_eof {
break;
}
continue;
}
}
let outcome = self.process_committed_window(
&window,
is_eof,
&mut scratch,
&mut writer,
&mut stats,
window_file_offset,
newlines_before_window,
&mut on_match,
)?;
let commit_point = outcome.commit_point;
newlines_before_window += count_newlines(&window[..commit_point]);
window_file_offset += commit_point as u64;
self.fold_chunk_counts(&mut scratch.pattern_counts, &mut stats);
on_progress(&ScanProgress {
bytes_processed: stats.bytes_processed,
bytes_output: stats.bytes_output,
total_bytes,
matches_found: stats.matches_found,
replacements_applied: stats.replacements_applied,
});
if outcome.overlong {
window_file_offset += (window.len() - commit_point) as u64;
consuming_overlong = true;
carry.clear();
continue;
}
if is_eof {
carry.clear();
break;
}
carry.clear();
carry.extend_from_slice(&window[commit_point..]);
}
Ok(stats)
}
#[allow(clippy::too_many_arguments)]
fn process_committed_window(
&self,
window: &[u8],
is_eof: bool,
scratch: &mut ScanScratch,
writer: &mut dyn io::Write,
stats: &mut ScanStats,
window_file_offset: u64,
newlines_before_window: u64,
on_match: &mut dyn FnMut(MatchLocation),
) -> Result<WindowOutcome> {
self.find_matches(window, scratch);
let base_commit = if is_eof {
window.len()
} else {
window.len().saturating_sub(self.config.overlap_size)
};
let commit_point =
self.adjusted_commit_point(&scratch.selected, base_commit, window.len(), is_eof);
let max_carry = self.config.chunk_size;
let overlong = !is_eof && window.len().saturating_sub(commit_point) > max_carry;
self.apply_replacements(
&window[..commit_point],
&scratch.selected,
stats,
&mut scratch.output,
&mut scratch.pattern_counts,
window_file_offset,
newlines_before_window,
on_match,
)?;
writer.write_all(&scratch.output)?;
stats.bytes_output += scratch.output.len() as u64;
if overlong {
writer.write_all(OVERLONG_MARKER)?;
stats.bytes_output += OVERLONG_MARKER.len() as u64;
stats.matches_found += 1;
stats.replacements_applied += 1;
on_match(MatchLocation {
line: newlines_before_window + count_newlines(&window[..commit_point]) + 1,
byte_offset: window_file_offset + commit_point as u64,
pattern: OVERLONG_LABEL.to_string(),
});
}
Ok(WindowOutcome {
commit_point,
overlong,
})
}
fn fold_chunk_counts(&self, counts: &mut [u64], stats: &mut ScanStats) {
for (idx, count) in counts.iter_mut().enumerate() {
if *count > 0 {
*stats
.pattern_counts
.entry(self.patterns[idx].label.clone())
.or_insert(0) += *count;
*count = 0;
}
}
}
pub fn scan_bytes(&self, input: &[u8]) -> Result<(Vec<u8>, ScanStats)> {
self.scan_bytes_with_progress(input, |_| {})
}
pub fn scan_bytes_with_progress<F>(
&self,
input: &[u8],
on_progress: F,
) -> Result<(Vec<u8>, ScanStats)>
where
F: FnMut(&ScanProgress),
{
let mut output = Vec::with_capacity(input.len());
let stats = self.scan_reader_with_callbacks(
input,
&mut output,
Some(input.len() as u64),
on_progress,
|_| {},
)?;
Ok((output, stats))
}
#[must_use]
pub fn config(&self) -> &ScanConfig {
&self.config
}
#[must_use]
pub fn store(&self) -> &Arc<MappingStore> {
&self.store
}
#[must_use]
pub fn pattern_count(&self) -> usize {
self.patterns.len()
}
pub fn from_encrypted_secrets(
encrypted_bytes: &[u8],
password: &str,
format: Option<crate::secrets::SecretsFormat>,
store: Arc<MappingStore>,
config: ScanConfig,
extra_patterns: Vec<ScanPattern>,
) -> Result<SecretsLoadResult> {
let ((mut patterns, warnings), allow_patterns) =
crate::secrets::load_encrypted_secrets(encrypted_bytes, password, format)?;
patterns.extend(extra_patterns);
let scanner = Self::new(patterns, store, config)?;
Ok(SecretsLoadResult {
scanner,
warnings,
allow_patterns,
})
}
pub fn from_plaintext_secrets(
plaintext: &[u8],
format: Option<crate::secrets::SecretsFormat>,
store: Arc<MappingStore>,
config: ScanConfig,
extra_patterns: Vec<ScanPattern>,
) -> Result<SecretsLoadResult> {
let ((mut patterns, warnings), allow_patterns) =
crate::secrets::load_plaintext_secrets(plaintext, format)?;
patterns.extend(extra_patterns);
let scanner = Self::new(patterns, store, config)?;
Ok(SecretsLoadResult {
scanner,
warnings,
allow_patterns,
})
}
fn find_matches(&self, window: &[u8], scratch: &mut ScanScratch) {
scratch.all_matches.clear();
scratch.selected.clear();
if let Some(ac) = &self.aho_corasick {
for mat in ac.find_overlapping_iter(window) {
let pattern_idx = self.literal_indices[mat.pattern().as_usize()];
if self.length_out_of_bounds(pattern_idx, mat.end() - mat.start()) {
continue;
}
if self.patterns[pattern_idx].word_boundary
&& !word_bounded(window, mat.start(), mat.end())
{
continue;
}
scratch.all_matches.push(RawMatch {
start: mat.start(),
end: mat.end(),
pattern_idx,
capture: None,
});
}
}
for rs_idx in self.regex_set.matches(window) {
let pattern_idx = self.regex_indices[rs_idx];
for cap in self.patterns[pattern_idx].regex.captures_iter(window) {
let full = cap.get(0).expect("group 0 always exists");
if self.length_out_of_bounds(pattern_idx, full.end() - full.start()) {
continue;
}
let capture = cap.get(1).map(|g| (g.start(), g.end()));
scratch.all_matches.push(RawMatch {
start: full.start(),
end: full.end(),
pattern_idx,
capture,
});
}
}
if scratch.all_matches.is_empty() {
return;
}
scratch.all_matches.sort_unstable_by(|a, b| {
a.start
.cmp(&b.start)
.then_with(|| (b.end - b.start).cmp(&(a.end - a.start)))
});
let mut last_end = 0;
for m in scratch.all_matches.drain(..) {
if m.start >= last_end {
last_end = m.end;
scratch.selected.push(m);
}
}
}
#[inline]
fn length_out_of_bounds(&self, idx: usize, len: usize) -> bool {
let p = &self.patterns[idx];
len < p.min_length || len > p.max_length
}
#[allow(clippy::unused_self)] fn adjusted_commit_point(
&self,
matches: &[RawMatch],
base_commit: usize,
window_len: usize,
is_eof: bool,
) -> usize {
if is_eof {
return window_len;
}
let mut commit = base_commit;
for m in matches {
if m.start < commit && m.end > commit {
if m.end >= window_len {
commit = m.start;
break;
}
commit = m.end;
}
}
commit.min(window_len)
}
#[allow(clippy::too_many_arguments)]
fn apply_replacements(
&self,
committed: &[u8],
matches: &[RawMatch],
stats: &mut ScanStats,
output_buf: &mut Vec<u8>,
pattern_counts: &mut [u64],
window_file_offset: u64,
newlines_before_window: u64,
on_match: &mut dyn FnMut(MatchLocation),
) -> Result<()> {
output_buf.clear();
let mut last_end = 0;
let mut newlines_in_committed: u64 = 0;
let mut newline_scan_pos: usize = 0;
for &m in matches {
if m.start >= committed.len() {
break;
}
output_buf.extend_from_slice(&committed[last_end..m.start]);
newlines_in_committed += count_newlines(&committed[newline_scan_pos..m.start]);
newline_scan_pos = m.start;
on_match(MatchLocation {
line: newlines_before_window + newlines_in_committed + 1,
byte_offset: window_file_offset + m.start as u64,
pattern: self.patterns[m.pattern_idx].label.clone(),
});
let pattern = &self.patterns[m.pattern_idx];
let capture = m.capture.filter(|&(cap_start, cap_end)| {
cap_start >= m.start && cap_end <= m.end && cap_start <= cap_end
});
if m.capture.is_some() && capture.is_none() {
let (cap_start, cap_end) = m.capture.unwrap_or_default();
tracing::warn!(
pattern = %pattern.label,
m_start = m.start,
m_end = m.end,
cap_start,
cap_end,
"capture group bounds outside match bounds — replacing full match"
);
}
if let Some((cap_start, cap_end)) = capture {
output_buf.extend_from_slice(&committed[m.start..cap_start]);
let secret = String::from_utf8_lossy(&committed[cap_start..cap_end]);
let replacement = self.store.get_or_insert(&pattern.category, &secret)?;
output_buf.extend_from_slice(replacement.as_bytes());
output_buf.extend_from_slice(&committed[cap_end..m.end]);
} else {
let matched_text = String::from_utf8_lossy(&committed[m.start..m.end]);
let replacement = self.store.get_or_insert(&pattern.category, &matched_text)?;
output_buf.extend_from_slice(replacement.as_bytes());
}
last_end = m.end;
stats.matches_found += 1;
stats.replacements_applied += 1;
pattern_counts[m.pattern_idx] += 1;
}
output_buf.extend_from_slice(&committed[last_end..]);
Ok(())
}
}
const _: fn() = || {
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
assert_send::<StreamScanner>();
assert_sync::<StreamScanner>();
};
#[inline]
fn count_newlines(data: &[u8]) -> u64 {
bytecount::count(data, b'\n') as u64
}
fn read_fully<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<usize> {
let mut total = 0;
while total < buf.len() {
match reader.read(&mut buf[total..]) {
Ok(0) => break, Ok(n) => total += n,
Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => return Err(SanitizeError::from(e)),
}
}
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generator::HmacGenerator;
fn test_scanner(patterns: Vec<ScanPattern>) -> StreamScanner {
let gen = Arc::new(HmacGenerator::new([42u8; 32]));
let store = Arc::new(MappingStore::new(gen, None));
StreamScanner::new(
patterns,
store,
ScanConfig {
chunk_size: 64,
overlap_size: 16,
},
)
.unwrap()
}
fn email_pattern() -> ScanPattern {
ScanPattern::from_regex(
r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
Category::Email,
"email",
)
.unwrap()
}
fn ipv4_pattern() -> ScanPattern {
ScanPattern::from_regex(
r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b",
Category::IpV4,
"ipv4",
)
.unwrap()
}
#[test]
fn name_literal_requires_word_boundaries() {
let pat = ScanPattern::from_literal("admin", Category::Name, "login").unwrap();
let scanner = test_scanner(vec![pat]);
let input = b"user=admin administrators adminProperties dssadmin (admin)";
let (out, stats) = scanner.scan_bytes(input).unwrap();
let out = String::from_utf8(out).unwrap();
assert_eq!(stats.matches_found, 2, "standalone occurrences only: {out}");
assert!(
!out.contains("user=admin"),
"standalone value replaced: {out}"
);
assert!(
out.contains("administrators") && out.contains("adminProperties"),
"larger words must survive: {out}"
);
assert!(out.contains("dssadmin"), "path segment must survive: {out}");
assert!(!out.contains("(admin)"), "delimited value replaced: {out}");
}
#[test]
fn token_literal_still_matches_inside_larger_strings() {
let pat = ScanPattern::from_literal("sekrit12345", Category::AuthToken, "token").unwrap();
let scanner = test_scanner(vec![pat]);
let (out, stats) = scanner
.scan_bytes(b"https://api.example.com/?key=Xsekrit12345Y")
.unwrap();
assert_eq!(stats.matches_found, 1);
assert!(!String::from_utf8(out).unwrap().contains("sekrit12345"));
}
#[test]
fn invalid_capture_bounds_fail_closed() {
let scanner = test_scanner(vec![email_pattern()]);
let committed = b"user alice@corp.com done";
let m = RawMatch {
start: 5,
end: 19,
pattern_idx: 0,
capture: Some((3, 100)), };
let mut stats = ScanStats::default();
let mut out = Vec::new();
let mut counts = vec![0u64; 1];
scanner
.apply_replacements(
committed,
&[m],
&mut stats,
&mut out,
&mut counts,
0,
0,
&mut |_| {},
)
.unwrap();
let text = String::from_utf8_lossy(&out).into_owned();
assert!(
!text.contains("alice@corp.com"),
"match emitted unreplaced: {text}"
);
assert!(
text.starts_with("user ") && text.ends_with(" done"),
"{text}"
);
assert_eq!(stats.replacements_applied, 1);
assert_eq!(counts[0], 1);
}
#[test]
fn scanner_creation() {
let scanner = test_scanner(vec![email_pattern()]);
assert_eq!(scanner.pattern_count(), 1);
}
#[test]
fn invalid_config_zero_chunk() {
let gen = Arc::new(HmacGenerator::new([0u8; 32]));
let store = Arc::new(MappingStore::new(gen, None));
let result = StreamScanner::new(vec![], store, ScanConfig::new(0, 0));
assert!(result.is_err());
}
#[test]
fn invalid_config_overlap_ge_chunk() {
let gen = Arc::new(HmacGenerator::new([0u8; 32]));
let store = Arc::new(MappingStore::new(gen, None));
let result = StreamScanner::new(vec![], store, ScanConfig::new(100, 100));
assert!(result.is_err());
}
#[test]
fn empty_input() {
let scanner = test_scanner(vec![email_pattern()]);
let (output, stats) = scanner.scan_bytes(b"").unwrap();
assert!(output.is_empty());
assert_eq!(stats.matches_found, 0);
assert_eq!(stats.bytes_processed, 0);
}
#[test]
fn no_matches() {
let scanner = test_scanner(vec![email_pattern()]);
let input = b"There are no email addresses here.";
let (output, stats) = scanner.scan_bytes(input).unwrap();
assert_eq!(output, input.as_slice());
assert_eq!(stats.matches_found, 0);
}
#[test]
fn single_email_replaced() {
let scanner = test_scanner(vec![email_pattern()]);
let input = b"Contact alice@corp.com for help.";
let (output, stats) = scanner.scan_bytes(input).unwrap();
assert_eq!(stats.matches_found, 1);
assert_eq!(stats.replacements_applied, 1);
assert!(!output
.windows(b"alice@corp.com".len())
.any(|w| w == b"alice@corp.com"));
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains("@corp.com"));
assert_eq!(output.len(), input.len(), "length must be preserved");
assert!(output_str.starts_with("Contact "));
assert!(output_str.ends_with(" for help."));
}
#[test]
fn multiple_emails_replaced() {
let scanner = test_scanner(vec![email_pattern()]);
let input = b"From alice@corp.com to bob@corp.com cc admin@corp.com";
let (output, stats) = scanner.scan_bytes(input).unwrap();
assert_eq!(stats.matches_found, 3);
let out_str = String::from_utf8_lossy(&output);
assert!(!out_str.contains("alice@corp.com"));
assert!(!out_str.contains("bob@corp.com"));
assert!(!out_str.contains("admin@corp.com"));
}
#[test]
fn same_secret_same_replacement() {
let scanner = test_scanner(vec![email_pattern()]);
let input = b"First alice@corp.com then alice@corp.com again.";
let (output, stats) = scanner.scan_bytes(input).unwrap();
assert_eq!(stats.matches_found, 2);
let out_str = String::from_utf8_lossy(&output);
let parts: Vec<&str> = out_str.split("@corp.com").collect();
assert_eq!(parts.len(), 3);
}
#[test]
fn literal_pattern_matched() {
let pat = ScanPattern::from_literal(
"SECRET_API_KEY_12345",
Category::Custom("api_key".into()),
"api_key",
)
.unwrap();
let scanner = test_scanner(vec![pat]);
let input = b"key=SECRET_API_KEY_12345&foo=bar";
let (output, stats) = scanner.scan_bytes(input).unwrap();
assert_eq!(stats.matches_found, 1);
assert!(!output
.windows(b"SECRET_API_KEY_12345".len())
.any(|w| w == b"SECRET_API_KEY_12345"));
}
#[test]
fn multiple_pattern_types() {
let scanner = test_scanner(vec![email_pattern(), ipv4_pattern()]);
let input = b"Server 192.168.1.100 contact admin@server.com";
let (output, stats) = scanner.scan_bytes(input).unwrap();
assert_eq!(stats.matches_found, 2);
let out_str = String::from_utf8_lossy(&output);
assert!(!out_str.contains("192.168.1.100"));
assert!(!out_str.contains("admin@server.com"));
assert_eq!(*stats.pattern_counts.get("email").unwrap(), 1);
assert_eq!(*stats.pattern_counts.get("ipv4").unwrap(), 1);
}
#[test]
fn match_at_chunk_boundary() {
let gen = Arc::new(HmacGenerator::new([42u8; 32]));
let store = Arc::new(MappingStore::new(gen, None));
let scanner = StreamScanner::new(
vec![email_pattern()],
store,
ScanConfig {
chunk_size: 20, overlap_size: 16,
},
)
.unwrap();
let input = b"AAAAAAAAAAAAAAAA alice@corp.com BBBBBBBBBBBBB";
let (output, stats) = scanner.scan_bytes(input).unwrap();
assert_eq!(stats.matches_found, 1);
let out_str = String::from_utf8_lossy(&output);
assert!(!out_str.contains("alice@corp.com"));
assert!(out_str.contains("@corp.com"), "domain must be preserved");
}
fn token_pattern() -> ScanPattern {
ScanPattern::from_regex(r"TOK[A-Za-z0-9]+", Category::AuthToken, "tok").unwrap()
}
#[test]
fn long_match_exceeding_overlap_is_fully_replaced() {
let gen = Arc::new(HmacGenerator::new([42u8; 32]));
let store = Arc::new(MappingStore::new(gen, None));
let scanner = StreamScanner::new(
vec![token_pattern()],
Arc::clone(&store),
ScanConfig {
chunk_size: 64,
overlap_size: 16,
},
)
.unwrap();
let secret = format!("TOK{}", "A".repeat(40)); let input = format!("{} {} {}", "X".repeat(30), secret, "Y".repeat(30));
let (output, stats) = scanner.scan_bytes(input.as_bytes()).unwrap();
let out = String::from_utf8_lossy(&output);
assert!(!out.contains("AAAAAAAAAA"), "raw token bytes leaked: {out}");
assert!(!out.contains(&secret), "full token survived: {out}");
assert_eq!(stats.matches_found, 1, "token must match exactly once");
assert_eq!(store.len(), 1, "token must map to a single replacement");
assert_eq!(
output.len(),
input.len(),
"length must be preserved for a non-overlong match"
);
}
#[test]
fn overlong_match_is_redacted_not_leaked() {
let scanner = test_scanner(vec![token_pattern()]); let secret = format!("TOK{}", "A".repeat(500)); let input = format!("before {secret} after");
let (output, _stats) = scanner.scan_bytes(input.as_bytes()).unwrap();
let out = String::from_utf8_lossy(&output);
assert!(
!out.contains("AAAAAAAAAA"),
"overlong token leaked raw bytes: {out}"
);
assert!(
out.contains(std::str::from_utf8(OVERLONG_MARKER).unwrap()),
"overlong token must be redacted with the marker: {out}"
);
assert!(out.starts_with("before "), "prefix preserved: {out}");
assert!(
out.ends_with(" after"),
"suffix after the run preserved: {out}"
);
}
#[test]
fn overlong_match_running_to_eof_is_redacted() {
let scanner = test_scanner(vec![token_pattern()]);
let secret = format!("TOK{}", "A".repeat(500));
let input = format!("before {secret}");
let (output, _stats) = scanner.scan_bytes(input.as_bytes()).unwrap();
let out = String::from_utf8_lossy(&output);
assert!(!out.contains("AAAAAAAAAA"), "leaked to EOF: {out}");
assert!(
out.contains(std::str::from_utf8(OVERLONG_MARKER).unwrap()),
"must redact with marker: {out}"
);
assert!(out.starts_with("before "), "prefix preserved: {out}");
}
#[test]
fn length_bounds_filter_matches() {
let pat = ScanPattern::from_regex(r"[0-9]+", Category::Custom("num".into()), "num")
.unwrap()
.with_length_bounds(4, 8);
let scanner = test_scanner(vec![pat]);
let input = b"a 12 b 1234 c 1234567890 d";
let (output, stats) = scanner.scan_bytes(input).unwrap();
let out = String::from_utf8_lossy(&output);
assert_eq!(stats.matches_found, 1, "only the 4-digit run is in range");
assert!(out.contains("12 "), "short run preserved: {out}");
assert!(out.contains("1234567890"), "long run preserved: {out}");
assert!(!out.contains(" 1234 "), "in-range run replaced: {out}");
assert_eq!(output.len(), input.len(), "length preserved");
}
#[test]
fn large_input_many_chunks() {
let scanner = test_scanner(vec![email_pattern()]);
let mut input = Vec::new();
let filler = b"Lorem ipsum dolor sit amet. ";
for i in 0..20 {
input.extend_from_slice(filler);
let email = format!("user{}@example.com ", i);
input.extend_from_slice(email.as_bytes());
}
let (output, stats) = scanner.scan_bytes(&input).unwrap();
assert_eq!(stats.matches_found, 20);
let out_str = String::from_utf8_lossy(&output);
for i in 0..20 {
let email = format!("user{}@example.com", i);
assert!(!out_str.contains(&email));
}
}
#[test]
fn scan_bytes_with_progress_preserves_output_and_stats() {
let scanner = test_scanner(vec![email_pattern()]);
let input = b"Contact alice@corp.com and bob@corp.com for help.";
let (baseline_output, baseline_stats) = scanner.scan_bytes(input).unwrap();
let mut updates = Vec::new();
let (progress_output, progress_stats) = scanner
.scan_bytes_with_progress(input, |progress| updates.push(progress.clone()))
.unwrap();
assert_eq!(progress_output, baseline_output);
assert_eq!(
progress_stats.bytes_processed,
baseline_stats.bytes_processed
);
assert_eq!(progress_stats.bytes_output, baseline_stats.bytes_output);
assert_eq!(progress_stats.matches_found, baseline_stats.matches_found);
assert_eq!(
progress_stats.replacements_applied,
baseline_stats.replacements_applied
);
assert!(!updates.is_empty());
assert_eq!(updates.last().unwrap().bytes_processed, input.len() as u64);
assert_eq!(
updates.last().unwrap().total_bytes,
Some(input.len() as u64)
);
assert_eq!(updates.last().unwrap().matches_found, 2);
}
#[test]
fn scan_reader_with_progress_reports_multiple_updates_for_multi_chunk_input() {
let scanner = test_scanner(vec![email_pattern()]);
let mut input = Vec::new();
for i in 0..8 {
input.extend_from_slice(b"padding padding padding ");
input.extend_from_slice(format!("user{i}@example.com ").as_bytes());
}
let mut output = Vec::new();
let mut updates = Vec::new();
let stats = scanner
.scan_reader_with_callbacks(
&input[..],
&mut output,
Some(input.len() as u64),
|progress| {
updates.push(progress.clone());
},
|_| {},
)
.unwrap();
assert!(updates.len() >= 2);
assert_eq!(
updates.last().unwrap().bytes_processed,
stats.bytes_processed
);
assert_eq!(updates.last().unwrap().bytes_output, stats.bytes_output);
assert_eq!(
updates.last().unwrap().total_bytes,
Some(input.len() as u64)
);
}
#[test]
fn scan_reader_writer() {
let scanner = test_scanner(vec![email_pattern()]);
let input = b"hello alice@corp.com world";
let mut output = Vec::new();
let stats = scanner.scan_reader(&input[..], &mut output).unwrap();
assert_eq!(stats.matches_found, 1);
let out_str = String::from_utf8_lossy(&output);
assert!(out_str.contains("@corp.com"), "domain must be preserved");
}
#[test]
fn invalid_regex_pattern() {
let result = ScanPattern::from_regex("[invalid(", Category::Email, "bad");
assert!(result.is_err());
}
#[test]
fn default_config_valid() {
ScanConfig::default().validate().unwrap();
}
#[test]
fn config_chunk_1_overlap_0() {
let gen = Arc::new(HmacGenerator::new([42u8; 32]));
let store = Arc::new(MappingStore::new(gen, None));
let scanner = StreamScanner::new(vec![], store, ScanConfig::new(1, 0)).unwrap();
let (output, _) = scanner.scan_bytes(b"hello").unwrap();
assert_eq!(output, b"hello");
}
#[test]
fn scan_stats_equality() {
let scanner = test_scanner(vec![email_pattern()]);
let input = b"hello alice@corp.com world";
let (_, stats_a) = scanner.scan_bytes(input).unwrap();
let (_, stats_b) = scanner.scan_bytes(input).unwrap();
assert_eq!(
stats_a, stats_b,
"identical inputs must produce identical stats"
);
assert_eq!(stats_a.matches_found, 1, "one email in input");
assert_eq!(stats_a.replacements_applied, 1);
assert_eq!(stats_a.bytes_processed, input.len() as u64);
assert_eq!(*stats_a.pattern_counts.get("email").unwrap_or(&0), 1);
let (_, stats_empty) = scanner.scan_bytes(b"no matches here").unwrap();
assert_ne!(stats_a, stats_empty);
assert_eq!(stats_empty.matches_found, 0);
assert_eq!(stats_empty.replacements_applied, 0);
}
#[test]
fn on_match_reports_correct_line_and_byte_offset() {
let scanner = test_scanner(vec![email_pattern()]);
let input = b"line one\nalice@corp.com\nline three\nbob@corp.com\n";
let mut locations = Vec::new();
let mut output = Vec::new();
scanner
.scan_reader_with_callbacks(
&input[..],
&mut output,
None,
|_| {},
|loc| locations.push(loc),
)
.unwrap();
assert_eq!(locations.len(), 2);
assert_eq!(locations[0].line, 2, "alice must be on line 2");
assert_eq!(locations[0].byte_offset, 9, "alice must start at byte 9");
assert_eq!(locations[1].line, 4, "bob must be on line 4");
assert_eq!(locations[1].byte_offset, 35, "bob must start at byte 35");
}
#[test]
fn on_match_line_numbers_stable_across_chunk_sizes() {
let input = b"line one\nalice@corp.com\nline three\nbob@corp.com\n";
let gen = Arc::new(HmacGenerator::new([42u8; 32]));
let store = Arc::new(MappingStore::new(gen, None));
for chunk_size in [16usize, 20, 24, 32, 64] {
let scanner = StreamScanner::new(
vec![email_pattern()],
Arc::clone(&store),
ScanConfig::new(chunk_size, 14),
)
.unwrap();
let mut locations = Vec::new();
let mut output = Vec::new();
scanner
.scan_reader_with_callbacks(
&input[..],
&mut output,
None,
|_| {},
|loc| locations.push(loc),
)
.unwrap();
assert_eq!(
locations.len(),
2,
"chunk_size={chunk_size}: expected 2 matches"
);
assert_eq!(
locations[0].line, 2,
"chunk_size={chunk_size}: alice must be on line 2"
);
assert_eq!(
locations[0].byte_offset, 9,
"chunk_size={chunk_size}: alice must start at byte 9"
);
assert_eq!(
locations[1].line, 4,
"chunk_size={chunk_size}: bob must be on line 4"
);
assert_eq!(
locations[1].byte_offset, 35,
"chunk_size={chunk_size}: bob must start at byte 35"
);
}
}
#[test]
fn bytes_output_preserved_on_replacement() {
let scanner = test_scanner(vec![email_pattern()]);
let input = b"a@b.cc"; let (output, stats) = scanner.scan_bytes(input).unwrap();
assert_eq!(stats.bytes_processed, input.len() as u64);
assert_eq!(stats.bytes_output, output.len() as u64);
assert_eq!(output.len(), input.len());
}
#[test]
fn randomized_length_decorrelates_numeric_output() {
use crate::generator::{LengthPolicy, RandomGenerator};
let gen = Arc::new(RandomGenerator::new().with_length_policy(LengthPolicy::Randomized));
let store = Arc::new(MappingStore::new(gen, None));
let pat = ScanPattern::from_regex(r"\b\d{4,}\b", Category::Phone, "num").unwrap();
let scanner = StreamScanner::new(
vec![pat],
store,
ScanConfig {
chunk_size: 64,
overlap_size: 16,
},
)
.unwrap();
let input = b"id=123456 end"; let (output, stats) = scanner.scan_bytes(input).unwrap();
let out = String::from_utf8(output).unwrap();
assert!(!out.contains("123456"), "value must be replaced: {out}");
assert_eq!(stats.replacements_applied, 1);
assert_eq!(stats.bytes_output, out.len() as u64);
assert!(
stats.bytes_output > stats.bytes_processed,
"randomized replacement (>=8 digits) must lengthen a 6-digit value: \
processed={} output={}",
stats.bytes_processed,
stats.bytes_output
);
}
}