Skip to main content

sioul/mcp/
mask.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What is never handed to an agent as it is (docs/ai.md, "Masking"): the
5//! one-time code, password or sign-in link a message carries, IBANs, payment
6//! card numbers and French social security numbers (NIR). Each number is
7//! checked by its own rule (ISO 13616's mod 97, Luhn's, the NIR's key), so an
8//! order number or a phone number stays as written. The window shows them all.
9
10use serde_json::Value;
11use sioul_core::codes::{self, CodeKind};
12use sioul_core::words::Words;
13
14const CODE: &str = "[code hidden]";
15const LINK: &str = "[link hidden]";
16const NIR: &str = "[social security number hidden]";
17const PASSWORD: &str = "[This message gives a password: its words stay in Sioul's window.]";
18
19/// A message's subject and text as an agent gets them, when it carries a
20/// short-lived secret (`codes::detect`): every code hidden wherever it is
21/// written, spaced or not; every link hidden, as good as the code (a reset,
22/// a sign-in link, an address to confirm); a password's message kept whole
23/// from the agent, as where the password sits is not known. Then the numbers
24/// `text` hides. `words`: what the codes detector looks for, as the
25/// configuration makes it (`Words::of`), so that a code it finds in the
26/// window is hidden from the agent too.
27pub fn message(words: &Words, subject: &str, body: &str) -> (String, String) {
28    let found = secrets(words, subject, body);
29    if found.kinds.is_empty() {
30        return (text(subject), text(body));
31    }
32    let hide = |text: &str| {
33        // Links first: a code inside one goes with it.
34        let text = hide_links(text);
35        found.codes.iter().fold(text, |text, code| hide_code(&text, code))
36    };
37    if found.kinds.contains(&CodeKind::Password) {
38        let own = secrets(words, subject, "").kinds.contains(&CodeKind::Password);
39        return (if own { PASSWORD.to_string() } else { self::text(&hide(subject)) }, PASSWORD.to_string());
40    }
41    (self::text(&hide(subject)), self::text(&hide(body)))
42}
43
44/// What a message carries: the kinds of secret, and the codes themselves.
45#[derive(Default)]
46struct Secrets {
47    kinds: Vec<CodeKind>,
48    codes: Vec<String>,
49}
50
51/// Characters `codes::detect` is given at once to find a secret that is not
52/// a code (it reads 6000), and how far two of these pieces overlap: more than
53/// any of its phrases.
54const PIECE: usize = 5000;
55const OVERLAP: usize = 200;
56
57/// Every secret of a message. `codes::detect` reads the first 6000
58/// characters, the first phrase it knows and the code beside it, and says one
59/// kind: so each number that could be a code is asked about with the words
60/// around it, in a text where codes printed apart ("4 8 2 9 1 3") are joined
61/// too; and the rest is read piece by piece with every digit blanked, so that
62/// a password or a link further on is not hidden behind a code.
63fn secrets(words: &Words, subject: &str, body: &str) -> Secrets {
64    let chars: Vec<char> = format!("{subject}\n{body}").chars().collect();
65    let mut found = Secrets::default();
66    let joined = squeezed(&chars);
67    for text in [&chars, &joined] {
68        for code in codes_in(words, text) {
69            if !found.codes.contains(&code) {
70                found.codes.push(code);
71            }
72        }
73    }
74    if !found.codes.is_empty() {
75        found.kinds.push(CodeKind::Code);
76    }
77    let blank: Vec<char> = chars.iter().map(|c| if c.is_ascii_digit() { '_' } else { *c }).collect();
78    let mut start = 0;
79    loop {
80        let end = (start + PIECE).min(blank.len());
81        let piece: String = blank[start..end].iter().collect();
82        if let Some(kind) = codes::detect(&words.codes, &piece, "").map(|s| s.kind)
83            && !found.kinds.contains(&kind)
84        {
85            found.kinds.push(kind);
86        }
87        if end == blank.len() {
88            break;
89        }
90        start = end - OVERLAP;
91    }
92    found
93}
94
95/// How far around a number `codes::detect` is asked about it: its phrases sit
96/// up to 160 characters before a code, or 100 after it (codes.rs). A piece
97/// that starts nearer is tried too, as `detect` reads only the first phrase
98/// of a piece ("never share your code… your code: 482913").
99const BEFORE: [usize; 4] = [250, 170, 90, 30];
100const AFTER: usize = 170;
101
102/// The codes a text holds, as `codes::detect` reads them.
103fn codes_in(words: &Words, chars: &[char]) -> Vec<String> {
104    let mut found: Vec<String> = Vec::new();
105    for at in (0..chars.len()).filter(|&i| could_be_code(chars, i)) {
106        let end = (at + AFTER).min(chars.len());
107        for before in BEFORE {
108            let piece: String = chars[at.saturating_sub(before)..end].iter().collect();
109            // The codes found already, hidden: the next one comes out.
110            let mut piece = found.iter().fold(piece, |piece, code| hide_code(&piece, code));
111            let mut new = false;
112            for _ in 0..4 {
113                let Some(code) = codes::detect(&words.codes, &piece, "").and_then(|s| s.code) else { break };
114                piece = hide_code(&piece, &code);
115                if !found.contains(&code) {
116                    found.push(code);
117                }
118                new = true;
119            }
120            if new {
121                break;
122            }
123        }
124    }
125    found
126}
127
128/// Whether a code could start here, as `codes::detect` takes one: four to
129/// eight digits that are not a year, or three and three ("482 913").
130fn could_be_code(chars: &[char], at: usize) -> bool {
131    if !chars[at].is_ascii_digit() || (at > 0 && (chars[at - 1].is_alphanumeric() || chars[at - 1] == '+')) {
132        return false;
133    }
134    let run = chars[at..].iter().take_while(|c| c.is_ascii_digit()).count();
135    let digits: String = chars[at..at + run].iter().collect();
136    match run {
137        3 => chars.get(at + 3).is_some_and(|c| apart(*c)) && chars.get(at + 4..at + 7).is_some_and(|w| w.iter().all(char::is_ascii_digit)),
138        4 => !digits.parse::<u32>().is_ok_and(|year| (1900..=2099).contains(&year)),
139        5..=8 => true,
140        _ => false,
141    }
142}
143
144/// What may stand between the characters of a code as it is printed: a
145/// space of any width, a hyphen, a dot, or the line break or tab of a table
146/// made text.
147fn apart(c: char) -> bool {
148    matches!(c, ' ' | '\u{a0}' | '\u{202f}' | '\u{2009}' | '\u{2007}' | '-' | '.' | '\n' | '\r' | '\t')
149}
150
151/// The text with short groups of digits joined when together they make four
152/// to eight ("4 8 2 9 1 3", "48 29 13", "482 913"): a code printed in boxes,
153/// or by a table turned into text, read as one. A longer series (a phone
154/// number) stays as it is.
155fn squeezed(chars: &[char]) -> Vec<char> {
156    let mut out = Vec::with_capacity(chars.len());
157    let mut i = 0;
158    while i < chars.len() {
159        if !chars[i].is_ascii_digit() || (i > 0 && chars[i - 1].is_alphanumeric()) {
160            out.push(chars[i]);
161            i += 1;
162            continue;
163        }
164        // The groups from here, one separator between two.
165        let (mut end, mut digits, mut groups, mut short) = (i, Vec::new(), 0, true);
166        loop {
167            let length = chars[end..].iter().take_while(|c| c.is_ascii_digit()).count();
168            digits.extend_from_slice(&chars[end..end + length]);
169            short &= length <= 3;
170            groups += 1;
171            end += length;
172            if end + 1 < chars.len() && apart(chars[end]) && chars[end + 1].is_ascii_digit() {
173                end += 1;
174            } else {
175                break;
176            }
177        }
178        let alone = end == chars.len() || !chars[end].is_alphanumeric();
179        if groups > 1 && short && alone && (4..=8).contains(&digits.len()) {
180            out.extend(digits);
181        } else {
182            out.extend_from_slice(&chars[i..end]);
183        }
184        i = end;
185    }
186    out
187}
188
189/// Every place `code` is written, its characters next to each other or
190/// apart ("482 913", "482-913", "4 8 2 9 1 3"), as a word of its own; and
191/// for a code printed with letters before it ("G-482913"), its digits alone too.
192fn hide_code(text: &str, code: &str) -> String {
193    let digits = code.split_once('-').map(|(_, d)| d).filter(|d| d.len() >= 4 && d.chars().all(|c| c.is_ascii_digit()));
194    let text = hide_code_as(text, code);
195    digits.map_or(text.clone(), |digits| hide_code_as(&text, digits))
196}
197
198fn hide_code_as(text: &str, code: &str) -> String {
199    let wanted: Vec<char> = code.chars().filter(|c| c.is_alphanumeric()).collect();
200    let chars: Vec<char> = text.chars().collect();
201    if wanted.is_empty() {
202        return text.to_string();
203    }
204    // Where the code written from `start` ends, if it is.
205    let ends_at = |start: usize| -> Option<usize> {
206        let mut at = start;
207        for (n, want) in wanted.iter().enumerate() {
208            if n > 0 && at < chars.len() && apart(chars[at]) && chars.get(at + 1) == Some(want) {
209                at += 1;
210            }
211            if chars.get(at) != Some(want) {
212                return None;
213            }
214            at += 1;
215        }
216        (at == chars.len() || !chars[at].is_alphanumeric()).then_some(at)
217    };
218    let mut out = String::with_capacity(text.len());
219    let mut i = 0;
220    while i < chars.len() {
221        if (i == 0 || !chars[i - 1].is_alphanumeric()) && let Some(end) = ends_at(i) {
222            out.push_str(CODE);
223            i = end;
224            continue;
225        }
226        out.push(chars[i]);
227        i += 1;
228    }
229    out
230}
231
232/// A text with its IBANs, card numbers and social security numbers hidden;
233/// the last four characters of an IBAN or a card stay, to tell them apart.
234pub fn text(text: &str) -> String {
235    let chars: Vec<char> = text.chars().collect();
236    let mut out = String::with_capacity(text.len());
237    let mut i = 0;
238    while i < chars.len() {
239        let starts_word = i == 0 || !chars[i - 1].is_alphanumeric();
240        if starts_word && let Some((end, hidden)) = iban_at(&chars, i).or_else(|| nir_at(&chars, i)).or_else(|| card_at(&chars, i)) {
241            out.push_str(&hidden);
242            i = end;
243            continue;
244        }
245        out.push(chars[i]);
246        i += 1;
247    }
248    out
249}
250
251/// Every text of an answer's data masked as `text` masks it: the data says
252/// no more than the lines do.
253pub fn json(value: Value) -> Value {
254    match value {
255        Value::String(s) => Value::String(text(&s)),
256        Value::Array(items) => Value::Array(items.into_iter().map(json).collect()),
257        Value::Object(map) => Value::Object(map.into_iter().map(|(key, value)| (key, json(value))).collect()),
258        other => other,
259    }
260}
261
262/// Every web address replaced: one with a scheme ("https://", "HTTPS://",
263/// an app's own "name://"), one that starts with "www.", or a host and a path
264/// ("bank.example/reset/…"), as a mail turned into text writes them.
265fn hide_links(text: &str) -> String {
266    let chars: Vec<char> = text.chars().collect();
267    let mut out = String::with_capacity(text.len());
268    let mut i = 0;
269    while i < chars.len() {
270        let starts = i == 0 || !(chars[i - 1].is_alphanumeric() || matches!(chars[i - 1], '+' | '-' | '.' | '_' | '/' | '@' | '%' | '='));
271        if starts && link_at(&chars[i..]) {
272            let mut end = i;
273            while end < chars.len() && !chars[end].is_whitespace() && !matches!(chars[end], '<' | '>' | '"' | '\'' | '(' | ')' | '[' | ']' | '{' | '}') {
274                end += 1;
275            }
276            // A sentence's own stop stays after it.
277            while end > i + 1 && matches!(chars[end - 1], '.' | ',' | ';' | ':' | '!' | '?') {
278                end -= 1;
279            }
280            out.push_str(LINK);
281            i = end;
282            continue;
283        }
284        out.push(chars[i]);
285        i += 1;
286    }
287    out
288}
289
290/// Whether a link starts the text.
291fn link_at(rest: &[char]) -> bool {
292    let lower: String = rest.iter().take(4).map(char::to_ascii_lowercase).collect();
293    if lower == "www." {
294        return true;
295    }
296    if !rest.first().is_some_and(char::is_ascii_alphanumeric) {
297        return false;
298    }
299    // A scheme: a letter, then letters, digits, "+", "-" or ".", then "://".
300    let scheme = rest.iter().take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')).count();
301    if rest[0].is_ascii_alphabetic() && rest.get(scheme..scheme + 3) == Some(&[':', '/', '/'][..]) {
302        return true;
303    }
304    // A host and a path: names and dots, a top-level name of letters, then "/".
305    let host: String = rest.iter().take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.')).collect();
306    let top = host.rsplit('.').next().unwrap_or("");
307    rest.get(host.chars().count()) == Some(&'/') && host.contains('.') && top.len() >= 2 && top.chars().all(|c| c.is_ascii_alphabetic())
308}
309
310/// What may stand between the groups of an account or a card number: a
311/// space, of any width (HTML mail turned into text has non-breaking ones).
312fn separator(c: char) -> bool {
313    matches!(c, ' ' | '\u{a0}' | '\u{202f}' | '\u{2009}' | '\u{2007}')
314}
315
316/// Runs of `allowed` characters from `start`, a single space (or hyphen,
317/// when `hyphens`) allowed between them: each place a run could end, with the
318/// characters kept so far. A place is kept only where no letter or digit follows.
319fn runs(chars: &[char], start: usize, allowed: impl Fn(char) -> bool, hyphens: bool, most: usize) -> Vec<(usize, String)> {
320    let mut ends = Vec::new();
321    let mut kept = String::new();
322    let mut i = start;
323    while i < chars.len() && kept.chars().count() < most {
324        if allowed(chars[i]) {
325            kept.push(chars[i]);
326            i += 1;
327            if i == chars.len() || !chars[i].is_alphanumeric() {
328                ends.push((i, kept.clone()));
329            }
330        } else if (separator(chars[i]) || (hyphens && chars[i] == '-')) && i + 1 < chars.len() && allowed(chars[i + 1]) && !kept.is_empty() {
331            i += 1;
332        } else {
333            break;
334        }
335    }
336    ends
337}
338
339/// An IBAN starting at `start`, written whole or in groups: ISO 13616, its
340/// check digits by mod 97. The longest form that checks out is taken.
341fn iban_at(chars: &[char], start: usize) -> Option<(usize, String)> {
342    let head: Vec<char> = chars.get(start..start + 4)?.to_vec();
343    if !(head[0].is_ascii_uppercase() && head[1].is_ascii_uppercase() && head[2].is_ascii_digit() && head[3].is_ascii_digit()) {
344        return None;
345    }
346    let ends = runs(chars, start, |c| c.is_ascii_uppercase() || c.is_ascii_digit(), false, 34);
347    ends.into_iter().rev().filter(|(_, kept)| kept.len() >= 15).find(|(_, kept)| iban_checks(kept)).map(|(end, kept)| (end, format!("[IBAN …{}]", &kept[kept.len() - 4..])))
348}
349
350fn iban_checks(iban: &str) -> bool {
351    let moved = iban[4..].chars().chain(iban[..4].chars());
352    let mut rest: u32 = 0;
353    for c in moved {
354        let value = match c {
355            '0'..='9' => c as u32 - '0' as u32,
356            'A'..='Z' => c as u32 - 'A' as u32 + 10,
357            _ => return false,
358        };
359        rest = if value >= 10 { (rest * 100 + value) % 97 } else { (rest * 10 + value) % 97 };
360    }
361    rest == 1
362}
363
364/// A payment card number, Luhn's check digit right: 16 or 19 digits from a
365/// network's first digit (2 to 6), or 15 from American Express's 34 and 37.
366/// Fourteen digits are left alone: a SIRET has a Luhn key too, and is no secret.
367fn card_at(chars: &[char], start: usize) -> Option<(usize, String)> {
368    if !matches!(chars.get(start), Some('2'..='6')) {
369        return None;
370    }
371    let shaped = |digits: &str| match digits.len() {
372        16 | 19 => true,
373        15 => digits.starts_with("34") || digits.starts_with("37"),
374        _ => false,
375    };
376    let ends = runs(chars, start, |c| c.is_ascii_digit(), true, 19);
377    ends.into_iter().rev().filter(|(_, digits)| shaped(digits)).find(|(_, digits)| luhn(digits)).map(|(end, digits)| (end, format!("[card …{}]", &digits[digits.len() - 4..])))
378}
379
380fn luhn(digits: &str) -> bool {
381    let sum: u32 = digits
382        .chars()
383        .rev()
384        .filter_map(|c| c.to_digit(10))
385        .enumerate()
386        .map(|(i, d)| if i % 2 == 1 { if d * 2 > 9 { d * 2 - 9 } else { d * 2 } } else { d })
387        .sum();
388    sum % 10 == 0
389}
390
391/// A French social security number (NIR): 15 characters, the first 1, 2, 7
392/// or 8, the last two its key, 97 less the first thirteen modulo 97; Corsica's
393/// departments, 2A and 2B, count as 19 and 18 in the key.
394fn nir_at(chars: &[char], start: usize) -> Option<(usize, String)> {
395    if !matches!(chars.get(start), Some('1' | '2' | '7' | '8')) {
396        return None;
397    }
398    let ends = runs(chars, start, |c| c.is_ascii_digit() || c == 'A' || c == 'B', false, 15);
399    let (end, kept) = ends.into_iter().find(|(_, kept)| kept.len() == 15)?;
400    let digits = match &kept[5..7] {
401        "2A" => format!("{}19{}", &kept[..5], &kept[7..]),
402        "2B" => format!("{}18{}", &kept[..5], &kept[7..]),
403        _ => kept,
404    };
405    let number: u64 = digits[..13].parse().ok()?;
406    let key: u64 = digits[13..].parse().ok()?;
407    (97 - number % 97 == key).then(|| (end, NIR.to_string()))
408}