Skip to main content

agent_sdk_foundation/privacy/
redaction.rs

1//! Configurable structural redaction policy for tool inputs, outputs,
2//! and observability payloads.
3//!
4//! Tool calls, audit records, and span attributes routinely carry
5//! sensitive data (passwords, API keys, tokens, connection strings,
6//! and — importantly for financial workloads — card PANs, CPFs,
7//! CNPJs, emails, phone numbers) that should not be stored in
8//! durable audit logs or exported across an observability boundary
9//! without explicit redaction. This module provides:
10//!
11//! - [`RedactionPolicy`] — configurable redaction rules with three
12//!   levels: [`None`](RedactionLevel::None),
13//!   [`Baseline`](RedactionLevel::Baseline), and
14//!   [`Full`](RedactionLevel::Full).
15//! - [`redact_value`] — applies redaction rules to a JSON value,
16//!   replacing sensitive keys with a `[REDACTED]` marker and
17//!   masking entity PII in string leaves with `[REDACTED:<category>]`.
18//! - [`redact_string`] / [`redact_error`] — apply redaction rules
19//!   to plain strings.
20//! - [`redact_for_observability`] — combined helper that runs the
21//!   structural [`RedactionPolicy`] *and* a caller-supplied
22//!   [`PiiDetector`] in a single pass, suitable for the SDK
23//!   observability boundary.
24//!
25//! # Baseline policy
26//!
27//! The [`RedactionPolicy::baseline`] constructor returns a policy
28//! that composes two redaction layers:
29//!
30//! 1. **Structural** — JSON object keys matching sensitive names
31//!    (`password`, `secret`, `token`, `api_key`, `authorization`,
32//!    `credential`, `cpf`, `cnpj`, etc.) wholesale-redact their
33//!    value. String values that *start with* a sensitive prefix
34//!    (`Bearer `, `sk-`, `ghp_`, `AKIA…`) are likewise wholesale
35//!    redacted.
36//! 2. **Entity-level** — a [`PiiDetector`] scans every remaining
37//!    string leaf for emails, E.164 phones, credit card PANs (Luhn
38//!    validated), Brazilian CPFs and CNPJs (mod-11 validated), Pix
39//!    UUID keys, IPv4 addresses, JWTs, and embedded credential
40//!    tokens. Detected spans are replaced with
41//!    `[REDACTED:<category>]` while the surrounding context stays
42//!    intact. This catches PII that leaks into freeform text (e.g.
43//!    a PAN mentioned in a tool response) without wrecking
44//!    debuggability.
45//!
46//! The detector defaults to [`BaselineDetector`]. Callers can plug
47//! in a custom detector by assigning [`RedactionPolicy::detector`]
48//! directly, or by passing a different detector to
49//! [`redact_for_observability`].
50//!
51//! # Default impl
52//!
53//! [`RedactionPolicy`] implements `Default` by returning
54//! [`RedactionPolicy::baseline()`] — never an empty policy. Code
55//! that wants a genuinely empty policy must opt in via
56//! [`RedactionPolicy::none`] and acknowledge the production-safety
57//! implications.
58//!
59//! # Serialisation
60//!
61//! [`RedactionPolicy`] is `Serialize` + `Deserialize`. The detector
62//! is skipped on serialize and re-populated with the process-wide
63//! baseline on deserialize — policies persisted to disk retain
64//! their levels and pattern lists, and the runtime detector is
65//! rebound on load.
66//!
67//! # Usage
68//!
69//! ```
70//! use agent_sdk_foundation::privacy::{RedactionPolicy, redact_value};
71//!
72//! let policy = RedactionPolicy::baseline();
73//! let input = serde_json::json!({
74//!     "command": "echo hello",
75//!     "api_key": "sk-abc123",
76//!     "note": "CPF 111.444.777-35 on file"
77//! });
78//! let redacted = redact_value(&input, &policy);
79//! // redacted["api_key"] == "[REDACTED]"       (sensitive key)
80//! // redacted["command"] == "echo hello"       (no PII)
81//! // redacted["note"] contains "[REDACTED:cpf]" (entity mask)
82//! # let _ = redacted;
83//! ```
84
85use serde::{Deserialize, Serialize};
86use std::sync::{Arc, LazyLock};
87
88use super::{BaselineDetector, NoopDetector, PiiDetector, SECRET_PREFIXES, mask_spans};
89
90/// Redaction marker used for wholesale redaction (sensitive key
91/// match or full-string secret prefix). Entity-level masks use
92/// `[REDACTED:<category>]` — see [`crate::privacy`].
93pub const REDACTED_MARKER: &str = "[REDACTED]";
94
95/// Shared baseline detector. Compiled lazily on first use; cloning
96/// the `Arc` is a single atomic inc.
97static BASELINE_DETECTOR: LazyLock<Arc<dyn PiiDetector>> = LazyLock::new(|| {
98    BaselineDetector::new().map_or_else(
99        |_| Arc::new(NoopDetector) as Arc<dyn PiiDetector>,
100        |d| Arc::new(d) as Arc<dyn PiiDetector>,
101    )
102});
103
104/// Shared noop detector.
105static NOOP_DETECTOR: LazyLock<Arc<dyn PiiDetector>> =
106    LazyLock::new(|| Arc::new(NoopDetector) as Arc<dyn PiiDetector>);
107
108/// Default detector used when a policy is deserialised without an
109/// embedded detector (which is always, since the field is
110/// `#[serde(skip)]`).
111fn default_detector() -> Arc<dyn PiiDetector> {
112    BASELINE_DETECTOR.clone()
113}
114
115// ─────────────────────────────────────────────────────────────────────
116// Redaction level
117// ─────────────────────────────────────────────────────────────────────
118
119/// How aggressively to redact a given field category.
120#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "snake_case")]
122pub enum RedactionLevel {
123    /// No redaction — store full values as-is.
124    None,
125    /// Redact values whose keys match sensitive patterns.
126    Baseline,
127    /// Full redaction — all values replaced with [`REDACTED_MARKER`].
128    Full,
129}
130
131// ─────────────────────────────────────────────────────────────────────
132// Redaction policy
133// ─────────────────────────────────────────────────────────────────────
134
135/// Configurable redaction rules for tool audit records and
136/// observability payloads.
137///
138/// Each field category (input, output, error) has its own
139/// [`RedactionLevel`]. At [`Baseline`](RedactionLevel::Baseline) the
140/// policy composes two layers:
141///
142/// 1. Structural — [`sensitive_key_patterns`](Self::sensitive_key_patterns)
143///    triggers wholesale replacement of JSON object values by key
144///    name, and [`sensitive_value_prefixes`](Self::sensitive_value_prefixes)
145///    does the same for strings that *start with* a known prefix.
146/// 2. Entity-level — [`detector`](Self::detector) scans every
147///    remaining string leaf for emails, PANs, CPFs, CNPJs, etc. and
148///    masks the spans it finds in place.
149///
150/// The detector is a runtime object not persisted across
151/// serialisation; on deserialise it is rebound to the process-wide
152/// [`BaselineDetector`].
153#[derive(Clone, Debug, Serialize, Deserialize)]
154pub struct RedactionPolicy {
155    /// Redaction level for tool input values.
156    pub input_level: RedactionLevel,
157    /// Redaction level for tool output values.
158    pub output_level: RedactionLevel,
159    /// Redaction level for error detail strings.
160    pub error_level: RedactionLevel,
161    /// Key substrings that trigger redaction at baseline level.
162    /// Stored lowercase; matched case-insensitively.
163    pub sensitive_key_patterns: Vec<String>,
164    /// String patterns in values that trigger redaction at baseline
165    /// level (e.g. `"Bearer "`, `"sk-"`). Case-sensitive prefix match.
166    pub sensitive_value_prefixes: Vec<String>,
167    /// Entity-level PII detector applied at baseline. Defaults to
168    /// [`BaselineDetector`]; assign directly to plug in a custom
169    /// implementation.
170    #[serde(skip, default = "default_detector")]
171    pub detector: Arc<dyn PiiDetector>,
172}
173
174impl RedactionPolicy {
175    /// Baseline redaction policy suitable for production audit logs
176    /// and observability exports.
177    ///
178    /// Redacts JSON object keys that look like credentials and
179    /// string values that look like tokens wholesale, and masks
180    /// entity-level PII (emails, PANs, CPFs, CNPJs, Pix UUIDs,
181    /// E.164 phones, IPs, JWTs) detected anywhere in remaining
182    /// string leaves. Preserves non-sensitive structural data for
183    /// debugging.
184    ///
185    /// All three levels (`input_level`, `output_level`,
186    /// `error_level`) default to [`Baseline`](RedactionLevel::Baseline).
187    /// Error strings routinely embed user data in stack traces
188    /// (`NotFound: user cpf=…`), so masking them is the safer
189    /// default — callers that need raw errors can explicitly set
190    /// `error_level: RedactionLevel::None` on a baseline policy.
191    #[must_use]
192    pub fn baseline() -> Self {
193        Self {
194            input_level: RedactionLevel::Baseline,
195            output_level: RedactionLevel::Baseline,
196            error_level: RedactionLevel::Baseline,
197            sensitive_key_patterns: vec![
198                "password".into(),
199                "passwd".into(),
200                "secret".into(),
201                "token".into(),
202                "api_key".into(),
203                "apikey".into(),
204                "authorization".into(),
205                "credential".into(),
206                "private_key".into(),
207                "private".into(),
208                "access_key".into(),
209                "session".into(),
210                "cookie".into(),
211                "bearer".into(),
212                "ssn".into(),
213                "credit_card".into(),
214                "cpf".into(),
215                "cnh".into(),
216                "cnpj".into(),
217                "crm".into(),
218                "passport".into(),
219                "driver_license".into(),
220                "social_security".into(),
221                "social_security_number".into(),
222            ],
223            // Built from the single shared prefix list so this and
224            // `SecretDetector` cannot drift (previously this list was missing
225            // `ghs_`, `ghu_`, and `AIza`).
226            sensitive_value_prefixes: SECRET_PREFIXES.iter().map(|p| (*p).to_owned()).collect(),
227            detector: default_detector(),
228        }
229    }
230
231    /// Baseline policy plus additional sensitive key patterns.
232    ///
233    /// Custom keys *augment* the baseline list — they do not replace
234    /// it. Patterns are normalised to lowercase to keep the
235    /// case-insensitive matching contract intact.
236    ///
237    /// ```
238    /// use agent_sdk_foundation::privacy::RedactionPolicy;
239    /// let policy = RedactionPolicy::with_keys(["chave_pix".to_owned()]);
240    /// assert!(policy.sensitive_key_patterns.iter().any(|p| p == "password"));
241    /// assert!(policy.sensitive_key_patterns.iter().any(|p| p == "chave_pix"));
242    /// ```
243    #[must_use]
244    pub fn with_keys(keys: impl IntoIterator<Item = String>) -> Self {
245        let mut policy = Self::baseline();
246        policy.extend(keys);
247        policy
248    }
249
250    /// Append additional sensitive key patterns to this policy.
251    ///
252    /// Patterns are normalised to lowercase. Duplicates (relative
253    /// to the existing list) are silently dropped.
254    pub fn extend(&mut self, keys: impl IntoIterator<Item = String>) {
255        for key in keys {
256            let lower = key.to_lowercase();
257            if !self.sensitive_key_patterns.contains(&lower) {
258                self.sensitive_key_patterns.push(lower);
259            }
260        }
261    }
262
263    /// No-redaction policy — stores all values as-is.
264    ///
265    /// Suitable only for development and testing. Never use in
266    /// production audit logs.
267    #[must_use]
268    pub fn none() -> Self {
269        Self {
270            input_level: RedactionLevel::None,
271            output_level: RedactionLevel::None,
272            error_level: RedactionLevel::None,
273            sensitive_key_patterns: Vec::new(),
274            sensitive_value_prefixes: Vec::new(),
275            detector: NOOP_DETECTOR.clone(),
276        }
277    }
278
279    /// Full-redaction policy — replaces all input/output/error content.
280    ///
281    /// Suitable for high-security environments where no tool data
282    /// should be stored in audit logs.
283    #[must_use]
284    pub fn full() -> Self {
285        Self {
286            input_level: RedactionLevel::Full,
287            output_level: RedactionLevel::Full,
288            error_level: RedactionLevel::Full,
289            sensitive_key_patterns: Vec::new(),
290            sensitive_value_prefixes: Vec::new(),
291            detector: NOOP_DETECTOR.clone(),
292        }
293    }
294
295    /// Inherent shorthand for [`redact_value`].
296    ///
297    /// Returns a fresh `serde_json::Value` with the policy applied.
298    /// Use [`redact_in_place`](Self::redact_in_place) instead when
299    /// the caller already owns the value and wants to avoid the
300    /// clone.
301    #[must_use]
302    pub fn redact(&self, value: &serde_json::Value) -> serde_json::Value {
303        redact_value(value, self)
304    }
305
306    /// Apply the policy's `input_level` rules to `value` in place.
307    ///
308    /// Mutates `value` directly: object/array contents are walked
309    /// and string leaves are replaced with masked strings without
310    /// cloning the entire tree.
311    pub fn redact_in_place(&self, value: &mut serde_json::Value) {
312        match self.input_level {
313            RedactionLevel::None => {}
314            RedactionLevel::Full => {
315                *value = serde_json::json!(REDACTED_MARKER);
316            }
317            RedactionLevel::Baseline => self.redact_baseline_in_place(value),
318        }
319    }
320
321    fn redact_baseline_in_place(&self, value: &mut serde_json::Value) {
322        self.redact_baseline_in_place_with(value, &*self.detector);
323    }
324
325    /// The single baseline tree walk, parameterised on the detector.
326    ///
327    /// Both the in-place entry point (using `self.detector`) and the cloning
328    /// [`redact_baseline_with_detector`] (using a caller-supplied detector)
329    /// funnel through here, so a redaction-rule change is made in exactly one
330    /// place and the two entry points can never silently diverge.
331    fn redact_baseline_in_place_with(
332        &self,
333        value: &mut serde_json::Value,
334        detector: &dyn PiiDetector,
335    ) {
336        match value {
337            serde_json::Value::Object(map) => {
338                for (key, val) in map.iter_mut() {
339                    if self.is_sensitive_key(key) {
340                        *val = serde_json::json!(REDACTED_MARKER);
341                    } else {
342                        self.redact_baseline_in_place_with(val, detector);
343                    }
344                }
345            }
346            serde_json::Value::Array(arr) => {
347                for v in arr.iter_mut() {
348                    self.redact_baseline_in_place_with(v, detector);
349                }
350            }
351            serde_json::Value::String(s) => {
352                if self.is_sensitive_value(s) {
353                    *value = serde_json::json!(REDACTED_MARKER);
354                    return;
355                }
356                let spans = detector.detect(s);
357                if !spans.is_empty() {
358                    *s = mask_spans(s, &spans);
359                }
360            }
361            _ => {}
362        }
363    }
364
365    /// Check whether a JSON key matches any sensitive key pattern
366    /// (case-insensitive substring match).
367    ///
368    /// Uses an allocation-free ASCII case-insensitive comparison: the baseline
369    /// patterns are all lowercase ASCII, so a per-key `to_lowercase()` (heap
370    /// allocation + full Unicode folding) buys nothing on the hot redaction
371    /// path. Non-ASCII keys never match an ASCII pattern and are handled safely.
372    #[must_use]
373    fn is_sensitive_key(&self, key: &str) -> bool {
374        self.sensitive_key_patterns
375            .iter()
376            .any(|pattern| ascii_contains_ignore_case(key, pattern))
377    }
378
379    /// Check whether a string value matches any sensitive value prefix.
380    #[must_use]
381    fn is_sensitive_value(&self, value: &str) -> bool {
382        self.sensitive_value_prefixes
383            .iter()
384            .any(|prefix| value.starts_with(prefix.as_str()))
385    }
386}
387
388impl Default for RedactionPolicy {
389    /// Returns [`Self::baseline()`] — never an empty policy.
390    ///
391    /// This is loud on purpose: code that derives `Default` on a
392    /// struct containing `RedactionPolicy` gets the baseline
393    /// (sensitive-key list + entity detector) automatically rather
394    /// than an empty pass-through that would silently leak PII.
395    /// Code that wants a genuinely empty policy must opt in via
396    /// [`RedactionPolicy::none`].
397    fn default() -> Self {
398        Self::baseline()
399    }
400}
401
402// ─────────────────────────────────────────────────────────────────────
403// Free redaction functions
404// ─────────────────────────────────────────────────────────────────────
405
406/// Apply redaction rules to a JSON value based on the given policy's
407/// input level.
408///
409/// - [`None`](RedactionLevel::None): returns the value unchanged.
410/// - [`Baseline`](RedactionLevel::Baseline): recursively walks JSON
411///   objects and redacts values whose keys match sensitive patterns,
412///   or string values that match sensitive value prefixes; remaining
413///   string leaves are scanned by [`RedactionPolicy::detector`].
414/// - [`Full`](RedactionLevel::Full): returns `json!("[REDACTED]")`.
415#[must_use]
416pub fn redact_value(value: &serde_json::Value, policy: &RedactionPolicy) -> serde_json::Value {
417    apply_redaction(value, policy.input_level, policy)
418}
419
420/// Apply redaction rules to a string value based on the given policy's
421/// output level.
422///
423/// - [`None`](RedactionLevel::None): returns the string unchanged.
424/// - [`Baseline`](RedactionLevel::Baseline): wholesale-masks if the
425///   string matches any sensitive value prefix; otherwise applies
426///   entity detection and masks individual PII spans
427///   (`[REDACTED:<category>]`) while preserving surrounding context.
428/// - [`Full`](RedactionLevel::Full): returns `"[REDACTED]"`.
429#[must_use]
430pub fn redact_string(value: &str, policy: &RedactionPolicy) -> String {
431    match policy.output_level {
432        RedactionLevel::None => value.to_owned(),
433        RedactionLevel::Baseline => baseline_redact_str(value, &*policy.detector, policy),
434        RedactionLevel::Full => REDACTED_MARKER.to_owned(),
435    }
436}
437
438/// Apply redaction rules to an error string based on the given policy's
439/// error level. Same semantics as [`redact_string`], but gated by
440/// [`RedactionPolicy::error_level`].
441#[must_use]
442pub fn redact_error(value: &str, policy: &RedactionPolicy) -> String {
443    match policy.error_level {
444        RedactionLevel::None => value.to_owned(),
445        RedactionLevel::Baseline => baseline_redact_str(value, &*policy.detector, policy),
446        RedactionLevel::Full => REDACTED_MARKER.to_owned(),
447    }
448}
449
450/// Combined helper for the SDK observability boundary.
451///
452/// Performs a single tree walk that:
453///
454/// 1. Runs the structural [`RedactionPolicy`] (key-name match
455///    wholesale-redacts, sensitive-prefix strings wholesale-redact).
456/// 2. Runs the supplied [`PiiDetector`] on remaining string leaves
457///    (`[REDACTED:<category>]` markers preserve surrounding context).
458///
459/// This signature lets callers reuse a process-wide
460/// `Arc<dyn PiiDetector>` across many call sites without forcing
461/// every policy instance to carry the same detector. The policy's
462/// own [`detector`](RedactionPolicy::detector) field is **not**
463/// consulted by this function — pass it explicitly if that is
464/// the desired behaviour.
465///
466/// Composition contract: running this helper twice produces the
467/// same output as running it once. Already-masked
468/// `[REDACTED]` / `[REDACTED:<category>]` markers are left intact —
469/// the entity detector's regexes do not match them.
470///
471/// Honours `policy.input_level`:
472/// - [`None`](RedactionLevel::None): clones `value` unchanged.
473/// - [`Full`](RedactionLevel::Full): returns `json!("[REDACTED]")`.
474/// - [`Baseline`](RedactionLevel::Baseline): structural + entity
475///   detection as described above.
476#[must_use]
477pub fn redact_for_observability(
478    value: &serde_json::Value,
479    policy: &RedactionPolicy,
480    detector: &dyn PiiDetector,
481) -> serde_json::Value {
482    match policy.input_level {
483        RedactionLevel::None => value.clone(),
484        RedactionLevel::Full => serde_json::json!(REDACTED_MARKER),
485        RedactionLevel::Baseline => redact_baseline_with_detector(value, policy, detector),
486    }
487}
488
489// ─────────────────────────────────────────────────────────────────────
490// Internal helpers
491// ─────────────────────────────────────────────────────────────────────
492
493/// Shared baseline redaction for a plain string: prefix-match first
494/// (wholesale), then entity detection (span-level).
495fn baseline_redact_str(
496    value: &str,
497    detector: &dyn PiiDetector,
498    policy: &RedactionPolicy,
499) -> String {
500    if policy.is_sensitive_value(value) {
501        return REDACTED_MARKER.to_owned();
502    }
503    let spans = detector.detect(value);
504    if spans.is_empty() {
505        value.to_owned()
506    } else {
507        mask_spans(value, &spans)
508    }
509}
510
511/// Internal recursive redaction for JSON values.
512fn apply_redaction(
513    value: &serde_json::Value,
514    level: RedactionLevel,
515    policy: &RedactionPolicy,
516) -> serde_json::Value {
517    match level {
518        RedactionLevel::None => value.clone(),
519        RedactionLevel::Full => serde_json::json!(REDACTED_MARKER),
520        RedactionLevel::Baseline => redact_baseline(value, policy),
521    }
522}
523
524/// Baseline redaction using the policy's bundled detector.
525fn redact_baseline(value: &serde_json::Value, policy: &RedactionPolicy) -> serde_json::Value {
526    redact_baseline_with_detector(value, policy, &*policy.detector)
527}
528
529/// Baseline redaction with an externally supplied detector. Clones `value`,
530/// then applies the single in-place baseline walk
531/// ([`RedactionPolicy::redact_baseline_in_place_with`]) so the cloning and
532/// in-place entry points share one implementation.
533fn redact_baseline_with_detector(
534    value: &serde_json::Value,
535    policy: &RedactionPolicy,
536    detector: &dyn PiiDetector,
537) -> serde_json::Value {
538    let mut cloned = value.clone();
539    policy.redact_baseline_in_place_with(&mut cloned, detector);
540    cloned
541}
542
543/// Allocation-free ASCII case-insensitive substring search.
544///
545/// `needle` is assumed to be ASCII (all baseline patterns are stored lowercase
546/// ASCII). Bytes `>= 0x80` never compare equal to an ASCII needle byte, so a
547/// match only ever lands on a genuine ASCII subsequence — making this
548/// equivalent to `haystack.to_lowercase().contains(needle)` for ASCII needles,
549/// without the per-call allocation.
550fn ascii_contains_ignore_case(haystack: &str, needle: &str) -> bool {
551    let needle = needle.as_bytes();
552    if needle.is_empty() {
553        return true;
554    }
555    let haystack = haystack.as_bytes();
556    if needle.len() > haystack.len() {
557        return false;
558    }
559    haystack.windows(needle.len()).any(|window| {
560        window
561            .iter()
562            .zip(needle)
563            .all(|(h, n)| h.eq_ignore_ascii_case(n))
564    })
565}
566
567// ─────────────────────────────────────────────────────────────────────
568// Tests
569// ─────────────────────────────────────────────────────────────────────
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use crate::privacy::BaselineDetector;
575
576    // ── RedactionLevel ──────────────────────────────────────────
577
578    #[test]
579    fn redaction_level_round_trips_through_json() -> serde_json::Result<()> {
580        for level in [
581            RedactionLevel::None,
582            RedactionLevel::Baseline,
583            RedactionLevel::Full,
584        ] {
585            let json = serde_json::to_string(&level)?;
586            let back: RedactionLevel = serde_json::from_str(&json)?;
587            assert_eq!(back, level);
588        }
589        Ok(())
590    }
591
592    // ── RedactionPolicy construction ────────────────────────────
593
594    #[test]
595    fn baseline_policy_has_expected_defaults() {
596        let policy = RedactionPolicy::baseline();
597        assert_eq!(policy.input_level, RedactionLevel::Baseline);
598        assert_eq!(policy.output_level, RedactionLevel::Baseline);
599        // Errors default to Baseline too — stack traces often leak
600        // user PII and we'd rather mask by default than ship raw.
601        assert_eq!(policy.error_level, RedactionLevel::Baseline);
602        assert!(!policy.sensitive_key_patterns.is_empty());
603        assert!(!policy.sensitive_value_prefixes.is_empty());
604    }
605
606    #[test]
607    fn default_impl_returns_baseline_not_empty() {
608        // Hard-rule: Default must not produce a silent pass-through
609        // policy. It must equal baseline() in every observable way.
610        let default_policy = RedactionPolicy::default();
611        let baseline = RedactionPolicy::baseline();
612        assert_eq!(default_policy.input_level, baseline.input_level);
613        assert_eq!(
614            default_policy.sensitive_key_patterns,
615            baseline.sensitive_key_patterns
616        );
617        assert_eq!(
618            default_policy.sensitive_value_prefixes,
619            baseline.sensitive_value_prefixes
620        );
621    }
622
623    #[test]
624    fn none_policy_has_no_redaction() {
625        let policy = RedactionPolicy::none();
626        assert_eq!(policy.input_level, RedactionLevel::None);
627        assert_eq!(policy.output_level, RedactionLevel::None);
628        assert_eq!(policy.error_level, RedactionLevel::None);
629    }
630
631    #[test]
632    fn full_policy_redacts_everything() {
633        let policy = RedactionPolicy::full();
634        assert_eq!(policy.input_level, RedactionLevel::Full);
635        assert_eq!(policy.output_level, RedactionLevel::Full);
636        assert_eq!(policy.error_level, RedactionLevel::Full);
637    }
638
639    #[test]
640    fn policy_round_trips_through_json() -> serde_json::Result<()> {
641        let policy = RedactionPolicy::baseline();
642        let json = serde_json::to_string(&policy)?;
643        let back: RedactionPolicy = serde_json::from_str(&json)?;
644        assert_eq!(back.input_level, policy.input_level);
645        assert_eq!(
646            back.sensitive_key_patterns.len(),
647            policy.sensitive_key_patterns.len(),
648        );
649        Ok(())
650    }
651
652    // ── with_keys / extend ──────────────────────────────────────
653
654    #[test]
655    fn with_keys_includes_baseline_and_custom_keys() {
656        let policy = RedactionPolicy::with_keys(["chave_pix".to_owned()]);
657        // Baseline keys are still present.
658        assert!(
659            policy
660                .sensitive_key_patterns
661                .iter()
662                .any(|k| k == "password")
663        );
664        assert!(policy.sensitive_key_patterns.iter().any(|k| k == "api_key"));
665        // Custom key was added.
666        assert!(
667            policy
668                .sensitive_key_patterns
669                .iter()
670                .any(|k| k == "chave_pix")
671        );
672        // Both kinds redact correctly.
673        let input = serde_json::json!({
674            "chave_pix": "abc-123",
675            "password": "secret",
676            "ok": "visible",
677        });
678        let redacted = redact_value(&input, &policy);
679        assert_eq!(redacted["chave_pix"], REDACTED_MARKER);
680        assert_eq!(redacted["password"], REDACTED_MARKER);
681        assert_eq!(redacted["ok"], "visible");
682    }
683
684    #[test]
685    fn with_keys_normalises_case() {
686        // Custom keys get lower-cased so the case-insensitive
687        // contains() matcher still works.
688        let policy = RedactionPolicy::with_keys(["Chave_Pix".to_owned()]);
689        let input = serde_json::json!({ "CHAVE_PIX": "abc-123" });
690        let redacted = redact_value(&input, &policy);
691        assert_eq!(redacted["CHAVE_PIX"], REDACTED_MARKER);
692    }
693
694    #[test]
695    fn extend_appends_keys_to_existing_policy() {
696        let mut policy = RedactionPolicy::baseline();
697        let baseline_len = policy.sensitive_key_patterns.len();
698        policy.extend(["chave_pix".to_owned(), "internal_id".to_owned()]);
699        assert_eq!(policy.sensitive_key_patterns.len(), baseline_len + 2);
700        let input = serde_json::json!({ "internal_id": "xyz" });
701        let redacted = redact_value(&input, &policy);
702        assert_eq!(redacted["internal_id"], REDACTED_MARKER);
703    }
704
705    #[test]
706    fn extend_drops_duplicates() {
707        let mut policy = RedactionPolicy::baseline();
708        let baseline_len = policy.sensitive_key_patterns.len();
709        // "PASSWORD" lower-cased duplicates the existing "password".
710        policy.extend(["PASSWORD".to_owned()]);
711        assert_eq!(policy.sensitive_key_patterns.len(), baseline_len);
712    }
713
714    // ── inherent redact / redact_in_place ───────────────────────
715
716    #[test]
717    fn redact_method_matches_redact_value() {
718        let policy = RedactionPolicy::baseline();
719        let input = serde_json::json!({
720            "api_key": "sk-abc",
721            "name": "test",
722        });
723        assert_eq!(policy.redact(&input), redact_value(&input, &policy));
724    }
725
726    #[test]
727    fn redact_in_place_mutates_in_place() {
728        let policy = RedactionPolicy::baseline();
729        let mut value = serde_json::json!({
730            "api_key": "sk-abc",
731            "nested": {
732                "password": "shh",
733                "name": "ok",
734            },
735            "note": "CPF 111.444.777-35 attached",
736        });
737        policy.redact_in_place(&mut value);
738        assert_eq!(value["api_key"], REDACTED_MARKER);
739        assert_eq!(value["nested"]["password"], REDACTED_MARKER);
740        assert_eq!(value["nested"]["name"], "ok");
741        let note = value["note"].as_str().expect("note remains a string");
742        assert!(note.contains("[REDACTED:cpf]"), "got: {note}");
743    }
744
745    #[test]
746    fn redact_in_place_handles_full_level() {
747        let policy = RedactionPolicy::full();
748        let mut value = serde_json::json!({"a": 1, "b": "two"});
749        policy.redact_in_place(&mut value);
750        assert_eq!(value, serde_json::json!(REDACTED_MARKER));
751    }
752
753    #[test]
754    fn redact_in_place_handles_none_level() {
755        let policy = RedactionPolicy::none();
756        let original = serde_json::json!({"api_key": "sk-abc", "ok": "vis"});
757        let mut value = original.clone();
758        policy.redact_in_place(&mut value);
759        assert_eq!(value, original);
760    }
761
762    // ── redact_value: none level ────────────────────────────────
763
764    #[test]
765    fn none_level_preserves_all_values() {
766        let policy = RedactionPolicy::none();
767        let input = serde_json::json!({
768            "password": "secret123",
769            "api_key": "sk-abc",
770            "normal": "hello",
771        });
772        let result = redact_value(&input, &policy);
773        assert_eq!(result, input);
774    }
775
776    // ── redact_value: full level ────────────────────────────────
777
778    #[test]
779    fn full_level_redacts_entire_value() {
780        let policy = RedactionPolicy::full();
781        let input = serde_json::json!({
782            "command": "echo hello",
783            "data": [1, 2, 3],
784        });
785        let result = redact_value(&input, &policy);
786        assert_eq!(result, serde_json::json!(REDACTED_MARKER));
787    }
788
789    // ── redact_value: baseline level ────────────────────────────
790
791    #[test]
792    fn baseline_redacts_sensitive_keys() {
793        let policy = RedactionPolicy::baseline();
794        let input = serde_json::json!({
795            "command": "echo hello",
796            "password": "secret123",
797            "api_key": "sk-abc",
798            "normal_field": "visible",
799        });
800        let result = redact_value(&input, &policy);
801
802        assert_eq!(result["command"], "echo hello");
803        assert_eq!(result["password"], REDACTED_MARKER);
804        assert_eq!(result["api_key"], REDACTED_MARKER);
805        assert_eq!(result["normal_field"], "visible");
806    }
807
808    #[test]
809    fn baseline_redacts_case_insensitively() {
810        let policy = RedactionPolicy::baseline();
811        let input = serde_json::json!({
812            "Password": "secret",
813            "API_KEY": "key",
814            "Authorization": "Bearer xyz",
815        });
816        let result = redact_value(&input, &policy);
817
818        assert_eq!(result["Password"], REDACTED_MARKER);
819        assert_eq!(result["API_KEY"], REDACTED_MARKER);
820        assert_eq!(result["Authorization"], REDACTED_MARKER);
821    }
822
823    #[test]
824    fn baseline_redacts_sensitive_value_prefixes() {
825        let policy = RedactionPolicy::baseline();
826        let input = serde_json::json!({
827            "header": "Bearer eyJ...",
828            "key": "sk-abc123",
829            "normal": "just a string",
830        });
831        let result = redact_value(&input, &policy);
832
833        assert_eq!(result["header"], REDACTED_MARKER);
834        assert_eq!(result["key"], REDACTED_MARKER);
835        assert_eq!(result["normal"], "just a string");
836    }
837
838    #[test]
839    fn baseline_recurses_into_nested_objects() {
840        let policy = RedactionPolicy::baseline();
841        let input = serde_json::json!({
842            "config": {
843                "api_key": "sk-nested",
844                "endpoint": "https://example.com",
845            },
846            "name": "test",
847        });
848        let result = redact_value(&input, &policy);
849
850        assert_eq!(result["config"]["api_key"], REDACTED_MARKER);
851        assert_eq!(result["config"]["endpoint"], "https://example.com");
852        assert_eq!(result["name"], "test");
853    }
854
855    #[test]
856    fn baseline_recurses_into_arrays() {
857        let policy = RedactionPolicy::baseline();
858        let input = serde_json::json!([
859            {"password": "secret", "name": "test"},
860            {"token": "abc", "data": 42},
861        ]);
862        let result = redact_value(&input, &policy);
863
864        assert_eq!(result[0]["password"], REDACTED_MARKER);
865        assert_eq!(result[0]["name"], "test");
866        assert_eq!(result[1]["token"], REDACTED_MARKER);
867        assert_eq!(result[1]["data"], 42);
868    }
869
870    #[test]
871    fn baseline_preserves_non_string_values() {
872        let policy = RedactionPolicy::baseline();
873        let input = serde_json::json!({
874            "count": 42,
875            "active": true,
876            "ratio": 2.72,
877            "empty": null,
878        });
879        let result = redact_value(&input, &policy);
880        assert_eq!(result, input);
881    }
882
883    #[test]
884    fn redact_value_is_noop_for_explicit_null() {
885        let policy = RedactionPolicy::baseline();
886        let input = serde_json::Value::Null;
887        let result = redact_value(&input, &policy);
888        assert_eq!(result, serde_json::Value::Null);
889    }
890
891    // ── redact_string ───────────────────────────────────────────
892
893    #[test]
894    fn redact_string_none_preserves() {
895        let policy = RedactionPolicy::none();
896        assert_eq!(redact_string("Bearer token123", &policy), "Bearer token123");
897    }
898
899    #[test]
900    fn redact_string_baseline_masks_sensitive() {
901        let policy = RedactionPolicy::baseline();
902        assert_eq!(redact_string("Bearer token123", &policy), REDACTED_MARKER);
903        assert_eq!(redact_string("sk-abc123", &policy), REDACTED_MARKER);
904        assert_eq!(
905            redact_string("just normal output", &policy),
906            "just normal output"
907        );
908    }
909
910    #[test]
911    fn redact_string_full_masks_everything() {
912        let policy = RedactionPolicy::full();
913        assert_eq!(
914            redact_string("totally safe output", &policy),
915            REDACTED_MARKER
916        );
917    }
918
919    // ── redact_error ────────────────────────────────────────────
920
921    #[test]
922    fn redact_error_baseline_preserves_non_pii() {
923        // Baseline error_level masks PII but leaves ordinary
924        // operational messages untouched.
925        let policy = RedactionPolicy::baseline();
926        assert_eq!(
927            redact_error("connection timeout after 30s", &policy),
928            "connection timeout after 30s"
929        );
930    }
931
932    #[test]
933    fn redact_error_baseline_masks_pii_by_default() {
934        // New default (ERROR level = Baseline) — PII in error strings
935        // gets entity-masked without requiring an explicit opt-in.
936        let policy = RedactionPolicy::baseline();
937        let masked = redact_error(
938            "Failed to process order for user CPF 111.444.777-35",
939            &policy,
940        );
941        assert!(masked.contains("[REDACTED:cpf]"), "got: {masked}");
942        assert!(!masked.contains("111.444.777-35"));
943    }
944
945    #[test]
946    fn redact_error_explicit_none_passes_through() {
947        // Callers that need raw errors can opt out of the default.
948        let policy = RedactionPolicy {
949            error_level: RedactionLevel::None,
950            ..RedactionPolicy::baseline()
951        };
952        let raw = "Failed to process order for user CPF 111.444.777-35";
953        assert_eq!(redact_error(raw, &policy), raw);
954    }
955
956    #[test]
957    fn redact_error_full_masks() {
958        let policy = RedactionPolicy::full();
959        assert_eq!(
960            redact_error("internal error details", &policy),
961            REDACTED_MARKER
962        );
963    }
964
965    // ── Sensitive key detection ─────────────────────────────────
966
967    #[test]
968    fn sensitive_key_detection() {
969        let policy = RedactionPolicy::baseline();
970
971        // Should match
972        assert!(policy.is_sensitive_key("password"));
973        assert!(policy.is_sensitive_key("user_password"));
974        assert!(policy.is_sensitive_key("api_key"));
975        assert!(policy.is_sensitive_key("MY_API_KEY"));
976        assert!(policy.is_sensitive_key("Authorization"));
977        assert!(policy.is_sensitive_key("session_id"));
978        assert!(policy.is_sensitive_key("private_key"));
979        assert!(policy.is_sensitive_key("access_key_id"));
980
981        // Should not match — guards against overly-short substring patterns
982        assert!(!policy.is_sensitive_key("username"));
983        assert!(!policy.is_sensitive_key("command"));
984        assert!(!policy.is_sensitive_key("amount"));
985        assert!(!policy.is_sensitive_key("path"));
986        assert!(!policy.is_sensitive_key("args"));
987        assert!(!policy.is_sensitive_key("target"));
988        assert!(!policy.is_sensitive_key("author"));
989        assert!(!policy.is_sensitive_key("org_id"));
990        assert!(!policy.is_sensitive_key("merge"));
991    }
992
993    // ── Sensitive value detection ───────────────────────────────
994
995    #[test]
996    fn sensitive_value_detection() {
997        let policy = RedactionPolicy::baseline();
998
999        // Should match
1000        assert!(policy.is_sensitive_value("Bearer eyJhbGciOiJIUzI1NiJ9"));
1001        assert!(policy.is_sensitive_value("sk-abc123def456"));
1002        assert!(policy.is_sensitive_value("ghp_xxxxxxxxxxxx"));
1003        assert!(policy.is_sensitive_value("xoxb-token-value"));
1004        assert!(policy.is_sensitive_value("AKIAIOSFODNN7EXAMPLE"));
1005
1006        // Should not match
1007        assert!(!policy.is_sensitive_value("hello world"));
1008        assert!(!policy.is_sensitive_value("echo test"));
1009        assert!(!policy.is_sensitive_value("123.45"));
1010    }
1011
1012    // ── Edge cases ──────────────────────────────────────────────
1013
1014    #[test]
1015    fn redact_empty_object() {
1016        let policy = RedactionPolicy::baseline();
1017        let input = serde_json::json!({});
1018        let result = redact_value(&input, &policy);
1019        assert_eq!(result, serde_json::json!({}));
1020    }
1021
1022    #[test]
1023    fn redact_empty_array() {
1024        let policy = RedactionPolicy::baseline();
1025        let input = serde_json::json!([]);
1026        let result = redact_value(&input, &policy);
1027        assert_eq!(result, serde_json::json!([]));
1028    }
1029
1030    #[test]
1031    fn redact_scalar_string() {
1032        let policy = RedactionPolicy::baseline();
1033        let input = serde_json::json!("sk-secret");
1034        let result = redact_value(&input, &policy);
1035        assert_eq!(result, serde_json::json!(REDACTED_MARKER));
1036    }
1037
1038    #[test]
1039    fn redact_scalar_number() {
1040        let policy = RedactionPolicy::baseline();
1041        let input = serde_json::json!(42);
1042        let result = redact_value(&input, &policy);
1043        assert_eq!(result, serde_json::json!(42));
1044    }
1045
1046    #[test]
1047    fn deeply_nested_redaction() {
1048        let policy = RedactionPolicy::baseline();
1049        let input = serde_json::json!({
1050            "level1": {
1051                "level2": {
1052                    "level3": {
1053                        "api_key": "sk-deep",
1054                        "value": "safe",
1055                    }
1056                }
1057            }
1058        });
1059        let result = redact_value(&input, &policy);
1060        assert_eq!(
1061            result["level1"]["level2"]["level3"]["api_key"],
1062            REDACTED_MARKER,
1063        );
1064        assert_eq!(result["level1"]["level2"]["level3"]["value"], "safe");
1065    }
1066
1067    #[test]
1068    fn non_ascii_keys_do_not_panic() {
1069        // Non-ASCII keys must not crash the case-insensitive
1070        // matcher — they simply do not match the lowercase ASCII
1071        // baseline patterns.
1072        let policy = RedactionPolicy::baseline();
1073        let input = serde_json::json!({
1074            "contraseña": "secret",
1075            "密码": "shh",
1076            "ok": "visible",
1077        });
1078        let result = redact_value(&input, &policy);
1079        // Patterns are ASCII; non-ASCII keys are not matched.
1080        assert_eq!(result["contraseña"], "secret");
1081        assert_eq!(result["密码"], "shh");
1082        assert_eq!(result["ok"], "visible");
1083    }
1084
1085    // ── Entity-level detection via the plugged-in PiiDetector ──
1086
1087    #[test]
1088    fn baseline_masks_email_in_non_sensitive_string_value() {
1089        let policy = RedactionPolicy::baseline();
1090        let input = serde_json::json!({
1091            "note": "forward to ana.silva+tag@example.com please"
1092        });
1093        let result = redact_value(&input, &policy);
1094        let note = result["note"].as_str().expect("note is string");
1095        assert!(note.contains("[REDACTED:email]"), "got: {note}");
1096        assert!(!note.contains("ana.silva+tag@example.com"));
1097    }
1098
1099    #[test]
1100    fn baseline_masks_cpf_in_freeform_text() {
1101        let policy = RedactionPolicy::baseline();
1102        let input = serde_json::json!({
1103            "description": "confirmou pelo CPF 111.444.777-35 ontem"
1104        });
1105        let result = redact_value(&input, &policy);
1106        let desc = result["description"].as_str().expect("desc is string");
1107        assert!(desc.contains("[REDACTED:cpf]"), "got: {desc}");
1108        assert!(!desc.contains("111.444.777-35"));
1109    }
1110
1111    #[test]
1112    fn baseline_masks_cnpj_in_freeform_text() {
1113        let policy = RedactionPolicy::baseline();
1114        let input = serde_json::json!({
1115            "description": "pagar CNPJ 11.222.333/0001-81 até sexta"
1116        });
1117        let result = redact_value(&input, &policy);
1118        let desc = result["description"].as_str().expect("desc is string");
1119        assert!(desc.contains("[REDACTED:cnpj]"), "got: {desc}");
1120    }
1121
1122    #[test]
1123    fn baseline_masks_luhn_valid_pan_in_tool_output() {
1124        let policy = RedactionPolicy::baseline();
1125        let output = "charged card 4111 1111 1111 1111 successfully for 150 BRL";
1126        let result = redact_string(output, &policy);
1127        assert!(result.contains("[REDACTED:credit_card]"), "got: {result}");
1128        assert!(!result.contains("4111 1111 1111 1111"));
1129    }
1130
1131    #[test]
1132    fn baseline_does_not_mask_luhn_invalid_digits() {
1133        // 16 digits that aren't Luhn-valid — must not be flagged as a PAN.
1134        let policy = RedactionPolicy::baseline();
1135        let output = "order 1234 5678 9012 3456 processed";
1136        let result = redact_string(output, &policy);
1137        assert!(
1138            !result.contains("[REDACTED:"),
1139            "false positive on non-PAN digits: {result}"
1140        );
1141    }
1142
1143    #[test]
1144    fn baseline_masks_pan_followed_by_amount() {
1145        // A real PAN trailed by an amount must not leak: the greedy match
1146        // fails Luhn over all 19 digits, but the 16-digit PAN sub-window does
1147        // not — it must still be masked.
1148        let policy = RedactionPolicy::baseline();
1149        let output = "charged card 4111 1111 1111 1111 150 successfully";
1150        let result = redact_string(output, &policy);
1151        assert!(result.contains("[REDACTED:credit_card]"), "got: {result}");
1152        assert!(
1153            !result.contains("4111 1111 1111 1111"),
1154            "PAN leaked: {result}"
1155        );
1156    }
1157
1158    #[test]
1159    fn baseline_value_prefixes_track_shared_const() {
1160        // The baseline value-prefix list is derived from the single shared
1161        // `SECRET_PREFIXES` const (no hand-maintained second copy), and the
1162        // previously-drifted prefixes are now present.
1163        let policy = RedactionPolicy::baseline();
1164        let expected: Vec<String> = crate::privacy::SECRET_PREFIXES
1165            .iter()
1166            .map(|p| (*p).to_owned())
1167            .collect();
1168        assert_eq!(policy.sensitive_value_prefixes, expected);
1169        for p in ["ghs_", "ghu_", "AIza"] {
1170            assert!(
1171                policy.sensitive_value_prefixes.iter().any(|x| x == p),
1172                "missing prefix {p}"
1173            );
1174        }
1175    }
1176
1177    #[test]
1178    fn is_sensitive_key_ascii_case_insensitive_without_alloc() {
1179        let policy = RedactionPolicy::baseline();
1180        // Mixed-case ASCII keys match the lowercase patterns.
1181        assert!(policy.is_sensitive_key("API_KEY"));
1182        assert!(policy.is_sensitive_key("Api_Key"));
1183        assert!(policy.is_sensitive_key("xXpasswordXx"));
1184        // Non-ASCII keys never match an ASCII pattern and never panic.
1185        assert!(!policy.is_sensitive_key("naïve_field"));
1186        assert!(!policy.is_sensitive_key("contraseña"));
1187    }
1188
1189    #[test]
1190    fn baseline_masks_embedded_secret_token() {
1191        // The wholesale prefix check only fires when the WHOLE string
1192        // starts with a prefix. Embedded secrets rely on the entity
1193        // detector's SecretDetector component.
1194        let policy = RedactionPolicy::baseline();
1195        let output = "deploy failed: key=sk-abcdefghijklmnopqrstuv rejected";
1196        let result = redact_string(output, &policy);
1197        assert!(result.contains("[REDACTED:secret]"), "got: {result}");
1198    }
1199
1200    #[test]
1201    fn baseline_preserves_wholesale_prefix_behaviour() {
1202        // A string that STARTS with a sensitive prefix still falls
1203        // into the wholesale `[REDACTED]` path — entity detection
1204        // does not override that stronger signal.
1205        let policy = RedactionPolicy::baseline();
1206        let result = redact_string("sk-abc123def456ghi789jkl", &policy);
1207        assert_eq!(result, REDACTED_MARKER);
1208    }
1209
1210    #[test]
1211    fn baseline_masks_pii_in_nested_string_leaves() {
1212        let policy = RedactionPolicy::baseline();
1213        let input = serde_json::json!({
1214            "audit_log": [
1215                {
1216                    "actor": "system",
1217                    "details": "user CPF 111.444.777-35 contacted from 192.168.1.100"
1218                }
1219            ]
1220        });
1221        let result = redact_value(&input, &policy);
1222        let details = result["audit_log"][0]["details"]
1223            .as_str()
1224            .expect("details string");
1225        assert!(details.contains("[REDACTED:cpf]"), "got: {details}");
1226        assert!(details.contains("[REDACTED:ip_address]"), "got: {details}");
1227    }
1228
1229    #[test]
1230    fn sensitive_key_match_wins_over_entity_detection() {
1231        // Values under a sensitive key still get wholesale `[REDACTED]`
1232        // — not a partial entity mask. Preserves the pre-upgrade
1233        // contract.
1234        let policy = RedactionPolicy::baseline();
1235        let input = serde_json::json!({
1236            "api_key": "sk-leaky",
1237            "access_token": "Bearer eyJ..."
1238        });
1239        let result = redact_value(&input, &policy);
1240        assert_eq!(result["api_key"], REDACTED_MARKER);
1241        assert_eq!(result["access_token"], REDACTED_MARKER);
1242    }
1243
1244    #[test]
1245    fn none_policy_performs_no_entity_detection() {
1246        let policy = RedactionPolicy::none();
1247        let input = serde_json::json!({
1248            "note": "CPF 111.444.777-35 email a@b.co"
1249        });
1250        let result = redact_value(&input, &policy);
1251        assert_eq!(result, input, "none policy must not mutate input");
1252    }
1253
1254    #[test]
1255    fn deserialized_policy_retains_baseline_entity_detection() -> serde_json::Result<()> {
1256        // The detector field is `#[serde(skip)]`. After a round-trip
1257        // through JSON, the policy must still perform entity
1258        // detection via the default BaselineDetector.
1259        let policy = RedactionPolicy::baseline();
1260        let json = serde_json::to_string(&policy)?;
1261        let back: RedactionPolicy = serde_json::from_str(&json)?;
1262        let result = redact_string("pix para CPF 111.444.777-35 agora", &back);
1263        assert!(
1264            result.contains("[REDACTED:cpf]"),
1265            "deserialized policy stopped detecting CPF: {result}"
1266        );
1267        Ok(())
1268    }
1269
1270    #[test]
1271    fn error_level_baseline_masks_entities_in_stack_trace() {
1272        // Opt-in: callers can flip error_level to Baseline and the
1273        // detector applies to error strings too.
1274        let policy = RedactionPolicy {
1275            error_level: RedactionLevel::Baseline,
1276            ..RedactionPolicy::baseline()
1277        };
1278        let trace = "NotFound: user with CPF 111.444.777-35 missing in table users";
1279        let result = redact_error(trace, &policy);
1280        assert!(result.contains("[REDACTED:cpf]"), "got: {result}");
1281    }
1282
1283    // ── redact_for_observability ────────────────────────────────
1284
1285    #[test]
1286    fn redact_for_observability_runs_structural_then_pii() -> Result<(), regex::Error> {
1287        let policy = RedactionPolicy::baseline();
1288        let detector = BaselineDetector::new()?;
1289        let input = serde_json::json!({
1290            "api_key": "sk-leaky",
1291            "details": "user CPF 111.444.777-35 in table users",
1292            "ok": "visible",
1293        });
1294        let result = redact_for_observability(&input, &policy, &detector);
1295        assert_eq!(result["api_key"], REDACTED_MARKER);
1296        let details = result["details"].as_str().expect("string");
1297        assert!(details.contains("[REDACTED:cpf]"), "got: {details}");
1298        assert_eq!(result["ok"], "visible");
1299        Ok(())
1300    }
1301
1302    #[test]
1303    fn redact_for_observability_idempotent_on_already_masked() -> Result<(), regex::Error> {
1304        let policy = RedactionPolicy::baseline();
1305        let detector = BaselineDetector::new()?;
1306        let input = serde_json::json!({
1307            "details": "user CPF 111.444.777-35 contacted",
1308        });
1309        let once = redact_for_observability(&input, &policy, &detector);
1310        let twice = redact_for_observability(&once, &policy, &detector);
1311        // Running the helper a second time over already-masked
1312        // output is a no-op — no double masking, no panic.
1313        assert_eq!(once, twice);
1314        Ok(())
1315    }
1316
1317    #[test]
1318    fn redact_for_observability_honours_full_level() -> Result<(), regex::Error> {
1319        let policy = RedactionPolicy::full();
1320        let detector = BaselineDetector::new()?;
1321        let input = serde_json::json!({"a": "b"});
1322        let result = redact_for_observability(&input, &policy, &detector);
1323        assert_eq!(result, serde_json::json!(REDACTED_MARKER));
1324        Ok(())
1325    }
1326
1327    #[test]
1328    fn redact_for_observability_honours_none_level() -> Result<(), regex::Error> {
1329        let policy = RedactionPolicy::none();
1330        let detector = BaselineDetector::new()?;
1331        let input = serde_json::json!({
1332            "api_key": "sk-leaky",
1333            "note": "CPF 111.444.777-35",
1334        });
1335        let result = redact_for_observability(&input, &policy, &detector);
1336        assert_eq!(result, input);
1337        Ok(())
1338    }
1339}