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