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.
252/// Only the part before the dot is looked at, so the suffix's spelling
253/// (`.loCal`) never matters. A CamelCase single-label hostname (`MyServer.lan`)
254/// is deliberately read as code; the operator's own host name is caught by
255/// `identity_word` instead.
256fn code_receiver(name: &str) -> bool {
257    if name.matches('.').count() != 1 || name.contains('-') {
258        return false;
259    }
260    let Some((receiver, _)) = name.split_once('.') else {
261        return false;
262    };
263    if receiver.eq_ignore_ascii_case("self") || receiver.eq_ignore_ascii_case("this") {
264        return true;
265    }
266    let mut prev_lower = false;
267    receiver.chars().any(|c| {
268        let hump = prev_lower && c.is_ascii_uppercase();
269        prev_lower = c.is_ascii_lowercase();
270        hump
271    })
272}
273
274/// `"password": "value"` and `'token':'value'`: the key is quoted, so the
275/// unquoted `key=` / `key: ` forms below never match.
276fn quoted_credential(rest: &str) -> Option<(usize, &'static str)> {
277    for key in [
278        "password", "token", "api_key", "api-key", "apikey", "secret",
279    ] {
280        let Some(after) = rest
281            .get(..key.len())
282            .filter(|p| p.eq_ignore_ascii_case(key))
283            .map(|_| &rest[key.len()..])
284        else {
285            continue;
286        };
287        let Some(after_quote) = after.strip_prefix(['"', '\'']) else {
288            continue;
289        };
290        let t = after_quote.trim_start();
291        let Some(t) = t.strip_prefix([':', '=']) else {
292            continue;
293        };
294        let t = t.trim_start();
295        // A quoted value runs to its closing quote and may hold spaces or commas.
296        let n = match t.chars().next() {
297            Some(q @ ('"' | '\'')) => {
298                let inner = &t[1..];
299                let end = quoted_end(inner, q).unwrap_or(inner.len());
300                1 + end + usize::from(end < inner.len())
301            }
302            _ => run(t, |c| {
303                !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | ',' | '}')
304            }),
305        };
306        let value = t;
307        if n > 0 {
308            return Some((rest.len() - value.len() + n, "[redacted-token]"));
309        }
310    }
311    None
312}
313
314/// Byte offset of the first `q` in `inner` that no backslash escapes.
315fn quoted_end(inner: &str, q: char) -> Option<usize> {
316    let mut chars = inner.char_indices();
317    while let Some((i, c)) = chars.next() {
318        if c == '\\' {
319            chars.next();
320        } else if c == q {
321            return Some(i);
322        }
323    }
324    None
325}
326
327fn credential(rest: &str) -> Option<(usize, &'static str)> {
328    if let Some(hit) = quoted_credential(rest) {
329        return Some(hit);
330    }
331    for key in [
332        "password=",
333        "token=",
334        "api_key=",
335        "api-key=",
336        "password: ",
337        "token: ",
338        "api_key: ",
339        "bearer ",
340        "password ",
341        "token ",
342        "api key ",
343    ] {
344        if rest
345            .get(..key.len())
346            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(key))
347        {
348            let tail = &rest[key.len()..];
349            let padding = tail.len() - tail.trim_start_matches([' ', '\'', '"']).len();
350            let value = &tail[padding..];
351            let n = run(value, |c| {
352                !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | '"' | '\'')
353            });
354            let contextual = matches!(key, "password " | "token " | "api key ");
355            let entropy = n >= 20
356                && value[..n].bytes().any(|b| b.is_ascii_digit())
357                && value[..n].bytes().any(|b| b.is_ascii_alphabetic());
358            if n > 0 && (!contextual || entropy) {
359                return Some((key.len() + padding + n, "[redacted-token]"));
360            }
361        }
362    }
363    None
364}
365
366fn absolute_path(rest: &str, prev: Option<char>) -> Option<(usize, &'static str)> {
367    if prev.is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | ':' | '.' | '~')) {
368        return None;
369    }
370    let b = rest.as_bytes();
371    let windows =
372        b.len() > 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && matches!(b[2], b'/' | b'\\');
373    // Known filesystem roots, rather than arbitrary slash-prefixed API routes.
374    let unix = [
375        "/tmp/",
376        "/private/",
377        "/var/",
378        "/etc/",
379        "/opt/",
380        "/srv/",
381        "/usr/",
382        "/mnt/",
383        "/Volumes/",
384    ]
385    .iter()
386    .any(|root| rest.starts_with(root));
387    if windows || unix {
388        let n = run(rest, |c| {
389            !c.is_whitespace() && !matches!(c, '`' | '"' | '\'' | '<' | '>')
390        });
391        return Some((n, "[redacted-path]"));
392    }
393    None
394}
395
396fn email(rest: &str) -> Option<(usize, &'static str)> {
397    let local = run(rest, is_email_local);
398    if local == 0 || !rest[local..].starts_with('@') {
399        return None;
400    }
401    let domain = &rest[local + 1..];
402    let mut end = 0;
403    let mut labels = 0;
404    loop {
405        let n = run(&domain[end..], |c| c.is_ascii_alphanumeric() || c == '-');
406        if n == 0 {
407            break;
408        }
409        end += n;
410        labels += 1;
411        if domain[end..].starts_with('.')
412            && run(&domain[end + 1..], |c| c.is_ascii_alphanumeric()) > 0
413        {
414            end += 1;
415        } else {
416            break;
417        }
418    }
419    (labels >= 2).then_some((local + 1 + end, "[redacted-email]"))
420}
421
422fn ipv4(rest: &str) -> Option<(usize, &'static str)> {
423    let mut len = 0;
424    for part in 0..4 {
425        let s = &rest[len..];
426        let n = run(s, |c| c.is_ascii_digit());
427        if n == 0 || n > 3 || s[..n].parse::<u16>().ok()? > 255 {
428            return None;
429        }
430        len += n;
431        if part < 3 {
432            if !rest[len..].starts_with('.') {
433                return None;
434            }
435            len += 1;
436        }
437    }
438    let after = &rest[len..];
439    let mut chars = after.chars();
440    match chars.next() {
441        Some(c) if c.is_ascii_alphanumeric() => return None,
442        Some('.') if chars.next().is_some_and(|c| c.is_ascii_digit()) => return None,
443        _ => {}
444    }
445    Some((len, "[redacted-ip]"))
446}
447
448fn ipv6(rest: &str) -> Option<(usize, &'static str)> {
449    let mut n = run(rest, |c| c.is_ascii_hexdigit() || c == ':');
450    // A trailing single colon is punctuation, not part of the address.
451    while n > 0 && rest[..n].ends_with(':') && !rest[..n].ends_with("::") {
452        n -= 1;
453    }
454    let cand = &rest[..n];
455    let colons = cand.matches(':').count();
456    let shaped = (cand.contains("::") && colons >= 2) || colons == 7;
457    if !shaped
458        || !cand.bytes().any(|b| b.is_ascii_digit())
459        || cand.split(':').any(|g| g.len() > 4)
460        || rest[n..]
461            .chars()
462            .next()
463            .is_some_and(|c| c.is_ascii_alphanumeric())
464    {
465        return None;
466    }
467    Some((n, "[redacted-ip]"))
468}
469
470/// Plain words (and the bare mDNS suffix) that show up as a hostname or account
471/// name on some machines but are ordinary prose everywhere. Matched against the
472/// whole trimmed value only, so `devbox.local` is still a hostname.
473const GENERIC_IDENTITY_WORDS: [&str; 12] = [
474    "local",
475    "localhost",
476    "localdomain",
477    "lan",
478    "internal",
479    "user",
480    "host",
481    "admin",
482    "root",
483    "test",
484    "default",
485    "unknown",
486];
487
488fn is_generic_identity(w: &str) -> bool {
489    let w = w.strip_prefix('.').unwrap_or(w);
490    GENERIC_IDENTITY_WORDS
491        .iter()
492        .any(|g| g.eq_ignore_ascii_case(w))
493}
494
495/// True when the text before the cursor ends with `github.com/` as the start of
496/// a URL, i.e. the cursor is on the owner segment of a GitHub URL.
497fn github_owner_slot(before: &str) -> bool {
498    let b = before.to_ascii_lowercase();
499    let Some(head) = b.strip_suffix("github.com/") else {
500        return false;
501    };
502    let head = head.strip_suffix("www.").unwrap_or(head);
503    head.ends_with("http://") || head.ends_with("https://")
504}
505
506fn identity_word(rest: &str, id: &Identity) -> Option<(usize, &'static str)> {
507    for (word, rep) in [(&id.user, "[redacted-user]"), (&id.host, "[redacted-host]")] {
508        let w = word.trim();
509        if w.len() < MIN_IDENTITY_WORD
510            || is_generic_identity(w)
511            || rest.len() < w.len()
512            || !rest.is_char_boundary(w.len())
513        {
514            continue;
515        }
516        if rest[..w.len()].eq_ignore_ascii_case(w)
517            && rest[w.len()..].chars().next().is_none_or(|c| !is_word(c))
518        {
519            return Some((w.len(), rep));
520        }
521    }
522    None
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    fn id() -> Identity {
530        Identity {
531            user: "alice".into(),
532            host: "buildbox".into(),
533            home: "/srv/people/alice".into(),
534        }
535    }
536
537    fn s(t: &str) -> String {
538        scrub(t, &id())
539    }
540
541    #[test]
542    fn locate_names_kind_and_line_without_the_text() {
543        let id = Identity::default();
544        let text = "fine\ntoken=abcdefghijklmnop1234 here\nmail a@b.example";
545        assert_eq!(locate(text, &id), vec![(2, "token"), (3, "email")]);
546    }
547
548    #[test]
549    fn quoted_credentials_honor_escaped_quotes() {
550        let out = s(r#"{"password":"prefix\"hunter2"} tail"#);
551        assert!(!out.contains("hunter2") && !out.contains("prefix"), "{out}");
552        assert!(out.ends_with("} tail"), "{out}");
553        // An even run of backslashes leaves the quote as the closer.
554        let out = s(r#"{"password":"a\\\\"} tail"#);
555        assert!(out.ends_with("} tail") && !out.contains("a\\"), "{out}");
556        // An odd run escapes it.
557        let out = s(r#"{"password":"a\\\"hunter2"} tail"#);
558        assert!(!out.contains("hunter2"), "{out}");
559        let out = s(r#"{'token':'x\'hunter2'} tail"#);
560        assert!(!out.contains("hunter2"), "{out}");
561        let out = s(r#"{"password":"日本\"語hunter2"} tail"#);
562        assert!(!out.contains("hunter2") && !out.contains('語'), "{out}");
563        // No closing quote: everything after is removed.
564        let out = s(r#"{"password":"abc\"hunter2"#);
565        assert!(!out.contains("hunter2"), "{out}");
566    }
567
568    #[test]
569    fn home_paths_collapse_and_keep_the_tail() {
570        assert_eq!(s("see /Users/bob/src/x.rs now"), "see ~/src/x.rs now");
571        assert_eq!(s("in /home/bob-1/.config"), "in ~/.config");
572        assert_eq!(s("at C:\\Users\\Bob\\proj\\a.rs"), "at ~\\proj\\a.rs");
573        assert_eq!(s("at C:/Users/Bob/proj"), "at ~/proj");
574        assert_eq!(s("/root/x and /root."), "~/x and ~.");
575        assert_eq!(s("/srv/people/alice/wt/a"), "~/wt/a");
576    }
577
578    #[test]
579    fn emails_ips_and_tokens() {
580        assert_eq!(s("mail dev.x+y@example.co.uk!"), "mail [redacted-email]!");
581        assert_eq!(s("host 192.168.0.12:8080"), "host [redacted-ip]:8080");
582        assert_eq!(
583            s("v6 fe80::1 and ::1"),
584            "v6 [redacted-ip] and [redacted-ip]"
585        );
586        assert_eq!(s("full 2001:db8:0:0:0:0:0:1."), "full [redacted-ip].");
587        assert_eq!(
588            s("tok ghp_abcdefghijklmnopqrstuv end"),
589            "tok [redacted-token] end"
590        );
591    }
592
593    #[test]
594    fn identity_words_match_whole_words_only() {
595        assert_eq!(
596            s("by Alice on buildbox"),
597            "by [redacted-user] on [redacted-host]"
598        );
599        assert_eq!(
600            s("alicein wonderland, xbuildbox"),
601            "alicein wonderland, xbuildbox"
602        );
603    }
604
605    #[test]
606    fn github_url_owner_is_kept_but_other_mentions_are_not() {
607        assert_eq!(
608            s("https://github.com/alice/magi/pull/681 by alice"),
609            "https://github.com/alice/magi/pull/681 by [redacted-user]"
610        );
611        assert_eq!(
612            s("http://www.GitHub.com/Alice/x"),
613            "http://www.GitHub.com/Alice/x"
614        );
615        for t in [
616            "https://github.com.evil/alice",
617            "https://github.com/x/alice",
618            "git@github.com:alice/x",
619            "https://api.github.com/alice",
620            "github.com/alice",
621        ] {
622            assert!(s(t).contains("[redacted-user]"), "{t}");
623        }
624        let text = "https://github.com/alice/magi alice";
625        assert_eq!(locate(text, &id()), vec![(1, "user")]);
626    }
627
628    #[test]
629    fn generic_host_and_user_values_do_not_redact_prose() {
630        for host in ["local", ".local", "devbox.local"] {
631            let i = Identity {
632                host: host.into(),
633                ..Identity::default()
634            };
635            let t = "`WatchState::local` and local mode";
636            assert_eq!(scrub(t, &i), t, "{host}");
637        }
638        let real = Identity {
639            host: "devbox.local".into(),
640            ..Identity::default()
641        };
642        assert_eq!(
643            scrub("on devbox.local now", &real),
644            "on [redacted-host] now"
645        );
646        for (user, host) in [("local", ""), ("admin", "localhost")] {
647            let i = Identity {
648                user: user.into(),
649                host: host.into(),
650                ..Identity::default()
651            };
652            let t = "local admin localhost";
653            assert_eq!(scrub(t, &i), t);
654        }
655        let once = s("https://github.com/alice/x alice buildbox");
656        assert_eq!(scrub(&once, &id()), once);
657        assert_eq!(s("on buildbox"), "on [redacted-host]");
658    }
659
660    #[test]
661    fn dotted_code_paths_ending_in_local_are_not_hosts() {
662        for t in [
663            "`WatchState.local` is true",
664            "WatchState.lan and self.local",
665            "WatchState.local",
666        ] {
667            assert_eq!(scrub(t, &Identity::default()), t);
668        }
669        // Known trade-off: a CamelCase single-label host reads as code.
670        for t in ["ssh MyServer.lan", "JohnsMacBook.local"] {
671            assert_eq!(scrub(t, &Identity::default()), t);
672        }
673        for (t, want) in [
674            ("ssh m1air.loCal now", "ssh [redacted-host] now"),
675            ("ssh devbox.LOCAL", "ssh [redacted-host]"),
676            ("ssh box.InterNal", "ssh [redacted-host]"),
677            ("ssh box.LaN", "ssh [redacted-host]"),
678        ] {
679            assert_eq!(scrub(t, &Identity::default()), want, "{t}");
680        }
681        for t in [
682            "ssh devbox.local",
683            "ssh BUILD-SERVER.local",
684            "ssh Alices-MacBook-Pro.local",
685            "`devbox.local`",
686            "ssh BUILD.local",
687        ] {
688            assert!(
689                scrub(t, &Identity::default()).contains("[redacted-host]"),
690                "{t}"
691            );
692        }
693    }
694
695    #[test]
696    fn ordinary_text_is_untouched() {
697        for t in [
698            "Change src/graph.rs and tests/common/mod.rs.",
699            "See https://github.com/o/r/pull/12 for #12",
700            "returns std::io::Error, or a::b, Vec::new()",
701            "released v1.2.3, version 0.41.1, 300.1.1.1",
702            "thanks @coderabbitai; at 12:34:56 it ran",
703            "/api/v1/runs; docs/home/x and ./Users/y",
704            "A plain sentence about background and motivation.",
705            "日本語のテキスト 🎉 with émoji",
706        ] {
707            assert_eq!(s(t), t);
708        }
709    }
710
711    #[test]
712    fn multibyte_input_does_not_panic_and_replacement_is_not_rescanned() {
713        assert_eq!(
714            s("C:\\日本語 and C:/日本"),
715            "[redacted-path] and [redacted-path]"
716        );
717        assert_eq!(s("é/Users/bob/é 日本 alice"), "é~/é 日本 [redacted-user]");
718        let once = s("/Users/bob 10.0.0.1 a@b.io alice");
719        assert_eq!(scrub(&once, &Identity::default()), once);
720        // A user named like the replacement text cannot loop or re-match.
721        let odd = Identity {
722            user: "redacted".into(),
723            ..Identity::default()
724        };
725        assert_eq!(scrub("redacted x", &odd), "[redacted-user] x");
726    }
727}