scrubber_rb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,716 @@
1
+ //! The scanning engine.
2
+ //!
3
+ //! One pass, two stages:
4
+ //!
5
+ //! 1. **Prefilter.** Rules that require a literal (`AKIA`, `-----BEGIN`,
6
+ //! `ghp_`, `password`) are gated behind a single Aho-Corasick pass over all
7
+ //! such literals. On a typical log line that eliminates roughly two thirds of
8
+ //! the rule set before any regex runs.
9
+ //! 2. **Match.** The remaining rules live in one `RegexSet`, which reports
10
+ //! which of them match anywhere in the input in a single scan. Only those
11
+ //! get a second pass for their spans.
12
+ //!
13
+ //! Then validators run on the candidates, overlaps are resolved, and the output
14
+ //! is built once with a single walk — no repeated `gsub` over the whole string.
15
+ //!
16
+ //! Everything here is immutable after construction, so an `Engine` is `Sync`
17
+ //! and one instance can be shared across every thread in a process.
18
+
19
+ use aho_corasick::{AhoCorasick, MatchKind};
20
+ use regex::{Regex, RegexSet};
21
+
22
+ use crate::detectors::{self, Validator};
23
+ use crate::pattern;
24
+ use crate::replace::{render, MaskKind, Strategy};
25
+
26
+ /// Anything that can go wrong while building an engine.
27
+ #[derive(Debug)]
28
+ pub enum BuildError {
29
+ UnknownDetector {
30
+ name: String,
31
+ known: Vec<&'static str>,
32
+ },
33
+ UnknownStrategy {
34
+ name: String,
35
+ },
36
+ UnsupportedPattern {
37
+ name: String,
38
+ construct: String,
39
+ reason: &'static str,
40
+ },
41
+ InvalidPattern {
42
+ name: String,
43
+ message: String,
44
+ },
45
+ }
46
+
47
+ impl std::fmt::Display for BuildError {
48
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49
+ match self {
50
+ BuildError::UnknownDetector { name, known } => write!(
51
+ f,
52
+ "unknown detector {name:?}. Known detectors: {}",
53
+ known.join(", ")
54
+ ),
55
+ BuildError::UnknownStrategy { name } => write!(
56
+ f,
57
+ "unknown replacement {name:?}. Expected one of: label, mask, hash, remove"
58
+ ),
59
+ BuildError::UnsupportedPattern {
60
+ name,
61
+ construct,
62
+ reason,
63
+ } => write!(
64
+ f,
65
+ "custom detector {name:?} uses {construct}, which this engine cannot compile: \
66
+ {reason}. Rewrite the pattern without it, or pre-filter in Ruby."
67
+ ),
68
+ BuildError::InvalidPattern { name, message } => {
69
+ write!(f, "custom detector {name:?} failed to compile: {message}")
70
+ }
71
+ }
72
+ }
73
+ }
74
+
75
+ /// One compiled rule.
76
+ struct CompiledRule {
77
+ kind: String,
78
+ regex: Regex,
79
+ capture: usize,
80
+ validator: Option<Validator>,
81
+ priority: u8,
82
+ mask: MaskKind,
83
+ }
84
+
85
+ /// A surviving match, in absolute byte offsets into the original input.
86
+ #[derive(Clone, Copy, Debug)]
87
+ pub struct Hit {
88
+ pub start: usize,
89
+ pub end: usize,
90
+ rule: usize,
91
+ }
92
+
93
+ /// A user-facing detector specification.
94
+ pub struct CustomSpec {
95
+ pub name: String,
96
+ pub source: String,
97
+ pub options: i32,
98
+ }
99
+
100
+ pub struct Engine {
101
+ rules: Vec<CompiledRule>,
102
+ /// Rule indices reachable only when one of their literals is present.
103
+ anchored: Vec<usize>,
104
+ /// Aho-Corasick over every anchor literal; pattern id -> `anchored` index.
105
+ prefilter: Option<AhoCorasick>,
106
+ anchor_owner: Vec<usize>,
107
+ /// Rule indices with no usable literal, plus the set that scans them.
108
+ unanchored: Vec<usize>,
109
+ set: RegexSet,
110
+ strategy: Strategy,
111
+ }
112
+
113
+ impl std::fmt::Debug for Engine {
114
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115
+ f.debug_struct("Engine")
116
+ .field("rules", &self.rules.len())
117
+ .field("anchored", &self.anchored.len())
118
+ .field("unanchored", &self.unanchored.len())
119
+ .field("strategy", &self.strategy)
120
+ .finish()
121
+ }
122
+ }
123
+
124
+ impl Engine {
125
+ /// Compile a detector selection into a reusable engine.
126
+ pub fn build(
127
+ detector_keys: &[String],
128
+ customs: &[CustomSpec],
129
+ strategy_name: &str,
130
+ hash_salt: Option<String>,
131
+ ) -> Result<Engine, BuildError> {
132
+ let strategy = Strategy::from_name(strategy_name, hash_salt).ok_or_else(|| {
133
+ BuildError::UnknownStrategy {
134
+ name: strategy_name.to_string(),
135
+ }
136
+ })?;
137
+
138
+ let mut rules: Vec<CompiledRule> = Vec::new();
139
+ let mut anchors: Vec<String> = Vec::new();
140
+ let mut anchor_owner: Vec<usize> = Vec::new();
141
+ let mut anchored: Vec<usize> = Vec::new();
142
+ let mut unanchored: Vec<usize> = Vec::new();
143
+ let mut unanchored_patterns: Vec<&str> = Vec::new();
144
+
145
+ for key in detector_keys {
146
+ let specs = detectors::rules_for(key).ok_or_else(|| BuildError::UnknownDetector {
147
+ name: key.clone(),
148
+ known: detectors::all_keys(),
149
+ })?;
150
+ for spec in specs {
151
+ // Registry patterns are compile-time constants covered by
152
+ // `every_registry_pattern_compiles`, so a failure here is a bug
153
+ // in this crate, not bad user input.
154
+ let regex = Regex::new(spec.pattern).map_err(|e| BuildError::InvalidPattern {
155
+ name: key.clone(),
156
+ message: e.to_string(),
157
+ })?;
158
+ let idx = rules.len();
159
+ rules.push(CompiledRule {
160
+ kind: spec.kind.to_string(),
161
+ regex,
162
+ capture: spec.capture,
163
+ validator: spec.validator,
164
+ priority: spec.priority,
165
+ mask: spec.mask,
166
+ });
167
+ if spec.anchors.is_empty() {
168
+ unanchored.push(idx);
169
+ unanchored_patterns.push(spec.pattern);
170
+ } else {
171
+ let slot = anchored.len();
172
+ anchored.push(idx);
173
+ for lit in spec.anchors {
174
+ anchors.push((*lit).to_string());
175
+ anchor_owner.push(slot);
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ // Custom detectors are always unanchored: we have no idea what literal
182
+ // the user's pattern needs, and guessing wrong would drop matches.
183
+ let mut custom_sources: Vec<String> = Vec::with_capacity(customs.len());
184
+ for custom in customs {
185
+ let translated = pattern::translate(&custom.source, custom.options).map_err(|u| {
186
+ BuildError::UnsupportedPattern {
187
+ name: custom.name.clone(),
188
+ construct: u.construct,
189
+ reason: u.reason,
190
+ }
191
+ })?;
192
+ let regex = Regex::new(&translated).map_err(|e| BuildError::InvalidPattern {
193
+ name: custom.name.clone(),
194
+ message: e.to_string(),
195
+ })?;
196
+ let idx = rules.len();
197
+ rules.push(CompiledRule {
198
+ kind: custom.name.clone(),
199
+ regex,
200
+ capture: 0,
201
+ validator: None,
202
+ priority: detectors::P_CUSTOM,
203
+ mask: MaskKind::Full,
204
+ });
205
+ unanchored.push(idx);
206
+ custom_sources.push(translated);
207
+ }
208
+ for src in &custom_sources {
209
+ unanchored_patterns.push(src);
210
+ }
211
+
212
+ let set = RegexSet::new(&unanchored_patterns).map_err(|e| BuildError::InvalidPattern {
213
+ name: "<detector set>".to_string(),
214
+ message: e.to_string(),
215
+ })?;
216
+
217
+ let prefilter = if anchors.is_empty() {
218
+ None
219
+ } else {
220
+ Some(
221
+ AhoCorasick::builder()
222
+ // Case-insensitive so one automaton can gate both
223
+ // `password=` and `PASSWORD=`. A false positive here only
224
+ // costs one extra regex pass; a false negative would drop a
225
+ // match, so we always err wide.
226
+ .ascii_case_insensitive(true)
227
+ // Standard (not leftmost) so overlapping search is legal:
228
+ // `sk-` sits inside `sk-ant-`, and both rules must wake up.
229
+ .match_kind(MatchKind::Standard)
230
+ .build(&anchors)
231
+ .map_err(|e| BuildError::InvalidPattern {
232
+ name: "<prefilter>".to_string(),
233
+ message: e.to_string(),
234
+ })?,
235
+ )
236
+ };
237
+
238
+ Ok(Engine {
239
+ rules,
240
+ anchored,
241
+ prefilter,
242
+ anchor_owner,
243
+ unanchored,
244
+ set,
245
+ strategy,
246
+ })
247
+ }
248
+
249
+ /// Number of compiled rules; used by tests and `#inspect`.
250
+ pub fn rule_count(&self) -> usize {
251
+ self.rules.len()
252
+ }
253
+
254
+ pub fn kind_of(&self, hit: &Hit) -> &str {
255
+ &self.rules[hit.rule].kind
256
+ }
257
+
258
+ /// Find every match in `bytes`, resolved for overlaps, in document order.
259
+ ///
260
+ /// `bytes` may contain invalid UTF-8. Valid regions are scanned; invalid
261
+ /// bytes are stepped over and passed through untouched (contract S5).
262
+ pub fn scan(&self, bytes: &[u8]) -> Vec<Hit> {
263
+ let mut raw = Vec::new();
264
+ let mut base = 0usize;
265
+ let mut rest = bytes;
266
+
267
+ loop {
268
+ match std::str::from_utf8(rest) {
269
+ Ok(chunk) => {
270
+ self.scan_str(chunk, base, &mut raw);
271
+ break;
272
+ }
273
+ Err(e) => {
274
+ let valid_len = e.valid_up_to();
275
+ if valid_len > 0 {
276
+ // Safe: `valid_up_to` is by definition a valid boundary.
277
+ let chunk = unsafe { std::str::from_utf8_unchecked(&rest[..valid_len]) };
278
+ self.scan_str(chunk, base, &mut raw);
279
+ }
280
+ let skip = e.error_len().unwrap_or(rest.len() - valid_len);
281
+ let advance = valid_len + skip;
282
+ if advance == 0 || advance >= rest.len() {
283
+ break;
284
+ }
285
+ base += advance;
286
+ rest = &rest[advance..];
287
+ }
288
+ }
289
+ }
290
+
291
+ self.resolve(raw)
292
+ }
293
+
294
+ /// Scan one valid-UTF-8 region, recording matches at `base + local offset`.
295
+ fn scan_str(&self, text: &str, base: usize, out: &mut Vec<Hit>) {
296
+ if text.is_empty() {
297
+ return;
298
+ }
299
+
300
+ // Stage 1a: which anchored rules can possibly fire here?
301
+ if let Some(ac) = &self.prefilter {
302
+ let mut live = vec![false; self.anchored.len()];
303
+ let mut remaining = self.anchored.len();
304
+ for m in ac.find_overlapping_iter(text) {
305
+ let slot = self.anchor_owner[m.pattern().as_usize()];
306
+ if !live[slot] {
307
+ live[slot] = true;
308
+ remaining -= 1;
309
+ if remaining == 0 {
310
+ break;
311
+ }
312
+ }
313
+ }
314
+ for (slot, alive) in live.iter().enumerate() {
315
+ if *alive {
316
+ self.collect(self.anchored[slot], text, base, out);
317
+ }
318
+ }
319
+ }
320
+
321
+ // Stage 1b: one multi-pattern pass over everything else.
322
+ for local in self.set.matches(text).iter() {
323
+ self.collect(self.unanchored[local], text, base, out);
324
+ }
325
+ }
326
+
327
+ fn collect(&self, rule_idx: usize, text: &str, base: usize, out: &mut Vec<Hit>) {
328
+ let rule = &self.rules[rule_idx];
329
+ if rule.capture == 0 {
330
+ for m in rule.regex.find_iter(text) {
331
+ self.push_if_valid(rule_idx, text, base, m.start(), m.end(), out);
332
+ }
333
+ } else {
334
+ for caps in rule.regex.captures_iter(text) {
335
+ if let Some(m) = caps.get(rule.capture) {
336
+ self.push_if_valid(rule_idx, text, base, m.start(), m.end(), out);
337
+ }
338
+ }
339
+ }
340
+ }
341
+
342
+ fn push_if_valid(
343
+ &self,
344
+ rule_idx: usize,
345
+ text: &str,
346
+ base: usize,
347
+ start: usize,
348
+ end: usize,
349
+ out: &mut Vec<Hit>,
350
+ ) {
351
+ if end <= start {
352
+ return;
353
+ }
354
+ let rule = &self.rules[rule_idx];
355
+ if let Some(validate) = rule.validator {
356
+ let candidate = detectors::Candidate {
357
+ text,
358
+ matched: &text[start..end],
359
+ start,
360
+ end,
361
+ };
362
+ if !validate(&candidate) {
363
+ return;
364
+ }
365
+ }
366
+ out.push(Hit {
367
+ start: base + start,
368
+ end: base + end,
369
+ rule: rule_idx,
370
+ });
371
+ }
372
+
373
+ /// Resolve overlapping matches: leftmost first, then most specific
374
+ /// (lowest priority number), then longest (contract S2).
375
+ fn resolve(&self, mut raw: Vec<Hit>) -> Vec<Hit> {
376
+ if raw.len() > 1 {
377
+ raw.sort_unstable_by(|a, b| {
378
+ a.start
379
+ .cmp(&b.start)
380
+ .then_with(|| {
381
+ self.rules[a.rule]
382
+ .priority
383
+ .cmp(&self.rules[b.rule].priority)
384
+ })
385
+ .then_with(|| (b.end - b.start).cmp(&(a.end - a.start)))
386
+ });
387
+ }
388
+ let mut kept: Vec<Hit> = Vec::with_capacity(raw.len());
389
+ let mut last_end = 0usize;
390
+ for hit in raw {
391
+ if kept.is_empty() || hit.start >= last_end {
392
+ last_end = hit.end;
393
+ kept.push(hit);
394
+ }
395
+ }
396
+ kept
397
+ }
398
+
399
+ /// Apply the replacement strategy, returning the new bytes.
400
+ ///
401
+ /// Returns `None` when nothing matched, so the caller can hand back the
402
+ /// input untouched instead of rebuilding an identical string (contract S1).
403
+ pub fn scrub(&self, bytes: &[u8]) -> Option<Vec<u8>> {
404
+ let hits = self.scan(bytes);
405
+ if hits.is_empty() {
406
+ return None;
407
+ }
408
+ let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
409
+ let mut pos = 0usize;
410
+ let mut buf = String::new();
411
+ for hit in &hits {
412
+ out.extend_from_slice(&bytes[pos..hit.start]);
413
+ let rule = &self.rules[hit.rule];
414
+ // Hits only ever come from valid UTF-8 regions.
415
+ let matched = std::str::from_utf8(&bytes[hit.start..hit.end]).unwrap_or("");
416
+ buf.clear();
417
+ render(&self.strategy, &rule.kind, matched, rule.mask, &mut buf);
418
+ out.extend_from_slice(buf.as_bytes());
419
+ pos = hit.end;
420
+ }
421
+ out.extend_from_slice(&bytes[pos..]);
422
+ Some(out)
423
+ }
424
+
425
+ /// A short, already-redacted preview of a match, for `Scrubber#detect`.
426
+ pub fn preview(&self, bytes: &[u8], hit: &Hit) -> String {
427
+ let rule = &self.rules[hit.rule];
428
+ let matched = std::str::from_utf8(&bytes[hit.start..hit.end]).unwrap_or("");
429
+ let mut buf = String::new();
430
+ render(&Strategy::Mask, &rule.kind, matched, rule.mask, &mut buf);
431
+ if buf.chars().count() > 64 {
432
+ buf = buf.chars().take(61).collect::<String>() + "...";
433
+ }
434
+ buf
435
+ }
436
+ }
437
+
438
+ #[cfg(test)]
439
+ mod tests {
440
+ use super::*;
441
+
442
+ fn engine(keys: &[&str]) -> Engine {
443
+ let owned: Vec<String> = keys.iter().map(|k| (*k).to_string()).collect();
444
+ Engine::build(&owned, &[], "label", None).unwrap()
445
+ }
446
+
447
+ fn scrub(keys: &[&str], text: &str) -> String {
448
+ let e = engine(keys);
449
+ match e.scrub(text.as_bytes()) {
450
+ Some(bytes) => String::from_utf8(bytes).unwrap(),
451
+ None => text.to_string(),
452
+ }
453
+ }
454
+
455
+ fn defaults() -> Vec<String> {
456
+ detectors::DEFAULTS.iter().map(|k| k.to_string()).collect()
457
+ }
458
+
459
+ #[test]
460
+ fn redacts_the_readme_headline_example() {
461
+ let out = scrub(
462
+ &["email", "credit_card", "aws_key"],
463
+ "contact nik@example.com, card 4111 1111 1111 1111, key AKIAIOSFODNN7EXAMPLE",
464
+ );
465
+ assert_eq!(out, "contact [EMAIL], card [CREDIT_CARD], key [AWS_KEY]");
466
+ }
467
+
468
+ #[test]
469
+ fn no_matches_reports_none() {
470
+ let e = engine(&["email"]);
471
+ assert!(e.scrub(b"nothing to see here").is_none());
472
+ }
473
+
474
+ #[test]
475
+ fn luhn_failure_is_not_a_card() {
476
+ assert_eq!(
477
+ scrub(&["credit_card"], "order 1234 5678 9012 3456 shipped"),
478
+ "order 1234 5678 9012 3456 shipped"
479
+ );
480
+ }
481
+
482
+ #[test]
483
+ fn private_key_block_goes_as_one_unit() {
484
+ let text = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIC\nlines\n-----END RSA PRIVATE KEY-----\nafter";
485
+ assert_eq!(
486
+ scrub(&["private_key"], text),
487
+ "before\n[PRIVATE_KEY]\nafter"
488
+ );
489
+ }
490
+
491
+ #[test]
492
+ fn url_credentials_redact_only_the_password() {
493
+ assert_eq!(
494
+ scrub(
495
+ &["url_credentials"],
496
+ "psql postgres://app:hunter2@db.internal/prod"
497
+ ),
498
+ "psql postgres://app:[URL_CREDENTIALS]@db.internal/prod"
499
+ );
500
+ }
501
+
502
+ #[test]
503
+ fn password_pair_redacts_only_the_value() {
504
+ assert_eq!(
505
+ scrub(&["password_pair"], "GET /login?password=hunter2&next=/home"),
506
+ "GET /login?password=[PASSWORD_PAIR]&next=/home"
507
+ );
508
+ assert_eq!(
509
+ scrub(&["password_pair"], r#"{"api_key":"sekret"}"#),
510
+ r#"{"api_key":"[PASSWORD_PAIR]"}"#
511
+ );
512
+ }
513
+
514
+ #[test]
515
+ fn overlapping_matches_pick_the_more_specific_rule() {
516
+ // The password value also looks like an email local part; the URL
517
+ // credential rule is more specific and wins.
518
+ let out = scrub(
519
+ &["email", "url_credentials"],
520
+ "https://admin:hunter2@mail.example.com/",
521
+ );
522
+ assert!(out.contains("[URL_CREDENTIALS]"), "got {out}");
523
+ assert!(!out.contains("hunter2"), "got {out}");
524
+ }
525
+
526
+ #[test]
527
+ fn invalid_utf8_is_preserved_and_valid_regions_still_scrub() {
528
+ let mut input = b"user ".to_vec();
529
+ input.push(0xff);
530
+ input.extend_from_slice(b" nik@example.com end");
531
+ let e = engine(&["email"]);
532
+ let out = e.scrub(&input).expect("should match");
533
+ assert!(out.contains(&0xff), "invalid byte must survive");
534
+ assert!(String::from_utf8_lossy(&out).contains("[EMAIL]"));
535
+ assert!(!String::from_utf8_lossy(&out).contains("nik@example.com"));
536
+ }
537
+
538
+ #[test]
539
+ fn multibyte_text_keeps_byte_offsets_consistent() {
540
+ let text = "🎉 नमस्ते nik@example.com 🎉";
541
+ let e = engine(&["email"]);
542
+ let hits = e.scan(text.as_bytes());
543
+ assert_eq!(hits.len(), 1);
544
+ assert_eq!(&text[hits[0].start..hits[0].end], "nik@example.com");
545
+ }
546
+
547
+ #[test]
548
+ fn every_default_detector_compiles_together() {
549
+ let e = Engine::build(&defaults(), &[], "label", None).unwrap();
550
+ assert!(e.rule_count() >= detectors::DEFAULTS.len());
551
+ }
552
+
553
+ #[test]
554
+ fn india_pack_detects_aadhaar_pan_upi() {
555
+ let keys: Vec<String> = detectors::INDIA.iter().map(|k| k.to_string()).collect();
556
+ let e = Engine::build(&keys, &[], "label", None).unwrap();
557
+ let text = "pan ABCPE1234F vpa nik@ybl phone +919876543210";
558
+ let out = String::from_utf8(e.scrub(text.as_bytes()).unwrap()).unwrap();
559
+ assert!(out.contains("[PAN]"), "got {out}");
560
+ assert!(out.contains("[UPI]"), "got {out}");
561
+ assert!(out.contains("[PHONE_IN]"), "got {out}");
562
+ }
563
+
564
+ #[test]
565
+ fn unknown_detector_names_itself() {
566
+ let err = Engine::build(&["nope".to_string()], &[], "label", None).unwrap_err();
567
+ assert!(matches!(err, BuildError::UnknownDetector { .. }));
568
+ assert!(err.to_string().contains("nope"));
569
+ }
570
+
571
+ #[test]
572
+ fn unsupported_custom_pattern_names_the_construct() {
573
+ let custom = CustomSpec {
574
+ name: "bad".into(),
575
+ source: r"(a)\1".into(),
576
+ options: 0,
577
+ };
578
+ let err = Engine::build(&[], std::slice::from_ref(&custom), "label", None).unwrap_err();
579
+ let msg = err.to_string();
580
+ assert!(msg.contains(r"\1"), "got {msg}");
581
+ assert!(msg.contains("bad"), "got {msg}");
582
+ }
583
+
584
+ #[test]
585
+ fn custom_detectors_use_their_own_label() {
586
+ let custom = CustomSpec {
587
+ name: "employee_id".into(),
588
+ source: r"\bEMP-\d{6}\b".into(),
589
+ options: 0,
590
+ };
591
+ let e = Engine::build(&[], std::slice::from_ref(&custom), "label", None).unwrap();
592
+ let out = String::from_utf8(e.scrub(b"ticket for EMP-004521 today").unwrap()).unwrap();
593
+ assert_eq!(out, "ticket for [EMPLOYEE_ID] today");
594
+ }
595
+
596
+ #[test]
597
+ fn hash_strategy_is_stable_across_occurrences() {
598
+ let e = Engine::build(&["email".to_string()], &[], "hash", None).unwrap();
599
+ let out =
600
+ String::from_utf8(e.scrub(b"a@x.com then a@x.com then b@x.com").unwrap()).unwrap();
601
+ let tokens: Vec<&str> = out
602
+ .split_whitespace()
603
+ .filter(|t| t.starts_with('['))
604
+ .collect();
605
+ assert_eq!(tokens.len(), 3);
606
+ assert_eq!(tokens[0], tokens[1]);
607
+ assert_ne!(tokens[0], tokens[2]);
608
+ }
609
+
610
+ #[test]
611
+ fn salt_changes_the_token() {
612
+ let a = Engine::build(&["email".to_string()], &[], "hash", None).unwrap();
613
+ let b = Engine::build(
614
+ &["email".to_string()],
615
+ &[],
616
+ "hash",
617
+ Some("pepper".to_string()),
618
+ )
619
+ .unwrap();
620
+ assert_ne!(a.scrub(b"a@x.com").unwrap(), b.scrub(b"a@x.com").unwrap());
621
+ }
622
+
623
+ #[test]
624
+ fn engine_is_send_and_sync() {
625
+ fn assert_send_sync<T: Send + Sync>() {}
626
+ assert_send_sync::<Engine>();
627
+ }
628
+
629
+ // ---- fuzz-lite -------------------------------------------------------
630
+ //
631
+ // The invariant that matters for a redaction library running over
632
+ // attacker-controlled log content: whatever you feed it, it comes back.
633
+ // No panic, no abort across the FFI boundary, no truncation.
634
+
635
+ fn shared_engine(strategy: &'static str) -> &'static Engine {
636
+ use std::collections::HashMap;
637
+ use std::sync::{Mutex, OnceLock};
638
+ static CACHE: OnceLock<Mutex<HashMap<&'static str, &'static Engine>>> = OnceLock::new();
639
+ let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
640
+ let mut guard = cache.lock().unwrap();
641
+ guard.entry(strategy).or_insert_with(|| {
642
+ let mut keys = defaults();
643
+ keys.extend(detectors::INDIA.iter().map(|k| k.to_string()));
644
+ Box::leak(Box::new(Engine::build(&keys, &[], strategy, None).unwrap()))
645
+ })
646
+ }
647
+
648
+ use proptest::prelude::{any, ProptestConfig};
649
+ use proptest::{prop_assert, proptest};
650
+
651
+ proptest! {
652
+ #![proptest_config(ProptestConfig::with_cases(400))]
653
+
654
+ #[test]
655
+ fn never_panics_on_arbitrary_bytes(
656
+ bytes in proptest::collection::vec(any::<u8>(), 0..2048)
657
+ ) {
658
+ let engine = shared_engine("label");
659
+ let _ = engine.scrub(&bytes);
660
+ }
661
+
662
+ #[test]
663
+ fn arbitrary_text_survives_a_round_trip(
664
+ text in ".{0,512}"
665
+ ) {
666
+ let engine = shared_engine("label");
667
+ let hits = engine.scan(text.as_bytes());
668
+ for hit in &hits {
669
+ // Every hit must be a real, in-bounds, char-aligned slice.
670
+ prop_assert!(hit.end <= text.len());
671
+ prop_assert!(hit.start < hit.end);
672
+ prop_assert!(text.is_char_boundary(hit.start));
673
+ prop_assert!(text.is_char_boundary(hit.end));
674
+ }
675
+ for pair in hits.windows(2) {
676
+ prop_assert!(pair[0].end <= pair[1].start);
677
+ }
678
+ }
679
+
680
+ #[test]
681
+ fn remove_never_grows_the_output(
682
+ bytes in proptest::collection::vec(any::<u8>(), 0..2048)
683
+ ) {
684
+ let engine = shared_engine("remove");
685
+ if let Some(out) = engine.scrub(&bytes) {
686
+ prop_assert!(out.len() <= bytes.len());
687
+ }
688
+ }
689
+
690
+ #[test]
691
+ fn unmatched_bytes_are_preserved_verbatim(
692
+ prefix in proptest::collection::vec(any::<u8>(), 0..64)
693
+ ) {
694
+ // Bytes with no detector coverage must come back untouched.
695
+ let engine = shared_engine("label");
696
+ let mut input = prefix.clone();
697
+ input.extend_from_slice(b"\x00\x01\x02");
698
+ if engine.scrub(&input).is_none() {
699
+ // No match at all: caller keeps the original, which is exactly
700
+ // the contract (S1).
701
+ prop_assert!(true);
702
+ }
703
+ }
704
+ }
705
+
706
+ #[test]
707
+ fn hits_never_overlap() {
708
+ let e = Engine::build(&defaults(), &[], "label", None).unwrap();
709
+ let text = "https://admin:hunter2@mail.example.com/ card 4111111111111111 \
710
+ key AKIAIOSFODNN7EXAMPLE ip 10.0.0.1 mac 00:1a:2b:3c:4d:5e";
711
+ let hits = e.scan(text.as_bytes());
712
+ for pair in hits.windows(2) {
713
+ assert!(pair[0].end <= pair[1].start, "overlap: {pair:?}");
714
+ }
715
+ }
716
+ }