use std::path::{Component, Path};
#[must_use]
pub fn sanitize_path_for_error(path: &Path) -> String {
dirs::home_dir().map_or_else(
|| path.display().to_string(),
|home| strip_home_prefix(path, &home).unwrap_or_else(|| scrub_username(path, &home)),
)
}
fn strip_home_prefix(path: &Path, home: &Path) -> Option<String> {
let mut path_components = path.components();
for home_component in home.components() {
if !components_match(home_component, path_components.next()?) {
return None;
}
}
let mut result = String::from("~");
for component in path_components {
result.push(std::path::MAIN_SEPARATOR);
result.push_str(&component.as_os_str().to_string_lossy());
}
Some(result)
}
fn scrub_username(path: &Path, home: &Path) -> String {
let path_str = path.display().to_string();
let Some(username) = home.file_name() else {
return path_str;
};
let username = username.to_string_lossy();
if username.is_empty() {
return path_str;
}
replace_case_aware(&path_str, &username, "~")
}
#[cfg(any(windows, target_os = "macos"))]
fn components_match(home: Component<'_>, path: Component<'_>) -> bool {
normalize_and_fold(&home.as_os_str().to_string_lossy())
== normalize_and_fold(&path.as_os_str().to_string_lossy())
}
#[cfg(not(any(windows, target_os = "macos")))]
fn components_match(home: Component<'_>, path: Component<'_>) -> bool {
home == path
}
#[cfg(any(windows, target_os = "macos"))]
fn normalize_and_fold(s: &str) -> String {
use unicode_normalization::UnicodeNormalization;
s.nfc().collect::<String>().to_lowercase().nfc().collect()
}
#[cfg(any(windows, target_os = "macos"))]
fn replace_case_aware(haystack: &str, needle: &str, replacement: &str) -> String {
use unicode_normalization::UnicodeNormalization;
if needle.is_empty() {
return haystack.to_owned();
}
let haystack: String = haystack.nfc().collect();
let needle: String = needle.nfc().collect();
let needle_len = needle.chars().count();
let needle_folded = normalize_and_fold(&needle);
let boundaries: Vec<usize> = haystack
.char_indices()
.map(|(i, _)| i)
.chain(std::iter::once(haystack.len()))
.collect();
let mut result = String::with_capacity(haystack.len());
let mut last_end = 0;
let mut i = 0;
while i + needle_len < boundaries.len() {
let start = boundaries[i];
let end = boundaries[i + needle_len];
if normalize_and_fold(&haystack[start..end]) == needle_folded {
result.push_str(&haystack[last_end..start]);
result.push_str(replacement);
last_end = end;
i += needle_len;
} else {
i += 1;
}
}
result.push_str(&haystack[last_end..]);
result
}
#[cfg(not(any(windows, target_os = "macos")))]
fn replace_case_aware(haystack: &str, needle: &str, replacement: &str) -> String {
haystack.replace(needle, replacement)
}
#[must_use]
pub fn validate_path_segment(segment: &str) -> Option<Component<'_>> {
let mut components = Path::new(segment).components();
match (components.next(), components.next()) {
(Some(component @ Component::Normal(_)), None) => Some(component),
_ => None,
}
}
#[must_use]
pub fn first_disallowed_identifier_char(s: &str) -> Option<char> {
use unicode_security::GeneralSecurityProfile;
s.chars().find(|c| !c.identifier_allowed())
}
#[must_use]
pub fn contains_parent_dir(path: &Path) -> bool {
path.components().any(|c| matches!(c, Component::ParentDir))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_path_segment_accepts_plain_name() {
assert!(validate_path_segment("my-server").is_some());
}
#[test]
fn validate_path_segment_rejects_empty() {
assert!(validate_path_segment("").is_none());
}
#[test]
fn validate_path_segment_rejects_parent_traversal() {
assert!(validate_path_segment("../other").is_none());
assert!(validate_path_segment("..").is_none());
}
#[test]
fn validate_path_segment_rejects_path_separator() {
assert!(validate_path_segment("a/b").is_none());
}
#[test]
fn first_disallowed_identifier_char_accepts_plain_and_non_ascii() {
assert_eq!(first_disallowed_identifier_char("my-server"), None);
assert_eq!(first_disallowed_identifier_char("café_menu_日本語"), None);
}
#[test]
fn first_disallowed_identifier_char_rejects_zwj() {
assert_eq!(
first_disallowed_identifier_char("get_issue\u{200D}"),
Some('\u{200D}')
);
}
#[test]
fn first_disallowed_identifier_char_guard_leaves_validate_path_segment_unchanged() {
assert!(validate_path_segment("my notes").is_some());
assert!(validate_path_segment("a\u{200D}b").is_some());
}
#[test]
fn contains_parent_dir_detects_traversal() {
assert!(contains_parent_dir(Path::new("..")));
assert!(contains_parent_dir(Path::new("../b")));
assert!(contains_parent_dir(Path::new("a/../b")));
assert!(contains_parent_dir(Path::new("a/..")));
assert!(!contains_parent_dir(Path::new("a/b")));
}
#[test]
fn sanitize_path_for_error_redacts_home_directory() {
let home = dirs::home_dir().unwrap();
let under_home = home.join(".claude").join("skills");
assert_eq!(
sanitize_path_for_error(&under_home),
format!(
"~{}.claude{}skills",
std::path::MAIN_SEPARATOR,
std::path::MAIN_SEPARATOR
)
);
}
#[test]
fn sanitize_path_for_error_leaves_non_home_path_unchanged() {
assert_eq!(sanitize_path_for_error(Path::new("/tmp/x")), "/tmp/x");
}
#[cfg(windows)]
#[test]
fn sanitize_path_for_error_redacts_home_directory_with_forward_slashes() {
let home = dirs::home_dir().unwrap();
let home_str = home.display().to_string().replace('\\', "/");
let under_home = format!("{home_str}/secret-file.md");
assert_eq!(
sanitize_path_for_error(Path::new(&under_home)),
format!("~{}secret-file.md", std::path::MAIN_SEPARATOR),
);
}
#[cfg(any(windows, target_os = "macos"))]
#[test]
fn sanitize_path_for_error_redacts_home_directory_case_insensitively() {
let home = dirs::home_dir().unwrap();
let flipped_case: String = home
.display()
.to_string()
.chars()
.map(|c| {
if c.is_ascii_uppercase() {
c.to_ascii_lowercase()
} else if c.is_ascii_lowercase() {
c.to_ascii_uppercase()
} else {
c
}
})
.collect();
let under_home = format!("{flipped_case}{}secret-file.md", std::path::MAIN_SEPARATOR);
assert_eq!(
sanitize_path_for_error(Path::new(&under_home)),
format!("~{}secret-file.md", std::path::MAIN_SEPARATOR),
);
}
#[cfg(any(windows, target_os = "macos"))]
#[test]
fn components_match_is_unicode_case_insensitive() {
let home_path = Path::new("Аня");
let path_path = Path::new("аня");
let home = home_path.components().next().unwrap();
let path = path_path.components().next().unwrap();
assert!(components_match(home, path));
}
#[cfg(any(windows, target_os = "macos"))]
#[test]
fn replace_case_aware_matches_non_ascii_case_variants() {
assert_eq!(
replace_case_aware("Аня/secret.md", "аня", "~"),
"~/secret.md"
);
}
#[cfg(any(windows, target_os = "macos"))]
#[test]
fn replace_case_aware_preserves_byte_offsets_when_fold_changes_length() {
assert_eq!(replace_case_aware("aİb", "İ", "~"), "a~b");
assert_eq!(replace_case_aware("aİb", "i", "~"), "aİb");
}
#[cfg(any(windows, target_os = "macos"))]
#[test]
fn components_match_is_unicode_normalization_insensitive() {
let home_path = Path::new("Jos\u{e9}");
let path_path = Path::new("Jose\u{301}");
let home = home_path.components().next().unwrap();
let path = path_path.components().next().unwrap();
assert!(components_match(home, path));
}
#[cfg(any(windows, target_os = "macos"))]
#[test]
fn components_match_handles_fold_without_precomposed_uppercase() {
let home_path = Path::new("J\u{30C}");
let path_path = Path::new("\u{1F0}");
let home = home_path.components().next().unwrap();
let path = path_path.components().next().unwrap();
assert!(components_match(home, path));
}
#[cfg(any(windows, target_os = "macos"))]
#[test]
fn replace_case_aware_matches_nfc_needle_against_nfd_haystack_span() {
let needle = "Jos\u{e9}"; let haystack = "/Volumes/Data/Users/Jose\u{301}/notes.md"; assert_eq!(
replace_case_aware(haystack, needle, "~"),
"/Volumes/Data/Users/~/notes.md"
);
}
#[cfg(any(windows, target_os = "macos"))]
#[test]
fn replace_case_aware_matches_nfd_needle_against_nfc_haystack_span() {
let needle = "Jose\u{301}"; let haystack = "/Volumes/Data/Users/Jos\u{e9}/notes.md"; assert_eq!(
replace_case_aware(haystack, needle, "~"),
"/Volumes/Data/Users/~/notes.md"
);
}
#[cfg(any(windows, target_os = "macos"))]
#[test]
fn replace_case_aware_matches_caseless_singleton_normalization() {
assert_eq!(replace_case_aware("a \u{387} b", "\u{b7}", "~"), "a ~ b");
}
#[cfg(any(windows, target_os = "macos"))]
#[test]
fn replace_case_aware_replaces_multiple_occurrences() {
assert_eq!(
replace_case_aware("Alice/Alice/notes.md", "alice", "~"),
"~/~/notes.md"
);
}
#[cfg(any(windows, target_os = "macos"))]
#[test]
fn replace_case_aware_matches_greek_final_sigma_case_variant() {
assert_eq!(
replace_case_aware("/home/ΣΑΣ/secret.md", "σας", "~"),
"/home/~/secret.md"
);
assert_eq!(
replace_case_aware("/home/σας/secret.md", "ΣΑΣ", "~"),
"/home/~/secret.md"
);
}
#[test]
fn sanitize_path_for_error_scrubs_username_when_home_is_not_a_leading_prefix() {
let home = dirs::home_dir().unwrap();
let username = home.file_name().unwrap().to_string_lossy().into_owned();
let mut mounted = std::path::PathBuf::from("mnt");
mounted.push("snapshot");
for component in home
.components()
.filter(|c| matches!(c, Component::Normal(_)))
{
mounted.push(component.as_os_str());
}
mounted.push("secret.md");
let sanitized = sanitize_path_for_error(&mounted);
assert!(!sanitized.to_lowercase().contains(&username.to_lowercase()));
assert!(sanitized.contains('~'));
}
#[cfg(windows)]
#[test]
fn sanitize_path_for_error_scrubs_username_from_canonicalized_home_path() {
let home = dirs::home_dir().unwrap();
let username = home.file_name().unwrap().to_string_lossy().into_owned();
let canonical = std::fs::canonicalize(&home).unwrap();
let sanitized = sanitize_path_for_error(&canonical);
assert!(!sanitized.to_lowercase().contains(&username.to_lowercase()));
}
}