#[derive(Debug, Clone, Default)]
pub struct Identity {
pub user: String,
pub host: String,
pub home: String,
}
impl Identity {
pub fn current() -> Self {
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty());
let host = env("HOSTNAME")
.or_else(|| env("COMPUTERNAME"))
.or_else(|| {
std::fs::read_to_string("/etc/hostname")
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
})
.unwrap_or_default();
Self {
user: env("USER").or_else(|| env("USERNAME")).unwrap_or_default(),
host,
home: dirs::home_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(),
}
}
}
const TOKEN_PREFIXES: [&str; 8] = [
"github_pat_",
"ghp_",
"gho_",
"ghs_",
"ghu_",
"sk-",
"xoxb-",
"xoxp-",
];
const TOKEN_MIN_TAIL: usize = 16;
const MIN_IDENTITY_WORD: usize = 3;
fn is_word(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '-'
}
fn is_name(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')
}
fn is_email_local(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '%' | '+' | '-')
}
fn name_len(s: &str) -> usize {
s[..run(s, is_name)].trim_end_matches('.').len()
}
fn run(s: &str, f: impl Fn(char) -> bool) -> usize {
s.char_indices()
.find(|&(_, c)| !f(c))
.map_or(s.len(), |(i, _)| i)
}
pub fn scrub(text: &str, id: &Identity) -> String {
let mut out = String::with_capacity(text.len());
let mut i = 0;
while i < text.len() {
let prev = text[..i].chars().next_back();
if let Some((len, rep)) = match_at(&text[i..], prev, id) {
out.push_str(rep);
i += len;
} else {
let c = text[i..].chars().next().expect("i is on a char boundary");
out.push(c);
i += c.len_utf8();
}
}
out
}
fn match_at(rest: &str, prev: Option<char>, id: &Identity) -> Option<(usize, &'static str)> {
let starts_word = prev.is_none_or(|p| !is_word(p));
home_path(rest, prev, id)
.or_else(|| starts_word.then(|| token(rest)).flatten())
.or_else(|| {
prev.is_none_or(|p| !is_email_local(p))
.then(|| email(rest))
.flatten()
})
.or_else(|| {
prev.is_none_or(|p| !p.is_ascii_alphanumeric() && p != '.' && p != ':')
.then(|| ipv4(rest).or_else(|| ipv6(rest)))
.flatten()
})
.or_else(|| starts_word.then(|| identity_word(rest, id)).flatten())
}
fn home_path(rest: &str, prev: Option<char>, id: &Identity) -> Option<(usize, &'static str)> {
if prev.is_some_and(|p| p.is_ascii_alphanumeric() || matches!(p, '.' | '_' | '-' | '~')) {
return None;
}
if id.home.len() > 1 && rest.starts_with(id.home.as_str()) {
let tail = &rest[id.home.len()..];
if tail.chars().next().is_none_or(|c| !is_name(c)) {
return Some((id.home.len(), "~"));
}
}
for base in ["/Users/", "/home/"] {
if let Some(tail) = rest.strip_prefix(base) {
let n = name_len(tail);
if n > 0 {
return Some((base.len() + n, "~"));
}
}
}
if let Some(tail) = rest.strip_prefix("/root")
&& tail.chars().next().is_none_or(|c| !is_word(c))
{
return Some(("/root".len(), "~"));
}
let b = rest.as_bytes();
if b.len() > 9
&& b[0].is_ascii_alphabetic()
&& b[1] == b':'
&& matches!(b[2], b'\\' | b'/')
&& b[3..8].eq_ignore_ascii_case(b"users")
&& matches!(b[8], b'\\' | b'/')
{
let n = name_len(&rest[9..]);
if n > 0 {
return Some((9 + n, "~"));
}
}
None
}
fn token(rest: &str) -> Option<(usize, &'static str)> {
let prefix = TOKEN_PREFIXES.iter().find(|p| rest.starts_with(**p))?;
let tail = run(&rest[prefix.len()..], is_word);
(tail >= TOKEN_MIN_TAIL).then_some((prefix.len() + tail, "[redacted-token]"))
}
fn email(rest: &str) -> Option<(usize, &'static str)> {
let local = run(rest, is_email_local);
if local == 0 || !rest[local..].starts_with('@') {
return None;
}
let domain = &rest[local + 1..];
let mut end = 0;
let mut labels = 0;
loop {
let n = run(&domain[end..], |c| c.is_ascii_alphanumeric() || c == '-');
if n == 0 {
break;
}
end += n;
labels += 1;
if domain[end..].starts_with('.')
&& run(&domain[end + 1..], |c| c.is_ascii_alphanumeric()) > 0
{
end += 1;
} else {
break;
}
}
(labels >= 2).then_some((local + 1 + end, "[redacted-email]"))
}
fn ipv4(rest: &str) -> Option<(usize, &'static str)> {
let mut len = 0;
for part in 0..4 {
let s = &rest[len..];
let n = run(s, |c| c.is_ascii_digit());
if n == 0 || n > 3 || s[..n].parse::<u16>().ok()? > 255 {
return None;
}
len += n;
if part < 3 {
if !rest[len..].starts_with('.') {
return None;
}
len += 1;
}
}
let after = &rest[len..];
let mut chars = after.chars();
match chars.next() {
Some(c) if c.is_ascii_alphanumeric() => return None,
Some('.') if chars.next().is_some_and(|c| c.is_ascii_digit()) => return None,
_ => {}
}
Some((len, "[redacted-ip]"))
}
fn ipv6(rest: &str) -> Option<(usize, &'static str)> {
let mut n = run(rest, |c| c.is_ascii_hexdigit() || c == ':');
while n > 0 && rest[..n].ends_with(':') && !rest[..n].ends_with("::") {
n -= 1;
}
let cand = &rest[..n];
let colons = cand.matches(':').count();
let shaped = (cand.contains("::") && colons >= 2) || colons == 7;
if !shaped
|| !cand.bytes().any(|b| b.is_ascii_digit())
|| cand.split(':').any(|g| g.len() > 4)
|| rest[n..]
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric())
{
return None;
}
Some((n, "[redacted-ip]"))
}
fn identity_word(rest: &str, id: &Identity) -> Option<(usize, &'static str)> {
for (word, rep) in [(&id.user, "[redacted-user]"), (&id.host, "[redacted-host]")] {
let w = word.trim();
if w.len() < MIN_IDENTITY_WORD || rest.len() < w.len() || !rest.is_char_boundary(w.len()) {
continue;
}
if rest[..w.len()].eq_ignore_ascii_case(w)
&& rest[w.len()..].chars().next().is_none_or(|c| !is_word(c))
{
return Some((w.len(), rep));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn id() -> Identity {
Identity {
user: "alice".into(),
host: "buildbox".into(),
home: "/srv/people/alice".into(),
}
}
fn s(t: &str) -> String {
scrub(t, &id())
}
#[test]
fn home_paths_collapse_and_keep_the_tail() {
assert_eq!(s("see /Users/bob/src/x.rs now"), "see ~/src/x.rs now");
assert_eq!(s("in /home/bob-1/.config"), "in ~/.config");
assert_eq!(s("at C:\\Users\\Bob\\proj\\a.rs"), "at ~\\proj\\a.rs");
assert_eq!(s("at C:/Users/Bob/proj"), "at ~/proj");
assert_eq!(s("/root/x and /root."), "~/x and ~.");
assert_eq!(s("/srv/people/alice/wt/a"), "~/wt/a");
}
#[test]
fn emails_ips_and_tokens() {
assert_eq!(s("mail dev.x+y@example.co.uk!"), "mail [redacted-email]!");
assert_eq!(s("host 192.168.0.12:8080"), "host [redacted-ip]:8080");
assert_eq!(
s("v6 fe80::1 and ::1"),
"v6 [redacted-ip] and [redacted-ip]"
);
assert_eq!(s("full 2001:db8:0:0:0:0:0:1."), "full [redacted-ip].");
assert_eq!(
s("tok ghp_abcdefghijklmnopqrstuv end"),
"tok [redacted-token] end"
);
}
#[test]
fn identity_words_match_whole_words_only() {
assert_eq!(
s("by Alice on buildbox"),
"by [redacted-user] on [redacted-host]"
);
assert_eq!(
s("alicein wonderland, xbuildbox"),
"alicein wonderland, xbuildbox"
);
}
#[test]
fn ordinary_text_is_untouched() {
for t in [
"Change src/graph.rs and tests/common/mod.rs.",
"See https://github.com/o/r/pull/12 for #12",
"returns std::io::Error, or a::b, Vec::new()",
"released v1.2.3, version 0.41.1, 300.1.1.1",
"thanks @coderabbitai; at 12:34:56 it ran",
"/tmp/x and /api/v1/runs; docs/home/x and ./Users/y",
"A plain sentence about background and motivation.",
"日本語のテキスト 🎉 with émoji",
] {
assert_eq!(s(t), t);
}
}
#[test]
fn multibyte_input_does_not_panic_and_replacement_is_not_rescanned() {
assert_eq!(s("C:\\日本語 and C:/日本"), "C:\\日本語 and C:/日本");
assert_eq!(s("é/Users/bob/é 日本 alice"), "é~/é 日本 [redacted-user]");
let once = s("/Users/bob 10.0.0.1 a@b.io alice");
assert_eq!(scrub(&once, &Identity::default()), once);
let odd = Identity {
user: "redacted".into(),
..Identity::default()
};
assert_eq!(scrub("redacted x", &odd), "[redacted-user] x");
}
}