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
14/// The local facts worth hiding. `Default` is "nothing known", which leaves only
15/// the pattern-based rules.
16#[derive(Debug, Clone, Default)]
17pub struct Identity {
18    /// Local account name.
19    pub user: String,
20    /// Machine hostname.
21    pub host: String,
22    /// Home directory path.
23    pub home: String,
24}
25
26impl Identity {
27    /// Read the current account, host and home directory. The only I/O here.
28    pub fn current() -> Self {
29        let env = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty());
30        let host = env("HOSTNAME")
31            .or_else(|| env("COMPUTERNAME"))
32            .or_else(|| {
33                std::fs::read_to_string("/etc/hostname")
34                    .ok()
35                    .map(|s| s.trim().to_owned())
36                    .filter(|s| !s.is_empty())
37            })
38            .unwrap_or_default();
39        Self {
40            user: env("USER").or_else(|| env("USERNAME")).unwrap_or_default(),
41            host,
42            home: dirs::home_dir()
43                .map(|p| p.to_string_lossy().into_owned())
44                .unwrap_or_default(),
45        }
46    }
47}
48
49const TOKEN_PREFIXES: [&str; 9] = [
50    "AKIA",
51    "github_pat_",
52    "ghp_",
53    "gho_",
54    "ghs_",
55    "ghu_",
56    "sk-",
57    "xoxb-",
58    "xoxp-",
59];
60const TOKEN_MIN_TAIL: usize = 16;
61/// Identity words shorter than this are too likely to be ordinary prose.
62const MIN_IDENTITY_WORD: usize = 3;
63
64fn is_word(c: char) -> bool {
65    c.is_ascii_alphanumeric() || c == '_' || c == '-'
66}
67
68fn is_name(c: char) -> bool {
69    c.is_alphanumeric() || matches!(c, '_' | '-' | '.')
70}
71
72fn is_email_local(c: char) -> bool {
73    c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '%' | '+' | '-')
74}
75
76/// A path component: the name run minus trailing dots, which are punctuation.
77fn name_len(s: &str) -> usize {
78    s[..run(s, is_name)].trim_end_matches('.').len()
79}
80
81/// Length in bytes of the leading run of `s` whose chars satisfy `f`.
82fn run(s: &str, f: impl Fn(char) -> bool) -> usize {
83    s.char_indices()
84        .find(|&(_, c)| !f(c))
85        .map_or(s.len(), |(i, _)| i)
86}
87
88/// Replace machine- and operator-identifying data in `text`. Pure.
89pub fn scrub(text: &str, id: &Identity) -> String {
90    let mut out = String::with_capacity(text.len());
91    let mut i = 0;
92    while i < text.len() {
93        if let Some((len, rep)) = match_at(&text[..i], &text[i..], id) {
94            out.push_str(rep);
95            i += len;
96        } else {
97            let c = text[i..].chars().next().expect("i is on a char boundary");
98            out.push(c);
99            i += c.len_utf8();
100        }
101    }
102    out
103}
104
105/// Where [`scrub`] would replace something: `(line, kind)` per match, lines
106/// counted from 1. Never carries the matched text. Pure.
107pub fn locate(text: &str, id: &Identity) -> Vec<(usize, &'static str)> {
108    let mut hits = Vec::new();
109    let mut i = 0;
110    let mut line = 1;
111    while i < text.len() {
112        if let Some((len, rep)) = match_at(&text[..i], &text[i..], id) {
113            let kind = match rep {
114                "~" => "home-path",
115                r => r
116                    .trim_start_matches('[')
117                    .trim_end_matches(']')
118                    .trim_start_matches("redacted-"),
119            };
120            hits.push((line, kind));
121            line += text[i..i + len].matches('\n').count();
122            i += len;
123        } else {
124            let c = text[i..].chars().next().expect("i is on a char boundary");
125            if c == '\n' {
126                line += 1;
127            }
128            i += c.len_utf8();
129        }
130    }
131    hits
132}
133
134fn match_at(before: &str, rest: &str, id: &Identity) -> Option<(usize, &'static str)> {
135    let prev = before.chars().next_back();
136    let starts_word = prev.is_none_or(|p| !is_word(p));
137    home_path(rest, prev, id)
138        .or_else(|| {
139            starts_word
140                .then(|| {
141                    token(rest).or_else(|| credential(rest)).or_else(|| {
142                        prev.is_none_or(|c| !is_name(c))
143                            .then(|| named_identity(rest))
144                            .flatten()
145                    })
146                })
147                .flatten()
148        })
149        .or_else(|| absolute_path(rest, prev))
150        .or_else(|| {
151            prev.is_none_or(|p| !is_email_local(p))
152                .then(|| email(rest))
153                .flatten()
154        })
155        .or_else(|| {
156            prev.is_none_or(|p| !p.is_ascii_alphanumeric() && p != '.' && p != ':')
157                .then(|| ipv4(rest).or_else(|| ipv6(rest)))
158                .flatten()
159        })
160        .or_else(|| {
161            (starts_word && !github_owner_slot(before))
162                .then(|| identity_word(rest, id))
163                .flatten()
164        })
165}
166
167/// `/Users/x`, `/home/x`, `/root`, `C:\Users\x`, `C:/Users/x` and the literal
168/// home directory, each collapsed to `~` so the repo-relative tail survives.
169fn home_path(rest: &str, prev: Option<char>, id: &Identity) -> Option<(usize, &'static str)> {
170    if prev.is_some_and(|p| p.is_ascii_alphanumeric() || matches!(p, '.' | '_' | '-' | '~')) {
171        return None;
172    }
173    if id.home.len() > 1 && rest.starts_with(id.home.as_str()) {
174        let tail = &rest[id.home.len()..];
175        if tail.chars().next().is_none_or(|c| !is_name(c)) {
176            return Some((id.home.len(), "~"));
177        }
178    }
179    for base in ["/Users/", "/home/"] {
180        if let Some(tail) = rest.strip_prefix(base) {
181            let n = name_len(tail);
182            if n > 0 {
183                return Some((base.len() + n, "~"));
184            }
185        }
186    }
187    if let Some(tail) = rest.strip_prefix("/root")
188        && tail.chars().next().is_none_or(|c| !is_word(c))
189    {
190        return Some(("/root".len(), "~"));
191    }
192    let b = rest.as_bytes();
193    if b.len() > 9
194        && b[0].is_ascii_alphabetic()
195        && b[1] == b':'
196        && matches!(b[2], b'\\' | b'/')
197        && b[3..8].eq_ignore_ascii_case(b"users")
198        && matches!(b[8], b'\\' | b'/')
199    {
200        let n = name_len(&rest[9..]);
201        if n > 0 {
202            return Some((9 + n, "~"));
203        }
204    }
205    None
206}
207
208fn token(rest: &str) -> Option<(usize, &'static str)> {
209    let prefix = TOKEN_PREFIXES.iter().find(|p| rest.starts_with(**p))?;
210    let tail = run(&rest[prefix.len()..], is_word);
211    (tail >= TOKEN_MIN_TAIL).then_some((prefix.len() + tail, "[redacted-token]"))
212}
213
214/// Explicit assignments avoid guessing whether an ordinary word is an account.
215fn named_identity(rest: &str) -> Option<(usize, &'static str)> {
216    for key in [
217        "hostname=",
218        "hostname: ",
219        "username=",
220        "username: ",
221        "user=",
222        "host=",
223    ] {
224        if rest
225            .get(..key.len())
226            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(key))
227        {
228            let n = run(&rest[key.len()..], is_name);
229            if n > 0 {
230                return Some((key.len() + n, "[redacted-identity]"));
231            }
232        }
233    }
234    let n = name_len(rest);
235    if n > 0
236        && !code_receiver(&rest[..n])
237        && [".local", ".internal", ".lan"].iter().any(|suffix| {
238            n.checked_sub(suffix.len())
239                .and_then(|start| rest.get(start..n))
240                .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix))
241        })
242    {
243        return Some((n, "[redacted-host]"));
244    }
245    None
246}
247
248/// A dotted name that reads as a code path (`WatchState.local`, `self.local`)
249/// rather than a hostname: one dot, no hyphen, and either a `self.` / `this.`
250/// receiver or a CamelCase one (a capital right after a lowercase letter).
251/// Anything else, mixed-case or backticked hostnames included, stays a host.
252fn code_receiver(name: &str) -> bool {
253    if name.matches('.').count() != 1 || name.contains('-') {
254        return false;
255    }
256    let lower = name.to_ascii_lowercase();
257    if lower.starts_with("self.") || lower.starts_with("this.") {
258        return true;
259    }
260    let mut prev_lower = false;
261    name.chars().any(|c| {
262        let hump = prev_lower && c.is_ascii_uppercase();
263        prev_lower = c.is_ascii_lowercase();
264        hump
265    })
266}
267
268/// `"password": "value"` and `'token':'value'`: the key is quoted, so the
269/// unquoted `key=` / `key: ` forms below never match.
270fn quoted_credential(rest: &str) -> Option<(usize, &'static str)> {
271    for key in [
272        "password", "token", "api_key", "api-key", "apikey", "secret",
273    ] {
274        let Some(after) = rest
275            .get(..key.len())
276            .filter(|p| p.eq_ignore_ascii_case(key))
277            .map(|_| &rest[key.len()..])
278        else {
279            continue;
280        };
281        let Some(after_quote) = after.strip_prefix(['"', '\'']) else {
282            continue;
283        };
284        let t = after_quote.trim_start();
285        let Some(t) = t.strip_prefix([':', '=']) else {
286            continue;
287        };
288        let t = t.trim_start();
289        // A quoted value runs to its closing quote and may hold spaces or commas.
290        let n = match t.chars().next() {
291            Some(q @ ('"' | '\'')) => {
292                let inner = &t[1..];
293                let end = quoted_end(inner, q).unwrap_or(inner.len());
294                1 + end + usize::from(end < inner.len())
295            }
296            _ => run(t, |c| {
297                !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | ',' | '}')
298            }),
299        };
300        let value = t;
301        if n > 0 {
302            return Some((rest.len() - value.len() + n, "[redacted-token]"));
303        }
304    }
305    None
306}
307
308/// Byte offset of the first `q` in `inner` that no backslash escapes.
309fn quoted_end(inner: &str, q: char) -> Option<usize> {
310    let mut chars = inner.char_indices();
311    while let Some((i, c)) = chars.next() {
312        if c == '\\' {
313            chars.next();
314        } else if c == q {
315            return Some(i);
316        }
317    }
318    None
319}
320
321fn credential(rest: &str) -> Option<(usize, &'static str)> {
322    if let Some(hit) = quoted_credential(rest) {
323        return Some(hit);
324    }
325    for key in [
326        "password=",
327        "token=",
328        "api_key=",
329        "api-key=",
330        "password: ",
331        "token: ",
332        "api_key: ",
333        "bearer ",
334        "password ",
335        "token ",
336        "api key ",
337    ] {
338        if rest
339            .get(..key.len())
340            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(key))
341        {
342            let tail = &rest[key.len()..];
343            let padding = tail.len() - tail.trim_start_matches([' ', '\'', '"']).len();
344            let value = &tail[padding..];
345            let n = run(value, |c| {
346                !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | '"' | '\'')
347            });
348            let contextual = matches!(key, "password " | "token " | "api key ");
349            let entropy = n >= 20
350                && value[..n].bytes().any(|b| b.is_ascii_digit())
351                && value[..n].bytes().any(|b| b.is_ascii_alphabetic());
352            if n > 0 && (!contextual || entropy) {
353                return Some((key.len() + padding + n, "[redacted-token]"));
354            }
355        }
356    }
357    None
358}
359
360fn absolute_path(rest: &str, prev: Option<char>) -> Option<(usize, &'static str)> {
361    if prev.is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | ':' | '.' | '~')) {
362        return None;
363    }
364    let b = rest.as_bytes();
365    let windows =
366        b.len() > 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && matches!(b[2], b'/' | b'\\');
367    // Known filesystem roots, rather than arbitrary slash-prefixed API routes.
368    let unix = [
369        "/tmp/",
370        "/private/",
371        "/var/",
372        "/etc/",
373        "/opt/",
374        "/srv/",
375        "/usr/",
376        "/mnt/",
377        "/Volumes/",
378    ]
379    .iter()
380    .any(|root| rest.starts_with(root));
381    if windows || unix {
382        let n = run(rest, |c| {
383            !c.is_whitespace() && !matches!(c, '`' | '"' | '\'' | '<' | '>')
384        });
385        return Some((n, "[redacted-path]"));
386    }
387    None
388}
389
390fn email(rest: &str) -> Option<(usize, &'static str)> {
391    let local = run(rest, is_email_local);
392    if local == 0 || !rest[local..].starts_with('@') {
393        return None;
394    }
395    let domain = &rest[local + 1..];
396    let mut end = 0;
397    let mut labels = 0;
398    loop {
399        let n = run(&domain[end..], |c| c.is_ascii_alphanumeric() || c == '-');
400        if n == 0 {
401            break;
402        }
403        end += n;
404        labels += 1;
405        if domain[end..].starts_with('.')
406            && run(&domain[end + 1..], |c| c.is_ascii_alphanumeric()) > 0
407        {
408            end += 1;
409        } else {
410            break;
411        }
412    }
413    (labels >= 2).then_some((local + 1 + end, "[redacted-email]"))
414}
415
416fn ipv4(rest: &str) -> Option<(usize, &'static str)> {
417    let mut len = 0;
418    for part in 0..4 {
419        let s = &rest[len..];
420        let n = run(s, |c| c.is_ascii_digit());
421        if n == 0 || n > 3 || s[..n].parse::<u16>().ok()? > 255 {
422            return None;
423        }
424        len += n;
425        if part < 3 {
426            if !rest[len..].starts_with('.') {
427                return None;
428            }
429            len += 1;
430        }
431    }
432    let after = &rest[len..];
433    let mut chars = after.chars();
434    match chars.next() {
435        Some(c) if c.is_ascii_alphanumeric() => return None,
436        Some('.') if chars.next().is_some_and(|c| c.is_ascii_digit()) => return None,
437        _ => {}
438    }
439    Some((len, "[redacted-ip]"))
440}
441
442fn ipv6(rest: &str) -> Option<(usize, &'static str)> {
443    let mut n = run(rest, |c| c.is_ascii_hexdigit() || c == ':');
444    // A trailing single colon is punctuation, not part of the address.
445    while n > 0 && rest[..n].ends_with(':') && !rest[..n].ends_with("::") {
446        n -= 1;
447    }
448    let cand = &rest[..n];
449    let colons = cand.matches(':').count();
450    let shaped = (cand.contains("::") && colons >= 2) || colons == 7;
451    if !shaped
452        || !cand.bytes().any(|b| b.is_ascii_digit())
453        || cand.split(':').any(|g| g.len() > 4)
454        || rest[n..]
455            .chars()
456            .next()
457            .is_some_and(|c| c.is_ascii_alphanumeric())
458    {
459        return None;
460    }
461    Some((n, "[redacted-ip]"))
462}
463
464/// Plain words (and the bare mDNS suffix) that show up as a hostname or account
465/// name on some machines but are ordinary prose everywhere. Matched against the
466/// whole trimmed value only, so `devbox.local` is still a hostname.
467const GENERIC_IDENTITY_WORDS: [&str; 12] = [
468    "local",
469    "localhost",
470    "localdomain",
471    "lan",
472    "internal",
473    "user",
474    "host",
475    "admin",
476    "root",
477    "test",
478    "default",
479    "unknown",
480];
481
482fn is_generic_identity(w: &str) -> bool {
483    let w = w.strip_prefix('.').unwrap_or(w);
484    GENERIC_IDENTITY_WORDS
485        .iter()
486        .any(|g| g.eq_ignore_ascii_case(w))
487}
488
489/// True when the text before the cursor ends with `github.com/` as the start of
490/// a URL, i.e. the cursor is on the owner segment of a GitHub URL.
491fn github_owner_slot(before: &str) -> bool {
492    let b = before.to_ascii_lowercase();
493    let Some(head) = b.strip_suffix("github.com/") else {
494        return false;
495    };
496    let head = head.strip_suffix("www.").unwrap_or(head);
497    head.ends_with("http://") || head.ends_with("https://")
498}
499
500fn identity_word(rest: &str, id: &Identity) -> Option<(usize, &'static str)> {
501    for (word, rep) in [(&id.user, "[redacted-user]"), (&id.host, "[redacted-host]")] {
502        let w = word.trim();
503        if w.len() < MIN_IDENTITY_WORD
504            || is_generic_identity(w)
505            || rest.len() < w.len()
506            || !rest.is_char_boundary(w.len())
507        {
508            continue;
509        }
510        if rest[..w.len()].eq_ignore_ascii_case(w)
511            && rest[w.len()..].chars().next().is_none_or(|c| !is_word(c))
512        {
513            return Some((w.len(), rep));
514        }
515    }
516    None
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    fn id() -> Identity {
524        Identity {
525            user: "alice".into(),
526            host: "buildbox".into(),
527            home: "/srv/people/alice".into(),
528        }
529    }
530
531    fn s(t: &str) -> String {
532        scrub(t, &id())
533    }
534
535    #[test]
536    fn locate_names_kind_and_line_without_the_text() {
537        let id = Identity::default();
538        let text = "fine\ntoken=abcdefghijklmnop1234 here\nmail a@b.example";
539        assert_eq!(locate(text, &id), vec![(2, "token"), (3, "email")]);
540    }
541
542    #[test]
543    fn quoted_credentials_honor_escaped_quotes() {
544        let out = s(r#"{"password":"prefix\"hunter2"} tail"#);
545        assert!(!out.contains("hunter2") && !out.contains("prefix"), "{out}");
546        assert!(out.ends_with("} tail"), "{out}");
547        // An even run of backslashes leaves the quote as the closer.
548        let out = s(r#"{"password":"a\\\\"} tail"#);
549        assert!(out.ends_with("} tail") && !out.contains("a\\"), "{out}");
550        // An odd run escapes it.
551        let out = s(r#"{"password":"a\\\"hunter2"} tail"#);
552        assert!(!out.contains("hunter2"), "{out}");
553        let out = s(r#"{'token':'x\'hunter2'} tail"#);
554        assert!(!out.contains("hunter2"), "{out}");
555        let out = s(r#"{"password":"日本\"語hunter2"} tail"#);
556        assert!(!out.contains("hunter2") && !out.contains('語'), "{out}");
557        // No closing quote: everything after is removed.
558        let out = s(r#"{"password":"abc\"hunter2"#);
559        assert!(!out.contains("hunter2"), "{out}");
560    }
561
562    #[test]
563    fn home_paths_collapse_and_keep_the_tail() {
564        assert_eq!(s("see /Users/bob/src/x.rs now"), "see ~/src/x.rs now");
565        assert_eq!(s("in /home/bob-1/.config"), "in ~/.config");
566        assert_eq!(s("at C:\\Users\\Bob\\proj\\a.rs"), "at ~\\proj\\a.rs");
567        assert_eq!(s("at C:/Users/Bob/proj"), "at ~/proj");
568        assert_eq!(s("/root/x and /root."), "~/x and ~.");
569        assert_eq!(s("/srv/people/alice/wt/a"), "~/wt/a");
570    }
571
572    #[test]
573    fn emails_ips_and_tokens() {
574        assert_eq!(s("mail dev.x+y@example.co.uk!"), "mail [redacted-email]!");
575        assert_eq!(s("host 192.168.0.12:8080"), "host [redacted-ip]:8080");
576        assert_eq!(
577            s("v6 fe80::1 and ::1"),
578            "v6 [redacted-ip] and [redacted-ip]"
579        );
580        assert_eq!(s("full 2001:db8:0:0:0:0:0:1."), "full [redacted-ip].");
581        assert_eq!(
582            s("tok ghp_abcdefghijklmnopqrstuv end"),
583            "tok [redacted-token] end"
584        );
585    }
586
587    #[test]
588    fn identity_words_match_whole_words_only() {
589        assert_eq!(
590            s("by Alice on buildbox"),
591            "by [redacted-user] on [redacted-host]"
592        );
593        assert_eq!(
594            s("alicein wonderland, xbuildbox"),
595            "alicein wonderland, xbuildbox"
596        );
597    }
598
599    #[test]
600    fn github_url_owner_is_kept_but_other_mentions_are_not() {
601        assert_eq!(
602            s("https://github.com/alice/magi/pull/681 by alice"),
603            "https://github.com/alice/magi/pull/681 by [redacted-user]"
604        );
605        assert_eq!(
606            s("http://www.GitHub.com/Alice/x"),
607            "http://www.GitHub.com/Alice/x"
608        );
609        for t in [
610            "https://github.com.evil/alice",
611            "https://github.com/x/alice",
612            "git@github.com:alice/x",
613            "https://api.github.com/alice",
614            "github.com/alice",
615        ] {
616            assert!(s(t).contains("[redacted-user]"), "{t}");
617        }
618        let text = "https://github.com/alice/magi alice";
619        assert_eq!(locate(text, &id()), vec![(1, "user")]);
620    }
621
622    #[test]
623    fn generic_host_and_user_values_do_not_redact_prose() {
624        for host in ["local", ".local", "devbox.local"] {
625            let i = Identity {
626                host: host.into(),
627                ..Identity::default()
628            };
629            let t = "`WatchState::local` and local mode";
630            assert_eq!(scrub(t, &i), t, "{host}");
631        }
632        let real = Identity {
633            host: "devbox.local".into(),
634            ..Identity::default()
635        };
636        assert_eq!(
637            scrub("on devbox.local now", &real),
638            "on [redacted-host] now"
639        );
640        for (user, host) in [("local", ""), ("admin", "localhost")] {
641            let i = Identity {
642                user: user.into(),
643                host: host.into(),
644                ..Identity::default()
645            };
646            let t = "local admin localhost";
647            assert_eq!(scrub(t, &i), t);
648        }
649        let once = s("https://github.com/alice/x alice buildbox");
650        assert_eq!(scrub(&once, &id()), once);
651        assert_eq!(s("on buildbox"), "on [redacted-host]");
652    }
653
654    #[test]
655    fn dotted_code_paths_ending_in_local_are_not_hosts() {
656        for t in [
657            "`WatchState.local` is true",
658            "WatchState.lan and self.local",
659            "WatchState.local",
660        ] {
661            assert_eq!(scrub(t, &Identity::default()), t);
662        }
663        for t in [
664            "ssh devbox.local",
665            "ssh BUILD-SERVER.local",
666            "ssh Alices-MacBook-Pro.local",
667            "`devbox.local`",
668            "ssh BUILD.local",
669        ] {
670            assert!(
671                scrub(t, &Identity::default()).contains("[redacted-host]"),
672                "{t}"
673            );
674        }
675    }
676
677    #[test]
678    fn ordinary_text_is_untouched() {
679        for t in [
680            "Change src/graph.rs and tests/common/mod.rs.",
681            "See https://github.com/o/r/pull/12 for #12",
682            "returns std::io::Error, or a::b, Vec::new()",
683            "released v1.2.3, version 0.41.1, 300.1.1.1",
684            "thanks @coderabbitai; at 12:34:56 it ran",
685            "/api/v1/runs; docs/home/x and ./Users/y",
686            "A plain sentence about background and motivation.",
687            "日本語のテキスト 🎉 with émoji",
688        ] {
689            assert_eq!(s(t), t);
690        }
691    }
692
693    #[test]
694    fn multibyte_input_does_not_panic_and_replacement_is_not_rescanned() {
695        assert_eq!(
696            s("C:\\日本語 and C:/日本"),
697            "[redacted-path] and [redacted-path]"
698        );
699        assert_eq!(s("é/Users/bob/é 日本 alice"), "é~/é 日本 [redacted-user]");
700        let once = s("/Users/bob 10.0.0.1 a@b.io alice");
701        assert_eq!(scrub(&once, &Identity::default()), once);
702        // A user named like the replacement text cannot loop or re-match.
703        let odd = Identity {
704            user: "redacted".into(),
705            ..Identity::default()
706        };
707        assert_eq!(scrub("redacted x", &odd), "[redacted-user] x");
708    }
709}