1#[derive(Debug, Clone, Default)]
19pub struct Identity {
20 pub user: String,
22 pub host: String,
24 pub home: String,
26}
27
28impl Identity {
29 pub fn current() -> Self {
31 let env = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty());
32 let host = env("HOSTNAME")
33 .or_else(|| env("COMPUTERNAME"))
34 .or_else(|| {
35 std::fs::read_to_string("/etc/hostname")
36 .ok()
37 .map(|s| s.trim().to_owned())
38 .filter(|s| !s.is_empty())
39 })
40 .unwrap_or_default();
41 Self {
42 user: env("USER").or_else(|| env("USERNAME")).unwrap_or_default(),
43 host,
44 home: dirs::home_dir()
45 .map(|p| p.to_string_lossy().into_owned())
46 .unwrap_or_default(),
47 }
48 }
49}
50
51const TOKEN_PREFIXES: [&str; 9] = [
52 "AKIA",
53 "github_pat_",
54 "ghp_",
55 "gho_",
56 "ghs_",
57 "ghu_",
58 "sk-",
59 "xoxb-",
60 "xoxp-",
61];
62const TOKEN_MIN_TAIL: usize = 16;
63const MIN_IDENTITY_WORD: usize = 3;
65
66fn is_word(c: char) -> bool {
67 c.is_ascii_alphanumeric() || c == '_' || c == '-'
68}
69
70fn is_name(c: char) -> bool {
71 c.is_alphanumeric() || matches!(c, '_' | '-' | '.')
72}
73
74fn is_email_local(c: char) -> bool {
75 c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '%' | '+' | '-')
76}
77
78fn name_len(s: &str) -> usize {
80 s[..run(s, is_name)].trim_end_matches('.').len()
81}
82
83fn run(s: &str, f: impl Fn(char) -> bool) -> usize {
85 s.char_indices()
86 .find(|&(_, c)| !f(c))
87 .map_or(s.len(), |(i, _)| i)
88}
89
90pub fn scrub(text: &str, id: &Identity) -> String {
92 let mut out = String::with_capacity(text.len());
93 let mut i = 0;
94 while i < text.len() {
95 if let Some((len, rep)) = match_at(&text[..i], &text[i..], id) {
96 out.push_str(rep);
97 i += len;
98 } else {
99 let c = text[i..].chars().next().expect("i is on a char boundary");
100 out.push(c);
101 i += c.len_utf8();
102 }
103 }
104 out
105}
106
107pub fn locate(text: &str, id: &Identity) -> Vec<(usize, &'static str)> {
110 let mut hits = Vec::new();
111 let mut i = 0;
112 let mut line = 1;
113 while i < text.len() {
114 if let Some((len, rep)) = match_at(&text[..i], &text[i..], id) {
115 let kind = match rep {
116 "~" => "home-path",
117 r => r
118 .trim_start_matches('[')
119 .trim_end_matches(']')
120 .trim_start_matches("redacted-"),
121 };
122 hits.push((line, kind));
123 line += text[i..i + len].matches('\n').count();
124 i += len;
125 } else {
126 let c = text[i..].chars().next().expect("i is on a char boundary");
127 if c == '\n' {
128 line += 1;
129 }
130 i += c.len_utf8();
131 }
132 }
133 hits
134}
135
136fn match_at(before: &str, rest: &str, id: &Identity) -> Option<(usize, &'static str)> {
137 let prev = before.chars().next_back();
138 let starts_word = prev.is_none_or(|p| !is_word(p));
139 home_path(rest, prev, id)
140 .or_else(|| {
141 starts_word
142 .then(|| {
143 token(rest).or_else(|| credential(rest)).or_else(|| {
144 prev.is_none_or(|c| !is_name(c))
145 .then(|| named_identity(rest))
146 .flatten()
147 })
148 })
149 .flatten()
150 })
151 .or_else(|| absolute_path(rest, prev))
152 .or_else(|| {
153 prev.is_none_or(|p| !is_email_local(p))
154 .then(|| email(rest))
155 .flatten()
156 })
157 .or_else(|| {
158 prev.is_none_or(|p| !p.is_ascii_alphanumeric() && p != '.' && p != ':')
159 .then(|| {
160 ipv4(rest)
161 .filter(|_| !section_reference(before))
162 .or_else(|| ipv6(rest))
163 })
164 .flatten()
165 })
166 .or_else(|| {
167 (starts_word && !github_owner_slot(before))
168 .then(|| identity_word(rest, id))
169 .flatten()
170 })
171}
172
173fn home_path(rest: &str, prev: Option<char>, id: &Identity) -> Option<(usize, &'static str)> {
176 if prev.is_some_and(|p| p.is_ascii_alphanumeric() || matches!(p, '.' | '_' | '-' | '~')) {
177 return None;
178 }
179 if id.home.len() > 1 && rest.starts_with(id.home.as_str()) {
180 let tail = &rest[id.home.len()..];
181 if tail.chars().next().is_none_or(|c| !is_name(c)) {
182 return Some((id.home.len(), "~"));
183 }
184 }
185 for base in ["/Users/", "/home/"] {
186 if let Some(tail) = rest.strip_prefix(base) {
187 let n = name_len(tail);
188 if n > 0 {
189 return Some((base.len() + n, "~"));
190 }
191 }
192 }
193 if let Some(tail) = rest.strip_prefix("/root")
194 && tail.chars().next().is_none_or(|c| !is_word(c))
195 {
196 return Some(("/root".len(), "~"));
197 }
198 let b = rest.as_bytes();
199 if b.len() > 9
200 && b[0].is_ascii_alphabetic()
201 && b[1] == b':'
202 && matches!(b[2], b'\\' | b'/')
203 && b[3..8].eq_ignore_ascii_case(b"users")
204 && matches!(b[8], b'\\' | b'/')
205 {
206 let n = name_len(&rest[9..]);
207 if n > 0 {
208 return Some((9 + n, "~"));
209 }
210 }
211 None
212}
213
214fn token(rest: &str) -> Option<(usize, &'static str)> {
215 let prefix = TOKEN_PREFIXES.iter().find(|p| rest.starts_with(**p))?;
216 let tail = run(&rest[prefix.len()..], is_word);
217 (tail >= TOKEN_MIN_TAIL).then_some((prefix.len() + tail, "[redacted-token]"))
218}
219
220fn named_identity(rest: &str) -> Option<(usize, &'static str)> {
222 for key in [
223 "hostname=",
224 "hostname: ",
225 "username=",
226 "username: ",
227 "user=",
228 "host=",
229 ] {
230 if rest
231 .get(..key.len())
232 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(key))
233 {
234 let n = run(&rest[key.len()..], is_name);
235 if n > 0 {
236 return Some((key.len() + n, "[redacted-identity]"));
237 }
238 }
239 }
240 let n = name_len(rest);
241 if n > 0
242 && !code_receiver(&rest[..n])
243 && [".local", ".internal", ".lan"].iter().any(|suffix| {
244 n.checked_sub(suffix.len())
245 .and_then(|start| rest.get(start..n))
246 .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix))
247 })
248 {
249 return Some((n, "[redacted-host]"));
250 }
251 None
252}
253
254fn code_receiver(name: &str) -> bool {
263 if name.matches('.').count() != 1 || name.contains('-') {
264 return false;
265 }
266 let Some((receiver, _)) = name.split_once('.') else {
267 return false;
268 };
269 if receiver.eq_ignore_ascii_case("self") || receiver.eq_ignore_ascii_case("this") {
270 return true;
271 }
272 let mut prev_lower = false;
273 receiver.chars().any(|c| {
274 let hump = prev_lower && c.is_ascii_uppercase();
275 prev_lower = c.is_ascii_lowercase();
276 hump
277 })
278}
279
280fn quoted_credential(rest: &str) -> Option<(usize, &'static str)> {
283 for key in [
284 "password", "token", "api_key", "api-key", "apikey", "secret",
285 ] {
286 let Some(after) = rest
287 .get(..key.len())
288 .filter(|p| p.eq_ignore_ascii_case(key))
289 .map(|_| &rest[key.len()..])
290 else {
291 continue;
292 };
293 let Some(after_quote) = after.strip_prefix(['"', '\'']) else {
294 continue;
295 };
296 let t = after_quote.trim_start();
297 let Some(t) = t.strip_prefix([':', '=']) else {
298 continue;
299 };
300 let t = t.trim_start();
301 let n = match t.chars().next() {
303 Some(q @ ('"' | '\'')) => {
304 let inner = &t[1..];
305 let end = quoted_end(inner, q).unwrap_or(inner.len());
306 1 + end + usize::from(end < inner.len())
307 }
308 _ => run(t, |c| {
309 !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | ',' | '}')
310 }),
311 };
312 let value = t;
313 if n > 0 {
314 return Some((rest.len() - value.len() + n, "[redacted-token]"));
315 }
316 }
317 None
318}
319
320fn quoted_end(inner: &str, q: char) -> Option<usize> {
322 let mut chars = inner.char_indices();
323 while let Some((i, c)) = chars.next() {
324 if c == '\\' {
325 chars.next();
326 } else if c == q {
327 return Some(i);
328 }
329 }
330 None
331}
332
333fn credential(rest: &str) -> Option<(usize, &'static str)> {
334 if let Some(hit) = quoted_credential(rest) {
335 return Some(hit);
336 }
337 for key in [
338 "password=",
339 "token=",
340 "api_key=",
341 "api-key=",
342 "password: ",
343 "token: ",
344 "api_key: ",
345 "bearer ",
346 "password ",
347 "token ",
348 "api key ",
349 ] {
350 if rest
351 .get(..key.len())
352 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(key))
353 {
354 let tail = &rest[key.len()..];
355 let padding = tail.len() - tail.trim_start_matches([' ', '\'', '"']).len();
356 let value = &tail[padding..];
357 let n = run(value, |c| {
358 !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | '"' | '\'')
359 });
360 let contextual = matches!(key, "password " | "token " | "api key ");
361 let entropy = n >= 20
362 && value[..n].bytes().any(|b| b.is_ascii_digit())
363 && value[..n].bytes().any(|b| b.is_ascii_alphabetic());
364 if n > 0 && (!contextual || entropy) {
365 return Some((key.len() + padding + n, "[redacted-token]"));
366 }
367 }
368 }
369 None
370}
371
372fn absolute_path(rest: &str, prev: Option<char>) -> Option<(usize, &'static str)> {
373 if prev.is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | ':' | '.' | '~')) {
374 return None;
375 }
376 let b = rest.as_bytes();
377 let windows =
378 b.len() > 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && matches!(b[2], b'/' | b'\\');
379 let unix = [
381 "/tmp/",
382 "/private/",
383 "/var/",
384 "/etc/",
385 "/opt/",
386 "/srv/",
387 "/usr/",
388 "/mnt/",
389 "/Volumes/",
390 ]
391 .iter()
392 .any(|root| rest.starts_with(root));
393 if windows || unix {
394 let n = run(rest, |c| {
395 !c.is_whitespace() && !matches!(c, '`' | '"' | '\'' | '<' | '>')
396 });
397 return Some((n, "[redacted-path]"));
398 }
399 None
400}
401
402fn email(rest: &str) -> Option<(usize, &'static str)> {
403 let local = run(rest, is_email_local);
404 if local == 0 || !rest[local..].starts_with('@') {
405 return None;
406 }
407 let domain = &rest[local + 1..];
408 let mut end = 0;
409 let mut labels = 0;
410 loop {
411 let n = run(&domain[end..], |c| c.is_ascii_alphanumeric() || c == '-');
412 if n == 0 {
413 break;
414 }
415 end += n;
416 labels += 1;
417 if domain[end..].starts_with('.')
418 && run(&domain[end + 1..], |c| c.is_ascii_alphanumeric()) > 0
419 {
420 end += 1;
421 } else {
422 break;
423 }
424 }
425 (labels >= 2).then_some((local + 1 + end, "[redacted-email]"))
426}
427
428fn section_reference(before: &str) -> bool {
431 let trimmed = before.trim_end_matches([' ', '\t']);
432 ["§", "section", "sec.", "clause"].iter().any(|marker| {
433 if *marker != "§" && trimmed.len() == before.len() {
434 return false;
435 }
436 let Some(start) = trimmed.len().checked_sub(marker.len()) else {
437 return false;
438 };
439 trimmed
440 .get(start..)
441 .is_some_and(|tail| tail.eq_ignore_ascii_case(marker))
442 && trimmed[..start]
443 .chars()
444 .next_back()
445 .is_none_or(|c| !c.is_alphanumeric() && !matches!(c, '_' | '-'))
446 })
447}
448
449fn ipv4(rest: &str) -> Option<(usize, &'static str)> {
450 let mut len = 0;
451 for part in 0..4 {
452 let s = &rest[len..];
453 let n = run(s, |c| c.is_ascii_digit());
454 if n == 0 || n > 3 || s[..n].parse::<u16>().ok()? > 255 {
455 return None;
456 }
457 len += n;
458 if part < 3 {
459 if !rest[len..].starts_with('.') {
460 return None;
461 }
462 len += 1;
463 }
464 }
465 let after = &rest[len..];
466 let mut chars = after.chars();
467 match chars.next() {
468 Some(c) if c.is_ascii_alphanumeric() => return None,
469 Some('.') if chars.next().is_some_and(|c| c.is_ascii_digit()) => return None,
470 _ => {}
471 }
472 Some((len, "[redacted-ip]"))
473}
474
475fn ipv6(rest: &str) -> Option<(usize, &'static str)> {
476 let mut n = run(rest, |c| c.is_ascii_hexdigit() || c == ':');
477 while n > 0 && rest[..n].ends_with(':') && !rest[..n].ends_with("::") {
479 n -= 1;
480 }
481 let cand = &rest[..n];
482 let colons = cand.matches(':').count();
483 let shaped = (cand.contains("::") && colons >= 2) || colons == 7;
484 if !shaped
485 || !cand.bytes().any(|b| b.is_ascii_digit())
486 || cand.split(':').any(|g| g.len() > 4)
487 || rest[n..]
488 .chars()
489 .next()
490 .is_some_and(|c| c.is_ascii_alphanumeric())
491 {
492 return None;
493 }
494 Some((n, "[redacted-ip]"))
495}
496
497const GENERIC_IDENTITY_WORDS: [&str; 12] = [
501 "local",
502 "localhost",
503 "localdomain",
504 "lan",
505 "internal",
506 "user",
507 "host",
508 "admin",
509 "root",
510 "test",
511 "default",
512 "unknown",
513];
514
515fn is_generic_identity(w: &str) -> bool {
516 let w = w.strip_prefix('.').unwrap_or(w);
517 GENERIC_IDENTITY_WORDS
518 .iter()
519 .any(|g| g.eq_ignore_ascii_case(w))
520}
521
522fn github_owner_slot(before: &str) -> bool {
525 let Some(head) = strip_suffix_ci(before.as_bytes(), b"github.com/") else {
526 return false;
527 };
528 let head = strip_suffix_ci(head, b"www.").unwrap_or(head);
529 strip_suffix_ci(head, b"http://").is_some() || strip_suffix_ci(head, b"https://").is_some()
530}
531
532fn strip_suffix_ci<'a>(s: &'a [u8], suffix: &[u8]) -> Option<&'a [u8]> {
533 let start = s.len().checked_sub(suffix.len())?;
534 s[start..]
535 .eq_ignore_ascii_case(suffix)
536 .then_some(&s[..start])
537}
538
539fn identity_word(rest: &str, id: &Identity) -> Option<(usize, &'static str)> {
540 for (word, rep) in [(&id.user, "[redacted-user]"), (&id.host, "[redacted-host]")] {
541 let w = word.trim();
542 if w.len() < MIN_IDENTITY_WORD
543 || is_generic_identity(w)
544 || rest.len() < w.len()
545 || !rest.is_char_boundary(w.len())
546 {
547 continue;
548 }
549 if rest[..w.len()].eq_ignore_ascii_case(w)
550 && rest[w.len()..].chars().next().is_none_or(|c| !is_word(c))
551 {
552 return Some((w.len(), rep));
553 }
554 }
555 None
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561
562 fn id() -> Identity {
563 Identity {
564 user: "alice".into(),
565 host: "buildbox".into(),
566 home: "/srv/people/alice".into(),
567 }
568 }
569
570 fn s(t: &str) -> String {
571 scrub(t, &id())
572 }
573
574 #[test]
575 fn locate_names_kind_and_line_without_the_text() {
576 let id = Identity::default();
577 let text = "fine\ntoken=abcdefghijklmnop1234 here\nmail a@b.example";
578 assert_eq!(locate(text, &id), vec![(2, "token"), (3, "email")]);
579 }
580
581 #[test]
582 fn quoted_credentials_honor_escaped_quotes() {
583 let out = s(r#"{"password":"prefix\"hunter2"} tail"#);
584 assert!(!out.contains("hunter2") && !out.contains("prefix"), "{out}");
585 assert!(out.ends_with("} tail"), "{out}");
586 let out = s(r#"{"password":"a\\\\"} tail"#);
588 assert!(out.ends_with("} tail") && !out.contains("a\\"), "{out}");
589 let out = s(r#"{"password":"a\\\"hunter2"} tail"#);
591 assert!(!out.contains("hunter2"), "{out}");
592 let out = s(r#"{'token':'x\'hunter2'} tail"#);
593 assert!(!out.contains("hunter2"), "{out}");
594 let out = s(r#"{"password":"日本\"語hunter2"} tail"#);
595 assert!(!out.contains("hunter2") && !out.contains('語'), "{out}");
596 let out = s(r#"{"password":"abc\"hunter2"#);
598 assert!(!out.contains("hunter2"), "{out}");
599 }
600
601 #[test]
602 fn home_paths_collapse_and_keep_the_tail() {
603 assert_eq!(s("see /Users/bob/src/x.rs now"), "see ~/src/x.rs now");
604 assert_eq!(s("in /home/bob-1/.config"), "in ~/.config");
605 assert_eq!(s("at C:\\Users\\Bob\\proj\\a.rs"), "at ~\\proj\\a.rs");
606 assert_eq!(s("at C:/Users/Bob/proj"), "at ~/proj");
607 assert_eq!(s("/root/x and /root."), "~/x and ~.");
608 assert_eq!(s("/srv/people/alice/wt/a"), "~/wt/a");
609 }
610
611 #[test]
612 fn emails_ips_and_tokens() {
613 assert_eq!(s("mail dev.x+y@example.co.uk!"), "mail [redacted-email]!");
614 assert_eq!(s("host 192.168.0.12:8080"), "host [redacted-ip]:8080");
615 assert_eq!(
616 s("v6 fe80::1 and ::1"),
617 "v6 [redacted-ip] and [redacted-ip]"
618 );
619 assert_eq!(s("full 2001:db8:0:0:0:0:0:1."), "full [redacted-ip].");
620 assert_eq!(
621 s("tok ghp_abcdefghijklmnopqrstuv end"),
622 "tok [redacted-token] end"
623 );
624 }
625
626 #[test]
627 fn ipv4_section_references_pass_scrub_and_locate() {
628 for text in [
629 "§2.12.4.1-4.4",
630 "see §2.12.4.1",
631 "§ 2.12.4.1",
632 "section 2.12.4.1",
633 "sec. 2.12.4.1",
634 "clause 2.12.4.1",
635 "SECTION\t2.12.4.1",
636 "See (Sec. \t2.12.4.1)",
637 "CLAUSE 2.12.4.1",
638 "仕様 §\t2.12.4.1",
639 "released v1.2.3, version 0.41.1, 300.1.1.1",
640 ] {
641 assert_eq!(s(text), text, "{text}");
642 assert!(locate(text, &id()).is_empty(), "{text}");
643 }
644 }
645
646 #[test]
647 fn ipv4_section_markers_require_boundaries_and_same_line() {
648 for before in [
649 "subsection ",
650 "xsec. ",
651 "9clause ",
652 "_section ",
653 "-clause ",
654 "ésection ",
655 "字sec. ",
656 "x§ ",
657 "section\n",
658 "§\n ",
659 "clause\r\n\t",
660 "section\u{a0}",
661 "RFC ",
662 ] {
663 let text = format!("{before}2.12.4.1");
664 assert_eq!(s(&text), format!("{before}[redacted-ip]"), "{text}");
665 let line = 1 + before.matches('\n').count();
666 assert_eq!(locate(&text, &id()), vec![(line, "ip")], "{text}");
667 }
668 for before in ["section", "sec.", "clause", "é", "字"] {
669 assert!(!section_reference(before), "{before}");
670 }
671 }
672
673 #[test]
674 fn section_exception_keeps_other_sensitive_data_redacted() {
675 for (text, expected, hits) in [
676 (
677 "host 192.168.0.12:8080",
678 "host [redacted-ip]:8080",
679 vec![(1, "ip")],
680 ),
681 (
682 "/Users/bob 10.0.0.1 a@b.io alice",
683 "~ [redacted-ip] [redacted-email] [redacted-user]",
684 vec![(1, "home-path"), (1, "ip"), (1, "email"), (1, "user")],
685 ),
686 (
687 "connect to 10.1.2.3",
688 "connect to [redacted-ip]",
689 vec![(1, "ip")],
690 ),
691 (
692 "§2.12.4.1\nconnect to 10.1.2.3",
693 "§2.12.4.1\nconnect to [redacted-ip]",
694 vec![(2, "ip")],
695 ),
696 (
697 "section 2.12.4.1-10.1.2.3, 10.0.0.1",
698 "section 2.12.4.1-[redacted-ip], [redacted-ip]",
699 vec![(1, "ip"), (1, "ip")],
700 ),
701 (
702 "§fe80::1\nsection ::1\nclause 2001:db8:0:0:0:0:0:1",
703 "§[redacted-ip]\nsection [redacted-ip]\nclause [redacted-ip]",
704 vec![(1, "ip"), (2, "ip"), (3, "ip")],
705 ),
706 ] {
707 assert_eq!(s(text), expected, "{text}");
708 assert_eq!(locate(text, &id()), hits, "{text}");
709 }
710 }
711
712 #[test]
713 fn section_context_uses_original_input_and_does_not_rescan_replacements() {
714 let identity = Identity {
715 user: "redacted-ip".into(),
716 ..Identity::default()
717 };
718 let text = "§2.12.4.1\nconnect to 10.1.2.3";
719 assert_eq!(
720 scrub(text, &identity),
721 "§2.12.4.1\nconnect to [redacted-ip]"
722 );
723 assert_eq!(locate(text, &identity), vec![(2, "ip")]);
724 let identity = Identity {
725 user: "section".into(),
726 ..Identity::default()
727 };
728 assert_eq!(
729 scrub("section 2.12.4.1", &identity),
730 "[redacted-user] 2.12.4.1"
731 );
732 assert_eq!(locate("section 2.12.4.1", &identity), vec![(1, "user")]);
733 }
734
735 #[test]
736 fn identity_words_match_whole_words_only() {
737 assert_eq!(
738 s("by Alice on buildbox"),
739 "by [redacted-user] on [redacted-host]"
740 );
741 assert_eq!(
742 s("alicein wonderland, xbuildbox"),
743 "alicein wonderland, xbuildbox"
744 );
745 }
746
747 #[test]
748 fn github_url_owner_is_kept_but_other_mentions_are_not() {
749 assert_eq!(
750 s("https://github.com/alice/magi/pull/681 by alice"),
751 "https://github.com/alice/magi/pull/681 by [redacted-user]"
752 );
753 assert_eq!(
754 s("http://www.GitHub.com/Alice/x"),
755 "http://www.GitHub.com/Alice/x"
756 );
757 for t in [
758 "https://github.com.evil/alice",
759 "https://github.com/x/alice",
760 "git@github.com:alice/x",
761 "https://api.github.com/alice",
762 "github.com/alice",
763 ] {
764 assert!(s(t).contains("[redacted-user]"), "{t}");
765 }
766 let text = "https://github.com/alice/magi alice";
767 assert_eq!(locate(text, &id()), vec![(1, "user")]);
768 }
769
770 #[test]
771 fn github_owner_slot_checks_only_ascii_url_suffixes() {
772 for before in [
773 "https://github.com/",
774 "http://github.com/",
775 "https://www.github.com/",
776 "http://www.github.com/",
777 "HTTPS://GitHub.com/",
778 "HTTP://WWW.GitHub.com/",
779 "prose éhttps://github.com/",
780 ] {
781 assert!(github_owner_slot(before), "{before}");
782 }
783 for before in [
784 "https://api.github.com/",
785 "github.com/",
786 "www.github.com/",
787 "",
788 "git",
789 "égithub.com/",
790 "éwww.github.com/",
791 "é",
792 ] {
793 assert!(!github_owner_slot(before), "{before}");
794 }
795 }
796
797 #[test]
798 fn long_body_of_short_words_passes_scrub_and_locate() {
799 let text = "a ".repeat(32768);
800 let id = Identity::default();
801 assert_eq!(scrub(&text, &id), text);
802 assert!(locate(&text, &id).is_empty());
803 }
804
805 #[test]
806 fn generic_host_and_user_values_do_not_redact_prose() {
807 for host in ["local", ".local", "devbox.local"] {
808 let i = Identity {
809 host: host.into(),
810 ..Identity::default()
811 };
812 let t = "`WatchState::local` and local mode";
813 assert_eq!(scrub(t, &i), t, "{host}");
814 }
815 let real = Identity {
816 host: "devbox.local".into(),
817 ..Identity::default()
818 };
819 assert_eq!(
820 scrub("on devbox.local now", &real),
821 "on [redacted-host] now"
822 );
823 for (user, host) in [("local", ""), ("admin", "localhost")] {
824 let i = Identity {
825 user: user.into(),
826 host: host.into(),
827 ..Identity::default()
828 };
829 let t = "local admin localhost";
830 assert_eq!(scrub(t, &i), t);
831 }
832 let once = s("https://github.com/alice/x alice buildbox");
833 assert_eq!(scrub(&once, &id()), once);
834 assert_eq!(s("on buildbox"), "on [redacted-host]");
835 }
836
837 #[test]
838 fn dotted_code_paths_ending_in_local_are_not_hosts() {
839 for t in [
840 "`WatchState.local` is true",
841 "WatchState.lan and self.local",
842 "WatchState.local",
843 ] {
844 assert_eq!(scrub(t, &Identity::default()), t);
845 }
846 for t in ["ssh MyServer.lan", "JohnsMacBook.local"] {
848 assert_eq!(scrub(t, &Identity::default()), t);
849 }
850 for (t, want) in [
851 ("ssh m1air.loCal now", "ssh [redacted-host] now"),
852 ("ssh devbox.LOCAL", "ssh [redacted-host]"),
853 ("ssh box.InterNal", "ssh [redacted-host]"),
854 ("ssh box.LaN", "ssh [redacted-host]"),
855 ] {
856 assert_eq!(scrub(t, &Identity::default()), want, "{t}");
857 }
858 for t in [
859 "ssh devbox.local",
860 "ssh BUILD-SERVER.local",
861 "ssh Alices-MacBook-Pro.local",
862 "`devbox.local`",
863 "ssh BUILD.local",
864 ] {
865 assert!(
866 scrub(t, &Identity::default()).contains("[redacted-host]"),
867 "{t}"
868 );
869 }
870 }
871
872 #[test]
873 fn ordinary_text_is_untouched() {
874 for t in [
875 "Change src/graph.rs and tests/common/mod.rs.",
876 "See https://github.com/o/r/pull/12 for #12",
877 "returns std::io::Error, or a::b, Vec::new()",
878 "released v1.2.3, version 0.41.1, 300.1.1.1",
879 "thanks @coderabbitai; at 12:34:56 it ran",
880 "/api/v1/runs; docs/home/x and ./Users/y",
881 "A plain sentence about background and motivation.",
882 "日本語のテキスト 🎉 with émoji",
883 ] {
884 assert_eq!(s(t), t);
885 }
886 }
887
888 #[test]
889 fn multibyte_input_does_not_panic_and_replacement_is_not_rescanned() {
890 assert_eq!(
891 s("C:\\日本語 and C:/日本"),
892 "[redacted-path] and [redacted-path]"
893 );
894 assert_eq!(s("é/Users/bob/é 日本 alice"), "é~/é 日本 [redacted-user]");
895 let once = s("/Users/bob 10.0.0.1 a@b.io alice");
896 assert_eq!(scrub(&once, &Identity::default()), once);
897 let odd = Identity {
899 user: "redacted".into(),
900 ..Identity::default()
901 };
902 assert_eq!(scrub("redacted x", &odd), "[redacted-user] x");
903 }
904}