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
299 .strip_prefix("\\\"")
300 .or_else(|| after.strip_prefix(['"', '\'']))
301 else {
302 continue;
303 };
304 let t = after_quote.trim_start();
305 let Some(t) = t.strip_prefix([':', '=']) else {
306 continue;
307 };
308 let t = t.trim_start();
309 let n = quoted_value_len(t).unwrap_or_else(|| {
311 run(t, |c| {
312 !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | ',' | '}')
313 })
314 });
315 let value = t;
316 if n > 0 {
317 return Some((rest.len() - value.len() + n, "[redacted-token]"));
318 }
319 }
320 None
321}
322
323fn quoted_end(inner: &str, q: char) -> Option<usize> {
325 let mut chars = inner.char_indices();
326 while let Some((i, c)) = chars.next() {
327 if c == '\\' {
328 chars.next();
329 } else if c == q {
330 return Some(i);
331 }
332 }
333 None
334}
335
336fn quoted_value_len(value: &str) -> Option<usize> {
340 if let Some(inner) = value.strip_prefix("\\\"") {
341 let mut slashes = 0;
342 for (i, c) in inner.char_indices() {
343 if c == '\\' {
344 slashes += 1;
345 continue;
346 }
347 if c == '"' && slashes % 4 == 1 {
350 return Some(2 + i + 1);
351 }
352 slashes = 0;
353 }
354 return Some(value.len());
355 }
356 let q = value.chars().next().filter(|c| matches!(c, '"' | '\''))?;
357 let inner = &value[1..];
358 let end = quoted_end(inner, q).unwrap_or(inner.len());
359 Some(1 + end + usize::from(end < inner.len()))
360}
361
362fn credential(rest: &str) -> Option<(usize, &'static str)> {
363 if let Some(hit) = quoted_credential(rest) {
364 return Some(hit);
365 }
366 for key in [
367 "password=",
368 "token=",
369 "api_key=",
370 "api-key=",
371 "password: ",
372 "token: ",
373 "api_key: ",
374 "bearer ",
375 "password ",
376 "token ",
377 "api key ",
378 ] {
379 if rest
380 .get(..key.len())
381 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(key))
382 {
383 let tail = &rest[key.len()..];
384 let padding = tail.len() - tail.trim_start_matches(' ').len();
385 let value = &tail[padding..];
386 let n = quoted_value_len(value).unwrap_or_else(|| {
387 run(value, |c| {
388 !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | '"' | '\'')
389 })
390 });
391 let contextual = matches!(key, "password " | "token " | "api key ");
392 let entropy_value = value.trim_start_matches(['\'', '"']);
393 let entropy_len = run(entropy_value, |c| {
394 !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | '"' | '\'')
395 });
396 let entropy = entropy_len >= 20
397 && entropy_value[..entropy_len]
398 .bytes()
399 .any(|b| b.is_ascii_digit())
400 && entropy_value[..entropy_len]
401 .bytes()
402 .any(|b| b.is_ascii_alphabetic());
403 if n > 0 && (!contextual || entropy) {
404 return Some((key.len() + padding + n, "[redacted-token]"));
405 }
406 }
407 }
408 None
409}
410
411fn absolute_path(rest: &str, prev: Option<char>) -> Option<(usize, &'static str)> {
412 if prev.is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | ':' | '.' | '~')) {
413 return None;
414 }
415 let b = rest.as_bytes();
416 let windows =
417 b.len() > 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && matches!(b[2], b'/' | b'\\');
418 let unix = [
420 "/tmp/",
421 "/private/",
422 "/var/",
423 "/etc/",
424 "/opt/",
425 "/srv/",
426 "/usr/",
427 "/mnt/",
428 "/Volumes/",
429 ]
430 .iter()
431 .any(|root| rest.starts_with(root));
432 if windows || unix {
433 let n = run(rest, |c| {
434 !c.is_whitespace() && !matches!(c, '`' | '"' | '\'' | '<' | '>')
435 });
436 if unix && public_system_path(&rest[..n]) {
437 return None;
438 }
439 return Some((n, "[redacted-path]"));
440 }
441 None
442}
443
444fn public_system_path(path: &str) -> bool {
445 matches!(
455 path.trim_end_matches(['.', ',', ';', ':', '!', '?', ')', ']']),
456 "/etc/hosts"
457 | "/usr/local/bin"
458 | "/usr/local/bin/foo"
459 | "/var/log/syslog"
460 | "/var/run/kanade/agent.sock"
461 )
462}
463
464fn email(rest: &str) -> Option<(usize, &'static str)> {
465 let local = run(rest, is_email_local);
466 if local == 0 || !rest[local..].starts_with('@') {
467 return None;
468 }
469 let domain = &rest[local + 1..];
470 let mut end = 0;
471 let mut labels = 0;
472 loop {
473 let n = run(&domain[end..], |c| c.is_ascii_alphanumeric() || c == '-');
474 if n == 0 {
475 break;
476 }
477 end += n;
478 labels += 1;
479 if domain[end..].starts_with('.')
480 && run(&domain[end + 1..], |c| c.is_ascii_alphanumeric()) > 0
481 {
482 end += 1;
483 } else {
484 break;
485 }
486 }
487 (labels >= 2).then_some((local + 1 + end, "[redacted-email]"))
488}
489
490fn section_reference(before: &str) -> bool {
493 let trimmed = before.trim_end_matches([' ', '\t']);
494 ["§", "section", "sec.", "clause"].iter().any(|marker| {
495 if *marker != "§" && trimmed.len() == before.len() {
496 return false;
497 }
498 let Some(start) = trimmed.len().checked_sub(marker.len()) else {
499 return false;
500 };
501 trimmed
502 .get(start..)
503 .is_some_and(|tail| tail.eq_ignore_ascii_case(marker))
504 && trimmed[..start]
505 .chars()
506 .next_back()
507 .is_none_or(|c| !c.is_alphanumeric() && !matches!(c, '_' | '-'))
508 })
509}
510
511fn ipv4(rest: &str) -> Option<(usize, &'static str)> {
512 let mut len = 0;
513 for part in 0..4 {
514 let s = &rest[len..];
515 let n = run(s, |c| c.is_ascii_digit());
516 if n == 0 || n > 3 || s[..n].parse::<u16>().ok()? > 255 {
517 return None;
518 }
519 len += n;
520 if part < 3 {
521 if !rest[len..].starts_with('.') {
522 return None;
523 }
524 len += 1;
525 }
526 }
527 let after = &rest[len..];
528 let mut chars = after.chars();
529 match chars.next() {
530 Some(c) if c.is_ascii_alphanumeric() => return None,
531 Some('.') if chars.next().is_some_and(|c| c.is_ascii_digit()) => return None,
532 _ => {}
533 }
534 Some((len, "[redacted-ip]"))
535}
536
537fn ipv6(rest: &str) -> Option<(usize, &'static str)> {
538 let mut n = run(rest, |c| c.is_ascii_hexdigit() || c == ':');
539 while n > 0 && rest[..n].ends_with(':') && !rest[..n].ends_with("::") {
541 n -= 1;
542 }
543 let cand = &rest[..n];
544 let colons = cand.matches(':').count();
545 let shaped = (cand.contains("::") && colons >= 2) || colons == 7;
546 if !shaped
547 || !cand.bytes().any(|b| b.is_ascii_digit())
548 || cand.split(':').any(|g| g.len() > 4)
549 || rest[n..]
550 .chars()
551 .next()
552 .is_some_and(|c| c.is_ascii_alphanumeric())
553 {
554 return None;
555 }
556 Some((n, "[redacted-ip]"))
557}
558
559const GENERIC_IDENTITY_WORDS: [&str; 12] = [
563 "local",
564 "localhost",
565 "localdomain",
566 "lan",
567 "internal",
568 "user",
569 "host",
570 "admin",
571 "root",
572 "test",
573 "default",
574 "unknown",
575];
576
577fn is_generic_identity(w: &str) -> bool {
578 let w = w.strip_prefix('.').unwrap_or(w);
579 GENERIC_IDENTITY_WORDS
580 .iter()
581 .any(|g| g.eq_ignore_ascii_case(w))
582}
583
584fn github_owner_slot(before: &str) -> bool {
587 let Some(head) = strip_suffix_ci(before.as_bytes(), b"github.com/") else {
588 return false;
589 };
590 let head = strip_suffix_ci(head, b"www.").unwrap_or(head);
591 strip_suffix_ci(head, b"http://").is_some() || strip_suffix_ci(head, b"https://").is_some()
592}
593
594fn strip_suffix_ci<'a>(s: &'a [u8], suffix: &[u8]) -> Option<&'a [u8]> {
595 let start = s.len().checked_sub(suffix.len())?;
596 s[start..]
597 .eq_ignore_ascii_case(suffix)
598 .then_some(&s[..start])
599}
600
601fn identity_word(rest: &str, id: &Identity) -> Option<(usize, &'static str)> {
602 for (word, rep) in [(&id.user, "[redacted-user]"), (&id.host, "[redacted-host]")] {
603 let w = word.trim();
604 if w.len() < MIN_IDENTITY_WORD
605 || is_generic_identity(w)
606 || rest.len() < w.len()
607 || !rest.is_char_boundary(w.len())
608 {
609 continue;
610 }
611 if rest[..w.len()].eq_ignore_ascii_case(w)
612 && rest[w.len()..].chars().next().is_none_or(|c| !is_word(c))
613 {
614 return Some((w.len(), rep));
615 }
616 }
617 None
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623
624 fn id() -> Identity {
625 Identity {
626 user: "alice".into(),
627 host: "buildbox".into(),
628 home: "/srv/people/alice".into(),
629 }
630 }
631
632 fn s(t: &str) -> String {
633 scrub(t, &id())
634 }
635
636 #[test]
637 fn locate_names_kind_and_line_without_the_text() {
638 let id = Identity::default();
639 let text = "fine\ntoken=abcdefghijklmnop1234 here\nmail a@b.example";
640 assert_eq!(locate(text, &id), vec![(2, "token"), (3, "email")]);
641 }
642
643 #[test]
644 fn quoted_credentials_honor_escaped_quotes() {
645 let out = s(r#"{"password":"prefix\"hunter2"} tail"#);
646 assert!(!out.contains("hunter2") && !out.contains("prefix"), "{out}");
647 assert!(out.ends_with("} tail"), "{out}");
648 let out = s(r#"{"password":"a\\\\"} tail"#);
650 assert!(out.ends_with("} tail") && !out.contains("a\\"), "{out}");
651 let out = s(r#"{"password":"a\\\"hunter2"} tail"#);
653 assert!(!out.contains("hunter2"), "{out}");
654 let out = s(r#"{'token':'x\'hunter2'} tail"#);
655 assert!(!out.contains("hunter2"), "{out}");
656 let out = s(r#"{"password":"日本\"語hunter2"} tail"#);
657 assert!(!out.contains("hunter2") && !out.contains('語'), "{out}");
658 let out = s(r#"{"password":"abc\"hunter2"#);
660 assert!(!out.contains("hunter2"), "{out}");
661 }
662
663 #[test]
664 fn unquoted_keys_redact_the_whole_quoted_value() {
665 for text in [
666 r#"password="prefix\"secret tail" safe"#,
667 r#"token: "prefix\\\"secret tail" safe"#,
668 r#"api_key='prefix\'secret tail' safe"#,
669 r#"password="prefix\\" safe"#,
670 r#"password="prefix\"secret tail"#,
671 ] {
672 let out = s(text);
673 assert!(!out.contains("prefix") && !out.contains("secret"), "{out}");
674 if text.ends_with(" safe") {
675 assert_eq!(out, "[redacted-token] safe");
676 } else {
677 assert_eq!(out, "[redacted-token]");
678 }
679 }
680 assert_eq!(
682 s(r#"token "ordinary words" safe"#),
683 r#"token "ordinary words" safe"#
684 );
685 assert_eq!(
686 s(r#"token "abcdefghijklmnopq1" safe"#),
687 r#"token "abcdefghijklmnopq1" safe"#
688 );
689 assert_eq!(
690 s(r#"token "abcdefghijklmnopqrst1\"secret tail" safe"#),
691 "[redacted-token] safe"
692 );
693 }
694
695 #[test]
696 fn json_encoded_credentials_redact_escaped_delimiters_and_content() {
697 for slashes in 0..5 {
698 for secret in [
699 format!("prefix{}\"secret tail", "\\".repeat(slashes)),
700 format!("prefix secret{}", "\\".repeat(slashes)),
701 ] {
702 let json = format!(
703 "{{\"password\":{},\"note\":\"safe\"}}",
704 serde_json::to_string(&secret).unwrap()
705 );
706 let encoded = serde_json::to_string(&json).unwrap();
707 for text in [json, encoded] {
708 let out = s(&text);
709 assert!(!out.contains("prefix") && !out.contains("secret"), "{out}");
710 assert!(out.contains("safe"), "{out}");
711 }
712 }
713 }
714 for text in [
715 r#"{\"token\": \"prefix\\\"secret tail"#,
716 r#"password=\"prefix\\\"secret tail"#,
717 ] {
718 let out = s(text);
719 assert!(out.ends_with("[redacted-token]"), "{out}");
720 assert!(!out.contains("prefix") && !out.contains("secret"), "{out}");
721 }
722 }
723
724 #[test]
725 fn home_paths_collapse_and_keep_the_tail() {
726 assert_eq!(s("see /Users/bob/src/x.rs now"), "see ~/src/x.rs now");
727 assert_eq!(s("in /home/bob-1/.config"), "in ~/.config");
728 assert_eq!(s("at C:\\Users\\Bob\\proj\\a.rs"), "at ~\\proj\\a.rs");
729 assert_eq!(s("at C:/Users/Bob/proj"), "at ~/proj");
730 assert_eq!(s("/root/x and /root."), "~/x and ~.");
731 assert_eq!(s("/srv/people/alice/wt/a"), "~/wt/a");
732 }
733
734 #[test]
735 fn public_system_paths_pass_scrub_and_locate() {
736 for path in [
737 "/run/kanade/agent.sock",
738 "/var/run/kanade/agent.sock",
739 "/etc/hosts",
740 "/usr/local/bin",
741 "/usr/local/bin/foo",
742 "/var/log/syslog",
743 ] {
744 for text in [
745 path.to_owned(),
746 format!("uses `{path}` on this platform"),
747 format!("uses \"{path}\" on this platform"),
748 format!("See ({path}), then {path}."),
749 ] {
750 assert_eq!(s(&text), text, "{text}");
751 assert!(locate(&text, &id()).is_empty(), "{text}");
752 }
753 }
754 }
755
756 #[test]
757 fn private_paths_and_non_public_shapes_still_redact() {
758 let identity = Identity::default();
759 for path in [
760 "/Volumes/disk-name/project",
761 "/Volumes/disk name/project",
762 "/srv/people/alice/wt/a",
763 "/tmp/checkout/build",
764 "/private/var/folders/ab/account/build",
765 "/var/folders/ab/account/build",
766 "/mnt/disk/project",
767 "/usr/home/bob/project",
768 "/opt/checkout/build",
769 "/etc/hosts/private",
770 "/etc/hostname",
771 "/etc/hosts-backup",
772 "/usr/local/bin/foo/private",
773 "/usr/local/bin/../private",
774 "/usr/local/bin/..",
775 "/usr/local/bin/./foo",
776 "/usr/local/bin//foo",
777 "/usr/local/bin/foo/",
778 "/var/log/../private",
779 "/var/log/service/private",
780 "/var/run/agent.sock",
781 "/var/run/service/agent.pid",
782 "/var/run/service/.sock",
783 "/var/run/../agent.sock",
784 "/var/run/service/../agent.sock",
785 "/var/run//agent.sock",
786 r"/var/log/foo\private",
787 r"C:\build\checkout",
788 ] {
789 assert!(
790 scrub(path, &identity).starts_with("[redacted-path]"),
791 "{path}"
792 );
793 assert_eq!(locate(path, &identity), vec![(1, "path")], "{path}");
794 }
795 for (text, expected) in [
796 ("/Users/bob/src/x", "~/src/x"),
797 ("/home/bob/.config", "~/.config"),
798 ("C:/Users/Bob/proj", "~/proj"),
799 (r"C:\Users\Bob\proj", r"~\proj"),
800 ("/srv/people/alice/wt/a", "~/wt/a"),
801 ] {
802 assert_eq!(s(text), expected, "{text}");
803 assert_eq!(locate(text, &id()), vec![(1, "home-path")], "{text}");
804 }
805 assert_eq!(
807 scrub("/Volumes/disk name/project", &identity),
808 "[redacted-path] name/project"
809 );
810 for text in [
811 "/unknown/checkout/build",
812 "/api/v1/runs",
813 "/run/service/agent.sock",
814 ] {
815 assert_eq!(scrub(text, &identity), text);
816 assert!(locate(text, &identity).is_empty());
817 }
818 }
819
820 #[test]
821 fn public_paths_keep_home_and_identity_rules_and_do_not_rescan() {
822 let identity = Identity {
823 home: "/usr/local/bin".into(),
824 ..id()
825 };
826 assert_eq!(scrub("/usr/local/bin/foo", &identity), "~/foo");
827 assert_eq!(
828 locate("/usr/local/bin/foo", &identity),
829 vec![(1, "home-path")]
830 );
831 for path in [
832 "/usr/local/bin/alice",
833 "/usr/local/bin/alice-build",
834 "/usr/local/bin/bob",
835 "/var/log/buildbox.log",
836 "/var/log/alice-build.log",
837 "/var/run/alice/agent.sock",
838 "/var/run/alice-agent/agent.sock",
839 "/var/run/bob/agent.sock",
840 "/var/log/box.local",
841 ] {
842 for identity in [id(), Identity::default()] {
843 assert_eq!(scrub(path, &identity), "[redacted-path]", "{path}");
844 assert_eq!(locate(path, &identity), vec![(1, "path")], "{path}");
845 }
846 }
847 let identity = Identity {
848 user: "foo".into(),
849 host: "kanade".into(),
850 ..Identity::default()
851 };
852 for (text, expected, kind) in [
853 (
854 "/usr/local/bin/foo",
855 "/usr/local/bin/[redacted-user]",
856 "user",
857 ),
858 (
859 "/var/run/kanade/agent.sock",
860 "/var/run/[redacted-host]/agent.sock",
861 "host",
862 ),
863 ] {
864 assert_eq!(scrub(text, &identity), expected);
865 assert_eq!(locate(text, &identity), vec![(1, kind)]);
866 }
867 let identity = Identity {
868 user: "redacted".into(),
869 host: "redacted-path".into(),
870 ..Identity::default()
871 };
872 let text = "/tmp/checkout redacted /usr/local/bin/foo";
873 assert_eq!(
874 scrub(text, &identity),
875 "[redacted-path] [redacted-user] /usr/local/bin/foo"
876 );
877 assert_eq!(locate(text, &identity), vec![(1, "path"), (1, "user")]);
878 }
879
880 #[test]
881 fn emails_ips_and_tokens() {
882 assert_eq!(s("mail dev.x+y@example.co.uk!"), "mail [redacted-email]!");
883 assert_eq!(s("host 192.168.0.12:8080"), "host [redacted-ip]:8080");
884 assert_eq!(
885 s("v6 fe80::1 and ::1"),
886 "v6 [redacted-ip] and [redacted-ip]"
887 );
888 assert_eq!(s("full 2001:db8:0:0:0:0:0:1."), "full [redacted-ip].");
889 assert_eq!(
890 s("tok ghp_abcdefghijklmnopqrstuv end"),
891 "tok [redacted-token] end"
892 );
893 }
894
895 #[test]
896 fn ipv4_section_references_pass_scrub_and_locate() {
897 for text in [
898 "§2.12.4.1-4.4",
899 "see §2.12.4.1",
900 "§ 2.12.4.1",
901 "section 2.12.4.1",
902 "sec. 2.12.4.1",
903 "clause 2.12.4.1",
904 "SECTION\t2.12.4.1",
905 "See (Sec. \t2.12.4.1)",
906 "CLAUSE 2.12.4.1",
907 "仕様 §\t2.12.4.1",
908 "released v1.2.3, version 0.41.1, 300.1.1.1",
909 ] {
910 assert_eq!(s(text), text, "{text}");
911 assert!(locate(text, &id()).is_empty(), "{text}");
912 }
913 }
914
915 #[test]
916 fn ipv4_section_markers_require_boundaries_and_same_line() {
917 for before in [
918 "subsection ",
919 "xsec. ",
920 "9clause ",
921 "_section ",
922 "-clause ",
923 "ésection ",
924 "字sec. ",
925 "x§ ",
926 "section\n",
927 "§\n ",
928 "clause\r\n\t",
929 "section\u{a0}",
930 "RFC ",
931 ] {
932 let text = format!("{before}2.12.4.1");
933 assert_eq!(s(&text), format!("{before}[redacted-ip]"), "{text}");
934 let line = 1 + before.matches('\n').count();
935 assert_eq!(locate(&text, &id()), vec![(line, "ip")], "{text}");
936 }
937 for before in ["section", "sec.", "clause", "é", "字"] {
938 assert!(!section_reference(before), "{before}");
939 }
940 }
941
942 #[test]
943 fn section_exception_keeps_other_sensitive_data_redacted() {
944 for (text, expected, hits) in [
945 (
946 "host 192.168.0.12:8080",
947 "host [redacted-ip]:8080",
948 vec![(1, "ip")],
949 ),
950 (
951 "/Users/bob 10.0.0.1 a@b.io alice",
952 "~ [redacted-ip] [redacted-email] [redacted-user]",
953 vec![(1, "home-path"), (1, "ip"), (1, "email"), (1, "user")],
954 ),
955 (
956 "connect to 10.1.2.3",
957 "connect to [redacted-ip]",
958 vec![(1, "ip")],
959 ),
960 (
961 "§2.12.4.1\nconnect to 10.1.2.3",
962 "§2.12.4.1\nconnect to [redacted-ip]",
963 vec![(2, "ip")],
964 ),
965 (
966 "section 2.12.4.1-10.1.2.3, 10.0.0.1",
967 "section 2.12.4.1-[redacted-ip], [redacted-ip]",
968 vec![(1, "ip"), (1, "ip")],
969 ),
970 (
971 "§fe80::1\nsection ::1\nclause 2001:db8:0:0:0:0:0:1",
972 "§[redacted-ip]\nsection [redacted-ip]\nclause [redacted-ip]",
973 vec![(1, "ip"), (2, "ip"), (3, "ip")],
974 ),
975 ] {
976 assert_eq!(s(text), expected, "{text}");
977 assert_eq!(locate(text, &id()), hits, "{text}");
978 }
979 }
980
981 #[test]
982 fn section_context_uses_original_input_and_does_not_rescan_replacements() {
983 let identity = Identity {
984 user: "redacted-ip".into(),
985 ..Identity::default()
986 };
987 let text = "§2.12.4.1\nconnect to 10.1.2.3";
988 assert_eq!(
989 scrub(text, &identity),
990 "§2.12.4.1\nconnect to [redacted-ip]"
991 );
992 assert_eq!(locate(text, &identity), vec![(2, "ip")]);
993 let identity = Identity {
994 user: "section".into(),
995 ..Identity::default()
996 };
997 assert_eq!(
998 scrub("section 2.12.4.1", &identity),
999 "[redacted-user] 2.12.4.1"
1000 );
1001 assert_eq!(locate("section 2.12.4.1", &identity), vec![(1, "user")]);
1002 }
1003
1004 #[test]
1005 fn identity_words_match_whole_words_only() {
1006 assert_eq!(
1007 s("by Alice on buildbox"),
1008 "by [redacted-user] on [redacted-host]"
1009 );
1010 assert_eq!(
1011 s("alicein wonderland, xbuildbox"),
1012 "alicein wonderland, xbuildbox"
1013 );
1014 }
1015
1016 #[test]
1017 fn github_url_owner_is_kept_but_other_mentions_are_not() {
1018 assert_eq!(
1019 s("https://github.com/alice/magi/pull/681 by alice"),
1020 "https://github.com/alice/magi/pull/681 by [redacted-user]"
1021 );
1022 assert_eq!(
1023 s("http://www.GitHub.com/Alice/x"),
1024 "http://www.GitHub.com/Alice/x"
1025 );
1026 for t in [
1027 "https://github.com.evil/alice",
1028 "https://github.com/x/alice",
1029 "git@github.com:alice/x",
1030 "https://api.github.com/alice",
1031 "github.com/alice",
1032 ] {
1033 assert!(s(t).contains("[redacted-user]"), "{t}");
1034 }
1035 let text = "https://github.com/alice/magi alice";
1036 assert_eq!(locate(text, &id()), vec![(1, "user")]);
1037 }
1038
1039 #[test]
1040 fn github_owner_slot_checks_only_ascii_url_suffixes() {
1041 for before in [
1042 "https://github.com/",
1043 "http://github.com/",
1044 "https://www.github.com/",
1045 "http://www.github.com/",
1046 "HTTPS://GitHub.com/",
1047 "HTTP://WWW.GitHub.com/",
1048 "prose éhttps://github.com/",
1049 ] {
1050 assert!(github_owner_slot(before), "{before}");
1051 }
1052 for before in [
1053 "https://api.github.com/",
1054 "github.com/",
1055 "www.github.com/",
1056 "",
1057 "git",
1058 "égithub.com/",
1059 "éwww.github.com/",
1060 "é",
1061 ] {
1062 assert!(!github_owner_slot(before), "{before}");
1063 }
1064 }
1065
1066 #[test]
1067 fn long_body_of_short_words_passes_scrub_and_locate() {
1068 let text = "a ".repeat(32768);
1069 let id = Identity::default();
1070 assert_eq!(scrub(&text, &id), text);
1071 assert!(locate(&text, &id).is_empty());
1072 }
1073
1074 #[test]
1075 fn generic_host_and_user_values_do_not_redact_prose() {
1076 for host in ["local", ".local", "devbox.local"] {
1077 let i = Identity {
1078 host: host.into(),
1079 ..Identity::default()
1080 };
1081 let t = "`WatchState::local` and local mode";
1082 assert_eq!(scrub(t, &i), t, "{host}");
1083 }
1084 let real = Identity {
1085 host: "devbox.local".into(),
1086 ..Identity::default()
1087 };
1088 assert_eq!(
1089 scrub("on devbox.local now", &real),
1090 "on [redacted-host] now"
1091 );
1092 for (user, host) in [("local", ""), ("admin", "localhost")] {
1093 let i = Identity {
1094 user: user.into(),
1095 host: host.into(),
1096 ..Identity::default()
1097 };
1098 let t = "local admin localhost";
1099 assert_eq!(scrub(t, &i), t);
1100 }
1101 let once = s("https://github.com/alice/x alice buildbox");
1102 assert_eq!(scrub(&once, &id()), once);
1103 assert_eq!(s("on buildbox"), "on [redacted-host]");
1104 }
1105
1106 #[test]
1107 fn dotted_code_paths_ending_in_local_are_not_hosts() {
1108 for t in [
1109 "`WatchState.local` is true",
1110 "WatchState.lan and self.local",
1111 "WatchState.local",
1112 ] {
1113 assert_eq!(scrub(t, &Identity::default()), t);
1114 }
1115 for t in ["ssh MyServer.lan", "JohnsMacBook.local"] {
1117 assert_eq!(scrub(t, &Identity::default()), t);
1118 }
1119 for (t, want) in [
1120 ("ssh m1air.loCal now", "ssh [redacted-host] now"),
1121 ("ssh devbox.LOCAL", "ssh [redacted-host]"),
1122 ("ssh box.InterNal", "ssh [redacted-host]"),
1123 ("ssh box.LaN", "ssh [redacted-host]"),
1124 ] {
1125 assert_eq!(scrub(t, &Identity::default()), want, "{t}");
1126 }
1127 for t in [
1128 "ssh devbox.local",
1129 "ssh BUILD-SERVER.local",
1130 "ssh Alices-MacBook-Pro.local",
1131 "ssh BUILD.local",
1132 ] {
1133 assert!(
1134 scrub(t, &Identity::default()).contains("[redacted-host]"),
1135 "{t}"
1136 );
1137 }
1138 }
1139
1140 #[test]
1141 fn lowercase_field_receivers_pass_scrub_and_locate() {
1142 let id = Identity::default();
1143 for text in [
1144 "`st.local` is true",
1145 "`cfg.local`",
1146 "`ctx.lan`",
1147 "`env.internal`",
1148 "my_state.local",
1149 "`my_state.local`",
1150 "`st.local`, then continue.",
1151 "`devbox.local`",
1152 ] {
1153 assert_eq!(scrub(text, &id), text, "{text}");
1154 assert!(locate(text, &id).is_empty(), "{text}");
1155 }
1156 }
1157
1158 #[test]
1159 fn field_receiver_exception_keeps_host_redaction() {
1160 let id = Identity::default();
1161 for text in [
1162 "st.local",
1163 "cfg.local",
1164 "devbox.local",
1165 "box.lan",
1166 "`st.local",
1167 "st.local`",
1168 "`st.local is true`",
1169 "`st.local.`",
1170 "`box1.local`",
1171 "`build-box.local`",
1172 "`box.example.local`",
1173 ] {
1174 assert!(scrub(text, &id).contains("[redacted-host]"), "{text}");
1175 assert_eq!(locate(text, &id), vec![(1, "host")], "{text}");
1176 }
1177 for text in ["host=st.local", "`host=cfg.local`"] {
1178 assert!(scrub(text, &id).contains("[redacted-identity]"), "{text}");
1179 assert_eq!(locate(text, &id), vec![(1, "identity")], "{text}");
1180 }
1181 let known = Identity {
1182 host: "devbox.local".into(),
1183 ..Identity::default()
1184 };
1185 let text = "`devbox.local`";
1186 assert_eq!(scrub(text, &known), "`[redacted-host]`");
1187 assert_eq!(locate(text, &known), vec![(1, "host")]);
1188 }
1189
1190 #[test]
1191 fn ordinary_text_is_untouched() {
1192 for t in [
1193 "Change src/graph.rs and tests/common/mod.rs.",
1194 "See https://github.com/o/r/pull/12 for #12",
1195 "returns std::io::Error, or a::b, Vec::new()",
1196 "released v1.2.3, version 0.41.1, 300.1.1.1",
1197 "thanks @coderabbitai; at 12:34:56 it ran",
1198 "/api/v1/runs; docs/home/x and ./Users/y",
1199 "A plain sentence about background and motivation.",
1200 "日本語のテキスト 🎉 with émoji",
1201 ] {
1202 assert_eq!(s(t), t);
1203 }
1204 }
1205
1206 #[test]
1207 fn multibyte_input_does_not_panic_and_replacement_is_not_rescanned() {
1208 assert_eq!(
1209 s("C:\\日本語 and C:/日本"),
1210 "[redacted-path] and [redacted-path]"
1211 );
1212 assert_eq!(s("é/Users/bob/é 日本 alice"), "é~/é 日本 [redacted-user]");
1213 let once = s("/Users/bob 10.0.0.1 a@b.io alice");
1214 assert_eq!(scrub(&once, &Identity::default()), once);
1215 let odd = Identity {
1217 user: "redacted".into(),
1218 ..Identity::default()
1219 };
1220 assert_eq!(scrub("redacted x", &odd), "[redacted-user] x");
1221 }
1222}