use super::edit::{Edit, chain};
use std::borrow::Cow;
const GENERIC_USERS: &[&str] = &[
"root",
"admin",
"administrator",
"user",
"users",
"home",
"guest",
"default",
"public",
"ubuntu",
"debian",
"docker",
"runner",
"jenkins",
"app",
"apps",
"service",
"services",
"var",
"tmp",
];
const MIN_USER_LEN: usize = 3;
pub(super) struct Home {
dir: String,
user: Option<String>,
}
impl Home {
pub(super) fn new(dir: impl Into<String>) -> Self {
let dir = dir.into();
let user = dir
.rsplit(['/', '\\'])
.find(|segment| !segment.is_empty())
.filter(|segment| segment.len() >= MIN_USER_LEN)
.filter(|segment| !GENERIC_USERS.contains(&segment.to_ascii_lowercase().as_str()))
.map(str::to_owned);
Home { dir, user }
}
pub(super) fn strip<'a>(&self, input: &'a str) -> Cow<'a, str> {
let stripped = replace_path_prefix(input, &self.dir, "~");
match &self.user {
Some(user) => chain(stripped, |text| replace_path_segment(text, user, "~user")),
None => stripped,
}
}
}
fn replace_path_prefix<'a>(input: &'a str, needle: &str, with: &str) -> Cow<'a, str> {
if needle.is_empty() || input.len() < needle.len() {
return Cow::Borrowed(input);
}
let bytes = input.as_bytes();
let mut edit = Edit::new(input);
let mut i = 0;
while i < bytes.len() {
if path_matches_at(input, i, needle)
&& starts_on_boundary(bytes, i)
&& ends_on_boundary(bytes, i + needle.len())
{
edit.replace(i, i + needle.len(), with);
i += needle.len();
continue;
}
i += 1;
while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
i += 1;
}
}
edit.finish()
}
fn replace_path_segment<'a>(input: &'a str, user: &str, with: &str) -> Cow<'a, str> {
let bytes = input.as_bytes();
let mut edit = Edit::new(input);
let mut i = 0;
while i < bytes.len() {
let segment_start = i + 1;
let segment_end = segment_start + user.len();
if is_separator(bytes[i])
&& segment_end <= bytes.len()
&& input.is_char_boundary(segment_start)
&& input.is_char_boundary(segment_end)
&& input[segment_start..segment_end].eq_ignore_ascii_case(user)
&& ends_on_boundary(bytes, segment_end)
{
edit.replace(segment_start, segment_end, with);
i = segment_end;
continue;
}
i += 1;
while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
i += 1;
}
}
edit.finish()
}
fn path_matches_at(input: &str, at: usize, needle: &str) -> bool {
let hay = input.as_bytes();
if at + needle.len() > hay.len() {
return false;
}
hay[at..at + needle.len()]
.iter()
.zip(needle.as_bytes())
.all(|(a, b)| (is_separator(*a) && is_separator(*b)) || a.eq_ignore_ascii_case(b))
}
fn starts_on_boundary(bytes: &[u8], at: usize) -> bool {
at == 0 || !(bytes[at - 1].is_ascii_alphanumeric() || bytes[at - 1] == b'~')
}
fn ends_on_boundary(bytes: &[u8], at: usize) -> bool {
at >= bytes.len() || !(bytes[at].is_ascii_alphanumeric() || matches!(bytes[at], b'_' | b'-'))
}
fn is_separator(b: u8) -> bool {
b == b'/' || b == b'\\'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_home_prefix_becomes_a_tilde_and_the_structure_survives() {
let home = Home::new("/home/ada");
assert_eq!(
home.strip("failed to open /home/ada/db/main.db"),
"failed to open ~/db/main.db"
);
assert_eq!(home.strip("/home/ada"), "~");
}
#[test]
fn matching_ignores_case_and_separator_spelling() {
let home = Home::new("C:\\Users\\Ada");
assert_eq!(
home.strip("open c:/users/ada/db/main.db"),
"open ~/db/main.db"
);
assert_eq!(home.strip("open C:\\Users\\Ada\\db"), "open ~\\db");
}
#[test]
fn a_home_that_prefixes_another_users_directory_does_not_corrupt_it() {
let home = Home::new("/home/ad");
assert_eq!(home.strip("/home/adam/db"), "/home/adam/db");
assert_eq!(home.strip("/home/ad/db"), "~/db");
}
#[test]
fn the_username_is_masked_outside_the_home_directory_too() {
let home = Home::new("/home/ada");
assert_eq!(
home.strip("/var/log/ada/store.log"),
"/var/log/~user/store.log"
);
assert_eq!(home.strip("C:\\Temp\\Ada\\x"), "C:\\Temp\\~user\\x");
assert_eq!(home.strip("/var/log/adapters"), "/var/log/adapters");
}
#[test]
fn a_generic_or_short_username_is_not_worth_the_structural_damage() {
assert_eq!(
Home::new("/home/root").strip("/var/root/x"),
"/var/root/x",
"masking `root` everywhere would strip structure and identify no one"
);
assert_eq!(Home::new("/home/ab").strip("/var/ab/x"), "/var/ab/x");
}
#[test]
fn stripping_is_idempotent() {
let home = Home::new("/home/ada");
let once = home.strip("/home/ada/db and /var/log/ada/x");
assert_eq!(home.strip(&once), once);
}
}