1use std::net::{Ipv4Addr, Ipv6Addr};
21
22#[derive(Debug, Clone, Default)]
25pub struct Identity {
26 pub user: String,
28 pub host: String,
30 pub home: String,
32}
33
34impl Identity {
35 pub fn current() -> Self {
37 let env = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty());
38 let host = env("HOSTNAME")
39 .or_else(|| env("COMPUTERNAME"))
40 .or_else(|| {
41 std::fs::read_to_string("/etc/hostname")
42 .ok()
43 .map(|s| s.trim().to_owned())
44 .filter(|s| !s.is_empty())
45 })
46 .unwrap_or_default();
47 Self {
48 user: env("USER").or_else(|| env("USERNAME")).unwrap_or_default(),
49 host,
50 home: dirs::home_dir()
51 .map(|p| p.to_string_lossy().into_owned())
52 .unwrap_or_default(),
53 }
54 }
55}
56
57const TOKEN_PREFIXES: [&str; 9] = [
58 "AKIA",
59 "github_pat_",
60 "ghp_",
61 "gho_",
62 "ghs_",
63 "ghu_",
64 "sk-",
65 "xoxb-",
66 "xoxp-",
67];
68const TOKEN_MIN_TAIL: usize = 16;
69const MIN_IDENTITY_WORD: usize = 3;
71
72fn is_word(c: char) -> bool {
73 c.is_ascii_alphanumeric() || c == '_' || c == '-'
74}
75
76fn is_name(c: char) -> bool {
77 c.is_alphanumeric() || matches!(c, '_' | '-' | '.')
78}
79
80fn is_email_local(c: char) -> bool {
81 c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '%' | '+' | '-')
82}
83
84fn name_len(s: &str) -> usize {
86 s[..run(s, is_name)].trim_end_matches('.').len()
87}
88
89fn run(s: &str, f: impl Fn(char) -> bool) -> usize {
91 s.char_indices()
92 .find(|&(_, c)| !f(c))
93 .map_or(s.len(), |(i, _)| i)
94}
95
96pub fn scrub(text: &str, id: &Identity) -> String {
98 scan(text, id, |_| {})
99}
100
101#[derive(Debug, Clone)]
103pub(crate) struct Edit {
104 pub(crate) original: std::ops::Range<usize>,
105 pub(crate) changed: std::ops::Range<usize>,
106 pub(crate) kind: &'static str,
107}
108
109pub(crate) fn scrub_with_edits(text: &str, id: &Identity) -> (String, Vec<Edit>) {
110 let mut edits = Vec::new();
111 let out = scan(text, id, |edit| edits.push(edit));
112 (out, edits)
113}
114
115fn scan(text: &str, id: &Identity, mut record: impl FnMut(Edit)) -> String {
116 let mut out = String::with_capacity(text.len());
117 let mut i = 0;
118 while i < text.len() {
119 if let Some((len, rep)) = match_at(&text[..i], &text[i..], id) {
120 let start = out.len();
121 out.push_str(rep);
122 record(Edit {
123 original: i..i + len,
124 changed: start..out.len(),
125 kind: replacement_kind(rep),
126 });
127 i += len;
128 } else {
129 let c = text[i..].chars().next().expect("i is on a char boundary");
130 out.push(c);
131 i += c.len_utf8();
132 }
133 }
134 out
135}
136
137fn replacement_kind(rep: &'static str) -> &'static str {
138 match rep {
139 "~" => "home-path",
140 r => r
141 .trim_start_matches('[')
142 .trim_end_matches(']')
143 .trim_start_matches("redacted-"),
144 }
145}
146
147pub fn locate(text: &str, id: &Identity) -> Vec<(usize, &'static str)> {
150 let mut hits = Vec::new();
151 let mut i = 0;
152 let mut line = 1;
153 while i < text.len() {
154 if let Some((len, rep)) = match_at(&text[..i], &text[i..], id) {
155 let kind = replacement_kind(rep);
156 hits.push((line, kind));
157 line += text[i..i + len].matches('\n').count();
158 i += len;
159 } else {
160 let c = text[i..].chars().next().expect("i is on a char boundary");
161 if c == '\n' {
162 line += 1;
163 }
164 i += c.len_utf8();
165 }
166 }
167 hits
168}
169
170fn match_at(before: &str, rest: &str, id: &Identity) -> Option<(usize, &'static str)> {
171 let prev = before.chars().next_back();
172 let starts_word = prev.is_none_or(|p| !is_word(p));
173 home_path(rest, prev, id)
174 .or_else(|| {
175 starts_word
176 .then(|| {
177 token(rest).or_else(|| credential(rest)).or_else(|| {
178 prev.is_none_or(|c| !is_name(c))
179 .then(|| named_identity(rest, prev))
180 .flatten()
181 })
182 })
183 .flatten()
184 })
185 .or_else(|| absolute_path(rest, prev))
186 .or_else(|| {
187 prev.is_none_or(|p| !is_email_local(p))
188 .then(|| email(rest))
189 .flatten()
190 })
191 .or_else(|| {
192 prev.is_none_or(|p| !p.is_ascii_alphanumeric() && p != '.' && p != ':')
193 .then(|| {
194 ipv4(rest)
195 .filter(|(len, _)| {
196 !section_reference(before) && !loopback_literal(rest, *len, prev, false)
197 })
198 .or_else(|| {
199 ipv6(rest).filter(|(len, _)| !loopback_literal(rest, *len, prev, true))
200 })
201 })
202 .flatten()
203 })
204 .or_else(|| {
205 (starts_word && !github_owner_slot(before))
206 .then(|| identity_word(rest, id))
207 .flatten()
208 })
209}
210
211fn home_path(rest: &str, prev: Option<char>, id: &Identity) -> Option<(usize, &'static str)> {
214 if prev.is_some_and(|p| p.is_ascii_alphanumeric() || matches!(p, '.' | '_' | '-' | '~')) {
215 return None;
216 }
217 if id.home.len() > 1 && rest.starts_with(id.home.as_str()) {
218 let tail = &rest[id.home.len()..];
219 if tail.chars().next().is_none_or(|c| !is_name(c)) {
220 return Some((id.home.len(), "~"));
221 }
222 }
223 for base in ["/Users/", "/home/"] {
224 if let Some(tail) = rest.strip_prefix(base) {
225 let n = name_len(tail);
226 if n > 0 {
227 return Some((base.len() + n, "~"));
228 }
229 }
230 }
231 if let Some(tail) = rest.strip_prefix("/root")
232 && tail.chars().next().is_none_or(|c| !is_word(c))
233 {
234 return Some(("/root".len(), "~"));
235 }
236 let b = rest.as_bytes();
237 if b.len() > 9
238 && b[0].is_ascii_alphabetic()
239 && b[1] == b':'
240 && matches!(b[2], b'\\' | b'/')
241 && b[3..8].eq_ignore_ascii_case(b"users")
242 && matches!(b[8], b'\\' | b'/')
243 {
244 let n = name_len(&rest[9..]);
245 if n > 0 {
246 return Some((9 + n, "~"));
247 }
248 }
249 None
250}
251
252fn token(rest: &str) -> Option<(usize, &'static str)> {
253 let prefix = TOKEN_PREFIXES.iter().find(|p| rest.starts_with(**p))?;
254 let tail = run(&rest[prefix.len()..], is_word);
255 (tail >= TOKEN_MIN_TAIL).then_some((prefix.len() + tail, "[redacted-token]"))
256}
257
258fn named_identity(rest: &str, prev: Option<char>) -> Option<(usize, &'static str)> {
260 for key in [
261 "hostname=",
262 "hostname: ",
263 "username=",
264 "username: ",
265 "user=",
266 "host=",
267 ] {
268 if rest
269 .get(..key.len())
270 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(key))
271 {
272 let n = run(&rest[key.len()..], is_name);
273 if n > 0 {
274 return Some((key.len() + n, "[redacted-identity]"));
275 }
276 }
277 }
278 let n = name_len(rest);
279 if n > 0
280 && !code_receiver(&rest[..n], prev == Some('`') && rest[n..].starts_with('`'))
281 && [".local", ".internal", ".lan"].iter().any(|suffix| {
282 n.checked_sub(suffix.len())
283 .and_then(|start| rest.get(start..n))
284 .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix))
285 })
286 {
287 return Some((n, "[redacted-host]"));
288 }
289 None
290}
291
292fn code_receiver(name: &str, backticked: bool) -> bool {
299 if name.matches('.').count() != 1 || name.contains('-') {
300 return false;
301 }
302 let Some((receiver, _)) = name.split_once('.') else {
303 return false;
304 };
305 if receiver.eq_ignore_ascii_case("self") || receiver.eq_ignore_ascii_case("this") {
306 return true;
307 }
308 let lowercase_identifier =
309 !receiver.is_empty() && receiver.chars().all(|c| c.is_ascii_lowercase() || c == '_');
310 if lowercase_identifier && (backticked || receiver.contains('_')) {
311 return true;
312 }
313 let mut prev_lower = false;
314 receiver.chars().any(|c| {
315 let hump = prev_lower && c.is_ascii_uppercase();
316 prev_lower = c.is_ascii_lowercase();
317 hump
318 })
319}
320
321fn quoted_credential(rest: &str) -> Option<(usize, &'static str)> {
324 for key in [
325 "password", "token", "api_key", "api-key", "apikey", "secret",
326 ] {
327 let Some(after) = rest
328 .get(..key.len())
329 .filter(|p| p.eq_ignore_ascii_case(key))
330 .map(|_| &rest[key.len()..])
331 else {
332 continue;
333 };
334 let Some(after_quote) = after
335 .strip_prefix("\\\"")
336 .or_else(|| after.strip_prefix(['"', '\'']))
337 else {
338 continue;
339 };
340 let t = after_quote.trim_start();
341 let Some(t) = t.strip_prefix([':', '=']) else {
342 continue;
343 };
344 let t = t.trim_start();
345 let n = quoted_value_len(t).unwrap_or_else(|| {
347 run(t, |c| {
348 !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | ',' | '}')
349 })
350 });
351 let value = t;
352 if n > 0 {
353 return Some((rest.len() - value.len() + n, "[redacted-token]"));
354 }
355 }
356 None
357}
358
359fn quoted_end(inner: &str, q: char) -> Option<usize> {
361 let mut chars = inner.char_indices();
362 while let Some((i, c)) = chars.next() {
363 if c == '\\' {
364 chars.next();
365 } else if c == q {
366 return Some(i);
367 }
368 }
369 None
370}
371
372fn quoted_value_len(value: &str) -> Option<usize> {
376 if let Some(inner) = value.strip_prefix("\\\"") {
377 let mut slashes = 0;
378 for (i, c) in inner.char_indices() {
379 if c == '\\' {
380 slashes += 1;
381 continue;
382 }
383 if c == '"' && slashes % 4 == 1 {
386 return Some(2 + i + 1);
387 }
388 slashes = 0;
389 }
390 return Some(value.len());
391 }
392 let q = value.chars().next().filter(|c| matches!(c, '"' | '\''))?;
393 let inner = &value[1..];
394 let end = quoted_end(inner, q).unwrap_or(inner.len());
395 Some(1 + end + usize::from(end < inner.len()))
396}
397
398fn credential(rest: &str) -> Option<(usize, &'static str)> {
399 if let Some(hit) = quoted_credential(rest) {
400 return Some(hit);
401 }
402 for key in [
403 "password=",
404 "token=",
405 "api_key=",
406 "api-key=",
407 "password: ",
408 "token: ",
409 "api_key: ",
410 "bearer ",
411 "password ",
412 "token ",
413 "api key ",
414 ] {
415 if rest
416 .get(..key.len())
417 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(key))
418 {
419 let tail = &rest[key.len()..];
420 let padding = tail.len() - tail.trim_start_matches(' ').len();
421 let value = &tail[padding..];
422 let n = quoted_value_len(value).unwrap_or_else(|| {
423 run(value, |c| {
424 !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | '"' | '\'')
425 })
426 });
427 let contextual = matches!(key, "password " | "token " | "api key ");
428 let entropy_value = value.trim_start_matches(['\'', '"']);
429 let entropy_len = run(entropy_value, |c| {
430 !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | '"' | '\'')
431 });
432 let entropy = entropy_len >= 20
433 && entropy_value[..entropy_len]
434 .bytes()
435 .any(|b| b.is_ascii_digit())
436 && entropy_value[..entropy_len]
437 .bytes()
438 .any(|b| b.is_ascii_alphabetic());
439 if n > 0 && (!contextual || entropy) {
440 return Some((key.len() + padding + n, "[redacted-token]"));
441 }
442 }
443 }
444 None
445}
446
447fn absolute_path(rest: &str, prev: Option<char>) -> Option<(usize, &'static str)> {
448 if prev.is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | ':' | '.' | '~')) {
449 return None;
450 }
451 let b = rest.as_bytes();
452 let windows =
453 b.len() > 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && matches!(b[2], b'/' | b'\\');
454 let unix = [
456 "/tmp/",
457 "/private/",
458 "/var/",
459 "/etc/",
460 "/opt/",
461 "/srv/",
462 "/usr/",
463 "/mnt/",
464 "/Volumes/",
465 ]
466 .iter()
467 .any(|root| rest.starts_with(root));
468 if windows || unix {
469 let n = run(rest, |c| {
470 !c.is_whitespace() && !matches!(c, '`' | '"' | '\'' | '<' | '>')
471 });
472 if unix && public_system_path(&rest[..n]) {
473 return None;
474 }
475 return Some((n, "[redacted-path]"));
476 }
477 None
478}
479
480fn public_system_path(path: &str) -> bool {
481 matches!(
491 path.trim_end_matches(['.', ',', ';', ':', '!', '?', ')', ']']),
492 "/etc/hosts"
493 | "/usr/local/bin"
494 | "/usr/local/bin/foo"
495 | "/var/log/syslog"
496 | "/var/run/kanade/agent.sock"
497 )
498}
499
500fn email(rest: &str) -> Option<(usize, &'static str)> {
501 let local = run(rest, is_email_local);
502 if local == 0 || !rest[local..].starts_with('@') {
503 return None;
504 }
505 let domain = &rest[local + 1..];
506 let mut end = 0;
507 let mut labels = 0;
508 loop {
509 let n = run(&domain[end..], |c| c.is_ascii_alphanumeric() || c == '-');
510 if n == 0 {
511 break;
512 }
513 end += n;
514 labels += 1;
515 if domain[end..].starts_with('.')
516 && run(&domain[end + 1..], |c| c.is_ascii_alphanumeric()) > 0
517 {
518 end += 1;
519 } else {
520 break;
521 }
522 }
523 (labels >= 2).then_some((local + 1 + end, "[redacted-email]"))
524}
525
526fn section_reference(before: &str) -> bool {
529 let trimmed = before.trim_end_matches([' ', '\t']);
530 ["§", "section", "sec.", "clause"].iter().any(|marker| {
531 if *marker != "§" && trimmed.len() == before.len() {
532 return false;
533 }
534 let Some(start) = trimmed.len().checked_sub(marker.len()) else {
535 return false;
536 };
537 trimmed
538 .get(start..)
539 .is_some_and(|tail| tail.eq_ignore_ascii_case(marker))
540 && trimmed[..start]
541 .chars()
542 .next_back()
543 .is_none_or(|c| !c.is_alphanumeric() && !matches!(c, '_' | '-'))
544 })
545}
546
547fn ipv4(rest: &str) -> Option<(usize, &'static str)> {
548 let mut len = 0;
549 for part in 0..4 {
550 let s = &rest[len..];
551 let n = run(s, |c| c.is_ascii_digit());
552 if n == 0 || n > 3 || s[..n].parse::<u16>().ok()? > 255 {
553 return None;
554 }
555 len += n;
556 if part < 3 {
557 if !rest[len..].starts_with('.') {
558 return None;
559 }
560 len += 1;
561 }
562 }
563 let after = &rest[len..];
564 let mut chars = after.chars();
565 match chars.next() {
566 Some(c) if c.is_ascii_alphanumeric() => return None,
567 Some('.') if chars.next().is_some_and(|c| c.is_ascii_digit()) => return None,
568 _ => {}
569 }
570 Some((len, "[redacted-ip]"))
571}
572
573fn ipv6(rest: &str) -> Option<(usize, &'static str)> {
574 let mut n = run(rest, |c| c.is_ascii_hexdigit() || c == ':');
575 while n > 0 && rest[..n].ends_with(':') && !rest[..n].ends_with("::") {
577 n -= 1;
578 }
579 let cand = &rest[..n];
580 let colons = cand.matches(':').count();
581 let shaped = (cand.contains("::") && colons >= 2) || colons == 7;
582 if !shaped
583 || !cand.bytes().any(|b| b.is_ascii_digit())
584 || cand.split(':').any(|g| g.len() > 4)
585 || rest[n..]
586 .chars()
587 .next()
588 .is_some_and(|c| c.is_ascii_alphanumeric())
589 {
590 return None;
591 }
592 Some((n, "[redacted-ip]"))
593}
594
595fn loopback_literal(rest: &str, len: usize, prev: Option<char>, v6: bool) -> bool {
600 let address = &rest[..len];
601 let loopback = if v6 {
602 address.parse::<Ipv6Addr>().is_ok_and(|ip| ip.is_loopback())
603 } else {
604 address.parse::<Ipv4Addr>().is_ok_and(|ip| ip.is_loopback())
605 };
606 if !loopback {
607 return false;
608 }
609 let mut tail = &rest[len..];
610 let bracketed = prev == Some('[');
611 if bracketed {
612 let Some(after) = tail.strip_prefix(']') else {
613 return false;
614 };
615 tail = after;
616 }
617 if (!v6 || bracketed)
620 && let Some(port) = tail.strip_prefix(':')
621 {
622 let n = run(port, |c| c.is_ascii_digit());
623 if n == 0 || port[..n].parse::<u16>().is_err() {
624 return false;
625 }
626 tail = &port[n..];
627 }
628 if v6
631 && !bracketed
632 && let Some(after) = tail.strip_prefix(':')
633 {
634 tail = after;
635 }
636 let mut chars = tail.chars();
637 match chars.next() {
638 Some(c) if c.is_ascii_alphanumeric() || matches!(c, '_' | '%') => false,
639 Some('.') if chars.next().is_some_and(|c| c.is_ascii_alphanumeric()) => false,
640 Some(':') => false,
641 _ => true,
642 }
643}
644
645const GENERIC_IDENTITY_WORDS: [&str; 12] = [
649 "local",
650 "localhost",
651 "localdomain",
652 "lan",
653 "internal",
654 "user",
655 "host",
656 "admin",
657 "root",
658 "test",
659 "default",
660 "unknown",
661];
662
663fn is_generic_identity(w: &str) -> bool {
664 let w = w.strip_prefix('.').unwrap_or(w);
665 GENERIC_IDENTITY_WORDS
666 .iter()
667 .any(|g| g.eq_ignore_ascii_case(w))
668}
669
670fn github_owner_slot(before: &str) -> bool {
673 let Some(head) = strip_suffix_ci(before.as_bytes(), b"github.com/") else {
674 return false;
675 };
676 let head = strip_suffix_ci(head, b"www.").unwrap_or(head);
677 strip_suffix_ci(head, b"http://").is_some() || strip_suffix_ci(head, b"https://").is_some()
678}
679
680fn strip_suffix_ci<'a>(s: &'a [u8], suffix: &[u8]) -> Option<&'a [u8]> {
681 let start = s.len().checked_sub(suffix.len())?;
682 s[start..]
683 .eq_ignore_ascii_case(suffix)
684 .then_some(&s[..start])
685}
686
687fn identity_word(rest: &str, id: &Identity) -> Option<(usize, &'static str)> {
688 for (word, rep) in [(&id.user, "[redacted-user]"), (&id.host, "[redacted-host]")] {
689 let w = word.trim();
690 if w.len() < MIN_IDENTITY_WORD
691 || is_generic_identity(w)
692 || rest.len() < w.len()
693 || !rest.is_char_boundary(w.len())
694 {
695 continue;
696 }
697 if rest[..w.len()].eq_ignore_ascii_case(w)
698 && rest[w.len()..].chars().next().is_none_or(|c| !is_word(c))
699 {
700 return Some((w.len(), rep));
701 }
702 }
703 None
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709
710 fn id() -> Identity {
711 Identity {
712 user: "alice".into(),
713 host: "buildbox".into(),
714 home: "/srv/people/alice".into(),
715 }
716 }
717
718 fn s(t: &str) -> String {
719 scrub(t, &id())
720 }
721
722 #[test]
723 fn locate_names_kind_and_line_without_the_text() {
724 let id = Identity::default();
725 let text = "fine\ntoken=abcdefghijklmnop1234 here\nmail a@b.example";
726 assert_eq!(locate(text, &id), vec![(2, "token"), (3, "email")]);
727 }
728
729 #[test]
730 fn quoted_credentials_honor_escaped_quotes() {
731 let out = s(r#"{"password":"prefix\"hunter2"} tail"#);
732 assert!(!out.contains("hunter2") && !out.contains("prefix"), "{out}");
733 assert!(out.ends_with("} tail"), "{out}");
734 let out = s(r#"{"password":"a\\\\"} tail"#);
736 assert!(out.ends_with("} tail") && !out.contains("a\\"), "{out}");
737 let out = s(r#"{"password":"a\\\"hunter2"} tail"#);
739 assert!(!out.contains("hunter2"), "{out}");
740 let out = s(r#"{'token':'x\'hunter2'} tail"#);
741 assert!(!out.contains("hunter2"), "{out}");
742 let out = s(r#"{"password":"日本\"語hunter2"} tail"#);
743 assert!(!out.contains("hunter2") && !out.contains('語'), "{out}");
744 let out = s(r#"{"password":"abc\"hunter2"#);
746 assert!(!out.contains("hunter2"), "{out}");
747 }
748
749 #[test]
750 fn unquoted_keys_redact_the_whole_quoted_value() {
751 for text in [
752 r#"password="prefix\"secret tail" safe"#,
753 r#"token: "prefix\\\"secret tail" safe"#,
754 r#"api_key='prefix\'secret tail' safe"#,
755 r#"password="prefix\\" safe"#,
756 r#"password="prefix\"secret tail"#,
757 ] {
758 let out = s(text);
759 assert!(!out.contains("prefix") && !out.contains("secret"), "{out}");
760 if text.ends_with(" safe") {
761 assert_eq!(out, "[redacted-token] safe");
762 } else {
763 assert_eq!(out, "[redacted-token]");
764 }
765 }
766 assert_eq!(
768 s(r#"token "ordinary words" safe"#),
769 r#"token "ordinary words" safe"#
770 );
771 assert_eq!(
772 s(r#"token "abcdefghijklmnopq1" safe"#),
773 r#"token "abcdefghijklmnopq1" safe"#
774 );
775 assert_eq!(
776 s(r#"token "abcdefghijklmnopqrst1\"secret tail" safe"#),
777 "[redacted-token] safe"
778 );
779 }
780
781 #[test]
782 fn json_encoded_credentials_redact_escaped_delimiters_and_content() {
783 for slashes in 0..5 {
784 for secret in [
785 format!("prefix{}\"secret tail", "\\".repeat(slashes)),
786 format!("prefix secret{}", "\\".repeat(slashes)),
787 ] {
788 let json = format!(
789 "{{\"password\":{},\"note\":\"safe\"}}",
790 serde_json::to_string(&secret).unwrap()
791 );
792 let encoded = serde_json::to_string(&json).unwrap();
793 for text in [json, encoded] {
794 let out = s(&text);
795 assert!(!out.contains("prefix") && !out.contains("secret"), "{out}");
796 assert!(out.contains("safe"), "{out}");
797 }
798 }
799 }
800 for text in [
801 r#"{\"token\": \"prefix\\\"secret tail"#,
802 r#"password=\"prefix\\\"secret tail"#,
803 ] {
804 let out = s(text);
805 assert!(out.ends_with("[redacted-token]"), "{out}");
806 assert!(!out.contains("prefix") && !out.contains("secret"), "{out}");
807 }
808 }
809
810 #[test]
811 fn home_paths_collapse_and_keep_the_tail() {
812 assert_eq!(s("see /Users/bob/src/x.rs now"), "see ~/src/x.rs now");
813 assert_eq!(s("in /home/bob-1/.config"), "in ~/.config");
814 assert_eq!(s("at C:\\Users\\Bob\\proj\\a.rs"), "at ~\\proj\\a.rs");
815 assert_eq!(s("at C:/Users/Bob/proj"), "at ~/proj");
816 assert_eq!(s("/root/x and /root."), "~/x and ~.");
817 assert_eq!(s("/srv/people/alice/wt/a"), "~/wt/a");
818 }
819
820 #[test]
821 fn public_system_paths_pass_scrub_and_locate() {
822 for path in [
823 "/run/kanade/agent.sock",
824 "/var/run/kanade/agent.sock",
825 "/etc/hosts",
826 "/usr/local/bin",
827 "/usr/local/bin/foo",
828 "/var/log/syslog",
829 ] {
830 for text in [
831 path.to_owned(),
832 format!("uses `{path}` on this platform"),
833 format!("uses \"{path}\" on this platform"),
834 format!("See ({path}), then {path}."),
835 ] {
836 assert_eq!(s(&text), text, "{text}");
837 assert!(locate(&text, &id()).is_empty(), "{text}");
838 }
839 }
840 }
841
842 #[test]
843 fn private_paths_and_non_public_shapes_still_redact() {
844 let identity = Identity::default();
845 for path in [
846 "/Volumes/disk-name/project",
847 "/Volumes/disk name/project",
848 "/srv/people/alice/wt/a",
849 "/tmp/checkout/build",
850 "/private/var/folders/ab/account/build",
851 "/var/folders/ab/account/build",
852 "/mnt/disk/project",
853 "/usr/home/bob/project",
854 "/opt/checkout/build",
855 "/etc/hosts/private",
856 "/etc/hostname",
857 "/etc/hosts-backup",
858 "/usr/local/bin/foo/private",
859 "/usr/local/bin/../private",
860 "/usr/local/bin/..",
861 "/usr/local/bin/./foo",
862 "/usr/local/bin//foo",
863 "/usr/local/bin/foo/",
864 "/var/log/../private",
865 "/var/log/service/private",
866 "/var/run/agent.sock",
867 "/var/run/service/agent.pid",
868 "/var/run/service/.sock",
869 "/var/run/../agent.sock",
870 "/var/run/service/../agent.sock",
871 "/var/run//agent.sock",
872 r"/var/log/foo\private",
873 r"C:\build\checkout",
874 ] {
875 assert!(
876 scrub(path, &identity).starts_with("[redacted-path]"),
877 "{path}"
878 );
879 assert_eq!(locate(path, &identity), vec![(1, "path")], "{path}");
880 }
881 for (text, expected) in [
882 ("/Users/bob/src/x", "~/src/x"),
883 ("/home/bob/.config", "~/.config"),
884 ("C:/Users/Bob/proj", "~/proj"),
885 (r"C:\Users\Bob\proj", r"~\proj"),
886 ("/srv/people/alice/wt/a", "~/wt/a"),
887 ] {
888 assert_eq!(s(text), expected, "{text}");
889 assert_eq!(locate(text, &id()), vec![(1, "home-path")], "{text}");
890 }
891 assert_eq!(
893 scrub("/Volumes/disk name/project", &identity),
894 "[redacted-path] name/project"
895 );
896 for text in [
897 "/unknown/checkout/build",
898 "/api/v1/runs",
899 "/run/service/agent.sock",
900 ] {
901 assert_eq!(scrub(text, &identity), text);
902 assert!(locate(text, &identity).is_empty());
903 }
904 }
905
906 #[test]
907 fn public_paths_keep_home_and_identity_rules_and_do_not_rescan() {
908 let identity = Identity {
909 home: "/usr/local/bin".into(),
910 ..id()
911 };
912 assert_eq!(scrub("/usr/local/bin/foo", &identity), "~/foo");
913 assert_eq!(
914 locate("/usr/local/bin/foo", &identity),
915 vec![(1, "home-path")]
916 );
917 for path in [
918 "/usr/local/bin/alice",
919 "/usr/local/bin/alice-build",
920 "/usr/local/bin/bob",
921 "/var/log/buildbox.log",
922 "/var/log/alice-build.log",
923 "/var/run/alice/agent.sock",
924 "/var/run/alice-agent/agent.sock",
925 "/var/run/bob/agent.sock",
926 "/var/log/box.local",
927 ] {
928 for identity in [id(), Identity::default()] {
929 assert_eq!(scrub(path, &identity), "[redacted-path]", "{path}");
930 assert_eq!(locate(path, &identity), vec![(1, "path")], "{path}");
931 }
932 }
933 let identity = Identity {
934 user: "foo".into(),
935 host: "kanade".into(),
936 ..Identity::default()
937 };
938 for (text, expected, kind) in [
939 (
940 "/usr/local/bin/foo",
941 "/usr/local/bin/[redacted-user]",
942 "user",
943 ),
944 (
945 "/var/run/kanade/agent.sock",
946 "/var/run/[redacted-host]/agent.sock",
947 "host",
948 ),
949 ] {
950 assert_eq!(scrub(text, &identity), expected);
951 assert_eq!(locate(text, &identity), vec![(1, kind)]);
952 }
953 let identity = Identity {
954 user: "redacted".into(),
955 host: "redacted-path".into(),
956 ..Identity::default()
957 };
958 let text = "/tmp/checkout redacted /usr/local/bin/foo";
959 assert_eq!(
960 scrub(text, &identity),
961 "[redacted-path] [redacted-user] /usr/local/bin/foo"
962 );
963 assert_eq!(locate(text, &identity), vec![(1, "path"), (1, "user")]);
964 }
965
966 #[test]
967 fn emails_ips_and_tokens() {
968 assert_eq!(s("mail dev.x+y@example.co.uk!"), "mail [redacted-email]!");
969 assert_eq!(s("host 192.168.0.12:8080"), "host [redacted-ip]:8080");
970 assert_eq!(s("v6 fe80::1 and ::1"), "v6 [redacted-ip] and ::1");
971 assert_eq!(s("full 2001:db8:0:0:0:0:0:1."), "full [redacted-ip].");
972 assert_eq!(
973 s("tok ghp_abcdefghijklmnopqrstuv end"),
974 "tok [redacted-token] end"
975 );
976 }
977
978 #[test]
979 fn loopback_literals_pass_scrub_and_locate() {
980 for text in [
981 "Start a test router on 127.0.0.1:0",
982 "127.0.0.0 127.255.255.255",
983 "127.1.2.3:65535",
984 "[127.0.0.1]:0",
985 "http://127.0.0.1:0/test",
986 "::1",
987 "0:0:0:0:0:0:0:1",
988 "0000:0000:0000:0000:0000:0000:0000:0001",
989 "[::1]:0 [0:0:0:0:0:0:0:1]:8080",
990 "http://[::1]:0/test",
991 "(127.0.0.1), `::1`. [::1]",
992 "::1: local socket",
993 ] {
994 assert_eq!(s(text), text, "{text}");
995 assert!(locate(text, &id()).is_empty(), "{text}");
996 }
997 }
998
999 #[test]
1000 fn loopback_exception_requires_complete_valid_literals() {
1001 for (text, expected) in [
1002 ("126.255.255.255", "[redacted-ip]"),
1003 ("128.0.0.0", "[redacted-ip]"),
1004 ("127.00.0.1", "[redacted-ip]"),
1005 ("127.0.0.01", "[redacted-ip]"),
1006 ("::1:8080", "[redacted-ip]"),
1007 ("::1.2.3.4", "[redacted-ip].2.3.4"),
1008 ("::ffff:127.0.0.1", "[redacted-ip].0.0.1"),
1009 ("::1%zone", "[redacted-ip]%zone"),
1010 ("::1_suffix", "[redacted-ip]_suffix"),
1011 ("::1.abc", "[redacted-ip].abc"),
1012 ("[::1", "[[redacted-ip]"),
1013 ("[::1]:65536", "[[redacted-ip]]:65536"),
1014 ("[::1]:0.10.0.0.1", "[[redacted-ip]]:0.10.0.0.1"),
1015 ("127.0.0.1:65536", "[redacted-ip]:65536"),
1016 ("127.0.0.1:0:10.0.0.1", "[redacted-ip]:0:10.0.0.1"),
1017 ] {
1018 assert_eq!(s(text), expected, "{text}");
1019 assert_eq!(locate(text, &id()), vec![(1, "ip")], "{text}");
1020 }
1021 }
1022
1023 #[test]
1024 fn loopback_exception_checks_after_trimmed_ipv6_colon() {
1025 let identity = Identity::default();
1026 for (text, expected) in [
1027 ("::1:.10.0.0.1", "[redacted-ip]:.10.0.0.1"),
1028 ("::1:%zone", "[redacted-ip]:%zone"),
1029 ] {
1030 assert_eq!(scrub(text, &identity), expected, "{text}");
1031 assert_eq!(locate(text, &identity), vec![(1, "ip")], "{text}");
1032 }
1033 }
1034
1035 #[test]
1036 fn loopback_exception_keeps_adjacent_sensitive_matches() {
1037 for separator in [" ", ",", ";", "-", "\n"] {
1038 for (local, remote) in [("127.0.0.1:0", "10.0.0.1"), ("[::1]:0", "fe80::1")] {
1039 let text = format!("{local}{separator}{remote}{separator}{local}");
1040 assert_eq!(
1041 s(&text),
1042 format!("{local}{separator}[redacted-ip]{separator}{local}")
1043 );
1044 let line = 1 + separator.matches('\n').count();
1045 assert_eq!(locate(&text, &id()), vec![(line, "ip")], "{text}");
1046 }
1047 }
1048 let text = "127.0.0.1:0 [::1]:0 alice token=secret";
1049 assert_eq!(
1050 s(text),
1051 "127.0.0.1:0 [::1]:0 [redacted-user] [redacted-token]"
1052 );
1053 assert_eq!(locate(text, &id()), vec![(1, "user"), (1, "token")]);
1054 let identity = Identity {
1055 user: "127".into(),
1056 ..Identity::default()
1057 };
1058 assert_eq!(scrub("127.0.0.1:0", &identity), "[redacted-user].0.0.1:0");
1059 assert_eq!(locate("127.0.0.1:0", &identity), vec![(1, "user")]);
1060 }
1061
1062 #[test]
1063 fn ipv4_section_references_pass_scrub_and_locate() {
1064 for text in [
1065 "§2.12.4.1-4.4",
1066 "see §2.12.4.1",
1067 "§ 2.12.4.1",
1068 "section 2.12.4.1",
1069 "sec. 2.12.4.1",
1070 "clause 2.12.4.1",
1071 "SECTION\t2.12.4.1",
1072 "See (Sec. \t2.12.4.1)",
1073 "CLAUSE 2.12.4.1",
1074 "仕様 §\t2.12.4.1",
1075 "released v1.2.3, version 0.41.1, 300.1.1.1",
1076 ] {
1077 assert_eq!(s(text), text, "{text}");
1078 assert!(locate(text, &id()).is_empty(), "{text}");
1079 }
1080 }
1081
1082 #[test]
1083 fn ipv4_section_markers_require_boundaries_and_same_line() {
1084 for before in [
1085 "subsection ",
1086 "xsec. ",
1087 "9clause ",
1088 "_section ",
1089 "-clause ",
1090 "ésection ",
1091 "字sec. ",
1092 "x§ ",
1093 "section\n",
1094 "§\n ",
1095 "clause\r\n\t",
1096 "section\u{a0}",
1097 "RFC ",
1098 ] {
1099 let text = format!("{before}2.12.4.1");
1100 assert_eq!(s(&text), format!("{before}[redacted-ip]"), "{text}");
1101 let line = 1 + before.matches('\n').count();
1102 assert_eq!(locate(&text, &id()), vec![(line, "ip")], "{text}");
1103 }
1104 for before in ["section", "sec.", "clause", "é", "字"] {
1105 assert!(!section_reference(before), "{before}");
1106 }
1107 }
1108
1109 #[test]
1110 fn section_exception_keeps_other_sensitive_data_redacted() {
1111 for (text, expected, hits) in [
1112 (
1113 "host 192.168.0.12:8080",
1114 "host [redacted-ip]:8080",
1115 vec![(1, "ip")],
1116 ),
1117 (
1118 "/Users/bob 10.0.0.1 a@b.io alice",
1119 "~ [redacted-ip] [redacted-email] [redacted-user]",
1120 vec![(1, "home-path"), (1, "ip"), (1, "email"), (1, "user")],
1121 ),
1122 (
1123 "connect to 10.1.2.3",
1124 "connect to [redacted-ip]",
1125 vec![(1, "ip")],
1126 ),
1127 (
1128 "§2.12.4.1\nconnect to 10.1.2.3",
1129 "§2.12.4.1\nconnect to [redacted-ip]",
1130 vec![(2, "ip")],
1131 ),
1132 (
1133 "section 2.12.4.1-10.1.2.3, 10.0.0.1",
1134 "section 2.12.4.1-[redacted-ip], [redacted-ip]",
1135 vec![(1, "ip"), (1, "ip")],
1136 ),
1137 (
1138 "§fe80::1\nsection ::1\nclause 2001:db8:0:0:0:0:0:1",
1139 "§[redacted-ip]\nsection ::1\nclause [redacted-ip]",
1140 vec![(1, "ip"), (3, "ip")],
1141 ),
1142 ] {
1143 assert_eq!(s(text), expected, "{text}");
1144 assert_eq!(locate(text, &id()), hits, "{text}");
1145 }
1146 }
1147
1148 #[test]
1149 fn section_context_uses_original_input_and_does_not_rescan_replacements() {
1150 let identity = Identity {
1151 user: "redacted-ip".into(),
1152 ..Identity::default()
1153 };
1154 let text = "§2.12.4.1\nconnect to 10.1.2.3";
1155 assert_eq!(
1156 scrub(text, &identity),
1157 "§2.12.4.1\nconnect to [redacted-ip]"
1158 );
1159 assert_eq!(locate(text, &identity), vec![(2, "ip")]);
1160 let identity = Identity {
1161 user: "section".into(),
1162 ..Identity::default()
1163 };
1164 assert_eq!(
1165 scrub("section 2.12.4.1", &identity),
1166 "[redacted-user] 2.12.4.1"
1167 );
1168 assert_eq!(locate("section 2.12.4.1", &identity), vec![(1, "user")]);
1169 }
1170
1171 #[test]
1172 fn identity_words_match_whole_words_only() {
1173 assert_eq!(
1174 s("by Alice on buildbox"),
1175 "by [redacted-user] on [redacted-host]"
1176 );
1177 assert_eq!(
1178 s("alicein wonderland, xbuildbox"),
1179 "alicein wonderland, xbuildbox"
1180 );
1181 }
1182
1183 #[test]
1184 fn github_url_owner_is_kept_but_other_mentions_are_not() {
1185 assert_eq!(
1186 s("https://github.com/alice/magi/pull/681 by alice"),
1187 "https://github.com/alice/magi/pull/681 by [redacted-user]"
1188 );
1189 assert_eq!(
1190 s("http://www.GitHub.com/Alice/x"),
1191 "http://www.GitHub.com/Alice/x"
1192 );
1193 for t in [
1194 "https://github.com.evil/alice",
1195 "https://github.com/x/alice",
1196 "git@github.com:alice/x",
1197 "https://api.github.com/alice",
1198 "github.com/alice",
1199 ] {
1200 assert!(s(t).contains("[redacted-user]"), "{t}");
1201 }
1202 let text = "https://github.com/alice/magi alice";
1203 assert_eq!(locate(text, &id()), vec![(1, "user")]);
1204 }
1205
1206 #[test]
1207 fn github_owner_slot_checks_only_ascii_url_suffixes() {
1208 for before in [
1209 "https://github.com/",
1210 "http://github.com/",
1211 "https://www.github.com/",
1212 "http://www.github.com/",
1213 "HTTPS://GitHub.com/",
1214 "HTTP://WWW.GitHub.com/",
1215 "prose éhttps://github.com/",
1216 ] {
1217 assert!(github_owner_slot(before), "{before}");
1218 }
1219 for before in [
1220 "https://api.github.com/",
1221 "github.com/",
1222 "www.github.com/",
1223 "",
1224 "git",
1225 "égithub.com/",
1226 "éwww.github.com/",
1227 "é",
1228 ] {
1229 assert!(!github_owner_slot(before), "{before}");
1230 }
1231 }
1232
1233 #[test]
1234 fn long_body_of_short_words_passes_scrub_and_locate() {
1235 let text = "a ".repeat(32768);
1236 let id = Identity::default();
1237 assert_eq!(scrub(&text, &id), text);
1238 assert!(locate(&text, &id).is_empty());
1239 }
1240
1241 #[test]
1242 fn generic_host_and_user_values_do_not_redact_prose() {
1243 for host in ["local", ".local", "devbox.local"] {
1244 let i = Identity {
1245 host: host.into(),
1246 ..Identity::default()
1247 };
1248 let t = "`WatchState::local` and local mode";
1249 assert_eq!(scrub(t, &i), t, "{host}");
1250 }
1251 let real = Identity {
1252 host: "devbox.local".into(),
1253 ..Identity::default()
1254 };
1255 assert_eq!(
1256 scrub("on devbox.local now", &real),
1257 "on [redacted-host] now"
1258 );
1259 for (user, host) in [("local", ""), ("admin", "localhost")] {
1260 let i = Identity {
1261 user: user.into(),
1262 host: host.into(),
1263 ..Identity::default()
1264 };
1265 let t = "local admin localhost";
1266 assert_eq!(scrub(t, &i), t);
1267 }
1268 let once = s("https://github.com/alice/x alice buildbox");
1269 assert_eq!(scrub(&once, &id()), once);
1270 assert_eq!(s("on buildbox"), "on [redacted-host]");
1271 }
1272
1273 #[test]
1274 fn dotted_code_paths_ending_in_local_are_not_hosts() {
1275 for t in [
1276 "`WatchState.local` is true",
1277 "WatchState.lan and self.local",
1278 "WatchState.local",
1279 ] {
1280 assert_eq!(scrub(t, &Identity::default()), t);
1281 }
1282 for t in ["ssh MyServer.lan", "JohnsMacBook.local"] {
1284 assert_eq!(scrub(t, &Identity::default()), t);
1285 }
1286 for (t, want) in [
1287 ("ssh m1air.loCal now", "ssh [redacted-host] now"),
1288 ("ssh devbox.LOCAL", "ssh [redacted-host]"),
1289 ("ssh box.InterNal", "ssh [redacted-host]"),
1290 ("ssh box.LaN", "ssh [redacted-host]"),
1291 ] {
1292 assert_eq!(scrub(t, &Identity::default()), want, "{t}");
1293 }
1294 for t in [
1295 "ssh devbox.local",
1296 "ssh BUILD-SERVER.local",
1297 "ssh Alices-MacBook-Pro.local",
1298 "ssh BUILD.local",
1299 ] {
1300 assert!(
1301 scrub(t, &Identity::default()).contains("[redacted-host]"),
1302 "{t}"
1303 );
1304 }
1305 }
1306
1307 #[test]
1308 fn lowercase_field_receivers_pass_scrub_and_locate() {
1309 let id = Identity::default();
1310 for text in [
1311 "`st.local` is true",
1312 "`cfg.local`",
1313 "`ctx.lan`",
1314 "`env.internal`",
1315 "my_state.local",
1316 "`my_state.local`",
1317 "`st.local`, then continue.",
1318 "`devbox.local`",
1319 ] {
1320 assert_eq!(scrub(text, &id), text, "{text}");
1321 assert!(locate(text, &id).is_empty(), "{text}");
1322 }
1323 }
1324
1325 #[test]
1326 fn field_receiver_exception_keeps_host_redaction() {
1327 let id = Identity::default();
1328 for text in [
1329 "st.local",
1330 "cfg.local",
1331 "devbox.local",
1332 "box.lan",
1333 "`st.local",
1334 "st.local`",
1335 "`st.local is true`",
1336 "`st.local.`",
1337 "`box1.local`",
1338 "`build-box.local`",
1339 "`box.example.local`",
1340 ] {
1341 assert!(scrub(text, &id).contains("[redacted-host]"), "{text}");
1342 assert_eq!(locate(text, &id), vec![(1, "host")], "{text}");
1343 }
1344 for text in ["host=st.local", "`host=cfg.local`"] {
1345 assert!(scrub(text, &id).contains("[redacted-identity]"), "{text}");
1346 assert_eq!(locate(text, &id), vec![(1, "identity")], "{text}");
1347 }
1348 let known = Identity {
1349 host: "devbox.local".into(),
1350 ..Identity::default()
1351 };
1352 let text = "`devbox.local`";
1353 assert_eq!(scrub(text, &known), "`[redacted-host]`");
1354 assert_eq!(locate(text, &known), vec![(1, "host")]);
1355 }
1356
1357 #[test]
1358 fn ordinary_text_is_untouched() {
1359 for t in [
1360 "Change src/graph.rs and tests/common/mod.rs.",
1361 "See https://github.com/o/r/pull/12 for #12",
1362 "returns std::io::Error, or a::b, Vec::new()",
1363 "released v1.2.3, version 0.41.1, 300.1.1.1",
1364 "thanks @coderabbitai; at 12:34:56 it ran",
1365 "/api/v1/runs; docs/home/x and ./Users/y",
1366 "A plain sentence about background and motivation.",
1367 "日本語のテキスト 🎉 with émoji",
1368 ] {
1369 assert_eq!(s(t), t);
1370 }
1371 }
1372
1373 #[test]
1374 fn multibyte_input_does_not_panic_and_replacement_is_not_rescanned() {
1375 assert_eq!(
1376 s("C:\\日本語 and C:/日本"),
1377 "[redacted-path] and [redacted-path]"
1378 );
1379 assert_eq!(s("é/Users/bob/é 日本 alice"), "é~/é 日本 [redacted-user]");
1380 let once = s("/Users/bob 10.0.0.1 a@b.io alice");
1381 assert_eq!(scrub(&once, &Identity::default()), once);
1382 let odd = Identity {
1384 user: "redacted".into(),
1385 ..Identity::default()
1386 };
1387 assert_eq!(scrub("redacted x", &odd), "[redacted-user] x");
1388 }
1389}