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: shared rules used by the GitHub text posting gate.
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//! Dotted IPv4-shaped section references after `§`, `section`, `sec.` or
14//! `clause` also pass; the exception never applies to IPv6.
15
16/// The local facts worth hiding. `Default` is "nothing known", which leaves only
17/// the pattern-based rules.
18#[derive(Debug, Clone, Default)]
19pub struct Identity {
20    /// Local account name.
21    pub user: String,
22    /// Machine hostname.
23    pub host: String,
24    /// Home directory path.
25    pub home: String,
26}
27
28impl Identity {
29    /// Read the current account, host and home directory. The only I/O here.
30    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;
63/// Identity words shorter than this are too likely to be ordinary prose.
64const 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
78/// A path component: the name run minus trailing dots, which are punctuation.
79fn name_len(s: &str) -> usize {
80    s[..run(s, is_name)].trim_end_matches('.').len()
81}
82
83/// Length in bytes of the leading run of `s` whose chars satisfy `f`.
84fn 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
90/// Replace machine- and operator-identifying data in `text`. Pure.
91pub 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
107/// Where [`scrub`] would replace something: `(line, kind)` per match, lines
108/// counted from 1. Never carries the matched text. Pure.
109pub 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))
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
173/// `/Users/x`, `/home/x`, `/root`, `C:\Users\x`, `C:/Users/x` and the literal
174/// home directory, each collapsed to `~` so the repo-relative tail survives.
175fn 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
220/// Explicit assignments avoid guessing whether an ordinary word is an account.
221fn named_identity(rest: &str) -> 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])
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
254/// A dotted name that reads as a code path (`WatchState.local`, `self.local`)
255/// rather than a hostname: one dot, no hyphen, and either a `self.` / `this.`
256/// receiver or a CamelCase one (a capital right after a lowercase letter).
257/// Anything else, mixed-case or backticked hostnames included, stays a host.
258/// Only the part before the dot is looked at, so the suffix's spelling
259/// (`.loCal`) never matters. A CamelCase single-label hostname (`MyServer.lan`)
260/// is deliberately read as code; the operator's own host name is caught by
261/// `identity_word` instead.
262fn code_receiver(name: &str) -> bool {
263    if name.matches('.').count() != 1 || name.contains('-') {
264        return false;
265    }
266    let Some((receiver, _)) = name.split_once('.') else {
267        return false;
268    };
269    if receiver.eq_ignore_ascii_case("self") || receiver.eq_ignore_ascii_case("this") {
270        return true;
271    }
272    let mut prev_lower = false;
273    receiver.chars().any(|c| {
274        let hump = prev_lower && c.is_ascii_uppercase();
275        prev_lower = c.is_ascii_lowercase();
276        hump
277    })
278}
279
280/// `"password": "value"` and `'token':'value'`: the key is quoted, so the
281/// unquoted `key=` / `key: ` forms below never match.
282fn quoted_credential(rest: &str) -> Option<(usize, &'static str)> {
283    for key in [
284        "password", "token", "api_key", "api-key", "apikey", "secret",
285    ] {
286        let Some(after) = rest
287            .get(..key.len())
288            .filter(|p| p.eq_ignore_ascii_case(key))
289            .map(|_| &rest[key.len()..])
290        else {
291            continue;
292        };
293        let Some(after_quote) = after.strip_prefix(['"', '\'']) else {
294            continue;
295        };
296        let t = after_quote.trim_start();
297        let Some(t) = t.strip_prefix([':', '=']) else {
298            continue;
299        };
300        let t = t.trim_start();
301        // A quoted value runs to its closing quote and may hold spaces or commas.
302        let n = match t.chars().next() {
303            Some(q @ ('"' | '\'')) => {
304                let inner = &t[1..];
305                let end = quoted_end(inner, q).unwrap_or(inner.len());
306                1 + end + usize::from(end < inner.len())
307            }
308            _ => run(t, |c| {
309                !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | ',' | '}')
310            }),
311        };
312        let value = t;
313        if n > 0 {
314            return Some((rest.len() - value.len() + n, "[redacted-token]"));
315        }
316    }
317    None
318}
319
320/// Byte offset of the first `q` in `inner` that no backslash escapes.
321fn quoted_end(inner: &str, q: char) -> Option<usize> {
322    let mut chars = inner.char_indices();
323    while let Some((i, c)) = chars.next() {
324        if c == '\\' {
325            chars.next();
326        } else if c == q {
327            return Some(i);
328        }
329    }
330    None
331}
332
333fn credential(rest: &str) -> Option<(usize, &'static str)> {
334    if let Some(hit) = quoted_credential(rest) {
335        return Some(hit);
336    }
337    for key in [
338        "password=",
339        "token=",
340        "api_key=",
341        "api-key=",
342        "password: ",
343        "token: ",
344        "api_key: ",
345        "bearer ",
346        "password ",
347        "token ",
348        "api key ",
349    ] {
350        if rest
351            .get(..key.len())
352            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(key))
353        {
354            let tail = &rest[key.len()..];
355            let padding = tail.len() - tail.trim_start_matches([' ', '\'', '"']).len();
356            let value = &tail[padding..];
357            let n = run(value, |c| {
358                !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | '"' | '\'')
359            });
360            let contextual = matches!(key, "password " | "token " | "api key ");
361            let entropy = n >= 20
362                && value[..n].bytes().any(|b| b.is_ascii_digit())
363                && value[..n].bytes().any(|b| b.is_ascii_alphabetic());
364            if n > 0 && (!contextual || entropy) {
365                return Some((key.len() + padding + n, "[redacted-token]"));
366            }
367        }
368    }
369    None
370}
371
372fn absolute_path(rest: &str, prev: Option<char>) -> Option<(usize, &'static str)> {
373    if prev.is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | ':' | '.' | '~')) {
374        return None;
375    }
376    let b = rest.as_bytes();
377    let windows =
378        b.len() > 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && matches!(b[2], b'/' | b'\\');
379    // Known filesystem roots, rather than arbitrary slash-prefixed API routes.
380    let unix = [
381        "/tmp/",
382        "/private/",
383        "/var/",
384        "/etc/",
385        "/opt/",
386        "/srv/",
387        "/usr/",
388        "/mnt/",
389        "/Volumes/",
390    ]
391    .iter()
392    .any(|root| rest.starts_with(root));
393    if windows || unix {
394        let n = run(rest, |c| {
395            !c.is_whitespace() && !matches!(c, '`' | '"' | '\'' | '<' | '>')
396        });
397        return Some((n, "[redacted-path]"));
398    }
399    None
400}
401
402fn email(rest: &str) -> Option<(usize, &'static str)> {
403    let local = run(rest, is_email_local);
404    if local == 0 || !rest[local..].starts_with('@') {
405        return None;
406    }
407    let domain = &rest[local + 1..];
408    let mut end = 0;
409    let mut labels = 0;
410    loop {
411        let n = run(&domain[end..], |c| c.is_ascii_alphanumeric() || c == '-');
412        if n == 0 {
413            break;
414        }
415        end += n;
416        labels += 1;
417        if domain[end..].starts_with('.')
418            && run(&domain[end + 1..], |c| c.is_ascii_alphanumeric()) > 0
419        {
420            end += 1;
421        } else {
422            break;
423        }
424    }
425    (labels >= 2).then_some((local + 1 + end, "[redacted-email]"))
426}
427
428/// Only an immediately preceding marker counts, with no newline between it
429/// and the number. English markers require horizontal whitespace; `§` does not.
430fn section_reference(before: &str) -> bool {
431    let trimmed = before.trim_end_matches([' ', '\t']);
432    ["§", "section", "sec.", "clause"].iter().any(|marker| {
433        if *marker != "§" && trimmed.len() == before.len() {
434            return false;
435        }
436        let Some(start) = trimmed.len().checked_sub(marker.len()) else {
437            return false;
438        };
439        trimmed
440            .get(start..)
441            .is_some_and(|tail| tail.eq_ignore_ascii_case(marker))
442            && trimmed[..start]
443                .chars()
444                .next_back()
445                .is_none_or(|c| !c.is_alphanumeric() && !matches!(c, '_' | '-'))
446    })
447}
448
449fn ipv4(rest: &str) -> Option<(usize, &'static str)> {
450    let mut len = 0;
451    for part in 0..4 {
452        let s = &rest[len..];
453        let n = run(s, |c| c.is_ascii_digit());
454        if n == 0 || n > 3 || s[..n].parse::<u16>().ok()? > 255 {
455            return None;
456        }
457        len += n;
458        if part < 3 {
459            if !rest[len..].starts_with('.') {
460                return None;
461            }
462            len += 1;
463        }
464    }
465    let after = &rest[len..];
466    let mut chars = after.chars();
467    match chars.next() {
468        Some(c) if c.is_ascii_alphanumeric() => return None,
469        Some('.') if chars.next().is_some_and(|c| c.is_ascii_digit()) => return None,
470        _ => {}
471    }
472    Some((len, "[redacted-ip]"))
473}
474
475fn ipv6(rest: &str) -> Option<(usize, &'static str)> {
476    let mut n = run(rest, |c| c.is_ascii_hexdigit() || c == ':');
477    // A trailing single colon is punctuation, not part of the address.
478    while n > 0 && rest[..n].ends_with(':') && !rest[..n].ends_with("::") {
479        n -= 1;
480    }
481    let cand = &rest[..n];
482    let colons = cand.matches(':').count();
483    let shaped = (cand.contains("::") && colons >= 2) || colons == 7;
484    if !shaped
485        || !cand.bytes().any(|b| b.is_ascii_digit())
486        || cand.split(':').any(|g| g.len() > 4)
487        || rest[n..]
488            .chars()
489            .next()
490            .is_some_and(|c| c.is_ascii_alphanumeric())
491    {
492        return None;
493    }
494    Some((n, "[redacted-ip]"))
495}
496
497/// Plain words (and the bare mDNS suffix) that show up as a hostname or account
498/// name on some machines but are ordinary prose everywhere. Matched against the
499/// whole trimmed value only, so `devbox.local` is still a hostname.
500const GENERIC_IDENTITY_WORDS: [&str; 12] = [
501    "local",
502    "localhost",
503    "localdomain",
504    "lan",
505    "internal",
506    "user",
507    "host",
508    "admin",
509    "root",
510    "test",
511    "default",
512    "unknown",
513];
514
515fn is_generic_identity(w: &str) -> bool {
516    let w = w.strip_prefix('.').unwrap_or(w);
517    GENERIC_IDENTITY_WORDS
518        .iter()
519        .any(|g| g.eq_ignore_ascii_case(w))
520}
521
522/// True when the text before the cursor ends with `github.com/` as the start of
523/// a URL, i.e. the cursor is on the owner segment of a GitHub URL.
524fn github_owner_slot(before: &str) -> bool {
525    let Some(head) = strip_suffix_ci(before.as_bytes(), b"github.com/") else {
526        return false;
527    };
528    let head = strip_suffix_ci(head, b"www.").unwrap_or(head);
529    strip_suffix_ci(head, b"http://").is_some() || strip_suffix_ci(head, b"https://").is_some()
530}
531
532fn strip_suffix_ci<'a>(s: &'a [u8], suffix: &[u8]) -> Option<&'a [u8]> {
533    let start = s.len().checked_sub(suffix.len())?;
534    s[start..]
535        .eq_ignore_ascii_case(suffix)
536        .then_some(&s[..start])
537}
538
539fn identity_word(rest: &str, id: &Identity) -> Option<(usize, &'static str)> {
540    for (word, rep) in [(&id.user, "[redacted-user]"), (&id.host, "[redacted-host]")] {
541        let w = word.trim();
542        if w.len() < MIN_IDENTITY_WORD
543            || is_generic_identity(w)
544            || rest.len() < w.len()
545            || !rest.is_char_boundary(w.len())
546        {
547            continue;
548        }
549        if rest[..w.len()].eq_ignore_ascii_case(w)
550            && rest[w.len()..].chars().next().is_none_or(|c| !is_word(c))
551        {
552            return Some((w.len(), rep));
553        }
554    }
555    None
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    fn id() -> Identity {
563        Identity {
564            user: "alice".into(),
565            host: "buildbox".into(),
566            home: "/srv/people/alice".into(),
567        }
568    }
569
570    fn s(t: &str) -> String {
571        scrub(t, &id())
572    }
573
574    #[test]
575    fn locate_names_kind_and_line_without_the_text() {
576        let id = Identity::default();
577        let text = "fine\ntoken=abcdefghijklmnop1234 here\nmail a@b.example";
578        assert_eq!(locate(text, &id), vec![(2, "token"), (3, "email")]);
579    }
580
581    #[test]
582    fn quoted_credentials_honor_escaped_quotes() {
583        let out = s(r#"{"password":"prefix\"hunter2"} tail"#);
584        assert!(!out.contains("hunter2") && !out.contains("prefix"), "{out}");
585        assert!(out.ends_with("} tail"), "{out}");
586        // An even run of backslashes leaves the quote as the closer.
587        let out = s(r#"{"password":"a\\\\"} tail"#);
588        assert!(out.ends_with("} tail") && !out.contains("a\\"), "{out}");
589        // An odd run escapes it.
590        let out = s(r#"{"password":"a\\\"hunter2"} tail"#);
591        assert!(!out.contains("hunter2"), "{out}");
592        let out = s(r#"{'token':'x\'hunter2'} tail"#);
593        assert!(!out.contains("hunter2"), "{out}");
594        let out = s(r#"{"password":"日本\"語hunter2"} tail"#);
595        assert!(!out.contains("hunter2") && !out.contains('語'), "{out}");
596        // No closing quote: everything after is removed.
597        let out = s(r#"{"password":"abc\"hunter2"#);
598        assert!(!out.contains("hunter2"), "{out}");
599    }
600
601    #[test]
602    fn home_paths_collapse_and_keep_the_tail() {
603        assert_eq!(s("see /Users/bob/src/x.rs now"), "see ~/src/x.rs now");
604        assert_eq!(s("in /home/bob-1/.config"), "in ~/.config");
605        assert_eq!(s("at C:\\Users\\Bob\\proj\\a.rs"), "at ~\\proj\\a.rs");
606        assert_eq!(s("at C:/Users/Bob/proj"), "at ~/proj");
607        assert_eq!(s("/root/x and /root."), "~/x and ~.");
608        assert_eq!(s("/srv/people/alice/wt/a"), "~/wt/a");
609    }
610
611    #[test]
612    fn emails_ips_and_tokens() {
613        assert_eq!(s("mail dev.x+y@example.co.uk!"), "mail [redacted-email]!");
614        assert_eq!(s("host 192.168.0.12:8080"), "host [redacted-ip]:8080");
615        assert_eq!(
616            s("v6 fe80::1 and ::1"),
617            "v6 [redacted-ip] and [redacted-ip]"
618        );
619        assert_eq!(s("full 2001:db8:0:0:0:0:0:1."), "full [redacted-ip].");
620        assert_eq!(
621            s("tok ghp_abcdefghijklmnopqrstuv end"),
622            "tok [redacted-token] end"
623        );
624    }
625
626    #[test]
627    fn ipv4_section_references_pass_scrub_and_locate() {
628        for text in [
629            "§2.12.4.1-4.4",
630            "see §2.12.4.1",
631            "§ 2.12.4.1",
632            "section 2.12.4.1",
633            "sec. 2.12.4.1",
634            "clause 2.12.4.1",
635            "SECTION\t2.12.4.1",
636            "See (Sec. \t2.12.4.1)",
637            "CLAUSE 2.12.4.1",
638            "仕様 §\t2.12.4.1",
639            "released v1.2.3, version 0.41.1, 300.1.1.1",
640        ] {
641            assert_eq!(s(text), text, "{text}");
642            assert!(locate(text, &id()).is_empty(), "{text}");
643        }
644    }
645
646    #[test]
647    fn ipv4_section_markers_require_boundaries_and_same_line() {
648        for before in [
649            "subsection ",
650            "xsec. ",
651            "9clause ",
652            "_section ",
653            "-clause ",
654            "ésection ",
655            "字sec. ",
656            "x§ ",
657            "section\n",
658            "§\n ",
659            "clause\r\n\t",
660            "section\u{a0}",
661            "RFC ",
662        ] {
663            let text = format!("{before}2.12.4.1");
664            assert_eq!(s(&text), format!("{before}[redacted-ip]"), "{text}");
665            let line = 1 + before.matches('\n').count();
666            assert_eq!(locate(&text, &id()), vec![(line, "ip")], "{text}");
667        }
668        for before in ["section", "sec.", "clause", "é", "字"] {
669            assert!(!section_reference(before), "{before}");
670        }
671    }
672
673    #[test]
674    fn section_exception_keeps_other_sensitive_data_redacted() {
675        for (text, expected, hits) in [
676            (
677                "host 192.168.0.12:8080",
678                "host [redacted-ip]:8080",
679                vec![(1, "ip")],
680            ),
681            (
682                "/Users/bob 10.0.0.1 a@b.io alice",
683                "~ [redacted-ip] [redacted-email] [redacted-user]",
684                vec![(1, "home-path"), (1, "ip"), (1, "email"), (1, "user")],
685            ),
686            (
687                "connect to 10.1.2.3",
688                "connect to [redacted-ip]",
689                vec![(1, "ip")],
690            ),
691            (
692                "§2.12.4.1\nconnect to 10.1.2.3",
693                "§2.12.4.1\nconnect to [redacted-ip]",
694                vec![(2, "ip")],
695            ),
696            (
697                "section 2.12.4.1-10.1.2.3, 10.0.0.1",
698                "section 2.12.4.1-[redacted-ip], [redacted-ip]",
699                vec![(1, "ip"), (1, "ip")],
700            ),
701            (
702                "§fe80::1\nsection ::1\nclause 2001:db8:0:0:0:0:0:1",
703                "§[redacted-ip]\nsection [redacted-ip]\nclause [redacted-ip]",
704                vec![(1, "ip"), (2, "ip"), (3, "ip")],
705            ),
706        ] {
707            assert_eq!(s(text), expected, "{text}");
708            assert_eq!(locate(text, &id()), hits, "{text}");
709        }
710    }
711
712    #[test]
713    fn section_context_uses_original_input_and_does_not_rescan_replacements() {
714        let identity = Identity {
715            user: "redacted-ip".into(),
716            ..Identity::default()
717        };
718        let text = "§2.12.4.1\nconnect to 10.1.2.3";
719        assert_eq!(
720            scrub(text, &identity),
721            "§2.12.4.1\nconnect to [redacted-ip]"
722        );
723        assert_eq!(locate(text, &identity), vec![(2, "ip")]);
724        let identity = Identity {
725            user: "section".into(),
726            ..Identity::default()
727        };
728        assert_eq!(
729            scrub("section 2.12.4.1", &identity),
730            "[redacted-user] 2.12.4.1"
731        );
732        assert_eq!(locate("section 2.12.4.1", &identity), vec![(1, "user")]);
733    }
734
735    #[test]
736    fn identity_words_match_whole_words_only() {
737        assert_eq!(
738            s("by Alice on buildbox"),
739            "by [redacted-user] on [redacted-host]"
740        );
741        assert_eq!(
742            s("alicein wonderland, xbuildbox"),
743            "alicein wonderland, xbuildbox"
744        );
745    }
746
747    #[test]
748    fn github_url_owner_is_kept_but_other_mentions_are_not() {
749        assert_eq!(
750            s("https://github.com/alice/magi/pull/681 by alice"),
751            "https://github.com/alice/magi/pull/681 by [redacted-user]"
752        );
753        assert_eq!(
754            s("http://www.GitHub.com/Alice/x"),
755            "http://www.GitHub.com/Alice/x"
756        );
757        for t in [
758            "https://github.com.evil/alice",
759            "https://github.com/x/alice",
760            "git@github.com:alice/x",
761            "https://api.github.com/alice",
762            "github.com/alice",
763        ] {
764            assert!(s(t).contains("[redacted-user]"), "{t}");
765        }
766        let text = "https://github.com/alice/magi alice";
767        assert_eq!(locate(text, &id()), vec![(1, "user")]);
768    }
769
770    #[test]
771    fn github_owner_slot_checks_only_ascii_url_suffixes() {
772        for before in [
773            "https://github.com/",
774            "http://github.com/",
775            "https://www.github.com/",
776            "http://www.github.com/",
777            "HTTPS://GitHub.com/",
778            "HTTP://WWW.GitHub.com/",
779            "prose éhttps://github.com/",
780        ] {
781            assert!(github_owner_slot(before), "{before}");
782        }
783        for before in [
784            "https://api.github.com/",
785            "github.com/",
786            "www.github.com/",
787            "",
788            "git",
789            "égithub.com/",
790            "éwww.github.com/",
791            "é",
792        ] {
793            assert!(!github_owner_slot(before), "{before}");
794        }
795    }
796
797    #[test]
798    fn long_body_of_short_words_passes_scrub_and_locate() {
799        let text = "a ".repeat(32768);
800        let id = Identity::default();
801        assert_eq!(scrub(&text, &id), text);
802        assert!(locate(&text, &id).is_empty());
803    }
804
805    #[test]
806    fn generic_host_and_user_values_do_not_redact_prose() {
807        for host in ["local", ".local", "devbox.local"] {
808            let i = Identity {
809                host: host.into(),
810                ..Identity::default()
811            };
812            let t = "`WatchState::local` and local mode";
813            assert_eq!(scrub(t, &i), t, "{host}");
814        }
815        let real = Identity {
816            host: "devbox.local".into(),
817            ..Identity::default()
818        };
819        assert_eq!(
820            scrub("on devbox.local now", &real),
821            "on [redacted-host] now"
822        );
823        for (user, host) in [("local", ""), ("admin", "localhost")] {
824            let i = Identity {
825                user: user.into(),
826                host: host.into(),
827                ..Identity::default()
828            };
829            let t = "local admin localhost";
830            assert_eq!(scrub(t, &i), t);
831        }
832        let once = s("https://github.com/alice/x alice buildbox");
833        assert_eq!(scrub(&once, &id()), once);
834        assert_eq!(s("on buildbox"), "on [redacted-host]");
835    }
836
837    #[test]
838    fn dotted_code_paths_ending_in_local_are_not_hosts() {
839        for t in [
840            "`WatchState.local` is true",
841            "WatchState.lan and self.local",
842            "WatchState.local",
843        ] {
844            assert_eq!(scrub(t, &Identity::default()), t);
845        }
846        // Known trade-off: a CamelCase single-label host reads as code.
847        for t in ["ssh MyServer.lan", "JohnsMacBook.local"] {
848            assert_eq!(scrub(t, &Identity::default()), t);
849        }
850        for (t, want) in [
851            ("ssh m1air.loCal now", "ssh [redacted-host] now"),
852            ("ssh devbox.LOCAL", "ssh [redacted-host]"),
853            ("ssh box.InterNal", "ssh [redacted-host]"),
854            ("ssh box.LaN", "ssh [redacted-host]"),
855        ] {
856            assert_eq!(scrub(t, &Identity::default()), want, "{t}");
857        }
858        for t in [
859            "ssh devbox.local",
860            "ssh BUILD-SERVER.local",
861            "ssh Alices-MacBook-Pro.local",
862            "`devbox.local`",
863            "ssh BUILD.local",
864        ] {
865            assert!(
866                scrub(t, &Identity::default()).contains("[redacted-host]"),
867                "{t}"
868            );
869        }
870    }
871
872    #[test]
873    fn ordinary_text_is_untouched() {
874        for t in [
875            "Change src/graph.rs and tests/common/mod.rs.",
876            "See https://github.com/o/r/pull/12 for #12",
877            "returns std::io::Error, or a::b, Vec::new()",
878            "released v1.2.3, version 0.41.1, 300.1.1.1",
879            "thanks @coderabbitai; at 12:34:56 it ran",
880            "/api/v1/runs; docs/home/x and ./Users/y",
881            "A plain sentence about background and motivation.",
882            "日本語のテキスト 🎉 with émoji",
883        ] {
884            assert_eq!(s(t), t);
885        }
886    }
887
888    #[test]
889    fn multibyte_input_does_not_panic_and_replacement_is_not_rescanned() {
890        assert_eq!(
891            s("C:\\日本語 and C:/日本"),
892            "[redacted-path] and [redacted-path]"
893        );
894        assert_eq!(s("é/Users/bob/é 日本 alice"), "é~/é 日本 [redacted-user]");
895        let once = s("/Users/bob 10.0.0.1 a@b.io alice");
896        assert_eq!(scrub(&once, &Identity::default()), once);
897        // A user named like the replacement text cannot loop or re-match.
898        let odd = Identity {
899            user: "redacted".into(),
900            ..Identity::default()
901        };
902        assert_eq!(scrub("redacted x", &odd), "[redacted-user] x");
903    }
904}