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