1#[derive(Debug, Clone, Default)]
21pub struct Identity {
22 pub user: String,
24 pub host: String,
26 pub home: String,
28}
29
30impl Identity {
31 pub fn current() -> Self {
33 let env = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty());
34 let host = env("HOSTNAME")
35 .or_else(|| env("COMPUTERNAME"))
36 .or_else(|| {
37 std::fs::read_to_string("/etc/hostname")
38 .ok()
39 .map(|s| s.trim().to_owned())
40 .filter(|s| !s.is_empty())
41 })
42 .unwrap_or_default();
43 Self {
44 user: env("USER").or_else(|| env("USERNAME")).unwrap_or_default(),
45 host,
46 home: dirs::home_dir()
47 .map(|p| p.to_string_lossy().into_owned())
48 .unwrap_or_default(),
49 }
50 }
51}
52
53const TOKEN_PREFIXES: [&str; 9] = [
54 "AKIA",
55 "github_pat_",
56 "ghp_",
57 "gho_",
58 "ghs_",
59 "ghu_",
60 "sk-",
61 "xoxb-",
62 "xoxp-",
63];
64const TOKEN_MIN_TAIL: usize = 16;
65const MIN_IDENTITY_WORD: usize = 3;
67
68fn is_word(c: char) -> bool {
69 c.is_ascii_alphanumeric() || c == '_' || c == '-'
70}
71
72fn is_name(c: char) -> bool {
73 c.is_alphanumeric() || matches!(c, '_' | '-' | '.')
74}
75
76fn is_email_local(c: char) -> bool {
77 c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '%' | '+' | '-')
78}
79
80fn name_len(s: &str) -> usize {
82 s[..run(s, is_name)].trim_end_matches('.').len()
83}
84
85fn run(s: &str, f: impl Fn(char) -> bool) -> usize {
87 s.char_indices()
88 .find(|&(_, c)| !f(c))
89 .map_or(s.len(), |(i, _)| i)
90}
91
92pub fn scrub(text: &str, id: &Identity) -> String {
94 let mut out = String::with_capacity(text.len());
95 let mut i = 0;
96 while i < text.len() {
97 if let Some((len, rep)) = match_at(&text[..i], &text[i..], id) {
98 out.push_str(rep);
99 i += len;
100 } else {
101 let c = text[i..].chars().next().expect("i is on a char boundary");
102 out.push(c);
103 i += c.len_utf8();
104 }
105 }
106 out
107}
108
109pub fn locate(text: &str, id: &Identity) -> Vec<(usize, &'static str)> {
112 let mut hits = Vec::new();
113 let mut i = 0;
114 let mut line = 1;
115 while i < text.len() {
116 if let Some((len, rep)) = match_at(&text[..i], &text[i..], id) {
117 let kind = match rep {
118 "~" => "home-path",
119 r => r
120 .trim_start_matches('[')
121 .trim_end_matches(']')
122 .trim_start_matches("redacted-"),
123 };
124 hits.push((line, kind));
125 line += text[i..i + len].matches('\n').count();
126 i += len;
127 } else {
128 let c = text[i..].chars().next().expect("i is on a char boundary");
129 if c == '\n' {
130 line += 1;
131 }
132 i += c.len_utf8();
133 }
134 }
135 hits
136}
137
138fn match_at(before: &str, rest: &str, id: &Identity) -> Option<(usize, &'static str)> {
139 let prev = before.chars().next_back();
140 let starts_word = prev.is_none_or(|p| !is_word(p));
141 home_path(rest, prev, id)
142 .or_else(|| {
143 starts_word
144 .then(|| {
145 token(rest).or_else(|| credential(rest)).or_else(|| {
146 prev.is_none_or(|c| !is_name(c))
147 .then(|| named_identity(rest, prev))
148 .flatten()
149 })
150 })
151 .flatten()
152 })
153 .or_else(|| absolute_path(rest, prev))
154 .or_else(|| {
155 prev.is_none_or(|p| !is_email_local(p))
156 .then(|| email(rest))
157 .flatten()
158 })
159 .or_else(|| {
160 prev.is_none_or(|p| !p.is_ascii_alphanumeric() && p != '.' && p != ':')
161 .then(|| {
162 ipv4(rest)
163 .filter(|_| !section_reference(before))
164 .or_else(|| ipv6(rest))
165 })
166 .flatten()
167 })
168 .or_else(|| {
169 (starts_word && !github_owner_slot(before))
170 .then(|| identity_word(rest, id))
171 .flatten()
172 })
173}
174
175fn home_path(rest: &str, prev: Option<char>, id: &Identity) -> Option<(usize, &'static str)> {
178 if prev.is_some_and(|p| p.is_ascii_alphanumeric() || matches!(p, '.' | '_' | '-' | '~')) {
179 return None;
180 }
181 if id.home.len() > 1 && rest.starts_with(id.home.as_str()) {
182 let tail = &rest[id.home.len()..];
183 if tail.chars().next().is_none_or(|c| !is_name(c)) {
184 return Some((id.home.len(), "~"));
185 }
186 }
187 for base in ["/Users/", "/home/"] {
188 if let Some(tail) = rest.strip_prefix(base) {
189 let n = name_len(tail);
190 if n > 0 {
191 return Some((base.len() + n, "~"));
192 }
193 }
194 }
195 if let Some(tail) = rest.strip_prefix("/root")
196 && tail.chars().next().is_none_or(|c| !is_word(c))
197 {
198 return Some(("/root".len(), "~"));
199 }
200 let b = rest.as_bytes();
201 if b.len() > 9
202 && b[0].is_ascii_alphabetic()
203 && b[1] == b':'
204 && matches!(b[2], b'\\' | b'/')
205 && b[3..8].eq_ignore_ascii_case(b"users")
206 && matches!(b[8], b'\\' | b'/')
207 {
208 let n = name_len(&rest[9..]);
209 if n > 0 {
210 return Some((9 + n, "~"));
211 }
212 }
213 None
214}
215
216fn token(rest: &str) -> Option<(usize, &'static str)> {
217 let prefix = TOKEN_PREFIXES.iter().find(|p| rest.starts_with(**p))?;
218 let tail = run(&rest[prefix.len()..], is_word);
219 (tail >= TOKEN_MIN_TAIL).then_some((prefix.len() + tail, "[redacted-token]"))
220}
221
222fn named_identity(rest: &str, prev: Option<char>) -> Option<(usize, &'static str)> {
224 for key in [
225 "hostname=",
226 "hostname: ",
227 "username=",
228 "username: ",
229 "user=",
230 "host=",
231 ] {
232 if rest
233 .get(..key.len())
234 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(key))
235 {
236 let n = run(&rest[key.len()..], is_name);
237 if n > 0 {
238 return Some((key.len() + n, "[redacted-identity]"));
239 }
240 }
241 }
242 let n = name_len(rest);
243 if n > 0
244 && !code_receiver(&rest[..n], prev == Some('`') && rest[n..].starts_with('`'))
245 && [".local", ".internal", ".lan"].iter().any(|suffix| {
246 n.checked_sub(suffix.len())
247 .and_then(|start| rest.get(start..n))
248 .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix))
249 })
250 {
251 return Some((n, "[redacted-host]"));
252 }
253 None
254}
255
256fn code_receiver(name: &str, backticked: bool) -> 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 lowercase_identifier =
273 !receiver.is_empty() && receiver.chars().all(|c| c.is_ascii_lowercase() || c == '_');
274 if lowercase_identifier && (backticked || receiver.contains('_')) {
275 return true;
276 }
277 let mut prev_lower = false;
278 receiver.chars().any(|c| {
279 let hump = prev_lower && c.is_ascii_uppercase();
280 prev_lower = c.is_ascii_lowercase();
281 hump
282 })
283}
284
285fn quoted_credential(rest: &str) -> Option<(usize, &'static str)> {
288 for key in [
289 "password", "token", "api_key", "api-key", "apikey", "secret",
290 ] {
291 let Some(after) = rest
292 .get(..key.len())
293 .filter(|p| p.eq_ignore_ascii_case(key))
294 .map(|_| &rest[key.len()..])
295 else {
296 continue;
297 };
298 let Some(after_quote) = after.strip_prefix(['"', '\'']) else {
299 continue;
300 };
301 let t = after_quote.trim_start();
302 let Some(t) = t.strip_prefix([':', '=']) else {
303 continue;
304 };
305 let t = t.trim_start();
306 let n = match t.chars().next() {
308 Some(q @ ('"' | '\'')) => {
309 let inner = &t[1..];
310 let end = quoted_end(inner, q).unwrap_or(inner.len());
311 1 + end + usize::from(end < inner.len())
312 }
313 _ => run(t, |c| {
314 !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | ',' | '}')
315 }),
316 };
317 let value = t;
318 if n > 0 {
319 return Some((rest.len() - value.len() + n, "[redacted-token]"));
320 }
321 }
322 None
323}
324
325fn quoted_end(inner: &str, q: char) -> Option<usize> {
327 let mut chars = inner.char_indices();
328 while let Some((i, c)) = chars.next() {
329 if c == '\\' {
330 chars.next();
331 } else if c == q {
332 return Some(i);
333 }
334 }
335 None
336}
337
338fn credential(rest: &str) -> Option<(usize, &'static str)> {
339 if let Some(hit) = quoted_credential(rest) {
340 return Some(hit);
341 }
342 for key in [
343 "password=",
344 "token=",
345 "api_key=",
346 "api-key=",
347 "password: ",
348 "token: ",
349 "api_key: ",
350 "bearer ",
351 "password ",
352 "token ",
353 "api key ",
354 ] {
355 if rest
356 .get(..key.len())
357 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(key))
358 {
359 let tail = &rest[key.len()..];
360 let padding = tail.len() - tail.trim_start_matches([' ', '\'', '"']).len();
361 let value = &tail[padding..];
362 let n = run(value, |c| {
363 !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | '"' | '\'')
364 });
365 let contextual = matches!(key, "password " | "token " | "api key ");
366 let entropy = n >= 20
367 && value[..n].bytes().any(|b| b.is_ascii_digit())
368 && value[..n].bytes().any(|b| b.is_ascii_alphabetic());
369 if n > 0 && (!contextual || entropy) {
370 return Some((key.len() + padding + n, "[redacted-token]"));
371 }
372 }
373 }
374 None
375}
376
377fn absolute_path(rest: &str, prev: Option<char>) -> Option<(usize, &'static str)> {
378 if prev.is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | ':' | '.' | '~')) {
379 return None;
380 }
381 let b = rest.as_bytes();
382 let windows =
383 b.len() > 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && matches!(b[2], b'/' | b'\\');
384 let unix = [
386 "/tmp/",
387 "/private/",
388 "/var/",
389 "/etc/",
390 "/opt/",
391 "/srv/",
392 "/usr/",
393 "/mnt/",
394 "/Volumes/",
395 ]
396 .iter()
397 .any(|root| rest.starts_with(root));
398 if windows || unix {
399 let n = run(rest, |c| {
400 !c.is_whitespace() && !matches!(c, '`' | '"' | '\'' | '<' | '>')
401 });
402 if unix && public_system_path(&rest[..n]) {
403 return None;
404 }
405 return Some((n, "[redacted-path]"));
406 }
407 None
408}
409
410fn public_system_path(path: &str) -> bool {
411 matches!(
421 path.trim_end_matches(['.', ',', ';', ':', '!', '?', ')', ']']),
422 "/etc/hosts"
423 | "/usr/local/bin"
424 | "/usr/local/bin/foo"
425 | "/var/log/syslog"
426 | "/var/run/kanade/agent.sock"
427 )
428}
429
430fn email(rest: &str) -> Option<(usize, &'static str)> {
431 let local = run(rest, is_email_local);
432 if local == 0 || !rest[local..].starts_with('@') {
433 return None;
434 }
435 let domain = &rest[local + 1..];
436 let mut end = 0;
437 let mut labels = 0;
438 loop {
439 let n = run(&domain[end..], |c| c.is_ascii_alphanumeric() || c == '-');
440 if n == 0 {
441 break;
442 }
443 end += n;
444 labels += 1;
445 if domain[end..].starts_with('.')
446 && run(&domain[end + 1..], |c| c.is_ascii_alphanumeric()) > 0
447 {
448 end += 1;
449 } else {
450 break;
451 }
452 }
453 (labels >= 2).then_some((local + 1 + end, "[redacted-email]"))
454}
455
456fn section_reference(before: &str) -> bool {
459 let trimmed = before.trim_end_matches([' ', '\t']);
460 ["§", "section", "sec.", "clause"].iter().any(|marker| {
461 if *marker != "§" && trimmed.len() == before.len() {
462 return false;
463 }
464 let Some(start) = trimmed.len().checked_sub(marker.len()) else {
465 return false;
466 };
467 trimmed
468 .get(start..)
469 .is_some_and(|tail| tail.eq_ignore_ascii_case(marker))
470 && trimmed[..start]
471 .chars()
472 .next_back()
473 .is_none_or(|c| !c.is_alphanumeric() && !matches!(c, '_' | '-'))
474 })
475}
476
477fn ipv4(rest: &str) -> Option<(usize, &'static str)> {
478 let mut len = 0;
479 for part in 0..4 {
480 let s = &rest[len..];
481 let n = run(s, |c| c.is_ascii_digit());
482 if n == 0 || n > 3 || s[..n].parse::<u16>().ok()? > 255 {
483 return None;
484 }
485 len += n;
486 if part < 3 {
487 if !rest[len..].starts_with('.') {
488 return None;
489 }
490 len += 1;
491 }
492 }
493 let after = &rest[len..];
494 let mut chars = after.chars();
495 match chars.next() {
496 Some(c) if c.is_ascii_alphanumeric() => return None,
497 Some('.') if chars.next().is_some_and(|c| c.is_ascii_digit()) => return None,
498 _ => {}
499 }
500 Some((len, "[redacted-ip]"))
501}
502
503fn ipv6(rest: &str) -> Option<(usize, &'static str)> {
504 let mut n = run(rest, |c| c.is_ascii_hexdigit() || c == ':');
505 while n > 0 && rest[..n].ends_with(':') && !rest[..n].ends_with("::") {
507 n -= 1;
508 }
509 let cand = &rest[..n];
510 let colons = cand.matches(':').count();
511 let shaped = (cand.contains("::") && colons >= 2) || colons == 7;
512 if !shaped
513 || !cand.bytes().any(|b| b.is_ascii_digit())
514 || cand.split(':').any(|g| g.len() > 4)
515 || rest[n..]
516 .chars()
517 .next()
518 .is_some_and(|c| c.is_ascii_alphanumeric())
519 {
520 return None;
521 }
522 Some((n, "[redacted-ip]"))
523}
524
525const GENERIC_IDENTITY_WORDS: [&str; 12] = [
529 "local",
530 "localhost",
531 "localdomain",
532 "lan",
533 "internal",
534 "user",
535 "host",
536 "admin",
537 "root",
538 "test",
539 "default",
540 "unknown",
541];
542
543fn is_generic_identity(w: &str) -> bool {
544 let w = w.strip_prefix('.').unwrap_or(w);
545 GENERIC_IDENTITY_WORDS
546 .iter()
547 .any(|g| g.eq_ignore_ascii_case(w))
548}
549
550fn github_owner_slot(before: &str) -> bool {
553 let Some(head) = strip_suffix_ci(before.as_bytes(), b"github.com/") else {
554 return false;
555 };
556 let head = strip_suffix_ci(head, b"www.").unwrap_or(head);
557 strip_suffix_ci(head, b"http://").is_some() || strip_suffix_ci(head, b"https://").is_some()
558}
559
560fn strip_suffix_ci<'a>(s: &'a [u8], suffix: &[u8]) -> Option<&'a [u8]> {
561 let start = s.len().checked_sub(suffix.len())?;
562 s[start..]
563 .eq_ignore_ascii_case(suffix)
564 .then_some(&s[..start])
565}
566
567fn identity_word(rest: &str, id: &Identity) -> Option<(usize, &'static str)> {
568 for (word, rep) in [(&id.user, "[redacted-user]"), (&id.host, "[redacted-host]")] {
569 let w = word.trim();
570 if w.len() < MIN_IDENTITY_WORD
571 || is_generic_identity(w)
572 || rest.len() < w.len()
573 || !rest.is_char_boundary(w.len())
574 {
575 continue;
576 }
577 if rest[..w.len()].eq_ignore_ascii_case(w)
578 && rest[w.len()..].chars().next().is_none_or(|c| !is_word(c))
579 {
580 return Some((w.len(), rep));
581 }
582 }
583 None
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 fn id() -> Identity {
591 Identity {
592 user: "alice".into(),
593 host: "buildbox".into(),
594 home: "/srv/people/alice".into(),
595 }
596 }
597
598 fn s(t: &str) -> String {
599 scrub(t, &id())
600 }
601
602 #[test]
603 fn locate_names_kind_and_line_without_the_text() {
604 let id = Identity::default();
605 let text = "fine\ntoken=abcdefghijklmnop1234 here\nmail a@b.example";
606 assert_eq!(locate(text, &id), vec![(2, "token"), (3, "email")]);
607 }
608
609 #[test]
610 fn quoted_credentials_honor_escaped_quotes() {
611 let out = s(r#"{"password":"prefix\"hunter2"} tail"#);
612 assert!(!out.contains("hunter2") && !out.contains("prefix"), "{out}");
613 assert!(out.ends_with("} tail"), "{out}");
614 let out = s(r#"{"password":"a\\\\"} tail"#);
616 assert!(out.ends_with("} tail") && !out.contains("a\\"), "{out}");
617 let out = s(r#"{"password":"a\\\"hunter2"} tail"#);
619 assert!(!out.contains("hunter2"), "{out}");
620 let out = s(r#"{'token':'x\'hunter2'} tail"#);
621 assert!(!out.contains("hunter2"), "{out}");
622 let out = s(r#"{"password":"日本\"語hunter2"} tail"#);
623 assert!(!out.contains("hunter2") && !out.contains('語'), "{out}");
624 let out = s(r#"{"password":"abc\"hunter2"#);
626 assert!(!out.contains("hunter2"), "{out}");
627 }
628
629 #[test]
630 fn home_paths_collapse_and_keep_the_tail() {
631 assert_eq!(s("see /Users/bob/src/x.rs now"), "see ~/src/x.rs now");
632 assert_eq!(s("in /home/bob-1/.config"), "in ~/.config");
633 assert_eq!(s("at C:\\Users\\Bob\\proj\\a.rs"), "at ~\\proj\\a.rs");
634 assert_eq!(s("at C:/Users/Bob/proj"), "at ~/proj");
635 assert_eq!(s("/root/x and /root."), "~/x and ~.");
636 assert_eq!(s("/srv/people/alice/wt/a"), "~/wt/a");
637 }
638
639 #[test]
640 fn public_system_paths_pass_scrub_and_locate() {
641 for path in [
642 "/run/kanade/agent.sock",
643 "/var/run/kanade/agent.sock",
644 "/etc/hosts",
645 "/usr/local/bin",
646 "/usr/local/bin/foo",
647 "/var/log/syslog",
648 ] {
649 for text in [
650 path.to_owned(),
651 format!("uses `{path}` on this platform"),
652 format!("uses \"{path}\" on this platform"),
653 format!("See ({path}), then {path}."),
654 ] {
655 assert_eq!(s(&text), text, "{text}");
656 assert!(locate(&text, &id()).is_empty(), "{text}");
657 }
658 }
659 }
660
661 #[test]
662 fn private_paths_and_non_public_shapes_still_redact() {
663 let identity = Identity::default();
664 for path in [
665 "/Volumes/disk-name/project",
666 "/Volumes/disk name/project",
667 "/srv/people/alice/wt/a",
668 "/tmp/checkout/build",
669 "/private/var/folders/ab/account/build",
670 "/var/folders/ab/account/build",
671 "/mnt/disk/project",
672 "/usr/home/bob/project",
673 "/opt/checkout/build",
674 "/etc/hosts/private",
675 "/etc/hostname",
676 "/etc/hosts-backup",
677 "/usr/local/bin/foo/private",
678 "/usr/local/bin/../private",
679 "/usr/local/bin/..",
680 "/usr/local/bin/./foo",
681 "/usr/local/bin//foo",
682 "/usr/local/bin/foo/",
683 "/var/log/../private",
684 "/var/log/service/private",
685 "/var/run/agent.sock",
686 "/var/run/service/agent.pid",
687 "/var/run/service/.sock",
688 "/var/run/../agent.sock",
689 "/var/run/service/../agent.sock",
690 "/var/run//agent.sock",
691 r"/var/log/foo\private",
692 r"C:\build\checkout",
693 ] {
694 assert!(
695 scrub(path, &identity).starts_with("[redacted-path]"),
696 "{path}"
697 );
698 assert_eq!(locate(path, &identity), vec![(1, "path")], "{path}");
699 }
700 for (text, expected) in [
701 ("/Users/bob/src/x", "~/src/x"),
702 ("/home/bob/.config", "~/.config"),
703 ("C:/Users/Bob/proj", "~/proj"),
704 (r"C:\Users\Bob\proj", r"~\proj"),
705 ("/srv/people/alice/wt/a", "~/wt/a"),
706 ] {
707 assert_eq!(s(text), expected, "{text}");
708 assert_eq!(locate(text, &id()), vec![(1, "home-path")], "{text}");
709 }
710 assert_eq!(
712 scrub("/Volumes/disk name/project", &identity),
713 "[redacted-path] name/project"
714 );
715 for text in [
716 "/unknown/checkout/build",
717 "/api/v1/runs",
718 "/run/service/agent.sock",
719 ] {
720 assert_eq!(scrub(text, &identity), text);
721 assert!(locate(text, &identity).is_empty());
722 }
723 }
724
725 #[test]
726 fn public_paths_keep_home_and_identity_rules_and_do_not_rescan() {
727 let identity = Identity {
728 home: "/usr/local/bin".into(),
729 ..id()
730 };
731 assert_eq!(scrub("/usr/local/bin/foo", &identity), "~/foo");
732 assert_eq!(
733 locate("/usr/local/bin/foo", &identity),
734 vec![(1, "home-path")]
735 );
736 for path in [
737 "/usr/local/bin/alice",
738 "/usr/local/bin/alice-build",
739 "/usr/local/bin/bob",
740 "/var/log/buildbox.log",
741 "/var/log/alice-build.log",
742 "/var/run/alice/agent.sock",
743 "/var/run/alice-agent/agent.sock",
744 "/var/run/bob/agent.sock",
745 "/var/log/box.local",
746 ] {
747 for identity in [id(), Identity::default()] {
748 assert_eq!(scrub(path, &identity), "[redacted-path]", "{path}");
749 assert_eq!(locate(path, &identity), vec![(1, "path")], "{path}");
750 }
751 }
752 let identity = Identity {
753 user: "foo".into(),
754 host: "kanade".into(),
755 ..Identity::default()
756 };
757 for (text, expected, kind) in [
758 (
759 "/usr/local/bin/foo",
760 "/usr/local/bin/[redacted-user]",
761 "user",
762 ),
763 (
764 "/var/run/kanade/agent.sock",
765 "/var/run/[redacted-host]/agent.sock",
766 "host",
767 ),
768 ] {
769 assert_eq!(scrub(text, &identity), expected);
770 assert_eq!(locate(text, &identity), vec![(1, kind)]);
771 }
772 let identity = Identity {
773 user: "redacted".into(),
774 host: "redacted-path".into(),
775 ..Identity::default()
776 };
777 let text = "/tmp/checkout redacted /usr/local/bin/foo";
778 assert_eq!(
779 scrub(text, &identity),
780 "[redacted-path] [redacted-user] /usr/local/bin/foo"
781 );
782 assert_eq!(locate(text, &identity), vec![(1, "path"), (1, "user")]);
783 }
784
785 #[test]
786 fn emails_ips_and_tokens() {
787 assert_eq!(s("mail dev.x+y@example.co.uk!"), "mail [redacted-email]!");
788 assert_eq!(s("host 192.168.0.12:8080"), "host [redacted-ip]:8080");
789 assert_eq!(
790 s("v6 fe80::1 and ::1"),
791 "v6 [redacted-ip] and [redacted-ip]"
792 );
793 assert_eq!(s("full 2001:db8:0:0:0:0:0:1."), "full [redacted-ip].");
794 assert_eq!(
795 s("tok ghp_abcdefghijklmnopqrstuv end"),
796 "tok [redacted-token] end"
797 );
798 }
799
800 #[test]
801 fn ipv4_section_references_pass_scrub_and_locate() {
802 for text in [
803 "§2.12.4.1-4.4",
804 "see §2.12.4.1",
805 "§ 2.12.4.1",
806 "section 2.12.4.1",
807 "sec. 2.12.4.1",
808 "clause 2.12.4.1",
809 "SECTION\t2.12.4.1",
810 "See (Sec. \t2.12.4.1)",
811 "CLAUSE 2.12.4.1",
812 "仕様 §\t2.12.4.1",
813 "released v1.2.3, version 0.41.1, 300.1.1.1",
814 ] {
815 assert_eq!(s(text), text, "{text}");
816 assert!(locate(text, &id()).is_empty(), "{text}");
817 }
818 }
819
820 #[test]
821 fn ipv4_section_markers_require_boundaries_and_same_line() {
822 for before in [
823 "subsection ",
824 "xsec. ",
825 "9clause ",
826 "_section ",
827 "-clause ",
828 "ésection ",
829 "字sec. ",
830 "x§ ",
831 "section\n",
832 "§\n ",
833 "clause\r\n\t",
834 "section\u{a0}",
835 "RFC ",
836 ] {
837 let text = format!("{before}2.12.4.1");
838 assert_eq!(s(&text), format!("{before}[redacted-ip]"), "{text}");
839 let line = 1 + before.matches('\n').count();
840 assert_eq!(locate(&text, &id()), vec![(line, "ip")], "{text}");
841 }
842 for before in ["section", "sec.", "clause", "é", "字"] {
843 assert!(!section_reference(before), "{before}");
844 }
845 }
846
847 #[test]
848 fn section_exception_keeps_other_sensitive_data_redacted() {
849 for (text, expected, hits) in [
850 (
851 "host 192.168.0.12:8080",
852 "host [redacted-ip]:8080",
853 vec![(1, "ip")],
854 ),
855 (
856 "/Users/bob 10.0.0.1 a@b.io alice",
857 "~ [redacted-ip] [redacted-email] [redacted-user]",
858 vec![(1, "home-path"), (1, "ip"), (1, "email"), (1, "user")],
859 ),
860 (
861 "connect to 10.1.2.3",
862 "connect to [redacted-ip]",
863 vec![(1, "ip")],
864 ),
865 (
866 "§2.12.4.1\nconnect to 10.1.2.3",
867 "§2.12.4.1\nconnect to [redacted-ip]",
868 vec![(2, "ip")],
869 ),
870 (
871 "section 2.12.4.1-10.1.2.3, 10.0.0.1",
872 "section 2.12.4.1-[redacted-ip], [redacted-ip]",
873 vec![(1, "ip"), (1, "ip")],
874 ),
875 (
876 "§fe80::1\nsection ::1\nclause 2001:db8:0:0:0:0:0:1",
877 "§[redacted-ip]\nsection [redacted-ip]\nclause [redacted-ip]",
878 vec![(1, "ip"), (2, "ip"), (3, "ip")],
879 ),
880 ] {
881 assert_eq!(s(text), expected, "{text}");
882 assert_eq!(locate(text, &id()), hits, "{text}");
883 }
884 }
885
886 #[test]
887 fn section_context_uses_original_input_and_does_not_rescan_replacements() {
888 let identity = Identity {
889 user: "redacted-ip".into(),
890 ..Identity::default()
891 };
892 let text = "§2.12.4.1\nconnect to 10.1.2.3";
893 assert_eq!(
894 scrub(text, &identity),
895 "§2.12.4.1\nconnect to [redacted-ip]"
896 );
897 assert_eq!(locate(text, &identity), vec![(2, "ip")]);
898 let identity = Identity {
899 user: "section".into(),
900 ..Identity::default()
901 };
902 assert_eq!(
903 scrub("section 2.12.4.1", &identity),
904 "[redacted-user] 2.12.4.1"
905 );
906 assert_eq!(locate("section 2.12.4.1", &identity), vec![(1, "user")]);
907 }
908
909 #[test]
910 fn identity_words_match_whole_words_only() {
911 assert_eq!(
912 s("by Alice on buildbox"),
913 "by [redacted-user] on [redacted-host]"
914 );
915 assert_eq!(
916 s("alicein wonderland, xbuildbox"),
917 "alicein wonderland, xbuildbox"
918 );
919 }
920
921 #[test]
922 fn github_url_owner_is_kept_but_other_mentions_are_not() {
923 assert_eq!(
924 s("https://github.com/alice/magi/pull/681 by alice"),
925 "https://github.com/alice/magi/pull/681 by [redacted-user]"
926 );
927 assert_eq!(
928 s("http://www.GitHub.com/Alice/x"),
929 "http://www.GitHub.com/Alice/x"
930 );
931 for t in [
932 "https://github.com.evil/alice",
933 "https://github.com/x/alice",
934 "git@github.com:alice/x",
935 "https://api.github.com/alice",
936 "github.com/alice",
937 ] {
938 assert!(s(t).contains("[redacted-user]"), "{t}");
939 }
940 let text = "https://github.com/alice/magi alice";
941 assert_eq!(locate(text, &id()), vec![(1, "user")]);
942 }
943
944 #[test]
945 fn github_owner_slot_checks_only_ascii_url_suffixes() {
946 for before in [
947 "https://github.com/",
948 "http://github.com/",
949 "https://www.github.com/",
950 "http://www.github.com/",
951 "HTTPS://GitHub.com/",
952 "HTTP://WWW.GitHub.com/",
953 "prose éhttps://github.com/",
954 ] {
955 assert!(github_owner_slot(before), "{before}");
956 }
957 for before in [
958 "https://api.github.com/",
959 "github.com/",
960 "www.github.com/",
961 "",
962 "git",
963 "égithub.com/",
964 "éwww.github.com/",
965 "é",
966 ] {
967 assert!(!github_owner_slot(before), "{before}");
968 }
969 }
970
971 #[test]
972 fn long_body_of_short_words_passes_scrub_and_locate() {
973 let text = "a ".repeat(32768);
974 let id = Identity::default();
975 assert_eq!(scrub(&text, &id), text);
976 assert!(locate(&text, &id).is_empty());
977 }
978
979 #[test]
980 fn generic_host_and_user_values_do_not_redact_prose() {
981 for host in ["local", ".local", "devbox.local"] {
982 let i = Identity {
983 host: host.into(),
984 ..Identity::default()
985 };
986 let t = "`WatchState::local` and local mode";
987 assert_eq!(scrub(t, &i), t, "{host}");
988 }
989 let real = Identity {
990 host: "devbox.local".into(),
991 ..Identity::default()
992 };
993 assert_eq!(
994 scrub("on devbox.local now", &real),
995 "on [redacted-host] now"
996 );
997 for (user, host) in [("local", ""), ("admin", "localhost")] {
998 let i = Identity {
999 user: user.into(),
1000 host: host.into(),
1001 ..Identity::default()
1002 };
1003 let t = "local admin localhost";
1004 assert_eq!(scrub(t, &i), t);
1005 }
1006 let once = s("https://github.com/alice/x alice buildbox");
1007 assert_eq!(scrub(&once, &id()), once);
1008 assert_eq!(s("on buildbox"), "on [redacted-host]");
1009 }
1010
1011 #[test]
1012 fn dotted_code_paths_ending_in_local_are_not_hosts() {
1013 for t in [
1014 "`WatchState.local` is true",
1015 "WatchState.lan and self.local",
1016 "WatchState.local",
1017 ] {
1018 assert_eq!(scrub(t, &Identity::default()), t);
1019 }
1020 for t in ["ssh MyServer.lan", "JohnsMacBook.local"] {
1022 assert_eq!(scrub(t, &Identity::default()), t);
1023 }
1024 for (t, want) in [
1025 ("ssh m1air.loCal now", "ssh [redacted-host] now"),
1026 ("ssh devbox.LOCAL", "ssh [redacted-host]"),
1027 ("ssh box.InterNal", "ssh [redacted-host]"),
1028 ("ssh box.LaN", "ssh [redacted-host]"),
1029 ] {
1030 assert_eq!(scrub(t, &Identity::default()), want, "{t}");
1031 }
1032 for t in [
1033 "ssh devbox.local",
1034 "ssh BUILD-SERVER.local",
1035 "ssh Alices-MacBook-Pro.local",
1036 "ssh BUILD.local",
1037 ] {
1038 assert!(
1039 scrub(t, &Identity::default()).contains("[redacted-host]"),
1040 "{t}"
1041 );
1042 }
1043 }
1044
1045 #[test]
1046 fn lowercase_field_receivers_pass_scrub_and_locate() {
1047 let id = Identity::default();
1048 for text in [
1049 "`st.local` is true",
1050 "`cfg.local`",
1051 "`ctx.lan`",
1052 "`env.internal`",
1053 "my_state.local",
1054 "`my_state.local`",
1055 "`st.local`, then continue.",
1056 "`devbox.local`",
1057 ] {
1058 assert_eq!(scrub(text, &id), text, "{text}");
1059 assert!(locate(text, &id).is_empty(), "{text}");
1060 }
1061 }
1062
1063 #[test]
1064 fn field_receiver_exception_keeps_host_redaction() {
1065 let id = Identity::default();
1066 for text in [
1067 "st.local",
1068 "cfg.local",
1069 "devbox.local",
1070 "box.lan",
1071 "`st.local",
1072 "st.local`",
1073 "`st.local is true`",
1074 "`st.local.`",
1075 "`box1.local`",
1076 "`build-box.local`",
1077 "`box.example.local`",
1078 ] {
1079 assert!(scrub(text, &id).contains("[redacted-host]"), "{text}");
1080 assert_eq!(locate(text, &id), vec![(1, "host")], "{text}");
1081 }
1082 for text in ["host=st.local", "`host=cfg.local`"] {
1083 assert!(scrub(text, &id).contains("[redacted-identity]"), "{text}");
1084 assert_eq!(locate(text, &id), vec![(1, "identity")], "{text}");
1085 }
1086 let known = Identity {
1087 host: "devbox.local".into(),
1088 ..Identity::default()
1089 };
1090 let text = "`devbox.local`";
1091 assert_eq!(scrub(text, &known), "`[redacted-host]`");
1092 assert_eq!(locate(text, &known), vec![(1, "host")]);
1093 }
1094
1095 #[test]
1096 fn ordinary_text_is_untouched() {
1097 for t in [
1098 "Change src/graph.rs and tests/common/mod.rs.",
1099 "See https://github.com/o/r/pull/12 for #12",
1100 "returns std::io::Error, or a::b, Vec::new()",
1101 "released v1.2.3, version 0.41.1, 300.1.1.1",
1102 "thanks @coderabbitai; at 12:34:56 it ran",
1103 "/api/v1/runs; docs/home/x and ./Users/y",
1104 "A plain sentence about background and motivation.",
1105 "日本語のテキスト 🎉 with émoji",
1106 ] {
1107 assert_eq!(s(t), t);
1108 }
1109 }
1110
1111 #[test]
1112 fn multibyte_input_does_not_panic_and_replacement_is_not_rescanned() {
1113 assert_eq!(
1114 s("C:\\日本語 and C:/日本"),
1115 "[redacted-path] and [redacted-path]"
1116 );
1117 assert_eq!(s("é/Users/bob/é 日本 alice"), "é~/é 日本 [redacted-user]");
1118 let once = s("/Users/bob 10.0.0.1 a@b.io alice");
1119 assert_eq!(scrub(&once, &Identity::default()), once);
1120 let odd = Identity {
1122 user: "redacted".into(),
1123 ..Identity::default()
1124 };
1125 assert_eq!(scrub("redacted x", &odd), "[redacted-user] x");
1126 }
1127}