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