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