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: ®ex::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}