Skip to main content

magi/
scrub.rs

1//! Last-line scrub for text that lands on GitHub.
2//!
3//! The prompts tell agents not to put machine- or operator-identifying data
4//! (hostnames, account names, IPs, home paths, emails, tokens) into pull
5//! requests and issues, but a prompt is advisory. [`scrub`] is the enforced
6//! half: a pure function run right before `gh pr create`.
7//!
8//! It scans the input left to right exactly once and pushes into a *separate*
9//! output string, so a replacement is never scanned again (compare the
10//! `blind::redact` loop, whose `[REDACTED]` contains the letters of the words it
11//! hunted). It prefers missing something to mangling ordinary prose: repo-
12//! relative paths, URLs, `std::io::Error`, `v1.2.3` and `@handle` all pass.
13
14/// The local facts worth hiding. `Default` is "nothing known", which leaves only
15/// the pattern-based rules.
16#[derive(Debug, Clone, Default)]
17pub struct Identity {
18    /// Local account name.
19    pub user: String,
20    /// Machine hostname.
21    pub host: String,
22    /// Home directory path.
23    pub home: String,
24}
25
26impl Identity {
27    /// Read the current account, host and home directory. The only I/O here.
28    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;
60/// Identity words shorter than this are too likely to be ordinary prose.
61const 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
75/// A path component: the name run minus trailing dots, which are punctuation.
76fn name_len(s: &str) -> usize {
77    s[..run(s, is_name)].trim_end_matches('.').len()
78}
79
80/// Length in bytes of the leading run of `s` whose chars satisfy `f`.
81fn 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
87/// Replace machine- and operator-identifying data in `text`. Pure.
88pub 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
122/// `/Users/x`, `/home/x`, `/root`, `C:\Users\x`, `C:/Users/x` and the literal
123/// home directory, each collapsed to `~` so the repo-relative tail survives.
124fn 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    // A trailing single colon is punctuation, not part of the address.
224    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        // A user named like the replacement text cannot loop or re-match.
334        let odd = Identity {
335            user: "redacted".into(),
336            ..Identity::default()
337        };
338        assert_eq!(scrub("redacted x", &odd), "[redacted-user] x");
339    }
340}