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
299            .strip_prefix("\\\"")
300            .or_else(|| after.strip_prefix(['"', '\'']))
301        else {
302            continue;
303        };
304        let t = after_quote.trim_start();
305        let Some(t) = t.strip_prefix([':', '=']) else {
306            continue;
307        };
308        let t = t.trim_start();
309        // A quoted value runs to its closing quote and may hold spaces or commas.
310        let n = quoted_value_len(t).unwrap_or_else(|| {
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
336/// Include the delimiters and all escaped content, even in a JSON-encoded
337/// representation whose delimiters are themselves escaped. An unclosed value
338/// consumes the rest rather than leaving a secret's suffix behind.
339fn quoted_value_len(value: &str) -> Option<usize> {
340    if let Some(inner) = value.strip_prefix("\\\"") {
341        let mut slashes = 0;
342        for (i, c) in inner.char_indices() {
343            if c == '\\' {
344                slashes += 1;
345                continue;
346            }
347            // One JSON encoding layer doubles backslashes. A delimiter has
348            // 4k+1 slashes; an escaped quote within the value has 4k+3.
349            if c == '"' && slashes % 4 == 1 {
350                return Some(2 + i + 1);
351            }
352            slashes = 0;
353        }
354        return Some(value.len());
355    }
356    let q = value.chars().next().filter(|c| matches!(c, '"' | '\''))?;
357    let inner = &value[1..];
358    let end = quoted_end(inner, q).unwrap_or(inner.len());
359    Some(1 + end + usize::from(end < inner.len()))
360}
361
362fn credential(rest: &str) -> Option<(usize, &'static str)> {
363    if let Some(hit) = quoted_credential(rest) {
364        return Some(hit);
365    }
366    for key in [
367        "password=",
368        "token=",
369        "api_key=",
370        "api-key=",
371        "password: ",
372        "token: ",
373        "api_key: ",
374        "bearer ",
375        "password ",
376        "token ",
377        "api key ",
378    ] {
379        if rest
380            .get(..key.len())
381            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(key))
382        {
383            let tail = &rest[key.len()..];
384            let padding = tail.len() - tail.trim_start_matches(' ').len();
385            let value = &tail[padding..];
386            let n = quoted_value_len(value).unwrap_or_else(|| {
387                run(value, |c| {
388                    !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | '"' | '\'')
389                })
390            });
391            let contextual = matches!(key, "password " | "token " | "api key ");
392            let entropy_value = value.trim_start_matches(['\'', '"']);
393            let entropy_len = run(entropy_value, |c| {
394                !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | '"' | '\'')
395            });
396            let entropy = entropy_len >= 20
397                && entropy_value[..entropy_len]
398                    .bytes()
399                    .any(|b| b.is_ascii_digit())
400                && entropy_value[..entropy_len]
401                    .bytes()
402                    .any(|b| b.is_ascii_alphabetic());
403            if n > 0 && (!contextual || entropy) {
404                return Some((key.len() + padding + n, "[redacted-token]"));
405            }
406        }
407    }
408    None
409}
410
411fn absolute_path(rest: &str, prev: Option<char>) -> Option<(usize, &'static str)> {
412    if prev.is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | ':' | '.' | '~')) {
413        return None;
414    }
415    let b = rest.as_bytes();
416    let windows =
417        b.len() > 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && matches!(b[2], b'/' | b'\\');
418    // Known filesystem roots, rather than arbitrary slash-prefixed API routes.
419    let unix = [
420        "/tmp/",
421        "/private/",
422        "/var/",
423        "/etc/",
424        "/opt/",
425        "/srv/",
426        "/usr/",
427        "/mnt/",
428        "/Volumes/",
429    ]
430    .iter()
431    .any(|root| rest.starts_with(root));
432    if windows || unix {
433        let n = run(rest, |c| {
434            !c.is_whitespace() && !matches!(c, '`' | '"' | '\'' | '<' | '>')
435        });
436        if unix && public_system_path(&rest[..n]) {
437            return None;
438        }
439        return Some((n, "[redacted-path]"));
440    }
441    None
442}
443
444fn public_system_path(path: &str) -> bool {
445    // Exact public locations, with no variable account/host/checkout/mount slot:
446    // /etc/hosts is the standard host table; /usr/local/bin is the command
447    // directory and foo a literal example command; /var/log/syslog is the
448    // standard system log; /var/run/kanade/agent.sock is the documented service
449    // endpoint. Do not generalize to arbitrary command, log or service names:
450    // those may identify an operator even when Identity does not know the name.
451    // Home rules retain priority and other identity rules still scan these paths.
452    // Ignore prose punctuation only for this check, preserving the original
453    // redaction boundary. Exact matching rejects traversal and extra levels.
454    matches!(
455        path.trim_end_matches(['.', ',', ';', ':', '!', '?', ')', ']']),
456        "/etc/hosts"
457            | "/usr/local/bin"
458            | "/usr/local/bin/foo"
459            | "/var/log/syslog"
460            | "/var/run/kanade/agent.sock"
461    )
462}
463
464fn email(rest: &str) -> Option<(usize, &'static str)> {
465    let local = run(rest, is_email_local);
466    if local == 0 || !rest[local..].starts_with('@') {
467        return None;
468    }
469    let domain = &rest[local + 1..];
470    let mut end = 0;
471    let mut labels = 0;
472    loop {
473        let n = run(&domain[end..], |c| c.is_ascii_alphanumeric() || c == '-');
474        if n == 0 {
475            break;
476        }
477        end += n;
478        labels += 1;
479        if domain[end..].starts_with('.')
480            && run(&domain[end + 1..], |c| c.is_ascii_alphanumeric()) > 0
481        {
482            end += 1;
483        } else {
484            break;
485        }
486    }
487    (labels >= 2).then_some((local + 1 + end, "[redacted-email]"))
488}
489
490/// Only an immediately preceding marker counts, with no newline between it
491/// and the number. English markers require horizontal whitespace; `§` does not.
492fn section_reference(before: &str) -> bool {
493    let trimmed = before.trim_end_matches([' ', '\t']);
494    ["§", "section", "sec.", "clause"].iter().any(|marker| {
495        if *marker != "§" && trimmed.len() == before.len() {
496            return false;
497        }
498        let Some(start) = trimmed.len().checked_sub(marker.len()) else {
499            return false;
500        };
501        trimmed
502            .get(start..)
503            .is_some_and(|tail| tail.eq_ignore_ascii_case(marker))
504            && trimmed[..start]
505                .chars()
506                .next_back()
507                .is_none_or(|c| !c.is_alphanumeric() && !matches!(c, '_' | '-'))
508    })
509}
510
511fn ipv4(rest: &str) -> Option<(usize, &'static str)> {
512    let mut len = 0;
513    for part in 0..4 {
514        let s = &rest[len..];
515        let n = run(s, |c| c.is_ascii_digit());
516        if n == 0 || n > 3 || s[..n].parse::<u16>().ok()? > 255 {
517            return None;
518        }
519        len += n;
520        if part < 3 {
521            if !rest[len..].starts_with('.') {
522                return None;
523            }
524            len += 1;
525        }
526    }
527    let after = &rest[len..];
528    let mut chars = after.chars();
529    match chars.next() {
530        Some(c) if c.is_ascii_alphanumeric() => return None,
531        Some('.') if chars.next().is_some_and(|c| c.is_ascii_digit()) => return None,
532        _ => {}
533    }
534    Some((len, "[redacted-ip]"))
535}
536
537fn ipv6(rest: &str) -> Option<(usize, &'static str)> {
538    let mut n = run(rest, |c| c.is_ascii_hexdigit() || c == ':');
539    // A trailing single colon is punctuation, not part of the address.
540    while n > 0 && rest[..n].ends_with(':') && !rest[..n].ends_with("::") {
541        n -= 1;
542    }
543    let cand = &rest[..n];
544    let colons = cand.matches(':').count();
545    let shaped = (cand.contains("::") && colons >= 2) || colons == 7;
546    if !shaped
547        || !cand.bytes().any(|b| b.is_ascii_digit())
548        || cand.split(':').any(|g| g.len() > 4)
549        || rest[n..]
550            .chars()
551            .next()
552            .is_some_and(|c| c.is_ascii_alphanumeric())
553    {
554        return None;
555    }
556    Some((n, "[redacted-ip]"))
557}
558
559/// Plain words (and the bare mDNS suffix) that show up as a hostname or account
560/// name on some machines but are ordinary prose everywhere. Matched against the
561/// whole trimmed value only, so `devbox.local` is still a hostname.
562const GENERIC_IDENTITY_WORDS: [&str; 12] = [
563    "local",
564    "localhost",
565    "localdomain",
566    "lan",
567    "internal",
568    "user",
569    "host",
570    "admin",
571    "root",
572    "test",
573    "default",
574    "unknown",
575];
576
577fn is_generic_identity(w: &str) -> bool {
578    let w = w.strip_prefix('.').unwrap_or(w);
579    GENERIC_IDENTITY_WORDS
580        .iter()
581        .any(|g| g.eq_ignore_ascii_case(w))
582}
583
584/// True when the text before the cursor ends with `github.com/` as the start of
585/// a URL, i.e. the cursor is on the owner segment of a GitHub URL.
586fn github_owner_slot(before: &str) -> bool {
587    let Some(head) = strip_suffix_ci(before.as_bytes(), b"github.com/") else {
588        return false;
589    };
590    let head = strip_suffix_ci(head, b"www.").unwrap_or(head);
591    strip_suffix_ci(head, b"http://").is_some() || strip_suffix_ci(head, b"https://").is_some()
592}
593
594fn strip_suffix_ci<'a>(s: &'a [u8], suffix: &[u8]) -> Option<&'a [u8]> {
595    let start = s.len().checked_sub(suffix.len())?;
596    s[start..]
597        .eq_ignore_ascii_case(suffix)
598        .then_some(&s[..start])
599}
600
601fn identity_word(rest: &str, id: &Identity) -> Option<(usize, &'static str)> {
602    for (word, rep) in [(&id.user, "[redacted-user]"), (&id.host, "[redacted-host]")] {
603        let w = word.trim();
604        if w.len() < MIN_IDENTITY_WORD
605            || is_generic_identity(w)
606            || rest.len() < w.len()
607            || !rest.is_char_boundary(w.len())
608        {
609            continue;
610        }
611        if rest[..w.len()].eq_ignore_ascii_case(w)
612            && rest[w.len()..].chars().next().is_none_or(|c| !is_word(c))
613        {
614            return Some((w.len(), rep));
615        }
616    }
617    None
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    fn id() -> Identity {
625        Identity {
626            user: "alice".into(),
627            host: "buildbox".into(),
628            home: "/srv/people/alice".into(),
629        }
630    }
631
632    fn s(t: &str) -> String {
633        scrub(t, &id())
634    }
635
636    #[test]
637    fn locate_names_kind_and_line_without_the_text() {
638        let id = Identity::default();
639        let text = "fine\ntoken=abcdefghijklmnop1234 here\nmail a@b.example";
640        assert_eq!(locate(text, &id), vec![(2, "token"), (3, "email")]);
641    }
642
643    #[test]
644    fn quoted_credentials_honor_escaped_quotes() {
645        let out = s(r#"{"password":"prefix\"hunter2"} tail"#);
646        assert!(!out.contains("hunter2") && !out.contains("prefix"), "{out}");
647        assert!(out.ends_with("} tail"), "{out}");
648        // An even run of backslashes leaves the quote as the closer.
649        let out = s(r#"{"password":"a\\\\"} tail"#);
650        assert!(out.ends_with("} tail") && !out.contains("a\\"), "{out}");
651        // An odd run escapes it.
652        let out = s(r#"{"password":"a\\\"hunter2"} tail"#);
653        assert!(!out.contains("hunter2"), "{out}");
654        let out = s(r#"{'token':'x\'hunter2'} tail"#);
655        assert!(!out.contains("hunter2"), "{out}");
656        let out = s(r#"{"password":"日本\"語hunter2"} tail"#);
657        assert!(!out.contains("hunter2") && !out.contains('語'), "{out}");
658        // No closing quote: everything after is removed.
659        let out = s(r#"{"password":"abc\"hunter2"#);
660        assert!(!out.contains("hunter2"), "{out}");
661    }
662
663    #[test]
664    fn unquoted_keys_redact_the_whole_quoted_value() {
665        for text in [
666            r#"password="prefix\"secret tail" safe"#,
667            r#"token: "prefix\\\"secret tail" safe"#,
668            r#"api_key='prefix\'secret tail' safe"#,
669            r#"password="prefix\\" safe"#,
670            r#"password="prefix\"secret tail"#,
671        ] {
672            let out = s(text);
673            assert!(!out.contains("prefix") && !out.contains("secret"), "{out}");
674            if text.ends_with(" safe") {
675                assert_eq!(out, "[redacted-token] safe");
676            } else {
677                assert_eq!(out, "[redacted-token]");
678            }
679        }
680        // Context words keep their existing entropy requirement.
681        assert_eq!(
682            s(r#"token "ordinary words" safe"#),
683            r#"token "ordinary words" safe"#
684        );
685        assert_eq!(
686            s(r#"token "abcdefghijklmnopq1" safe"#),
687            r#"token "abcdefghijklmnopq1" safe"#
688        );
689        assert_eq!(
690            s(r#"token "abcdefghijklmnopqrst1\"secret tail" safe"#),
691            "[redacted-token] safe"
692        );
693    }
694
695    #[test]
696    fn json_encoded_credentials_redact_escaped_delimiters_and_content() {
697        for slashes in 0..5 {
698            for secret in [
699                format!("prefix{}\"secret tail", "\\".repeat(slashes)),
700                format!("prefix secret{}", "\\".repeat(slashes)),
701            ] {
702                let json = format!(
703                    "{{\"password\":{},\"note\":\"safe\"}}",
704                    serde_json::to_string(&secret).unwrap()
705                );
706                let encoded = serde_json::to_string(&json).unwrap();
707                for text in [json, encoded] {
708                    let out = s(&text);
709                    assert!(!out.contains("prefix") && !out.contains("secret"), "{out}");
710                    assert!(out.contains("safe"), "{out}");
711                }
712            }
713        }
714        for text in [
715            r#"{\"token\": \"prefix\\\"secret tail"#,
716            r#"password=\"prefix\\\"secret tail"#,
717        ] {
718            let out = s(text);
719            assert!(out.ends_with("[redacted-token]"), "{out}");
720            assert!(!out.contains("prefix") && !out.contains("secret"), "{out}");
721        }
722    }
723
724    #[test]
725    fn home_paths_collapse_and_keep_the_tail() {
726        assert_eq!(s("see /Users/bob/src/x.rs now"), "see ~/src/x.rs now");
727        assert_eq!(s("in /home/bob-1/.config"), "in ~/.config");
728        assert_eq!(s("at C:\\Users\\Bob\\proj\\a.rs"), "at ~\\proj\\a.rs");
729        assert_eq!(s("at C:/Users/Bob/proj"), "at ~/proj");
730        assert_eq!(s("/root/x and /root."), "~/x and ~.");
731        assert_eq!(s("/srv/people/alice/wt/a"), "~/wt/a");
732    }
733
734    #[test]
735    fn public_system_paths_pass_scrub_and_locate() {
736        for path in [
737            "/run/kanade/agent.sock",
738            "/var/run/kanade/agent.sock",
739            "/etc/hosts",
740            "/usr/local/bin",
741            "/usr/local/bin/foo",
742            "/var/log/syslog",
743        ] {
744            for text in [
745                path.to_owned(),
746                format!("uses `{path}` on this platform"),
747                format!("uses \"{path}\" on this platform"),
748                format!("See ({path}), then {path}."),
749            ] {
750                assert_eq!(s(&text), text, "{text}");
751                assert!(locate(&text, &id()).is_empty(), "{text}");
752            }
753        }
754    }
755
756    #[test]
757    fn private_paths_and_non_public_shapes_still_redact() {
758        let identity = Identity::default();
759        for path in [
760            "/Volumes/disk-name/project",
761            "/Volumes/disk name/project",
762            "/srv/people/alice/wt/a",
763            "/tmp/checkout/build",
764            "/private/var/folders/ab/account/build",
765            "/var/folders/ab/account/build",
766            "/mnt/disk/project",
767            "/usr/home/bob/project",
768            "/opt/checkout/build",
769            "/etc/hosts/private",
770            "/etc/hostname",
771            "/etc/hosts-backup",
772            "/usr/local/bin/foo/private",
773            "/usr/local/bin/../private",
774            "/usr/local/bin/..",
775            "/usr/local/bin/./foo",
776            "/usr/local/bin//foo",
777            "/usr/local/bin/foo/",
778            "/var/log/../private",
779            "/var/log/service/private",
780            "/var/run/agent.sock",
781            "/var/run/service/agent.pid",
782            "/var/run/service/.sock",
783            "/var/run/../agent.sock",
784            "/var/run/service/../agent.sock",
785            "/var/run//agent.sock",
786            r"/var/log/foo\private",
787            r"C:\build\checkout",
788        ] {
789            assert!(
790                scrub(path, &identity).starts_with("[redacted-path]"),
791                "{path}"
792            );
793            assert_eq!(locate(path, &identity), vec![(1, "path")], "{path}");
794        }
795        for (text, expected) in [
796            ("/Users/bob/src/x", "~/src/x"),
797            ("/home/bob/.config", "~/.config"),
798            ("C:/Users/Bob/proj", "~/proj"),
799            (r"C:\Users\Bob\proj", r"~\proj"),
800            ("/srv/people/alice/wt/a", "~/wt/a"),
801        ] {
802            assert_eq!(s(text), expected, "{text}");
803            assert_eq!(locate(text, &id()), vec![(1, "home-path")], "{text}");
804        }
805        // The existing whitespace consumption boundary stays unchanged.
806        assert_eq!(
807            scrub("/Volumes/disk name/project", &identity),
808            "[redacted-path] name/project"
809        );
810        for text in [
811            "/unknown/checkout/build",
812            "/api/v1/runs",
813            "/run/service/agent.sock",
814        ] {
815            assert_eq!(scrub(text, &identity), text);
816            assert!(locate(text, &identity).is_empty());
817        }
818    }
819
820    #[test]
821    fn public_paths_keep_home_and_identity_rules_and_do_not_rescan() {
822        let identity = Identity {
823            home: "/usr/local/bin".into(),
824            ..id()
825        };
826        assert_eq!(scrub("/usr/local/bin/foo", &identity), "~/foo");
827        assert_eq!(
828            locate("/usr/local/bin/foo", &identity),
829            vec![(1, "home-path")]
830        );
831        for path in [
832            "/usr/local/bin/alice",
833            "/usr/local/bin/alice-build",
834            "/usr/local/bin/bob",
835            "/var/log/buildbox.log",
836            "/var/log/alice-build.log",
837            "/var/run/alice/agent.sock",
838            "/var/run/alice-agent/agent.sock",
839            "/var/run/bob/agent.sock",
840            "/var/log/box.local",
841        ] {
842            for identity in [id(), Identity::default()] {
843                assert_eq!(scrub(path, &identity), "[redacted-path]", "{path}");
844                assert_eq!(locate(path, &identity), vec![(1, "path")], "{path}");
845            }
846        }
847        let identity = Identity {
848            user: "foo".into(),
849            host: "kanade".into(),
850            ..Identity::default()
851        };
852        for (text, expected, kind) in [
853            (
854                "/usr/local/bin/foo",
855                "/usr/local/bin/[redacted-user]",
856                "user",
857            ),
858            (
859                "/var/run/kanade/agent.sock",
860                "/var/run/[redacted-host]/agent.sock",
861                "host",
862            ),
863        ] {
864            assert_eq!(scrub(text, &identity), expected);
865            assert_eq!(locate(text, &identity), vec![(1, kind)]);
866        }
867        let identity = Identity {
868            user: "redacted".into(),
869            host: "redacted-path".into(),
870            ..Identity::default()
871        };
872        let text = "/tmp/checkout redacted /usr/local/bin/foo";
873        assert_eq!(
874            scrub(text, &identity),
875            "[redacted-path] [redacted-user] /usr/local/bin/foo"
876        );
877        assert_eq!(locate(text, &identity), vec![(1, "path"), (1, "user")]);
878    }
879
880    #[test]
881    fn emails_ips_and_tokens() {
882        assert_eq!(s("mail dev.x+y@example.co.uk!"), "mail [redacted-email]!");
883        assert_eq!(s("host 192.168.0.12:8080"), "host [redacted-ip]:8080");
884        assert_eq!(
885            s("v6 fe80::1 and ::1"),
886            "v6 [redacted-ip] and [redacted-ip]"
887        );
888        assert_eq!(s("full 2001:db8:0:0:0:0:0:1."), "full [redacted-ip].");
889        assert_eq!(
890            s("tok ghp_abcdefghijklmnopqrstuv end"),
891            "tok [redacted-token] end"
892        );
893    }
894
895    #[test]
896    fn ipv4_section_references_pass_scrub_and_locate() {
897        for text in [
898            "§2.12.4.1-4.4",
899            "see §2.12.4.1",
900            "§ 2.12.4.1",
901            "section 2.12.4.1",
902            "sec. 2.12.4.1",
903            "clause 2.12.4.1",
904            "SECTION\t2.12.4.1",
905            "See (Sec. \t2.12.4.1)",
906            "CLAUSE 2.12.4.1",
907            "仕様 §\t2.12.4.1",
908            "released v1.2.3, version 0.41.1, 300.1.1.1",
909        ] {
910            assert_eq!(s(text), text, "{text}");
911            assert!(locate(text, &id()).is_empty(), "{text}");
912        }
913    }
914
915    #[test]
916    fn ipv4_section_markers_require_boundaries_and_same_line() {
917        for before in [
918            "subsection ",
919            "xsec. ",
920            "9clause ",
921            "_section ",
922            "-clause ",
923            "ésection ",
924            "字sec. ",
925            "x§ ",
926            "section\n",
927            "§\n ",
928            "clause\r\n\t",
929            "section\u{a0}",
930            "RFC ",
931        ] {
932            let text = format!("{before}2.12.4.1");
933            assert_eq!(s(&text), format!("{before}[redacted-ip]"), "{text}");
934            let line = 1 + before.matches('\n').count();
935            assert_eq!(locate(&text, &id()), vec![(line, "ip")], "{text}");
936        }
937        for before in ["section", "sec.", "clause", "é", "字"] {
938            assert!(!section_reference(before), "{before}");
939        }
940    }
941
942    #[test]
943    fn section_exception_keeps_other_sensitive_data_redacted() {
944        for (text, expected, hits) in [
945            (
946                "host 192.168.0.12:8080",
947                "host [redacted-ip]:8080",
948                vec![(1, "ip")],
949            ),
950            (
951                "/Users/bob 10.0.0.1 a@b.io alice",
952                "~ [redacted-ip] [redacted-email] [redacted-user]",
953                vec![(1, "home-path"), (1, "ip"), (1, "email"), (1, "user")],
954            ),
955            (
956                "connect to 10.1.2.3",
957                "connect to [redacted-ip]",
958                vec![(1, "ip")],
959            ),
960            (
961                "§2.12.4.1\nconnect to 10.1.2.3",
962                "§2.12.4.1\nconnect to [redacted-ip]",
963                vec![(2, "ip")],
964            ),
965            (
966                "section 2.12.4.1-10.1.2.3, 10.0.0.1",
967                "section 2.12.4.1-[redacted-ip], [redacted-ip]",
968                vec![(1, "ip"), (1, "ip")],
969            ),
970            (
971                "§fe80::1\nsection ::1\nclause 2001:db8:0:0:0:0:0:1",
972                "§[redacted-ip]\nsection [redacted-ip]\nclause [redacted-ip]",
973                vec![(1, "ip"), (2, "ip"), (3, "ip")],
974            ),
975        ] {
976            assert_eq!(s(text), expected, "{text}");
977            assert_eq!(locate(text, &id()), hits, "{text}");
978        }
979    }
980
981    #[test]
982    fn section_context_uses_original_input_and_does_not_rescan_replacements() {
983        let identity = Identity {
984            user: "redacted-ip".into(),
985            ..Identity::default()
986        };
987        let text = "§2.12.4.1\nconnect to 10.1.2.3";
988        assert_eq!(
989            scrub(text, &identity),
990            "§2.12.4.1\nconnect to [redacted-ip]"
991        );
992        assert_eq!(locate(text, &identity), vec![(2, "ip")]);
993        let identity = Identity {
994            user: "section".into(),
995            ..Identity::default()
996        };
997        assert_eq!(
998            scrub("section 2.12.4.1", &identity),
999            "[redacted-user] 2.12.4.1"
1000        );
1001        assert_eq!(locate("section 2.12.4.1", &identity), vec![(1, "user")]);
1002    }
1003
1004    #[test]
1005    fn identity_words_match_whole_words_only() {
1006        assert_eq!(
1007            s("by Alice on buildbox"),
1008            "by [redacted-user] on [redacted-host]"
1009        );
1010        assert_eq!(
1011            s("alicein wonderland, xbuildbox"),
1012            "alicein wonderland, xbuildbox"
1013        );
1014    }
1015
1016    #[test]
1017    fn github_url_owner_is_kept_but_other_mentions_are_not() {
1018        assert_eq!(
1019            s("https://github.com/alice/magi/pull/681 by alice"),
1020            "https://github.com/alice/magi/pull/681 by [redacted-user]"
1021        );
1022        assert_eq!(
1023            s("http://www.GitHub.com/Alice/x"),
1024            "http://www.GitHub.com/Alice/x"
1025        );
1026        for t in [
1027            "https://github.com.evil/alice",
1028            "https://github.com/x/alice",
1029            "git@github.com:alice/x",
1030            "https://api.github.com/alice",
1031            "github.com/alice",
1032        ] {
1033            assert!(s(t).contains("[redacted-user]"), "{t}");
1034        }
1035        let text = "https://github.com/alice/magi alice";
1036        assert_eq!(locate(text, &id()), vec![(1, "user")]);
1037    }
1038
1039    #[test]
1040    fn github_owner_slot_checks_only_ascii_url_suffixes() {
1041        for before in [
1042            "https://github.com/",
1043            "http://github.com/",
1044            "https://www.github.com/",
1045            "http://www.github.com/",
1046            "HTTPS://GitHub.com/",
1047            "HTTP://WWW.GitHub.com/",
1048            "prose éhttps://github.com/",
1049        ] {
1050            assert!(github_owner_slot(before), "{before}");
1051        }
1052        for before in [
1053            "https://api.github.com/",
1054            "github.com/",
1055            "www.github.com/",
1056            "",
1057            "git",
1058            "égithub.com/",
1059            "éwww.github.com/",
1060            "é",
1061        ] {
1062            assert!(!github_owner_slot(before), "{before}");
1063        }
1064    }
1065
1066    #[test]
1067    fn long_body_of_short_words_passes_scrub_and_locate() {
1068        let text = "a ".repeat(32768);
1069        let id = Identity::default();
1070        assert_eq!(scrub(&text, &id), text);
1071        assert!(locate(&text, &id).is_empty());
1072    }
1073
1074    #[test]
1075    fn generic_host_and_user_values_do_not_redact_prose() {
1076        for host in ["local", ".local", "devbox.local"] {
1077            let i = Identity {
1078                host: host.into(),
1079                ..Identity::default()
1080            };
1081            let t = "`WatchState::local` and local mode";
1082            assert_eq!(scrub(t, &i), t, "{host}");
1083        }
1084        let real = Identity {
1085            host: "devbox.local".into(),
1086            ..Identity::default()
1087        };
1088        assert_eq!(
1089            scrub("on devbox.local now", &real),
1090            "on [redacted-host] now"
1091        );
1092        for (user, host) in [("local", ""), ("admin", "localhost")] {
1093            let i = Identity {
1094                user: user.into(),
1095                host: host.into(),
1096                ..Identity::default()
1097            };
1098            let t = "local admin localhost";
1099            assert_eq!(scrub(t, &i), t);
1100        }
1101        let once = s("https://github.com/alice/x alice buildbox");
1102        assert_eq!(scrub(&once, &id()), once);
1103        assert_eq!(s("on buildbox"), "on [redacted-host]");
1104    }
1105
1106    #[test]
1107    fn dotted_code_paths_ending_in_local_are_not_hosts() {
1108        for t in [
1109            "`WatchState.local` is true",
1110            "WatchState.lan and self.local",
1111            "WatchState.local",
1112        ] {
1113            assert_eq!(scrub(t, &Identity::default()), t);
1114        }
1115        // Known trade-off: a CamelCase single-label host reads as code.
1116        for t in ["ssh MyServer.lan", "JohnsMacBook.local"] {
1117            assert_eq!(scrub(t, &Identity::default()), t);
1118        }
1119        for (t, want) in [
1120            ("ssh m1air.loCal now", "ssh [redacted-host] now"),
1121            ("ssh devbox.LOCAL", "ssh [redacted-host]"),
1122            ("ssh box.InterNal", "ssh [redacted-host]"),
1123            ("ssh box.LaN", "ssh [redacted-host]"),
1124        ] {
1125            assert_eq!(scrub(t, &Identity::default()), want, "{t}");
1126        }
1127        for t in [
1128            "ssh devbox.local",
1129            "ssh BUILD-SERVER.local",
1130            "ssh Alices-MacBook-Pro.local",
1131            "ssh BUILD.local",
1132        ] {
1133            assert!(
1134                scrub(t, &Identity::default()).contains("[redacted-host]"),
1135                "{t}"
1136            );
1137        }
1138    }
1139
1140    #[test]
1141    fn lowercase_field_receivers_pass_scrub_and_locate() {
1142        let id = Identity::default();
1143        for text in [
1144            "`st.local` is true",
1145            "`cfg.local`",
1146            "`ctx.lan`",
1147            "`env.internal`",
1148            "my_state.local",
1149            "`my_state.local`",
1150            "`st.local`, then continue.",
1151            "`devbox.local`",
1152        ] {
1153            assert_eq!(scrub(text, &id), text, "{text}");
1154            assert!(locate(text, &id).is_empty(), "{text}");
1155        }
1156    }
1157
1158    #[test]
1159    fn field_receiver_exception_keeps_host_redaction() {
1160        let id = Identity::default();
1161        for text in [
1162            "st.local",
1163            "cfg.local",
1164            "devbox.local",
1165            "box.lan",
1166            "`st.local",
1167            "st.local`",
1168            "`st.local is true`",
1169            "`st.local.`",
1170            "`box1.local`",
1171            "`build-box.local`",
1172            "`box.example.local`",
1173        ] {
1174            assert!(scrub(text, &id).contains("[redacted-host]"), "{text}");
1175            assert_eq!(locate(text, &id), vec![(1, "host")], "{text}");
1176        }
1177        for text in ["host=st.local", "`host=cfg.local`"] {
1178            assert!(scrub(text, &id).contains("[redacted-identity]"), "{text}");
1179            assert_eq!(locate(text, &id), vec![(1, "identity")], "{text}");
1180        }
1181        let known = Identity {
1182            host: "devbox.local".into(),
1183            ..Identity::default()
1184        };
1185        let text = "`devbox.local`";
1186        assert_eq!(scrub(text, &known), "`[redacted-host]`");
1187        assert_eq!(locate(text, &known), vec![(1, "host")]);
1188    }
1189
1190    #[test]
1191    fn ordinary_text_is_untouched() {
1192        for t in [
1193            "Change src/graph.rs and tests/common/mod.rs.",
1194            "See https://github.com/o/r/pull/12 for #12",
1195            "returns std::io::Error, or a::b, Vec::new()",
1196            "released v1.2.3, version 0.41.1, 300.1.1.1",
1197            "thanks @coderabbitai; at 12:34:56 it ran",
1198            "/api/v1/runs; docs/home/x and ./Users/y",
1199            "A plain sentence about background and motivation.",
1200            "日本語のテキスト 🎉 with émoji",
1201        ] {
1202            assert_eq!(s(t), t);
1203        }
1204    }
1205
1206    #[test]
1207    fn multibyte_input_does_not_panic_and_replacement_is_not_rescanned() {
1208        assert_eq!(
1209            s("C:\\日本語 and C:/日本"),
1210            "[redacted-path] and [redacted-path]"
1211        );
1212        assert_eq!(s("é/Users/bob/é 日本 alice"), "é~/é 日本 [redacted-user]");
1213        let once = s("/Users/bob 10.0.0.1 a@b.io alice");
1214        assert_eq!(scrub(&once, &Identity::default()), once);
1215        // A user named like the replacement text cannot loop or re-match.
1216        let odd = Identity {
1217            user: "redacted".into(),
1218            ..Identity::default()
1219        };
1220        assert_eq!(scrub("redacted x", &odd), "[redacted-user] x");
1221    }
1222}