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//! A few conventional public system paths also pass the absolute-path rule;
16//! configured homes and known identity words within them are still scrubbed.
17
18/// The local facts worth hiding. `Default` is "nothing known", which leaves only
19/// the pattern-based rules.
20#[derive(Debug, Clone, Default)]
21pub struct Identity {
22    /// Local account name.
23    pub user: String,
24    /// Machine hostname.
25    pub host: String,
26    /// Home directory path.
27    pub home: String,
28}
29
30impl Identity {
31    /// Read the current account, host and home directory. The only I/O here.
32    pub fn current() -> Self {
33        let env = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty());
34        let host = env("HOSTNAME")
35            .or_else(|| env("COMPUTERNAME"))
36            .or_else(|| {
37                std::fs::read_to_string("/etc/hostname")
38                    .ok()
39                    .map(|s| s.trim().to_owned())
40                    .filter(|s| !s.is_empty())
41            })
42            .unwrap_or_default();
43        Self {
44            user: env("USER").or_else(|| env("USERNAME")).unwrap_or_default(),
45            host,
46            home: dirs::home_dir()
47                .map(|p| p.to_string_lossy().into_owned())
48                .unwrap_or_default(),
49        }
50    }
51}
52
53const TOKEN_PREFIXES: [&str; 9] = [
54    "AKIA",
55    "github_pat_",
56    "ghp_",
57    "gho_",
58    "ghs_",
59    "ghu_",
60    "sk-",
61    "xoxb-",
62    "xoxp-",
63];
64const TOKEN_MIN_TAIL: usize = 16;
65/// Identity words shorter than this are too likely to be ordinary prose.
66const MIN_IDENTITY_WORD: usize = 3;
67
68fn is_word(c: char) -> bool {
69    c.is_ascii_alphanumeric() || c == '_' || c == '-'
70}
71
72fn is_name(c: char) -> bool {
73    c.is_alphanumeric() || matches!(c, '_' | '-' | '.')
74}
75
76fn is_email_local(c: char) -> bool {
77    c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '%' | '+' | '-')
78}
79
80/// A path component: the name run minus trailing dots, which are punctuation.
81fn name_len(s: &str) -> usize {
82    s[..run(s, is_name)].trim_end_matches('.').len()
83}
84
85/// Length in bytes of the leading run of `s` whose chars satisfy `f`.
86fn run(s: &str, f: impl Fn(char) -> bool) -> usize {
87    s.char_indices()
88        .find(|&(_, c)| !f(c))
89        .map_or(s.len(), |(i, _)| i)
90}
91
92/// Replace machine- and operator-identifying data in `text`. Pure.
93pub fn scrub(text: &str, id: &Identity) -> String {
94    let mut out = String::with_capacity(text.len());
95    let mut i = 0;
96    while i < text.len() {
97        if let Some((len, rep)) = match_at(&text[..i], &text[i..], id) {
98            out.push_str(rep);
99            i += len;
100        } else {
101            let c = text[i..].chars().next().expect("i is on a char boundary");
102            out.push(c);
103            i += c.len_utf8();
104        }
105    }
106    out
107}
108
109/// Where [`scrub`] would replace something: `(line, kind)` per match, lines
110/// counted from 1. Never carries the matched text. Pure.
111pub fn locate(text: &str, id: &Identity) -> Vec<(usize, &'static str)> {
112    let mut hits = Vec::new();
113    let mut i = 0;
114    let mut line = 1;
115    while i < text.len() {
116        if let Some((len, rep)) = match_at(&text[..i], &text[i..], id) {
117            let kind = match rep {
118                "~" => "home-path",
119                r => r
120                    .trim_start_matches('[')
121                    .trim_end_matches(']')
122                    .trim_start_matches("redacted-"),
123            };
124            hits.push((line, kind));
125            line += text[i..i + len].matches('\n').count();
126            i += len;
127        } else {
128            let c = text[i..].chars().next().expect("i is on a char boundary");
129            if c == '\n' {
130                line += 1;
131            }
132            i += c.len_utf8();
133        }
134    }
135    hits
136}
137
138fn match_at(before: &str, rest: &str, id: &Identity) -> Option<(usize, &'static str)> {
139    let prev = before.chars().next_back();
140    let starts_word = prev.is_none_or(|p| !is_word(p));
141    home_path(rest, prev, id)
142        .or_else(|| {
143            starts_word
144                .then(|| {
145                    token(rest).or_else(|| credential(rest)).or_else(|| {
146                        prev.is_none_or(|c| !is_name(c))
147                            .then(|| named_identity(rest, prev))
148                            .flatten()
149                    })
150                })
151                .flatten()
152        })
153        .or_else(|| absolute_path(rest, prev))
154        .or_else(|| {
155            prev.is_none_or(|p| !is_email_local(p))
156                .then(|| email(rest))
157                .flatten()
158        })
159        .or_else(|| {
160            prev.is_none_or(|p| !p.is_ascii_alphanumeric() && p != '.' && p != ':')
161                .then(|| {
162                    ipv4(rest)
163                        .filter(|_| !section_reference(before))
164                        .or_else(|| ipv6(rest))
165                })
166                .flatten()
167        })
168        .or_else(|| {
169            (starts_word && !github_owner_slot(before))
170                .then(|| identity_word(rest, id))
171                .flatten()
172        })
173}
174
175/// `/Users/x`, `/home/x`, `/root`, `C:\Users\x`, `C:/Users/x` and the literal
176/// home directory, each collapsed to `~` so the repo-relative tail survives.
177fn home_path(rest: &str, prev: Option<char>, id: &Identity) -> Option<(usize, &'static str)> {
178    if prev.is_some_and(|p| p.is_ascii_alphanumeric() || matches!(p, '.' | '_' | '-' | '~')) {
179        return None;
180    }
181    if id.home.len() > 1 && rest.starts_with(id.home.as_str()) {
182        let tail = &rest[id.home.len()..];
183        if tail.chars().next().is_none_or(|c| !is_name(c)) {
184            return Some((id.home.len(), "~"));
185        }
186    }
187    for base in ["/Users/", "/home/"] {
188        if let Some(tail) = rest.strip_prefix(base) {
189            let n = name_len(tail);
190            if n > 0 {
191                return Some((base.len() + n, "~"));
192            }
193        }
194    }
195    if let Some(tail) = rest.strip_prefix("/root")
196        && tail.chars().next().is_none_or(|c| !is_word(c))
197    {
198        return Some(("/root".len(), "~"));
199    }
200    let b = rest.as_bytes();
201    if b.len() > 9
202        && b[0].is_ascii_alphabetic()
203        && b[1] == b':'
204        && matches!(b[2], b'\\' | b'/')
205        && b[3..8].eq_ignore_ascii_case(b"users")
206        && matches!(b[8], b'\\' | b'/')
207    {
208        let n = name_len(&rest[9..]);
209        if n > 0 {
210            return Some((9 + n, "~"));
211        }
212    }
213    None
214}
215
216fn token(rest: &str) -> Option<(usize, &'static str)> {
217    let prefix = TOKEN_PREFIXES.iter().find(|p| rest.starts_with(**p))?;
218    let tail = run(&rest[prefix.len()..], is_word);
219    (tail >= TOKEN_MIN_TAIL).then_some((prefix.len() + tail, "[redacted-token]"))
220}
221
222/// Explicit assignments avoid guessing whether an ordinary word is an account.
223fn named_identity(rest: &str, prev: Option<char>) -> Option<(usize, &'static str)> {
224    for key in [
225        "hostname=",
226        "hostname: ",
227        "username=",
228        "username: ",
229        "user=",
230        "host=",
231    ] {
232        if rest
233            .get(..key.len())
234            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(key))
235        {
236            let n = run(&rest[key.len()..], is_name);
237            if n > 0 {
238                return Some((key.len() + n, "[redacted-identity]"));
239            }
240        }
241    }
242    let n = name_len(rest);
243    if n > 0
244        && !code_receiver(&rest[..n], prev == Some('`') && rest[n..].starts_with('`'))
245        && [".local", ".internal", ".lan"].iter().any(|suffix| {
246            n.checked_sub(suffix.len())
247                .and_then(|start| rest.get(start..n))
248                .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix))
249        })
250    {
251        return Some((n, "[redacted-host]"));
252    }
253    None
254}
255
256/// A dotted name that reads as a code path: one dot, no hyphen, and either
257/// a `self.` / `this.` receiver, a CamelCase one, or a lowercase ASCII
258/// identifier containing an underscore or directly enclosed in backticks.
259/// Only the receiver is inspected, so the suffix's case never matters.
260/// Unknown CamelCase hosts and backticked alphabetic hosts also read as code;
261/// the operator's known hostname is still caught by `identity_word`.
262fn code_receiver(name: &str, backticked: bool) -> 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 lowercase_identifier =
273        !receiver.is_empty() && receiver.chars().all(|c| c.is_ascii_lowercase() || c == '_');
274    if lowercase_identifier && (backticked || receiver.contains('_')) {
275        return true;
276    }
277    let mut prev_lower = false;
278    receiver.chars().any(|c| {
279        let hump = prev_lower && c.is_ascii_uppercase();
280        prev_lower = c.is_ascii_lowercase();
281        hump
282    })
283}
284
285/// `"password": "value"` and `'token':'value'`: the key is quoted, so the
286/// unquoted `key=` / `key: ` forms below never match.
287fn quoted_credential(rest: &str) -> Option<(usize, &'static str)> {
288    for key in [
289        "password", "token", "api_key", "api-key", "apikey", "secret",
290    ] {
291        let Some(after) = rest
292            .get(..key.len())
293            .filter(|p| p.eq_ignore_ascii_case(key))
294            .map(|_| &rest[key.len()..])
295        else {
296            continue;
297        };
298        let Some(after_quote) = after.strip_prefix(['"', '\'']) else {
299            continue;
300        };
301        let t = after_quote.trim_start();
302        let Some(t) = t.strip_prefix([':', '=']) else {
303            continue;
304        };
305        let t = t.trim_start();
306        // A quoted value runs to its closing quote and may hold spaces or commas.
307        let n = match t.chars().next() {
308            Some(q @ ('"' | '\'')) => {
309                let inner = &t[1..];
310                let end = quoted_end(inner, q).unwrap_or(inner.len());
311                1 + end + usize::from(end < inner.len())
312            }
313            _ => run(t, |c| {
314                !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | ',' | '}')
315            }),
316        };
317        let value = t;
318        if n > 0 {
319            return Some((rest.len() - value.len() + n, "[redacted-token]"));
320        }
321    }
322    None
323}
324
325/// Byte offset of the first `q` in `inner` that no backslash escapes.
326fn quoted_end(inner: &str, q: char) -> Option<usize> {
327    let mut chars = inner.char_indices();
328    while let Some((i, c)) = chars.next() {
329        if c == '\\' {
330            chars.next();
331        } else if c == q {
332            return Some(i);
333        }
334    }
335    None
336}
337
338fn credential(rest: &str) -> Option<(usize, &'static str)> {
339    if let Some(hit) = quoted_credential(rest) {
340        return Some(hit);
341    }
342    for key in [
343        "password=",
344        "token=",
345        "api_key=",
346        "api-key=",
347        "password: ",
348        "token: ",
349        "api_key: ",
350        "bearer ",
351        "password ",
352        "token ",
353        "api key ",
354    ] {
355        if rest
356            .get(..key.len())
357            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(key))
358        {
359            let tail = &rest[key.len()..];
360            let padding = tail.len() - tail.trim_start_matches([' ', '\'', '"']).len();
361            let value = &tail[padding..];
362            let n = run(value, |c| {
363                !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | '"' | '\'')
364            });
365            let contextual = matches!(key, "password " | "token " | "api key ");
366            let entropy = n >= 20
367                && value[..n].bytes().any(|b| b.is_ascii_digit())
368                && value[..n].bytes().any(|b| b.is_ascii_alphabetic());
369            if n > 0 && (!contextual || entropy) {
370                return Some((key.len() + padding + n, "[redacted-token]"));
371            }
372        }
373    }
374    None
375}
376
377fn absolute_path(rest: &str, prev: Option<char>) -> Option<(usize, &'static str)> {
378    if prev.is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | ':' | '.' | '~')) {
379        return None;
380    }
381    let b = rest.as_bytes();
382    let windows =
383        b.len() > 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && matches!(b[2], b'/' | b'\\');
384    // Known filesystem roots, rather than arbitrary slash-prefixed API routes.
385    let unix = [
386        "/tmp/",
387        "/private/",
388        "/var/",
389        "/etc/",
390        "/opt/",
391        "/srv/",
392        "/usr/",
393        "/mnt/",
394        "/Volumes/",
395    ]
396    .iter()
397    .any(|root| rest.starts_with(root));
398    if windows || unix {
399        let n = run(rest, |c| {
400            !c.is_whitespace() && !matches!(c, '`' | '"' | '\'' | '<' | '>')
401        });
402        if unix && public_system_path(&rest[..n]) {
403            return None;
404        }
405        return Some((n, "[redacted-path]"));
406    }
407    None
408}
409
410fn public_system_path(path: &str) -> bool {
411    // Exact public locations, with no variable account/host/checkout/mount slot:
412    // /etc/hosts is the standard host table; /usr/local/bin is the command
413    // directory and foo a literal example command; /var/log/syslog is the
414    // standard system log; /var/run/kanade/agent.sock is the documented service
415    // endpoint. Do not generalize to arbitrary command, log or service names:
416    // those may identify an operator even when Identity does not know the name.
417    // Home rules retain priority and other identity rules still scan these paths.
418    // Ignore prose punctuation only for this check, preserving the original
419    // redaction boundary. Exact matching rejects traversal and extra levels.
420    matches!(
421        path.trim_end_matches(['.', ',', ';', ':', '!', '?', ')', ']']),
422        "/etc/hosts"
423            | "/usr/local/bin"
424            | "/usr/local/bin/foo"
425            | "/var/log/syslog"
426            | "/var/run/kanade/agent.sock"
427    )
428}
429
430fn email(rest: &str) -> Option<(usize, &'static str)> {
431    let local = run(rest, is_email_local);
432    if local == 0 || !rest[local..].starts_with('@') {
433        return None;
434    }
435    let domain = &rest[local + 1..];
436    let mut end = 0;
437    let mut labels = 0;
438    loop {
439        let n = run(&domain[end..], |c| c.is_ascii_alphanumeric() || c == '-');
440        if n == 0 {
441            break;
442        }
443        end += n;
444        labels += 1;
445        if domain[end..].starts_with('.')
446            && run(&domain[end + 1..], |c| c.is_ascii_alphanumeric()) > 0
447        {
448            end += 1;
449        } else {
450            break;
451        }
452    }
453    (labels >= 2).then_some((local + 1 + end, "[redacted-email]"))
454}
455
456/// Only an immediately preceding marker counts, with no newline between it
457/// and the number. English markers require horizontal whitespace; `§` does not.
458fn section_reference(before: &str) -> bool {
459    let trimmed = before.trim_end_matches([' ', '\t']);
460    ["§", "section", "sec.", "clause"].iter().any(|marker| {
461        if *marker != "§" && trimmed.len() == before.len() {
462            return false;
463        }
464        let Some(start) = trimmed.len().checked_sub(marker.len()) else {
465            return false;
466        };
467        trimmed
468            .get(start..)
469            .is_some_and(|tail| tail.eq_ignore_ascii_case(marker))
470            && trimmed[..start]
471                .chars()
472                .next_back()
473                .is_none_or(|c| !c.is_alphanumeric() && !matches!(c, '_' | '-'))
474    })
475}
476
477fn ipv4(rest: &str) -> Option<(usize, &'static str)> {
478    let mut len = 0;
479    for part in 0..4 {
480        let s = &rest[len..];
481        let n = run(s, |c| c.is_ascii_digit());
482        if n == 0 || n > 3 || s[..n].parse::<u16>().ok()? > 255 {
483            return None;
484        }
485        len += n;
486        if part < 3 {
487            if !rest[len..].starts_with('.') {
488                return None;
489            }
490            len += 1;
491        }
492    }
493    let after = &rest[len..];
494    let mut chars = after.chars();
495    match chars.next() {
496        Some(c) if c.is_ascii_alphanumeric() => return None,
497        Some('.') if chars.next().is_some_and(|c| c.is_ascii_digit()) => return None,
498        _ => {}
499    }
500    Some((len, "[redacted-ip]"))
501}
502
503fn ipv6(rest: &str) -> Option<(usize, &'static str)> {
504    let mut n = run(rest, |c| c.is_ascii_hexdigit() || c == ':');
505    // A trailing single colon is punctuation, not part of the address.
506    while n > 0 && rest[..n].ends_with(':') && !rest[..n].ends_with("::") {
507        n -= 1;
508    }
509    let cand = &rest[..n];
510    let colons = cand.matches(':').count();
511    let shaped = (cand.contains("::") && colons >= 2) || colons == 7;
512    if !shaped
513        || !cand.bytes().any(|b| b.is_ascii_digit())
514        || cand.split(':').any(|g| g.len() > 4)
515        || rest[n..]
516            .chars()
517            .next()
518            .is_some_and(|c| c.is_ascii_alphanumeric())
519    {
520        return None;
521    }
522    Some((n, "[redacted-ip]"))
523}
524
525/// Plain words (and the bare mDNS suffix) that show up as a hostname or account
526/// name on some machines but are ordinary prose everywhere. Matched against the
527/// whole trimmed value only, so `devbox.local` is still a hostname.
528const GENERIC_IDENTITY_WORDS: [&str; 12] = [
529    "local",
530    "localhost",
531    "localdomain",
532    "lan",
533    "internal",
534    "user",
535    "host",
536    "admin",
537    "root",
538    "test",
539    "default",
540    "unknown",
541];
542
543fn is_generic_identity(w: &str) -> bool {
544    let w = w.strip_prefix('.').unwrap_or(w);
545    GENERIC_IDENTITY_WORDS
546        .iter()
547        .any(|g| g.eq_ignore_ascii_case(w))
548}
549
550/// True when the text before the cursor ends with `github.com/` as the start of
551/// a URL, i.e. the cursor is on the owner segment of a GitHub URL.
552fn github_owner_slot(before: &str) -> bool {
553    let Some(head) = strip_suffix_ci(before.as_bytes(), b"github.com/") else {
554        return false;
555    };
556    let head = strip_suffix_ci(head, b"www.").unwrap_or(head);
557    strip_suffix_ci(head, b"http://").is_some() || strip_suffix_ci(head, b"https://").is_some()
558}
559
560fn strip_suffix_ci<'a>(s: &'a [u8], suffix: &[u8]) -> Option<&'a [u8]> {
561    let start = s.len().checked_sub(suffix.len())?;
562    s[start..]
563        .eq_ignore_ascii_case(suffix)
564        .then_some(&s[..start])
565}
566
567fn identity_word(rest: &str, id: &Identity) -> Option<(usize, &'static str)> {
568    for (word, rep) in [(&id.user, "[redacted-user]"), (&id.host, "[redacted-host]")] {
569        let w = word.trim();
570        if w.len() < MIN_IDENTITY_WORD
571            || is_generic_identity(w)
572            || rest.len() < w.len()
573            || !rest.is_char_boundary(w.len())
574        {
575            continue;
576        }
577        if rest[..w.len()].eq_ignore_ascii_case(w)
578            && rest[w.len()..].chars().next().is_none_or(|c| !is_word(c))
579        {
580            return Some((w.len(), rep));
581        }
582    }
583    None
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    fn id() -> Identity {
591        Identity {
592            user: "alice".into(),
593            host: "buildbox".into(),
594            home: "/srv/people/alice".into(),
595        }
596    }
597
598    fn s(t: &str) -> String {
599        scrub(t, &id())
600    }
601
602    #[test]
603    fn locate_names_kind_and_line_without_the_text() {
604        let id = Identity::default();
605        let text = "fine\ntoken=abcdefghijklmnop1234 here\nmail a@b.example";
606        assert_eq!(locate(text, &id), vec![(2, "token"), (3, "email")]);
607    }
608
609    #[test]
610    fn quoted_credentials_honor_escaped_quotes() {
611        let out = s(r#"{"password":"prefix\"hunter2"} tail"#);
612        assert!(!out.contains("hunter2") && !out.contains("prefix"), "{out}");
613        assert!(out.ends_with("} tail"), "{out}");
614        // An even run of backslashes leaves the quote as the closer.
615        let out = s(r#"{"password":"a\\\\"} tail"#);
616        assert!(out.ends_with("} tail") && !out.contains("a\\"), "{out}");
617        // An odd run escapes it.
618        let out = s(r#"{"password":"a\\\"hunter2"} tail"#);
619        assert!(!out.contains("hunter2"), "{out}");
620        let out = s(r#"{'token':'x\'hunter2'} tail"#);
621        assert!(!out.contains("hunter2"), "{out}");
622        let out = s(r#"{"password":"日本\"語hunter2"} tail"#);
623        assert!(!out.contains("hunter2") && !out.contains('語'), "{out}");
624        // No closing quote: everything after is removed.
625        let out = s(r#"{"password":"abc\"hunter2"#);
626        assert!(!out.contains("hunter2"), "{out}");
627    }
628
629    #[test]
630    fn home_paths_collapse_and_keep_the_tail() {
631        assert_eq!(s("see /Users/bob/src/x.rs now"), "see ~/src/x.rs now");
632        assert_eq!(s("in /home/bob-1/.config"), "in ~/.config");
633        assert_eq!(s("at C:\\Users\\Bob\\proj\\a.rs"), "at ~\\proj\\a.rs");
634        assert_eq!(s("at C:/Users/Bob/proj"), "at ~/proj");
635        assert_eq!(s("/root/x and /root."), "~/x and ~.");
636        assert_eq!(s("/srv/people/alice/wt/a"), "~/wt/a");
637    }
638
639    #[test]
640    fn public_system_paths_pass_scrub_and_locate() {
641        for path in [
642            "/run/kanade/agent.sock",
643            "/var/run/kanade/agent.sock",
644            "/etc/hosts",
645            "/usr/local/bin",
646            "/usr/local/bin/foo",
647            "/var/log/syslog",
648        ] {
649            for text in [
650                path.to_owned(),
651                format!("uses `{path}` on this platform"),
652                format!("uses \"{path}\" on this platform"),
653                format!("See ({path}), then {path}."),
654            ] {
655                assert_eq!(s(&text), text, "{text}");
656                assert!(locate(&text, &id()).is_empty(), "{text}");
657            }
658        }
659    }
660
661    #[test]
662    fn private_paths_and_non_public_shapes_still_redact() {
663        let identity = Identity::default();
664        for path in [
665            "/Volumes/disk-name/project",
666            "/Volumes/disk name/project",
667            "/srv/people/alice/wt/a",
668            "/tmp/checkout/build",
669            "/private/var/folders/ab/account/build",
670            "/var/folders/ab/account/build",
671            "/mnt/disk/project",
672            "/usr/home/bob/project",
673            "/opt/checkout/build",
674            "/etc/hosts/private",
675            "/etc/hostname",
676            "/etc/hosts-backup",
677            "/usr/local/bin/foo/private",
678            "/usr/local/bin/../private",
679            "/usr/local/bin/..",
680            "/usr/local/bin/./foo",
681            "/usr/local/bin//foo",
682            "/usr/local/bin/foo/",
683            "/var/log/../private",
684            "/var/log/service/private",
685            "/var/run/agent.sock",
686            "/var/run/service/agent.pid",
687            "/var/run/service/.sock",
688            "/var/run/../agent.sock",
689            "/var/run/service/../agent.sock",
690            "/var/run//agent.sock",
691            r"/var/log/foo\private",
692            r"C:\build\checkout",
693        ] {
694            assert!(
695                scrub(path, &identity).starts_with("[redacted-path]"),
696                "{path}"
697            );
698            assert_eq!(locate(path, &identity), vec![(1, "path")], "{path}");
699        }
700        for (text, expected) in [
701            ("/Users/bob/src/x", "~/src/x"),
702            ("/home/bob/.config", "~/.config"),
703            ("C:/Users/Bob/proj", "~/proj"),
704            (r"C:\Users\Bob\proj", r"~\proj"),
705            ("/srv/people/alice/wt/a", "~/wt/a"),
706        ] {
707            assert_eq!(s(text), expected, "{text}");
708            assert_eq!(locate(text, &id()), vec![(1, "home-path")], "{text}");
709        }
710        // The existing whitespace consumption boundary stays unchanged.
711        assert_eq!(
712            scrub("/Volumes/disk name/project", &identity),
713            "[redacted-path] name/project"
714        );
715        for text in [
716            "/unknown/checkout/build",
717            "/api/v1/runs",
718            "/run/service/agent.sock",
719        ] {
720            assert_eq!(scrub(text, &identity), text);
721            assert!(locate(text, &identity).is_empty());
722        }
723    }
724
725    #[test]
726    fn public_paths_keep_home_and_identity_rules_and_do_not_rescan() {
727        let identity = Identity {
728            home: "/usr/local/bin".into(),
729            ..id()
730        };
731        assert_eq!(scrub("/usr/local/bin/foo", &identity), "~/foo");
732        assert_eq!(
733            locate("/usr/local/bin/foo", &identity),
734            vec![(1, "home-path")]
735        );
736        for path in [
737            "/usr/local/bin/alice",
738            "/usr/local/bin/alice-build",
739            "/usr/local/bin/bob",
740            "/var/log/buildbox.log",
741            "/var/log/alice-build.log",
742            "/var/run/alice/agent.sock",
743            "/var/run/alice-agent/agent.sock",
744            "/var/run/bob/agent.sock",
745            "/var/log/box.local",
746        ] {
747            for identity in [id(), Identity::default()] {
748                assert_eq!(scrub(path, &identity), "[redacted-path]", "{path}");
749                assert_eq!(locate(path, &identity), vec![(1, "path")], "{path}");
750            }
751        }
752        let identity = Identity {
753            user: "foo".into(),
754            host: "kanade".into(),
755            ..Identity::default()
756        };
757        for (text, expected, kind) in [
758            (
759                "/usr/local/bin/foo",
760                "/usr/local/bin/[redacted-user]",
761                "user",
762            ),
763            (
764                "/var/run/kanade/agent.sock",
765                "/var/run/[redacted-host]/agent.sock",
766                "host",
767            ),
768        ] {
769            assert_eq!(scrub(text, &identity), expected);
770            assert_eq!(locate(text, &identity), vec![(1, kind)]);
771        }
772        let identity = Identity {
773            user: "redacted".into(),
774            host: "redacted-path".into(),
775            ..Identity::default()
776        };
777        let text = "/tmp/checkout redacted /usr/local/bin/foo";
778        assert_eq!(
779            scrub(text, &identity),
780            "[redacted-path] [redacted-user] /usr/local/bin/foo"
781        );
782        assert_eq!(locate(text, &identity), vec![(1, "path"), (1, "user")]);
783    }
784
785    #[test]
786    fn emails_ips_and_tokens() {
787        assert_eq!(s("mail dev.x+y@example.co.uk!"), "mail [redacted-email]!");
788        assert_eq!(s("host 192.168.0.12:8080"), "host [redacted-ip]:8080");
789        assert_eq!(
790            s("v6 fe80::1 and ::1"),
791            "v6 [redacted-ip] and [redacted-ip]"
792        );
793        assert_eq!(s("full 2001:db8:0:0:0:0:0:1."), "full [redacted-ip].");
794        assert_eq!(
795            s("tok ghp_abcdefghijklmnopqrstuv end"),
796            "tok [redacted-token] end"
797        );
798    }
799
800    #[test]
801    fn ipv4_section_references_pass_scrub_and_locate() {
802        for text in [
803            "§2.12.4.1-4.4",
804            "see §2.12.4.1",
805            "§ 2.12.4.1",
806            "section 2.12.4.1",
807            "sec. 2.12.4.1",
808            "clause 2.12.4.1",
809            "SECTION\t2.12.4.1",
810            "See (Sec. \t2.12.4.1)",
811            "CLAUSE 2.12.4.1",
812            "仕様 §\t2.12.4.1",
813            "released v1.2.3, version 0.41.1, 300.1.1.1",
814        ] {
815            assert_eq!(s(text), text, "{text}");
816            assert!(locate(text, &id()).is_empty(), "{text}");
817        }
818    }
819
820    #[test]
821    fn ipv4_section_markers_require_boundaries_and_same_line() {
822        for before in [
823            "subsection ",
824            "xsec. ",
825            "9clause ",
826            "_section ",
827            "-clause ",
828            "ésection ",
829            "字sec. ",
830            "x§ ",
831            "section\n",
832            "§\n ",
833            "clause\r\n\t",
834            "section\u{a0}",
835            "RFC ",
836        ] {
837            let text = format!("{before}2.12.4.1");
838            assert_eq!(s(&text), format!("{before}[redacted-ip]"), "{text}");
839            let line = 1 + before.matches('\n').count();
840            assert_eq!(locate(&text, &id()), vec![(line, "ip")], "{text}");
841        }
842        for before in ["section", "sec.", "clause", "é", "字"] {
843            assert!(!section_reference(before), "{before}");
844        }
845    }
846
847    #[test]
848    fn section_exception_keeps_other_sensitive_data_redacted() {
849        for (text, expected, hits) in [
850            (
851                "host 192.168.0.12:8080",
852                "host [redacted-ip]:8080",
853                vec![(1, "ip")],
854            ),
855            (
856                "/Users/bob 10.0.0.1 a@b.io alice",
857                "~ [redacted-ip] [redacted-email] [redacted-user]",
858                vec![(1, "home-path"), (1, "ip"), (1, "email"), (1, "user")],
859            ),
860            (
861                "connect to 10.1.2.3",
862                "connect to [redacted-ip]",
863                vec![(1, "ip")],
864            ),
865            (
866                "§2.12.4.1\nconnect to 10.1.2.3",
867                "§2.12.4.1\nconnect to [redacted-ip]",
868                vec![(2, "ip")],
869            ),
870            (
871                "section 2.12.4.1-10.1.2.3, 10.0.0.1",
872                "section 2.12.4.1-[redacted-ip], [redacted-ip]",
873                vec![(1, "ip"), (1, "ip")],
874            ),
875            (
876                "§fe80::1\nsection ::1\nclause 2001:db8:0:0:0:0:0:1",
877                "§[redacted-ip]\nsection [redacted-ip]\nclause [redacted-ip]",
878                vec![(1, "ip"), (2, "ip"), (3, "ip")],
879            ),
880        ] {
881            assert_eq!(s(text), expected, "{text}");
882            assert_eq!(locate(text, &id()), hits, "{text}");
883        }
884    }
885
886    #[test]
887    fn section_context_uses_original_input_and_does_not_rescan_replacements() {
888        let identity = Identity {
889            user: "redacted-ip".into(),
890            ..Identity::default()
891        };
892        let text = "§2.12.4.1\nconnect to 10.1.2.3";
893        assert_eq!(
894            scrub(text, &identity),
895            "§2.12.4.1\nconnect to [redacted-ip]"
896        );
897        assert_eq!(locate(text, &identity), vec![(2, "ip")]);
898        let identity = Identity {
899            user: "section".into(),
900            ..Identity::default()
901        };
902        assert_eq!(
903            scrub("section 2.12.4.1", &identity),
904            "[redacted-user] 2.12.4.1"
905        );
906        assert_eq!(locate("section 2.12.4.1", &identity), vec![(1, "user")]);
907    }
908
909    #[test]
910    fn identity_words_match_whole_words_only() {
911        assert_eq!(
912            s("by Alice on buildbox"),
913            "by [redacted-user] on [redacted-host]"
914        );
915        assert_eq!(
916            s("alicein wonderland, xbuildbox"),
917            "alicein wonderland, xbuildbox"
918        );
919    }
920
921    #[test]
922    fn github_url_owner_is_kept_but_other_mentions_are_not() {
923        assert_eq!(
924            s("https://github.com/alice/magi/pull/681 by alice"),
925            "https://github.com/alice/magi/pull/681 by [redacted-user]"
926        );
927        assert_eq!(
928            s("http://www.GitHub.com/Alice/x"),
929            "http://www.GitHub.com/Alice/x"
930        );
931        for t in [
932            "https://github.com.evil/alice",
933            "https://github.com/x/alice",
934            "git@github.com:alice/x",
935            "https://api.github.com/alice",
936            "github.com/alice",
937        ] {
938            assert!(s(t).contains("[redacted-user]"), "{t}");
939        }
940        let text = "https://github.com/alice/magi alice";
941        assert_eq!(locate(text, &id()), vec![(1, "user")]);
942    }
943
944    #[test]
945    fn github_owner_slot_checks_only_ascii_url_suffixes() {
946        for before in [
947            "https://github.com/",
948            "http://github.com/",
949            "https://www.github.com/",
950            "http://www.github.com/",
951            "HTTPS://GitHub.com/",
952            "HTTP://WWW.GitHub.com/",
953            "prose éhttps://github.com/",
954        ] {
955            assert!(github_owner_slot(before), "{before}");
956        }
957        for before in [
958            "https://api.github.com/",
959            "github.com/",
960            "www.github.com/",
961            "",
962            "git",
963            "égithub.com/",
964            "éwww.github.com/",
965            "é",
966        ] {
967            assert!(!github_owner_slot(before), "{before}");
968        }
969    }
970
971    #[test]
972    fn long_body_of_short_words_passes_scrub_and_locate() {
973        let text = "a ".repeat(32768);
974        let id = Identity::default();
975        assert_eq!(scrub(&text, &id), text);
976        assert!(locate(&text, &id).is_empty());
977    }
978
979    #[test]
980    fn generic_host_and_user_values_do_not_redact_prose() {
981        for host in ["local", ".local", "devbox.local"] {
982            let i = Identity {
983                host: host.into(),
984                ..Identity::default()
985            };
986            let t = "`WatchState::local` and local mode";
987            assert_eq!(scrub(t, &i), t, "{host}");
988        }
989        let real = Identity {
990            host: "devbox.local".into(),
991            ..Identity::default()
992        };
993        assert_eq!(
994            scrub("on devbox.local now", &real),
995            "on [redacted-host] now"
996        );
997        for (user, host) in [("local", ""), ("admin", "localhost")] {
998            let i = Identity {
999                user: user.into(),
1000                host: host.into(),
1001                ..Identity::default()
1002            };
1003            let t = "local admin localhost";
1004            assert_eq!(scrub(t, &i), t);
1005        }
1006        let once = s("https://github.com/alice/x alice buildbox");
1007        assert_eq!(scrub(&once, &id()), once);
1008        assert_eq!(s("on buildbox"), "on [redacted-host]");
1009    }
1010
1011    #[test]
1012    fn dotted_code_paths_ending_in_local_are_not_hosts() {
1013        for t in [
1014            "`WatchState.local` is true",
1015            "WatchState.lan and self.local",
1016            "WatchState.local",
1017        ] {
1018            assert_eq!(scrub(t, &Identity::default()), t);
1019        }
1020        // Known trade-off: a CamelCase single-label host reads as code.
1021        for t in ["ssh MyServer.lan", "JohnsMacBook.local"] {
1022            assert_eq!(scrub(t, &Identity::default()), t);
1023        }
1024        for (t, want) in [
1025            ("ssh m1air.loCal now", "ssh [redacted-host] now"),
1026            ("ssh devbox.LOCAL", "ssh [redacted-host]"),
1027            ("ssh box.InterNal", "ssh [redacted-host]"),
1028            ("ssh box.LaN", "ssh [redacted-host]"),
1029        ] {
1030            assert_eq!(scrub(t, &Identity::default()), want, "{t}");
1031        }
1032        for t in [
1033            "ssh devbox.local",
1034            "ssh BUILD-SERVER.local",
1035            "ssh Alices-MacBook-Pro.local",
1036            "ssh BUILD.local",
1037        ] {
1038            assert!(
1039                scrub(t, &Identity::default()).contains("[redacted-host]"),
1040                "{t}"
1041            );
1042        }
1043    }
1044
1045    #[test]
1046    fn lowercase_field_receivers_pass_scrub_and_locate() {
1047        let id = Identity::default();
1048        for text in [
1049            "`st.local` is true",
1050            "`cfg.local`",
1051            "`ctx.lan`",
1052            "`env.internal`",
1053            "my_state.local",
1054            "`my_state.local`",
1055            "`st.local`, then continue.",
1056            "`devbox.local`",
1057        ] {
1058            assert_eq!(scrub(text, &id), text, "{text}");
1059            assert!(locate(text, &id).is_empty(), "{text}");
1060        }
1061    }
1062
1063    #[test]
1064    fn field_receiver_exception_keeps_host_redaction() {
1065        let id = Identity::default();
1066        for text in [
1067            "st.local",
1068            "cfg.local",
1069            "devbox.local",
1070            "box.lan",
1071            "`st.local",
1072            "st.local`",
1073            "`st.local is true`",
1074            "`st.local.`",
1075            "`box1.local`",
1076            "`build-box.local`",
1077            "`box.example.local`",
1078        ] {
1079            assert!(scrub(text, &id).contains("[redacted-host]"), "{text}");
1080            assert_eq!(locate(text, &id), vec![(1, "host")], "{text}");
1081        }
1082        for text in ["host=st.local", "`host=cfg.local`"] {
1083            assert!(scrub(text, &id).contains("[redacted-identity]"), "{text}");
1084            assert_eq!(locate(text, &id), vec![(1, "identity")], "{text}");
1085        }
1086        let known = Identity {
1087            host: "devbox.local".into(),
1088            ..Identity::default()
1089        };
1090        let text = "`devbox.local`";
1091        assert_eq!(scrub(text, &known), "`[redacted-host]`");
1092        assert_eq!(locate(text, &known), vec![(1, "host")]);
1093    }
1094
1095    #[test]
1096    fn ordinary_text_is_untouched() {
1097        for t in [
1098            "Change src/graph.rs and tests/common/mod.rs.",
1099            "See https://github.com/o/r/pull/12 for #12",
1100            "returns std::io::Error, or a::b, Vec::new()",
1101            "released v1.2.3, version 0.41.1, 300.1.1.1",
1102            "thanks @coderabbitai; at 12:34:56 it ran",
1103            "/api/v1/runs; docs/home/x and ./Users/y",
1104            "A plain sentence about background and motivation.",
1105            "日本語のテキスト 🎉 with émoji",
1106        ] {
1107            assert_eq!(s(t), t);
1108        }
1109    }
1110
1111    #[test]
1112    fn multibyte_input_does_not_panic_and_replacement_is_not_rescanned() {
1113        assert_eq!(
1114            s("C:\\日本語 and C:/日本"),
1115            "[redacted-path] and [redacted-path]"
1116        );
1117        assert_eq!(s("é/Users/bob/é 日本 alice"), "é~/é 日本 [redacted-user]");
1118        let once = s("/Users/bob 10.0.0.1 a@b.io alice");
1119        assert_eq!(scrub(&once, &Identity::default()), once);
1120        // A user named like the replacement text cannot loop or re-match.
1121        let odd = Identity {
1122            user: "redacted".into(),
1123            ..Identity::default()
1124        };
1125        assert_eq!(scrub("redacted x", &odd), "[redacted-user] x");
1126    }
1127}