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 {
home.as_os_str()
.to_string_lossy()
.eq_ignore_ascii_case(&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 replace_case_aware(haystack: &str, needle: &str, replacement: &str) -> String {
let haystack_lower = haystack.to_ascii_lowercase();
let needle_lower = needle.to_ascii_lowercase();
let mut result = String::with_capacity(haystack.len());
let mut last_end = 0;
for (start, _) in haystack_lower.match_indices(needle_lower.as_str()) {
result.push_str(&haystack[last_end..start]);
result.push_str(replacement);
last_end = start + needle.len();
}
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 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 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),
);
}
#[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()));
}
}