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 let prev = text[..i].chars().next_back();
94 if let Some((len, rep)) = match_at(&text[i..], prev, id) {
95 out.push_str(rep);
96 i += len;
97 } else {
98 let c = text[i..].chars().next().expect("i is on a char boundary");
99 out.push(c);
100 i += c.len_utf8();
101 }
102 }
103 out
104}
105
106pub fn locate(text: &str, id: &Identity) -> Vec<(usize, &'static str)> {
109 let mut hits = Vec::new();
110 let mut i = 0;
111 let mut line = 1;
112 while i < text.len() {
113 let prev = text[..i].chars().next_back();
114 if let Some((len, rep)) = match_at(&text[i..], prev, 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(rest: &str, prev: Option<char>, id: &Identity) -> Option<(usize, &'static str)> {
137 let starts_word = prev.is_none_or(|p| !is_word(p));
138 home_path(rest, prev, id)
139 .or_else(|| {
140 starts_word
141 .then(|| {
142 token(rest).or_else(|| credential(rest)).or_else(|| {
143 prev.is_none_or(|c| !is_name(c))
144 .then(|| named_identity(rest))
145 .flatten()
146 })
147 })
148 .flatten()
149 })
150 .or_else(|| absolute_path(rest, prev))
151 .or_else(|| {
152 prev.is_none_or(|p| !is_email_local(p))
153 .then(|| email(rest))
154 .flatten()
155 })
156 .or_else(|| {
157 prev.is_none_or(|p| !p.is_ascii_alphanumeric() && p != '.' && p != ':')
158 .then(|| ipv4(rest).or_else(|| ipv6(rest)))
159 .flatten()
160 })
161 .or_else(|| starts_word.then(|| identity_word(rest, id)).flatten())
162}
163
164fn home_path(rest: &str, prev: Option<char>, id: &Identity) -> Option<(usize, &'static str)> {
167 if prev.is_some_and(|p| p.is_ascii_alphanumeric() || matches!(p, '.' | '_' | '-' | '~')) {
168 return None;
169 }
170 if id.home.len() > 1 && rest.starts_with(id.home.as_str()) {
171 let tail = &rest[id.home.len()..];
172 if tail.chars().next().is_none_or(|c| !is_name(c)) {
173 return Some((id.home.len(), "~"));
174 }
175 }
176 for base in ["/Users/", "/home/"] {
177 if let Some(tail) = rest.strip_prefix(base) {
178 let n = name_len(tail);
179 if n > 0 {
180 return Some((base.len() + n, "~"));
181 }
182 }
183 }
184 if let Some(tail) = rest.strip_prefix("/root")
185 && tail.chars().next().is_none_or(|c| !is_word(c))
186 {
187 return Some(("/root".len(), "~"));
188 }
189 let b = rest.as_bytes();
190 if b.len() > 9
191 && b[0].is_ascii_alphabetic()
192 && b[1] == b':'
193 && matches!(b[2], b'\\' | b'/')
194 && b[3..8].eq_ignore_ascii_case(b"users")
195 && matches!(b[8], b'\\' | b'/')
196 {
197 let n = name_len(&rest[9..]);
198 if n > 0 {
199 return Some((9 + n, "~"));
200 }
201 }
202 None
203}
204
205fn token(rest: &str) -> Option<(usize, &'static str)> {
206 let prefix = TOKEN_PREFIXES.iter().find(|p| rest.starts_with(**p))?;
207 let tail = run(&rest[prefix.len()..], is_word);
208 (tail >= TOKEN_MIN_TAIL).then_some((prefix.len() + tail, "[redacted-token]"))
209}
210
211fn named_identity(rest: &str) -> Option<(usize, &'static str)> {
213 for key in [
214 "hostname=",
215 "hostname: ",
216 "username=",
217 "username: ",
218 "user=",
219 "host=",
220 ] {
221 if rest
222 .get(..key.len())
223 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(key))
224 {
225 let n = run(&rest[key.len()..], is_name);
226 if n > 0 {
227 return Some((key.len() + n, "[redacted-identity]"));
228 }
229 }
230 }
231 let n = name_len(rest);
232 if n > 0
233 && [".local", ".internal", ".lan"].iter().any(|suffix| {
234 n.checked_sub(suffix.len())
235 .and_then(|start| rest.get(start..n))
236 .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix))
237 })
238 {
239 return Some((n, "[redacted-host]"));
240 }
241 None
242}
243
244fn quoted_credential(rest: &str) -> Option<(usize, &'static str)> {
247 for key in [
248 "password", "token", "api_key", "api-key", "apikey", "secret",
249 ] {
250 let Some(after) = rest
251 .get(..key.len())
252 .filter(|p| p.eq_ignore_ascii_case(key))
253 .map(|_| &rest[key.len()..])
254 else {
255 continue;
256 };
257 let Some(after_quote) = after.strip_prefix(['"', '\'']) else {
258 continue;
259 };
260 let t = after_quote.trim_start();
261 let Some(t) = t.strip_prefix([':', '=']) else {
262 continue;
263 };
264 let t = t.trim_start();
265 let n = match t.chars().next() {
267 Some(q @ ('"' | '\'')) => {
268 let inner = &t[1..];
269 let end = quoted_end(inner, q).unwrap_or(inner.len());
270 1 + end + usize::from(end < inner.len())
271 }
272 _ => run(t, |c| {
273 !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | ',' | '}')
274 }),
275 };
276 let value = t;
277 if n > 0 {
278 return Some((rest.len() - value.len() + n, "[redacted-token]"));
279 }
280 }
281 None
282}
283
284fn quoted_end(inner: &str, q: char) -> Option<usize> {
286 let mut chars = inner.char_indices();
287 while let Some((i, c)) = chars.next() {
288 if c == '\\' {
289 chars.next();
290 } else if c == q {
291 return Some(i);
292 }
293 }
294 None
295}
296
297fn credential(rest: &str) -> Option<(usize, &'static str)> {
298 if let Some(hit) = quoted_credential(rest) {
299 return Some(hit);
300 }
301 for key in [
302 "password=",
303 "token=",
304 "api_key=",
305 "api-key=",
306 "password: ",
307 "token: ",
308 "api_key: ",
309 "bearer ",
310 "password ",
311 "token ",
312 "api key ",
313 ] {
314 if rest
315 .get(..key.len())
316 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(key))
317 {
318 let tail = &rest[key.len()..];
319 let padding = tail.len() - tail.trim_start_matches([' ', '\'', '"']).len();
320 let value = &tail[padding..];
321 let n = run(value, |c| {
322 !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | '"' | '\'')
323 });
324 let contextual = matches!(key, "password " | "token " | "api key ");
325 let entropy = n >= 20
326 && value[..n].bytes().any(|b| b.is_ascii_digit())
327 && value[..n].bytes().any(|b| b.is_ascii_alphabetic());
328 if n > 0 && (!contextual || entropy) {
329 return Some((key.len() + padding + n, "[redacted-token]"));
330 }
331 }
332 }
333 None
334}
335
336fn absolute_path(rest: &str, prev: Option<char>) -> Option<(usize, &'static str)> {
337 if prev.is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | ':' | '.' | '~')) {
338 return None;
339 }
340 let b = rest.as_bytes();
341 let windows =
342 b.len() > 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && matches!(b[2], b'/' | b'\\');
343 let unix = [
345 "/tmp/",
346 "/private/",
347 "/var/",
348 "/etc/",
349 "/opt/",
350 "/srv/",
351 "/usr/",
352 "/mnt/",
353 "/Volumes/",
354 ]
355 .iter()
356 .any(|root| rest.starts_with(root));
357 if windows || unix {
358 let n = run(rest, |c| {
359 !c.is_whitespace() && !matches!(c, '`' | '"' | '\'' | '<' | '>')
360 });
361 return Some((n, "[redacted-path]"));
362 }
363 None
364}
365
366fn email(rest: &str) -> Option<(usize, &'static str)> {
367 let local = run(rest, is_email_local);
368 if local == 0 || !rest[local..].starts_with('@') {
369 return None;
370 }
371 let domain = &rest[local + 1..];
372 let mut end = 0;
373 let mut labels = 0;
374 loop {
375 let n = run(&domain[end..], |c| c.is_ascii_alphanumeric() || c == '-');
376 if n == 0 {
377 break;
378 }
379 end += n;
380 labels += 1;
381 if domain[end..].starts_with('.')
382 && run(&domain[end + 1..], |c| c.is_ascii_alphanumeric()) > 0
383 {
384 end += 1;
385 } else {
386 break;
387 }
388 }
389 (labels >= 2).then_some((local + 1 + end, "[redacted-email]"))
390}
391
392fn ipv4(rest: &str) -> Option<(usize, &'static str)> {
393 let mut len = 0;
394 for part in 0..4 {
395 let s = &rest[len..];
396 let n = run(s, |c| c.is_ascii_digit());
397 if n == 0 || n > 3 || s[..n].parse::<u16>().ok()? > 255 {
398 return None;
399 }
400 len += n;
401 if part < 3 {
402 if !rest[len..].starts_with('.') {
403 return None;
404 }
405 len += 1;
406 }
407 }
408 let after = &rest[len..];
409 let mut chars = after.chars();
410 match chars.next() {
411 Some(c) if c.is_ascii_alphanumeric() => return None,
412 Some('.') if chars.next().is_some_and(|c| c.is_ascii_digit()) => return None,
413 _ => {}
414 }
415 Some((len, "[redacted-ip]"))
416}
417
418fn ipv6(rest: &str) -> Option<(usize, &'static str)> {
419 let mut n = run(rest, |c| c.is_ascii_hexdigit() || c == ':');
420 while n > 0 && rest[..n].ends_with(':') && !rest[..n].ends_with("::") {
422 n -= 1;
423 }
424 let cand = &rest[..n];
425 let colons = cand.matches(':').count();
426 let shaped = (cand.contains("::") && colons >= 2) || colons == 7;
427 if !shaped
428 || !cand.bytes().any(|b| b.is_ascii_digit())
429 || cand.split(':').any(|g| g.len() > 4)
430 || rest[n..]
431 .chars()
432 .next()
433 .is_some_and(|c| c.is_ascii_alphanumeric())
434 {
435 return None;
436 }
437 Some((n, "[redacted-ip]"))
438}
439
440fn identity_word(rest: &str, id: &Identity) -> Option<(usize, &'static str)> {
441 for (word, rep) in [(&id.user, "[redacted-user]"), (&id.host, "[redacted-host]")] {
442 let w = word.trim();
443 if w.len() < MIN_IDENTITY_WORD || rest.len() < w.len() || !rest.is_char_boundary(w.len()) {
444 continue;
445 }
446 if rest[..w.len()].eq_ignore_ascii_case(w)
447 && rest[w.len()..].chars().next().is_none_or(|c| !is_word(c))
448 {
449 return Some((w.len(), rep));
450 }
451 }
452 None
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458
459 fn id() -> Identity {
460 Identity {
461 user: "alice".into(),
462 host: "buildbox".into(),
463 home: "/srv/people/alice".into(),
464 }
465 }
466
467 fn s(t: &str) -> String {
468 scrub(t, &id())
469 }
470
471 #[test]
472 fn locate_names_kind_and_line_without_the_text() {
473 let id = Identity::default();
474 let text = "fine\ntoken=abcdefghijklmnop1234 here\nmail a@b.example";
475 assert_eq!(locate(text, &id), vec![(2, "token"), (3, "email")]);
476 }
477
478 #[test]
479 fn quoted_credentials_honor_escaped_quotes() {
480 let out = s(r#"{"password":"prefix\"hunter2"} tail"#);
481 assert!(!out.contains("hunter2") && !out.contains("prefix"), "{out}");
482 assert!(out.ends_with("} tail"), "{out}");
483 let out = s(r#"{"password":"a\\\\"} tail"#);
485 assert!(out.ends_with("} tail") && !out.contains("a\\"), "{out}");
486 let out = s(r#"{"password":"a\\\"hunter2"} tail"#);
488 assert!(!out.contains("hunter2"), "{out}");
489 let out = s(r#"{'token':'x\'hunter2'} tail"#);
490 assert!(!out.contains("hunter2"), "{out}");
491 let out = s(r#"{"password":"日本\"語hunter2"} tail"#);
492 assert!(!out.contains("hunter2") && !out.contains('語'), "{out}");
493 let out = s(r#"{"password":"abc\"hunter2"#);
495 assert!(!out.contains("hunter2"), "{out}");
496 }
497
498 #[test]
499 fn home_paths_collapse_and_keep_the_tail() {
500 assert_eq!(s("see /Users/bob/src/x.rs now"), "see ~/src/x.rs now");
501 assert_eq!(s("in /home/bob-1/.config"), "in ~/.config");
502 assert_eq!(s("at C:\\Users\\Bob\\proj\\a.rs"), "at ~\\proj\\a.rs");
503 assert_eq!(s("at C:/Users/Bob/proj"), "at ~/proj");
504 assert_eq!(s("/root/x and /root."), "~/x and ~.");
505 assert_eq!(s("/srv/people/alice/wt/a"), "~/wt/a");
506 }
507
508 #[test]
509 fn emails_ips_and_tokens() {
510 assert_eq!(s("mail dev.x+y@example.co.uk!"), "mail [redacted-email]!");
511 assert_eq!(s("host 192.168.0.12:8080"), "host [redacted-ip]:8080");
512 assert_eq!(
513 s("v6 fe80::1 and ::1"),
514 "v6 [redacted-ip] and [redacted-ip]"
515 );
516 assert_eq!(s("full 2001:db8:0:0:0:0:0:1."), "full [redacted-ip].");
517 assert_eq!(
518 s("tok ghp_abcdefghijklmnopqrstuv end"),
519 "tok [redacted-token] end"
520 );
521 }
522
523 #[test]
524 fn identity_words_match_whole_words_only() {
525 assert_eq!(
526 s("by Alice on buildbox"),
527 "by [redacted-user] on [redacted-host]"
528 );
529 assert_eq!(
530 s("alicein wonderland, xbuildbox"),
531 "alicein wonderland, xbuildbox"
532 );
533 }
534
535 #[test]
536 fn ordinary_text_is_untouched() {
537 for t in [
538 "Change src/graph.rs and tests/common/mod.rs.",
539 "See https://github.com/o/r/pull/12 for #12",
540 "returns std::io::Error, or a::b, Vec::new()",
541 "released v1.2.3, version 0.41.1, 300.1.1.1",
542 "thanks @coderabbitai; at 12:34:56 it ran",
543 "/api/v1/runs; docs/home/x and ./Users/y",
544 "A plain sentence about background and motivation.",
545 "日本語のテキスト 🎉 with émoji",
546 ] {
547 assert_eq!(s(t), t);
548 }
549 }
550
551 #[test]
552 fn multibyte_input_does_not_panic_and_replacement_is_not_rescanned() {
553 assert_eq!(
554 s("C:\\日本語 and C:/日本"),
555 "[redacted-path] and [redacted-path]"
556 );
557 assert_eq!(s("é/Users/bob/é 日本 alice"), "é~/é 日本 [redacted-user]");
558 let once = s("/Users/bob 10.0.0.1 a@b.io alice");
559 assert_eq!(scrub(&once, &Identity::default()), once);
560 let odd = Identity {
562 user: "redacted".into(),
563 ..Identity::default()
564 };
565 assert_eq!(scrub("redacted x", &odd), "[redacted-user] x");
566 }
567}