gemini_adk_rs/live/
redaction.rs

1//! Transcript redaction — sensitive data never reaches the lanes.
2//!
3//! A voice caller will read card numbers, one-time passcodes, and account
4//! identifiers out loud, and speech recognition will faithfully transcribe
5//! them. Anything downstream of the transcript — callbacks, the transcript
6//! buffer, extraction, persistence snapshots, application logs — then holds
7//! that data unless it is removed first.
8//!
9//! A [`TranscriptRedactor`] installed on the session
10//! ([`LiveSessionBuilder::redaction`](super::builder::LiveSessionBuilder::redaction))
11//! is applied at the event router, *before* either lane sees the text: what
12//! the fast-lane callbacks receive, what the transcript buffer accumulates,
13//! what extractors read, and what a persistence backend stores are all the
14//! redacted form. There is deliberately no unredacted side channel.
15//!
16//! Two limits worth knowing:
17//!
18//! - **Streaming text deltas are not redacted.** A card number can straddle
19//!   delta boundaries where no single chunk matches anything. Deltas are a
20//!   text-mode display stream; voice deployments should treat transcripts
21//!   and [`TextComplete`](super::events::LiveEvent::TextComplete) (both
22//!   redacted) as the record.
23//! - **Redaction is pattern-based**, applied to each partial and final
24//!   transcript independently. It removes well-formed sensitive strings; it
25//!   is a complement to, not a replacement for, infrastructure-level data
26//!   loss prevention on stored audio.
27
28use regex::Regex;
29
30/// A card-number match is replaced by this, keeping the last four digits —
31/// enough for the conversation to stay coherent ("the card ending 1234")
32/// without retaining the number.
33fn card_replacement(last4: &str) -> String {
34    format!("[card ending {last4}]")
35}
36
37const NUMBER_REPLACEMENT: &str = "[redacted number]";
38
39/// Pattern-based transcript scrubber. Build one with the methods below and
40/// install it with
41/// [`LiveSessionBuilder::redaction`](super::builder::LiveSessionBuilder::redaction).
42///
43/// ```
44/// use gemini_adk_rs::live::redaction::TranscriptRedactor;
45///
46/// let redactor = TranscriptRedactor::new().card_numbers().long_digits(6);
47/// assert_eq!(
48///     redactor.redact("my card is 4111 1111 1111 1111 ok".into()),
49///     "my card is [card ending 1111] ok"
50/// );
51/// ```
52#[derive(Debug, Default)]
53pub struct TranscriptRedactor {
54    card_numbers: bool,
55    long_digits: Option<usize>,
56    custom: Vec<(Regex, String)>,
57}
58
59impl TranscriptRedactor {
60    /// A redactor with nothing enabled. Chain the methods below.
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Redact payment-card numbers: runs of 13–19 digits (spaces and dashes
66    /// between groups allowed) that pass a Luhn check, replaced with
67    /// `[card ending NNNN]`. The Luhn check keeps ordinary long numbers —
68    /// tracking codes, reference IDs — from being eaten as cards.
69    pub fn card_numbers(mut self) -> Self {
70        self.card_numbers = true;
71        self
72    }
73
74    /// Redact any remaining run of `min` or more consecutive digits
75    /// (one-time passcodes, account numbers), replaced with
76    /// `[redacted number]`. Runs after the card pass, so a card keeps its
77    /// `[card ending NNNN]` form.
78    pub fn long_digits(mut self, min: usize) -> Self {
79        self.long_digits = Some(min);
80        self
81    }
82
83    /// Redact a custom pattern with a fixed replacement — national
84    /// identifiers, internal reference formats, whatever the deployment's
85    /// compliance regime names.
86    pub fn pattern(mut self, regex: Regex, replacement: impl Into<String>) -> Self {
87        self.custom.push((regex, replacement.into()));
88        self
89    }
90
91    /// Whether any rule is enabled. A no-op redactor is skipped entirely.
92    pub fn is_active(&self) -> bool {
93        self.card_numbers || self.long_digits.is_some() || !self.custom.is_empty()
94    }
95
96    /// Apply every enabled rule. Returns the input unchanged (no
97    /// reallocation) when nothing matches.
98    pub fn redact(&self, text: String) -> String {
99        let mut text = text;
100        if self.card_numbers {
101            text = redact_cards(&text);
102        }
103        if let Some(min) = self.long_digits {
104            text = redact_digit_runs(&text, min);
105        }
106        for (regex, replacement) in &self.custom {
107            if regex.is_match(&text) {
108                text = regex.replace_all(&text, replacement.as_str()).into_owned();
109            }
110        }
111        text
112    }
113}
114
115/// Candidate card spans: digit groups joined by optional single spaces or
116/// dashes. Verified by length and Luhn before replacement.
117fn card_regex() -> &'static Regex {
118    static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
119    RE.get_or_init(|| Regex::new(r"\d(?:[ -]?\d){11,18}").expect("valid regex"))
120}
121
122fn digit_run_regex(min: usize) -> Regex {
123    Regex::new(&format!(r"\d{{{min},}}")).expect("valid regex")
124}
125
126fn redact_cards(text: &str) -> String {
127    if !card_regex().is_match(text) {
128        return text.to_string();
129    }
130    card_regex()
131        .replace_all(text, |caps: &regex::Captures<'_>| {
132            let raw = &caps[0];
133            let digits: String = raw.chars().filter(char::is_ascii_digit).collect();
134            if (13..=19).contains(&digits.len()) && luhn_valid(&digits) {
135                card_replacement(&digits[digits.len() - 4..])
136            } else {
137                raw.to_string()
138            }
139        })
140        .into_owned()
141}
142
143fn redact_digit_runs(text: &str, min: usize) -> String {
144    let regex = digit_run_regex(min);
145    if !regex.is_match(text) {
146        return text.to_string();
147    }
148    regex.replace_all(text, NUMBER_REPLACEMENT).into_owned()
149}
150
151/// The Luhn checksum every payment-card number satisfies (ISO/IEC 7812).
152fn luhn_valid(digits: &str) -> bool {
153    let mut sum = 0u32;
154    for (i, ch) in digits.chars().rev().enumerate() {
155        let Some(d) = ch.to_digit(10) else {
156            return false;
157        };
158        let d = if i % 2 == 1 {
159            let doubled = d * 2;
160            if doubled > 9 { doubled - 9 } else { doubled }
161        } else {
162            d
163        };
164        sum += d;
165    }
166    sum.is_multiple_of(10)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    fn full() -> TranscriptRedactor {
174        TranscriptRedactor::new().card_numbers().long_digits(6)
175    }
176
177    #[test]
178    fn redacts_a_spoken_card_number_keeping_last_four() {
179        assert_eq!(
180            full().redact("it's 4111 1111 1111 1111 thanks".into()),
181            "it's [card ending 1111] thanks"
182        );
183        // Dashed and unseparated forms too.
184        assert_eq!(
185            full().redact("4012-8888-8888-1881".into()),
186            "[card ending 1881]"
187        );
188        assert_eq!(
189            full().redact("378282246310005".into()),
190            "[card ending 0005]",
191            "15-digit card numbers are in range"
192        );
193    }
194
195    #[test]
196    fn luhn_failures_are_not_cards_but_still_hit_the_digit_rule() {
197        // 16 digits, wrong checksum: not a card, but still a long number.
198        assert_eq!(
199            full().redact("ref 4111111111111112".into()),
200            format!("ref {NUMBER_REPLACEMENT}")
201        );
202        // Without the digit rule it passes through untouched.
203        assert_eq!(
204            TranscriptRedactor::new()
205                .card_numbers()
206                .redact("ref 4111111111111112".into()),
207            "ref 4111111111111112"
208        );
209    }
210
211    #[test]
212    fn short_numbers_survive_otp_length_does_not() {
213        assert_eq!(
214            full().redact("table for 4 at 19:00".into()),
215            "table for 4 at 19:00"
216        );
217        assert_eq!(
218            full().redact("the code is 493028".into()),
219            format!("the code is {NUMBER_REPLACEMENT}")
220        );
221    }
222
223    #[test]
224    fn custom_patterns_apply() {
225        let redactor = TranscriptRedactor::new()
226            .pattern(Regex::new(r"[STFG]\d{7}[A-Z]").unwrap(), "[redacted id]");
227        assert_eq!(
228            redactor.redact("my id is S1234567D ok".into()),
229            "my id is [redacted id] ok"
230        );
231    }
232
233    #[test]
234    fn inactive_redactor_reports_itself() {
235        assert!(!TranscriptRedactor::new().is_active());
236        assert!(TranscriptRedactor::new().card_numbers().is_active());
237    }
238}