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; 8] = [
50 "github_pat_",
51 "ghp_",
52 "gho_",
53 "ghs_",
54 "ghu_",
55 "sk-",
56 "xoxb-",
57 "xoxp-",
58];
59const TOKEN_MIN_TAIL: usize = 16;
60const MIN_IDENTITY_WORD: usize = 3;
62
63fn is_word(c: char) -> bool {
64 c.is_ascii_alphanumeric() || c == '_' || c == '-'
65}
66
67fn is_name(c: char) -> bool {
68 c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')
69}
70
71fn is_email_local(c: char) -> bool {
72 c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '%' | '+' | '-')
73}
74
75fn name_len(s: &str) -> usize {
77 s[..run(s, is_name)].trim_end_matches('.').len()
78}
79
80fn run(s: &str, f: impl Fn(char) -> bool) -> usize {
82 s.char_indices()
83 .find(|&(_, c)| !f(c))
84 .map_or(s.len(), |(i, _)| i)
85}
86
87pub fn scrub(text: &str, id: &Identity) -> String {
89 let mut out = String::with_capacity(text.len());
90 let mut i = 0;
91 while i < text.len() {
92 let prev = text[..i].chars().next_back();
93 if let Some((len, rep)) = match_at(&text[i..], prev, 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
105fn match_at(rest: &str, prev: Option<char>, id: &Identity) -> Option<(usize, &'static str)> {
106 let starts_word = prev.is_none_or(|p| !is_word(p));
107 home_path(rest, prev, id)
108 .or_else(|| starts_word.then(|| token(rest)).flatten())
109 .or_else(|| {
110 prev.is_none_or(|p| !is_email_local(p))
111 .then(|| email(rest))
112 .flatten()
113 })
114 .or_else(|| {
115 prev.is_none_or(|p| !p.is_ascii_alphanumeric() && p != '.' && p != ':')
116 .then(|| ipv4(rest).or_else(|| ipv6(rest)))
117 .flatten()
118 })
119 .or_else(|| starts_word.then(|| identity_word(rest, id)).flatten())
120}
121
122fn home_path(rest: &str, prev: Option<char>, id: &Identity) -> Option<(usize, &'static str)> {
125 if prev.is_some_and(|p| p.is_ascii_alphanumeric() || matches!(p, '.' | '_' | '-' | '~')) {
126 return None;
127 }
128 if id.home.len() > 1 && rest.starts_with(id.home.as_str()) {
129 let tail = &rest[id.home.len()..];
130 if tail.chars().next().is_none_or(|c| !is_name(c)) {
131 return Some((id.home.len(), "~"));
132 }
133 }
134 for base in ["/Users/", "/home/"] {
135 if let Some(tail) = rest.strip_prefix(base) {
136 let n = name_len(tail);
137 if n > 0 {
138 return Some((base.len() + n, "~"));
139 }
140 }
141 }
142 if let Some(tail) = rest.strip_prefix("/root")
143 && tail.chars().next().is_none_or(|c| !is_word(c))
144 {
145 return Some(("/root".len(), "~"));
146 }
147 let b = rest.as_bytes();
148 if b.len() > 9
149 && b[0].is_ascii_alphabetic()
150 && b[1] == b':'
151 && matches!(b[2], b'\\' | b'/')
152 && b[3..8].eq_ignore_ascii_case(b"users")
153 && matches!(b[8], b'\\' | b'/')
154 {
155 let n = name_len(&rest[9..]);
156 if n > 0 {
157 return Some((9 + n, "~"));
158 }
159 }
160 None
161}
162
163fn token(rest: &str) -> Option<(usize, &'static str)> {
164 let prefix = TOKEN_PREFIXES.iter().find(|p| rest.starts_with(**p))?;
165 let tail = run(&rest[prefix.len()..], is_word);
166 (tail >= TOKEN_MIN_TAIL).then_some((prefix.len() + tail, "[redacted-token]"))
167}
168
169fn email(rest: &str) -> Option<(usize, &'static str)> {
170 let local = run(rest, is_email_local);
171 if local == 0 || !rest[local..].starts_with('@') {
172 return None;
173 }
174 let domain = &rest[local + 1..];
175 let mut end = 0;
176 let mut labels = 0;
177 loop {
178 let n = run(&domain[end..], |c| c.is_ascii_alphanumeric() || c == '-');
179 if n == 0 {
180 break;
181 }
182 end += n;
183 labels += 1;
184 if domain[end..].starts_with('.')
185 && run(&domain[end + 1..], |c| c.is_ascii_alphanumeric()) > 0
186 {
187 end += 1;
188 } else {
189 break;
190 }
191 }
192 (labels >= 2).then_some((local + 1 + end, "[redacted-email]"))
193}
194
195fn ipv4(rest: &str) -> Option<(usize, &'static str)> {
196 let mut len = 0;
197 for part in 0..4 {
198 let s = &rest[len..];
199 let n = run(s, |c| c.is_ascii_digit());
200 if n == 0 || n > 3 || s[..n].parse::<u16>().ok()? > 255 {
201 return None;
202 }
203 len += n;
204 if part < 3 {
205 if !rest[len..].starts_with('.') {
206 return None;
207 }
208 len += 1;
209 }
210 }
211 let after = &rest[len..];
212 let mut chars = after.chars();
213 match chars.next() {
214 Some(c) if c.is_ascii_alphanumeric() => return None,
215 Some('.') if chars.next().is_some_and(|c| c.is_ascii_digit()) => return None,
216 _ => {}
217 }
218 Some((len, "[redacted-ip]"))
219}
220
221fn ipv6(rest: &str) -> Option<(usize, &'static str)> {
222 let mut n = run(rest, |c| c.is_ascii_hexdigit() || c == ':');
223 while n > 0 && rest[..n].ends_with(':') && !rest[..n].ends_with("::") {
225 n -= 1;
226 }
227 let cand = &rest[..n];
228 let colons = cand.matches(':').count();
229 let shaped = (cand.contains("::") && colons >= 2) || colons == 7;
230 if !shaped
231 || !cand.bytes().any(|b| b.is_ascii_digit())
232 || cand.split(':').any(|g| g.len() > 4)
233 || rest[n..]
234 .chars()
235 .next()
236 .is_some_and(|c| c.is_ascii_alphanumeric())
237 {
238 return None;
239 }
240 Some((n, "[redacted-ip]"))
241}
242
243fn identity_word(rest: &str, id: &Identity) -> Option<(usize, &'static str)> {
244 for (word, rep) in [(&id.user, "[redacted-user]"), (&id.host, "[redacted-host]")] {
245 let w = word.trim();
246 if w.len() < MIN_IDENTITY_WORD || rest.len() < w.len() || !rest.is_char_boundary(w.len()) {
247 continue;
248 }
249 if rest[..w.len()].eq_ignore_ascii_case(w)
250 && rest[w.len()..].chars().next().is_none_or(|c| !is_word(c))
251 {
252 return Some((w.len(), rep));
253 }
254 }
255 None
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 fn id() -> Identity {
263 Identity {
264 user: "alice".into(),
265 host: "buildbox".into(),
266 home: "/srv/people/alice".into(),
267 }
268 }
269
270 fn s(t: &str) -> String {
271 scrub(t, &id())
272 }
273
274 #[test]
275 fn home_paths_collapse_and_keep_the_tail() {
276 assert_eq!(s("see /Users/bob/src/x.rs now"), "see ~/src/x.rs now");
277 assert_eq!(s("in /home/bob-1/.config"), "in ~/.config");
278 assert_eq!(s("at C:\\Users\\Bob\\proj\\a.rs"), "at ~\\proj\\a.rs");
279 assert_eq!(s("at C:/Users/Bob/proj"), "at ~/proj");
280 assert_eq!(s("/root/x and /root."), "~/x and ~.");
281 assert_eq!(s("/srv/people/alice/wt/a"), "~/wt/a");
282 }
283
284 #[test]
285 fn emails_ips_and_tokens() {
286 assert_eq!(s("mail dev.x+y@example.co.uk!"), "mail [redacted-email]!");
287 assert_eq!(s("host 192.168.0.12:8080"), "host [redacted-ip]:8080");
288 assert_eq!(
289 s("v6 fe80::1 and ::1"),
290 "v6 [redacted-ip] and [redacted-ip]"
291 );
292 assert_eq!(s("full 2001:db8:0:0:0:0:0:1."), "full [redacted-ip].");
293 assert_eq!(
294 s("tok ghp_abcdefghijklmnopqrstuv end"),
295 "tok [redacted-token] end"
296 );
297 }
298
299 #[test]
300 fn identity_words_match_whole_words_only() {
301 assert_eq!(
302 s("by Alice on buildbox"),
303 "by [redacted-user] on [redacted-host]"
304 );
305 assert_eq!(
306 s("alicein wonderland, xbuildbox"),
307 "alicein wonderland, xbuildbox"
308 );
309 }
310
311 #[test]
312 fn ordinary_text_is_untouched() {
313 for t in [
314 "Change src/graph.rs and tests/common/mod.rs.",
315 "See https://github.com/o/r/pull/12 for #12",
316 "returns std::io::Error, or a::b, Vec::new()",
317 "released v1.2.3, version 0.41.1, 300.1.1.1",
318 "thanks @coderabbitai; at 12:34:56 it ran",
319 "/tmp/x and /api/v1/runs; docs/home/x and ./Users/y",
320 "A plain sentence about background and motivation.",
321 "日本語のテキスト 🎉 with émoji",
322 ] {
323 assert_eq!(s(t), t);
324 }
325 }
326
327 #[test]
328 fn multibyte_input_does_not_panic_and_replacement_is_not_rescanned() {
329 assert_eq!(s("C:\\日本語 and C:/日本"), "C:\\日本語 and C:/日本");
330 assert_eq!(s("é/Users/bob/é 日本 alice"), "é~/é 日本 [redacted-user]");
331 let once = s("/Users/bob 10.0.0.1 a@b.io alice");
332 assert_eq!(scrub(&once, &Identity::default()), once);
333 let odd = Identity {
335 user: "redacted".into(),
336 ..Identity::default()
337 };
338 assert_eq!(scrub("redacted x", &odd), "[redacted-user] x");
339 }
340}