1use 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
19pub 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 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#[derive(Default)]
46struct Secrets {
47 kinds: Vec<CodeKind>,
48 codes: Vec<String>,
49}
50
51const PIECE: usize = 5000;
55const OVERLAP: usize = 200;
56
57fn 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
95const BEFORE: [usize; 4] = [250, 170, 90, 30];
100const AFTER: usize = 170;
101
102fn 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 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
128fn 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
144fn apart(c: char) -> bool {
148 matches!(c, ' ' | '\u{a0}' | '\u{202f}' | '\u{2009}' | '\u{2007}' | '-' | '.' | '\n' | '\r' | '\t')
149}
150
151fn 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 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
189fn 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 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
232pub 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
251pub 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
262fn 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 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
290fn 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 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 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
310fn separator(c: char) -> bool {
313 matches!(c, ' ' | '\u{a0}' | '\u{202f}' | '\u{2009}' | '\u{2007}')
314}
315
316fn 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
339fn 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
364fn 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
391fn 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}