Skip to main content

harn_vm/redact/
patterns.rs

1//! Free-form string secret patterns reused for redaction.
2//!
3//! Each pattern is named so the replacement placeholder is
4//! `<redacted:<pattern_name>:<len>>` and audit events can attribute the
5//! redaction to a specific provider. The shared
6//! [`crate::secret_patterns`] catalog is also used by the
7//! `secret_scan` builtin, so a string that scanning reports is also a
8//! string that redaction scrubs.
9//!
10//! # Custom patterns
11//!
12//! Hosts and scripts can register additional named patterns through
13//! [`register_custom_pattern`]. Custom patterns live on a thread-local
14//! stack so test pollution stays contained and so a per-orchestrator
15//! override can be installed alongside the existing
16//! [`crate::redact::PolicyGuard`].
17//!
18//! # Audit
19//!
20//! Every redaction synchronously records a [`RedactionEvent`] in a
21//! per-thread ring drainable via [`drain_audit_ring`], and also fires
22//! an optional [`AuditSink`] callback. The default sink installed by
23//! the [`crate::stdlib::token_redaction`] stdlib forwards events to
24//! the live events pipeline and, on a multi-threaded Tokio runtime,
25//! to the `audit.token_redaction` event-log topic. Audit entries
26//! carry the diagnostic identifier `HARN-OAU-001` from the OA-06
27//! epic — they never include the raw token.
28
29use std::borrow::Cow;
30use std::cell::RefCell;
31use std::collections::BTreeMap;
32
33use regex::Regex;
34
35use crate::secret_patterns::compiled_default_secret_patterns;
36
37/// Stable identifier emitted in audit logs for every token-redaction
38/// event. Part of the OA-06 epic's compliance contract.
39pub const TOKEN_REDACTION_DIAGNOSTIC: &str = "HARN-OAU-001";
40
41/// Event-log topic used for token-redaction audit events.
42pub const TOKEN_REDACTION_AUDIT_TOPIC: &str = "audit.token_redaction";
43
44/// Size of a single regex scan window. Inputs at or below this size are
45/// scanned in one pass; larger inputs are scanned in overlapping windows of
46/// this size (see [`SCAN_WINDOW_OVERLAP_BYTES`]) so no single regex call ever
47/// runs over more than this many bytes — keeping a pathological (custom)
48/// pattern from triggering catastrophic behavior on the persistence hot path
49/// — while a secret embedded anywhere in an oversized value is still redacted.
50const MAX_SCAN_INPUT_BYTES: usize = 256 * 1024;
51
52/// Overlap between consecutive scan windows for oversized inputs. Every window
53/// re-scans this many bytes of its predecessor's tail so a secret straddling a
54/// window boundary is fully contained in — and detected by — at least one
55/// window. It must exceed the longest possible secret match; 8 KiB is far above
56/// any real API key / token / PEM header while staying negligible relative to
57/// the 256 KiB window, so the windowed scan stays linear in the input length.
58const SCAN_WINDOW_OVERLAP_BYTES: usize = 8 * 1024;
59
60/// One redaction pattern with a stable display name.
61#[derive(Clone)]
62pub struct NamedPattern {
63    /// Short, kebab-case identifier (e.g. `"github_pat_classic"`).
64    /// Stable across versions — emitted in audit events and in the
65    /// `<redacted:name:len>` placeholder.
66    pub name: &'static str,
67    /// Compiled regex. Always anchored on `\b` or non-word boundaries
68    /// so it does not chew unrelated identifiers.
69    pub regex: Regex,
70}
71
72impl std::fmt::Debug for NamedPattern {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.debug_struct("NamedPattern")
75            .field("name", &self.name)
76            .field("regex", &self.regex.as_str())
77            .finish()
78    }
79}
80
81thread_local! {
82    /// Custom token patterns installed by stdlib callers. Stored on a
83    /// per-thread stack the same way [`crate::redact::PolicyGuard`]
84    /// stores active policies; `reset_thread_local_state` clears them.
85    static CUSTOM_PATTERNS: RefCell<Vec<NamedPattern>> = const { RefCell::new(Vec::new()) };
86
87    /// Callback that receives one entry per pattern that matched.
88    /// Set by callers that want to audit redactions
89    /// (`stdlib::token_redaction` installs a default sink that
90    /// forwards to the event log when a runtime is available).
91    /// `None` means "no extra audit collection on this thread".
92    /// Every redaction also lands in [`AUDIT_RING`] regardless of
93    /// whether a sink is installed.
94    static AUDIT_SINK: RefCell<Option<AuditSink>> = const { RefCell::new(None) };
95
96    /// Authoritative per-thread audit ring. Always populated on
97    /// every redaction so the synchronous compliance contract holds
98    /// in every execution context (sync host calls, single-threaded
99    /// LocalSet, multi-thread runtime). Drained by stdlib via
100    /// [`drain_audit_ring`].
101    static AUDIT_RING: RefCell<Vec<RedactionEvent>> = const { RefCell::new(Vec::new()) };
102}
103
104/// Per-redaction event passed to an installed [`AuditSink`].
105#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct RedactionEvent {
107    pub pattern_name: String,
108    pub match_count: usize,
109    /// Total bytes redacted across all matches of this pattern.
110    pub bytes_redacted: usize,
111}
112
113/// Thread-local callback invoked once per pattern that matched during a
114/// single `scan_secret_patterns` call.
115pub type AuditSink = std::rc::Rc<dyn Fn(&RedactionEvent)>;
116
117/// Register a custom named pattern on the calling thread. Returns an
118/// error if the regex fails to compile. The pattern is appended after
119/// the default catalog, so default patterns still win when multiple
120/// would match the same substring.
121pub fn register_custom_pattern(name: impl Into<String>, regex_source: &str) -> Result<(), String> {
122    let regex = Regex::new(regex_source).map_err(|error| format!("invalid regex: {error}"))?;
123    // Leak the name to `'static` so the pattern's name field stays
124    // borrow-free and serialization can carry the same lifetime as
125    // the default catalog. Custom patterns are rare and never freed
126    // — the leak is bounded by the number of distinct user-supplied
127    // names per process.
128    let name_static: &'static str = Box::leak(name.into().into_boxed_str());
129    CUSTOM_PATTERNS.with(|cell| {
130        cell.borrow_mut().push(NamedPattern {
131            name: name_static,
132            regex,
133        });
134    });
135    Ok(())
136}
137
138/// Drop all custom patterns installed via [`register_custom_pattern`]
139/// on the calling thread. Idempotent.
140pub fn clear_custom_patterns() {
141    CUSTOM_PATTERNS.with(|cell| cell.borrow_mut().clear());
142}
143
144/// Return the names of every default pattern, in catalog order.
145pub fn default_pattern_names() -> Vec<&'static str> {
146    compiled_default_secret_patterns()
147        .iter()
148        .map(|pattern| pattern.spec.redaction_name)
149        .collect()
150}
151
152/// Return the names of every custom pattern currently installed on the
153/// calling thread.
154pub fn custom_pattern_names() -> Vec<String> {
155    CUSTOM_PATTERNS.with(|cell| cell.borrow().iter().map(|p| p.name.to_string()).collect())
156}
157
158/// Install a per-thread audit sink. The previous sink (if any) is
159/// returned so callers can chain or restore.
160pub fn install_audit_sink(sink: Option<AuditSink>) -> Option<AuditSink> {
161    AUDIT_SINK.with(|cell| std::mem::replace(&mut *cell.borrow_mut(), sink))
162}
163
164fn emit_audit(events: &[RedactionEvent]) {
165    if events.is_empty() {
166        return;
167    }
168    // Always push to the per-thread ring so a synchronous
169    // `drain_audit_ring` call returns every event recorded since
170    // the last drain, regardless of whether an extra sink is
171    // installed on this thread.
172    AUDIT_RING.with(|ring| {
173        let mut ring = ring.borrow_mut();
174        for event in events {
175            // Bounded cap: 1024 entries is well above any realistic
176            // per-step audit pressure but small enough to be a
177            // no-op for normal workloads and to keep a runaway
178            // sink from OOMing the process.
179            if ring.len() >= 1024 {
180                ring.remove(0);
181            }
182            ring.push(event.clone());
183        }
184    });
185    let sink = AUDIT_SINK.with(|cell| cell.borrow().clone());
186    if let Some(sink) = sink {
187        for event in events {
188            sink(event);
189        }
190    }
191}
192
193/// Drain every audit event recorded on the calling thread since the
194/// last drain. The returned vec is in the order events fired.
195pub fn drain_audit_ring() -> Vec<RedactionEvent> {
196    AUDIT_RING.with(|ring| std::mem::take(&mut *ring.borrow_mut()))
197}
198
199/// Clear the per-thread audit ring without returning its contents.
200/// Used by `clear_policy_stack` so tests sharing a thread cannot
201/// leak audit events into each other.
202pub fn clear_audit_ring() {
203    AUDIT_RING.with(|ring| ring.borrow_mut().clear());
204}
205
206/// Build the per-match replacement string in the canonical
207/// `<redacted:<name>:<len>>` form. Length reflects the redacted match
208/// in UTF-8 bytes.
209fn replacement_for(name: &str, matched: &str) -> String {
210    format!("<redacted:{name}:{}>", matched.len())
211}
212
213/// Replace any high-confidence secret matches in `input` with the
214/// canonical `<redacted:<pattern_name>:<len>>` placeholder. Returns
215/// `Cow::Borrowed` when nothing matched, so callers paying for a clone
216/// only pay when there was real work.
217///
218/// The legacy `placeholder` argument is kept for callers that want a
219/// flat `[redacted]` form (e.g. headers and URL params). When the
220/// placeholder is the canonical `[redacted]` constant the named form
221/// is used; any other placeholder is substituted verbatim so callers
222/// that need a specific marker (URL-param escaping, etc.) still get
223/// it byte-for-byte.
224pub fn scan_secret_patterns<'a>(input: &'a str, placeholder: &str) -> Cow<'a, str> {
225    if input.is_empty() {
226        return Cow::Borrowed(input);
227    }
228    let use_named_placeholder = placeholder == crate::redact::REDACTED_PLACEHOLDER;
229
230    // Oversized inputs are scanned in overlapping windows instead of being
231    // passed through unredacted: a secret embedded in a large tool result,
232    // transcript, or base64 blob under a non-sensitive field name must not leak
233    // just because the whole value exceeds the single-pass window. No individual
234    // regex call ever sees more than one window, so a pathological custom
235    // pattern still cannot run over the entire giant string at once.
236    if input.len() > MAX_SCAN_INPUT_BYTES {
237        return scan_secret_patterns_windowed(input, use_named_placeholder, placeholder);
238    }
239
240    let mut owned: Option<String> = None;
241    let mut audit_events: BTreeMap<&'static str, RedactionEvent> = BTreeMap::new();
242
243    // Drive defaults then custom patterns. We collect custom
244    // patterns into a Vec so the closure does not borrow the
245    // thread-local across the regex calls.
246    let custom: Vec<NamedPattern> = CUSTOM_PATTERNS.with(|cell| cell.borrow().clone());
247    let all_patterns = compiled_default_secret_patterns()
248        .iter()
249        .map(|pattern| (pattern.spec.redaction_name, &pattern.regex))
250        .chain(custom.iter().map(|pattern| (pattern.name, &pattern.regex)));
251
252    for (pattern_name, regex) in all_patterns {
253        let target: &str = owned.as_deref().unwrap_or(input);
254        let matches: Vec<(usize, usize)> = regex
255            .find_iter(target)
256            .map(|m| (m.start(), m.end()))
257            .collect();
258        if matches.is_empty() {
259            continue;
260        }
261        let total_bytes: usize = matches.iter().map(|(s, e)| e - s).sum();
262        audit_events.insert(
263            pattern_name,
264            RedactionEvent {
265                pattern_name: pattern_name.to_string(),
266                match_count: matches.len(),
267                bytes_redacted: total_bytes,
268            },
269        );
270
271        // Walk matches in reverse so we can splice without
272        // recomputing offsets after each cut.
273        let mut buffer = target.to_string();
274        for (start, end) in matches.into_iter().rev() {
275            #[expect(
276                clippy::string_slice,
277                reason = "regex match offsets; reverse splices keep earlier offsets intact"
278            )]
279            let matched_slice = &buffer[start..end];
280            let replacement = if use_named_placeholder {
281                replacement_for(pattern_name, matched_slice)
282            } else {
283                placeholder.to_string()
284            };
285            buffer.replace_range(start..end, &replacement);
286        }
287        owned = Some(buffer);
288    }
289
290    let result = match owned {
291        Some(value) if value == input => Cow::Borrowed(input),
292        Some(value) => Cow::Owned(value),
293        None => Cow::Borrowed(input),
294    };
295
296    if matches!(result, Cow::Owned(_)) {
297        let events: Vec<RedactionEvent> = audit_events.into_values().collect();
298        emit_audit(&events);
299    }
300
301    result
302}
303
304/// Round `offset` down to the nearest UTF-8 char boundary (or `0`).
305fn floor_char_boundary(s: &str, mut offset: usize) -> usize {
306    if offset >= s.len() {
307        return s.len();
308    }
309    while offset > 0 && !s.is_char_boundary(offset) {
310        offset -= 1;
311    }
312    offset
313}
314
315/// Round `offset` up to the nearest UTF-8 char boundary (or `s.len()`).
316fn ceil_char_boundary(s: &str, mut offset: usize) -> usize {
317    if offset >= s.len() {
318        return s.len();
319    }
320    while offset < s.len() && !s.is_char_boundary(offset) {
321        offset += 1;
322    }
323    offset
324}
325
326/// Overlapping-window variant of [`scan_secret_patterns`] for inputs larger
327/// than [`MAX_SCAN_INPUT_BYTES`].
328///
329/// Each pattern is scanned over consecutive windows of `MAX_SCAN_INPUT_BYTES`
330/// bytes that overlap their predecessor by [`SCAN_WINDOW_OVERLAP_BYTES`]. Because
331/// the overlap exceeds the longest possible secret, every match is *strictly
332/// interior* to at least one window; a match that touches an artificial
333/// (non-terminal) window edge is dropped there — which also discards the false
334/// `\b` word boundaries that slicing would otherwise create — and picked up
335/// whole in the neighbouring window. Global match ranges are collected in
336/// pattern-priority order (earlier patterns win on overlap, matching the
337/// single-pass path) and spliced once from the end so offsets stay valid.
338#[expect(
339    clippy::string_slice,
340    reason = "ws/we are char-boundary-snapped; gs/ge are window-relative regex offsets"
341)]
342fn scan_secret_patterns_windowed<'a>(
343    input: &'a str,
344    use_named_placeholder: bool,
345    placeholder: &str,
346) -> Cow<'a, str> {
347    let step = MAX_SCAN_INPUT_BYTES - SCAN_WINDOW_OVERLAP_BYTES;
348    let custom: Vec<NamedPattern> = CUSTOM_PATTERNS.with(|cell| cell.borrow().clone());
349
350    // Claimed global byte ranges, kept sorted by start. Each range also carries
351    // its replacement text and the pattern that produced it (for audit).
352    struct Claim {
353        start: usize,
354        end: usize,
355        replacement: String,
356        pattern: &'static str,
357    }
358    let mut claims: Vec<Claim> = Vec::new();
359
360    let all_patterns = compiled_default_secret_patterns()
361        .iter()
362        .map(|pattern| (pattern.spec.redaction_name, &pattern.regex))
363        .chain(custom.iter().map(|pattern| (pattern.name, &pattern.regex)));
364    for (pattern_name, regex) in all_patterns {
365        let mut window_start = 0usize;
366        loop {
367            let ws = floor_char_boundary(input, window_start);
368            let we = ceil_char_boundary(
369                input,
370                (window_start + MAX_SCAN_INPUT_BYTES).min(input.len()),
371            );
372            for m in regex.find_iter(&input[ws..we]) {
373                let gs = ws + m.start();
374                let ge = ws + m.end();
375                // Drop matches touching an artificial window edge; the same
376                // secret is strictly interior to the neighbouring window.
377                if (gs == ws && ws != 0) || (ge == we && we != input.len()) {
378                    continue;
379                }
380                // First (highest-priority) pattern wins on overlap; this also
381                // deduplicates a match seen in two overlapping windows.
382                if claims.iter().any(|c| gs < c.end && c.start < ge) {
383                    continue;
384                }
385                let replacement = if use_named_placeholder {
386                    replacement_for(pattern_name, &input[gs..ge])
387                } else {
388                    placeholder.to_string()
389                };
390                claims.push(Claim {
391                    start: gs,
392                    end: ge,
393                    replacement,
394                    pattern: pattern_name,
395                });
396            }
397            if we >= input.len() {
398                break;
399            }
400            window_start += step;
401        }
402    }
403
404    if claims.is_empty() {
405        return Cow::Borrowed(input);
406    }
407
408    claims.sort_by_key(|c| c.start);
409
410    // Audit tallies, grouped by pattern in catalog order.
411    let mut audit_events: BTreeMap<&'static str, RedactionEvent> = BTreeMap::new();
412    for claim in &claims {
413        let event = audit_events
414            .entry(claim.pattern)
415            .or_insert_with(|| RedactionEvent {
416                pattern_name: claim.pattern.to_string(),
417                match_count: 0,
418                bytes_redacted: 0,
419            });
420        event.match_count += 1;
421        event.bytes_redacted += claim.end - claim.start;
422    }
423
424    let mut out = input.to_string();
425    for claim in claims.iter().rev() {
426        out.replace_range(claim.start..claim.end, &claim.replacement);
427    }
428
429    emit_audit(&audit_events.into_values().collect::<Vec<_>>());
430    Cow::Owned(out)
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    fn run_clean() {
438        clear_custom_patterns();
439        install_audit_sink(None);
440        clear_audit_ring();
441    }
442
443    #[test]
444    fn returns_borrowed_when_clean() {
445        run_clean();
446        let out = scan_secret_patterns("just plain text", crate::redact::REDACTED_PLACEHOLDER);
447        assert!(matches!(out, Cow::Borrowed(_)));
448    }
449
450    #[test]
451    fn replaces_aws_and_github_tokens_with_named_placeholder() {
452        run_clean();
453        let input = "AKIAABCDEFGHIJKLMNOP and ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
454        let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
455        let rendered = out.into_owned();
456        assert!(rendered.contains("<redacted:aws_access_key:20>"));
457        assert!(rendered.contains("<redacted:github_token:40>"));
458        assert!(!rendered.contains("AKIAABCDEFGHIJKLMNOP"));
459    }
460
461    #[test]
462    fn legacy_placeholder_path_still_works_for_url_param_values() {
463        run_clean();
464        let input = "AKIAABCDEFGHIJKLMNOP";
465        // A non-`[redacted]` placeholder is used verbatim — this is
466        // the URL-param escaping path.
467        let out = scan_secret_patterns(input, "%5Bredacted%5D");
468        assert!(out.contains("%5Bredacted%5D"));
469        assert!(!out.contains("AKIAABCDEFGHIJKLMNOP"));
470    }
471
472    #[test]
473    fn replaces_bearer_token_inside_text() {
474        run_clean();
475        let input = "header: Authorization: Bearer abcDEFghi123_-+/=xyz tail";
476        let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
477        assert!(out.contains("<redacted:bearer_token:"));
478        assert!(!out.contains("abcDEFghi123_-+/=xyz"));
479        assert!(out.contains("tail"));
480    }
481
482    #[test]
483    fn replaces_sensitive_assignments_inside_text() {
484        run_clean();
485        let input = "retry with token=abc123 and max_tokens=200";
486        let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
487        assert!(out.contains("<redacted:sensitive_assignment:"));
488        assert!(!out.contains("token=abc123"));
489        assert!(out.contains("max_tokens=200"));
490    }
491
492    #[test]
493    fn sensitive_assignment_preserves_source_declarations() {
494        run_clean();
495        let input = "pub const Token = struct { kind: u8 };\nconst Secret = enum { a, b };";
496        let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
497        assert!(matches!(out, Cow::Borrowed(_)));
498    }
499
500    #[test]
501    fn sensitive_assignment_redacts_placeholder_secret_words() {
502        run_clean();
503        let input = "Checkout incident needed the same query token=secret";
504        let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
505        assert!(out.contains("<redacted:sensitive_assignment:"));
506        assert!(!out.contains("token=secret"));
507    }
508
509    #[test]
510    fn replaces_jwt_tokens() {
511        run_clean();
512        let input = "token=eyJabcd.eyJefgh.signature_pad here";
513        let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
514        assert!(out.contains("<redacted:jwt:"));
515        assert!(!out.contains("eyJabcd.eyJefgh.signature_pad"));
516    }
517
518    #[test]
519    fn replaces_private_key_blocks() {
520        run_clean();
521        let input =
522            "-----BEGIN OPENSSH PRIVATE KEY-----\nsecret-material\n-----END OPENSSH PRIVATE KEY-----";
523        let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
524        assert!(out.contains("<redacted:private_key_block:"));
525        assert!(!out.contains("secret-material"));
526    }
527
528    #[test]
529    fn replaces_ai_provider_tokens() {
530        run_clean();
531        let huggingface = format!("hf_{}", "a".repeat(24));
532        let cerebras = format!("csk-{}", "b".repeat(48));
533        let together = format!("tgp_v1_{}", "c".repeat(32));
534        let google = format!("AIza{}", "D".repeat(35));
535        let input = format!("{huggingface} {cerebras} {together} {google}");
536
537        let out = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
538        let rendered = out.into_owned();
539
540        assert!(rendered.contains("<redacted:huggingface_token:"));
541        assert!(rendered.contains("<redacted:cerebras_key:"));
542        assert!(rendered.contains("<redacted:together_key:"));
543        assert!(rendered.contains("<redacted:google_api_key:"));
544        assert!(!rendered.contains(&huggingface));
545        assert!(!rendered.contains(&cerebras));
546        assert!(!rendered.contains(&together));
547        assert!(!rendered.contains(&google));
548    }
549
550    #[test]
551    fn custom_pattern_redacts_and_is_introspectable() {
552        run_clean();
553        register_custom_pattern("acme_token", r"\bACME-[A-Z0-9]{8}\b").unwrap();
554        assert_eq!(custom_pattern_names(), vec!["acme_token".to_string()]);
555        let out = scan_secret_patterns(
556            "header ACME-12345678 trailer",
557            crate::redact::REDACTED_PLACEHOLDER,
558        );
559        assert!(
560            out.contains("<redacted:acme_token:13>"),
561            "expected acme_token redaction, got: {out}"
562        );
563        clear_custom_patterns();
564        assert!(custom_pattern_names().is_empty());
565    }
566
567    #[test]
568    fn audit_sink_receives_one_event_per_matching_pattern() {
569        use std::cell::RefCell;
570        use std::rc::Rc;
571        run_clean();
572        let captured: Rc<RefCell<Vec<RedactionEvent>>> = Rc::new(RefCell::new(Vec::new()));
573        let sink_captured = captured.clone();
574        install_audit_sink(Some(Rc::new(move |event| {
575            sink_captured.borrow_mut().push(event.clone());
576        })));
577        let input =
578            "AKIAABCDEFGHIJKLMNOP AKIA0000000000000000 ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
579        let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
580        assert!(matches!(out, Cow::Owned(_)));
581        let events = captured.borrow();
582        assert_eq!(events.len(), 2);
583        let by_name: BTreeMap<&str, &RedactionEvent> = events
584            .iter()
585            .map(|event| (event.pattern_name.as_str(), event))
586            .collect();
587        assert_eq!(by_name.get("aws_access_key").unwrap().match_count, 2);
588        assert_eq!(by_name.get("github_token").unwrap().match_count, 1);
589        // The synchronous ring captures the same events so a
590        // compliance drain returns them regardless of which sink
591        // (if any) is installed.
592        drop(events);
593        install_audit_sink(None);
594        let ring = drain_audit_ring();
595        assert_eq!(ring.len(), 2);
596    }
597
598    #[test]
599    fn audit_ring_records_events_even_without_a_sink() {
600        run_clean();
601        let _ = scan_secret_patterns("AKIAABCDEFGHIJKLMNOP", crate::redact::REDACTED_PLACEHOLDER);
602        let ring = drain_audit_ring();
603        assert_eq!(ring.len(), 1);
604        assert_eq!(ring[0].pattern_name, "aws_access_key");
605        // Drain is destructive.
606        assert!(drain_audit_ring().is_empty());
607    }
608
609    const AWS_KEY: &str = "AKIAABCDEFGHIJKLMNOP";
610
611    #[test]
612    fn secret_past_the_scan_cap_is_redacted() {
613        // A secret placed well beyond MAX_SCAN_INPUT_BYTES must still be scrubbed
614        // — the old behavior passed the whole value through unredacted.
615        run_clean();
616        let mut input = " ".repeat(MAX_SCAN_INPUT_BYTES + 4096);
617        input.push_str(AWS_KEY);
618        input.push(' ');
619        assert!(input.len() > MAX_SCAN_INPUT_BYTES);
620        let out = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
621        assert!(matches!(out, Cow::Owned(_)), "oversized secret must redact");
622        #[expect(clippy::string_slice, reason = "test input is ASCII")]
623        let tail = &out[out.len().saturating_sub(64)..];
624        assert!(!out.contains(AWS_KEY), "secret leaked: {tail}");
625        assert!(out.contains("<redacted:aws_access_key:20>"));
626    }
627
628    #[test]
629    fn secret_straddling_a_window_boundary_is_redacted() {
630        // Place the 20-byte key so it spans the first window's end
631        // (MAX_SCAN_INPUT_BYTES): half inside window 0, half beyond. The overlap
632        // guarantees it is fully interior to window 1 and thus detected.
633        run_clean();
634        let prefix_len = MAX_SCAN_INPUT_BYTES - (AWS_KEY.len() / 2);
635        let mut input = " ".repeat(prefix_len);
636        input.push_str(AWS_KEY);
637        input.push_str(&" ".repeat(SCAN_WINDOW_OVERLAP_BYTES)); // ensure a 2nd window exists
638        let out = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
639        assert!(!out.contains(AWS_KEY), "straddling secret leaked");
640        assert!(out.contains("<redacted:aws_access_key:20>"));
641        // Exactly one redaction — the overlap must not double-count it.
642        assert_eq!(out.matches("<redacted:aws_access_key:20>").count(), 1);
643    }
644
645    #[test]
646    fn oversized_non_secret_blob_is_not_over_redacted() {
647        // A large innocuous value (no secret) must pass through untouched, not be
648        // blanket-redacted.
649        run_clean();
650        let blob = "lorem ipsum dolor sit amet ".repeat(MAX_SCAN_INPUT_BYTES / 20);
651        assert!(blob.len() > MAX_SCAN_INPUT_BYTES);
652        let out = scan_secret_patterns(&blob, crate::redact::REDACTED_PLACEHOLDER);
653        assert!(
654            matches!(out, Cow::Borrowed(_)),
655            "clean blob must not be rewritten"
656        );
657        assert_eq!(out.as_ref(), blob);
658    }
659
660    #[test]
661    fn oversized_scan_records_audit_event() {
662        run_clean();
663        let mut input = " ".repeat(MAX_SCAN_INPUT_BYTES + 100);
664        input.push_str(AWS_KEY);
665        input.push(' ');
666        let _ = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
667        let ring = drain_audit_ring();
668        assert_eq!(ring.len(), 1);
669        assert_eq!(ring[0].pattern_name, "aws_access_key");
670        assert_eq!(ring[0].match_count, 1);
671        assert_eq!(ring[0].bytes_redacted, 20);
672    }
673
674    #[test]
675    fn multi_megabyte_scan_stays_linear_and_redacts() {
676        // ~5 MiB (≈20 windows) with a single embedded secret. The windowed scan
677        // is linear in the input, so this returns near-instantly; a catastrophic
678        // (e.g. O(n²)) regression would instead blow the test-runner timeout. We
679        // assert on the result rather than the wall clock to stay deterministic.
680        run_clean();
681        let mut input = "x ".repeat(5 * 1024 * 1024 / 2);
682        input.push_str(AWS_KEY);
683        input.push(' ');
684        let out = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
685        assert!(!out.contains(AWS_KEY));
686        assert_eq!(out.matches("<redacted:aws_access_key:20>").count(), 1);
687    }
688
689    #[test]
690    fn default_pattern_names_are_stable() {
691        let names = default_pattern_names();
692        assert!(names.contains(&"jwt"));
693        assert!(names.contains(&"github_token"));
694        assert!(names.contains(&"github_pat_fine"));
695        assert!(names.contains(&"slack_token"));
696        assert!(names.contains(&"aws_access_key"));
697        assert!(names.contains(&"huggingface_token"));
698        assert!(names.contains(&"cerebras_key"));
699        assert!(names.contains(&"together_key"));
700        assert!(names.contains(&"google_api_key"));
701        assert!(names.contains(&"private_key_block"));
702        assert!(names.contains(&"bearer_token"));
703        assert!(names.contains(&"sensitive_assignment"));
704    }
705}