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