use std::path::{Path, PathBuf};
use unicode_normalization::UnicodeNormalization;
#[derive(Debug, Clone)]
struct Rule {
prefix: String,
sentinel: &'static str,
}
#[derive(Debug, Clone)]
pub struct PathNormalizer {
rules: Vec<Rule>,
}
impl PathNormalizer {
pub fn from_env(workspace_root: Option<&Path>) -> Self {
let mut rules = Vec::new();
push_rule_with_variants(
&mut rules,
std::env::var_os("KACHE_BASE_DIR")
.filter(|v| !v.is_empty())
.and_then(|v| canonical_string(Path::new(&v))),
"<BASE_DIR>",
);
push_rule_with_variants(
&mut rules,
std::env::current_dir()
.ok()
.and_then(|p| canonical_string(&p)),
"<WORKSPACE>",
);
push_rule_with_variants(
&mut rules,
workspace_root.and_then(canonical_string),
"<WORKSPACE>",
);
push_rule_with_variants(
&mut rules,
std::env::var_os("CARGO_TARGET_DIR").and_then(|p| canonical_string(Path::new(&p))),
"<TARGET>",
);
let cargo_home = std::env::var_os("CARGO_HOME")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".cargo")));
push_rule_with_variants(
&mut rules,
cargo_home.and_then(|p| canonical_string(&p)),
"<CARGO_HOME>",
);
let rustup_home = std::env::var_os("RUSTUP_HOME")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".rustup")));
push_rule_with_variants(
&mut rules,
rustup_home.and_then(|p| canonical_string(&p)),
"<RUSTUP_HOME>",
);
push_rule_with_variants(
&mut rules,
dirs::home_dir().and_then(|p| canonical_string(&p)),
"<HOME>",
);
for (env_key, sentinel) in [
("APPDATA", "<APPDATA>"),
("LOCALAPPDATA", "<LOCALAPPDATA>"),
("PROGRAMFILES", "<PROGRAMFILES>"),
] {
push_rule_with_variants(
&mut rules,
std::env::var_os(env_key).and_then(|v| canonical_string(Path::new(&v))),
sentinel,
);
}
push_rule_with_variants(
&mut rules,
canonical_string(&std::env::temp_dir()),
"<TMPDIR>",
);
rules.dedup_by(|a, b| a.prefix == b.prefix);
Self { rules }
}
#[allow(dead_code)]
pub fn empty() -> Self {
Self { rules: Vec::new() }
}
pub fn normalize<S: AsRef<str>>(&self, s: S) -> String {
let mut out: String = s.as_ref().nfc().collect();
for rule in &self.rules {
if rule.prefix.is_empty() {
continue;
}
out = out.replace(&rule.prefix, rule.sentinel);
}
warn_if_path_leaked(&out);
out
}
pub fn remap_args(&self) -> Vec<String> {
self.rules
.iter()
.rev()
.filter(|r| !r.prefix.is_empty())
.map(|r| format!("--remap-path-prefix={}={}", r.prefix, r.sentinel))
.collect()
}
}
pub(crate) fn check_for_path_leak(value: &str, context: &str) {
const SUSPICIOUS_PREFIXES: &[&str] = &[
"/Users/", "/home/", "/private/tmp/", "/private/var/", "/var/folders/", "C:\\Users\\", ];
for prefix in SUSPICIOUS_PREFIXES {
if let Some(idx) = value.find(prefix) {
let start = idx.saturating_sub(40);
let end = (idx + prefix.len() + 40).min(value.len());
tracing::warn!(
"residual absolute path detected in `{}` (prefix `{}`): ...{}...",
context,
prefix,
&value[start..end]
);
return;
}
}
}
fn warn_if_path_leaked(s: &str) {
check_for_path_leak(s, "PathNormalizer::normalize");
}
fn canonical_string(path: &Path) -> Option<String> {
let canon = path.canonicalize().ok()?;
let lossy = canon.to_string_lossy();
let s: String = strip_verbatim_prefix(&lossy).nfc().collect();
if s.is_empty() { None } else { Some(s) }
}
fn strip_verbatim_prefix(s: &str) -> std::borrow::Cow<'_, str> {
if let Some(unc) = s.strip_prefix(r"\\?\UNC\") {
std::borrow::Cow::Owned(format!(r"\\{unc}"))
} else if let Some(drive) = s.strip_prefix(r"\\?\") {
std::borrow::Cow::Borrowed(drive)
} else {
std::borrow::Cow::Borrowed(s)
}
}
fn push_rule_with_variants(rules: &mut Vec<Rule>, prefix: Option<String>, sentinel: &'static str) {
let Some(prefix) = prefix else { return };
if prefix.is_empty() {
return;
}
rules.push(Rule {
prefix: prefix.clone(),
sentinel,
});
if !cfg!(windows) {
return;
}
push_slash_and_case_variants(rules, &prefix, sentinel);
if let Some(short) = short_path_name(&prefix)
&& short != prefix
{
rules.push(Rule {
prefix: short.clone(),
sentinel,
});
push_slash_and_case_variants(rules, &short, sentinel);
}
}
fn push_slash_and_case_variants(rules: &mut Vec<Rule>, prefix: &str, sentinel: &'static str) {
let fs = prefix.replace('\\', "/");
if fs != prefix {
rules.push(Rule {
prefix: fs.clone(),
sentinel,
});
}
let lc = lowercase_drive_letter(prefix);
if let Some(ref lc_str) = lc
&& lc_str != prefix
{
rules.push(Rule {
prefix: lc_str.clone(),
sentinel,
});
}
if let Some(fs_lc) = lowercase_drive_letter(&fs)
&& fs_lc != fs
&& Some(&fs_lc) != lc.as_ref()
{
rules.push(Rule {
prefix: fs_lc,
sentinel,
});
}
}
#[cfg(windows)]
fn short_path_name(path: &str) -> Option<String> {
use std::ffi::{OsStr, OsString};
use std::os::windows::ffi::{OsStrExt, OsStringExt};
use windows_sys::Win32::Storage::FileSystem::GetShortPathNameW;
let wide: Vec<u16> = OsStr::new(path)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let needed = unsafe { GetShortPathNameW(wide.as_ptr(), std::ptr::null_mut(), 0) };
if needed == 0 {
return None;
}
let mut buf = vec![0u16; needed as usize];
let written = unsafe { GetShortPathNameW(wide.as_ptr(), buf.as_mut_ptr(), needed) };
if written == 0 || written >= needed {
return None;
}
Some(
OsString::from_wide(&buf[..written as usize])
.to_string_lossy()
.into_owned(),
)
}
#[cfg(not(windows))]
fn short_path_name(_path: &str) -> Option<String> {
None
}
fn lowercase_drive_letter(s: &str) -> Option<String> {
let bytes = s.as_bytes();
if bytes.len() < 2 || bytes[1] != b':' || !bytes[0].is_ascii_uppercase() {
return None;
}
let mut out = s.to_string();
unsafe {
out.as_bytes_mut()[0] = bytes[0].to_ascii_lowercase();
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn empty_normalizer_is_identity() {
let n = PathNormalizer::empty();
assert_eq!(n.normalize("/anything/at/all"), "/anything/at/all");
assert_eq!(n.normalize(""), "");
}
#[test]
fn canonicalizes_workspace_prefix_and_replaces_with_sentinel() {
let dir = TempDir::new().unwrap();
let n = PathNormalizer::from_env(Some(dir.path()));
let canonical = dir.path().canonicalize().unwrap();
let input = format!("{}/src/main.rs", canonical.display());
assert!(
n.normalize(&input).contains("<WORKSPACE>"),
"got {} for input {input}",
n.normalize(&input)
);
}
#[test]
fn workspace_rule_strips_macos_private_tmp_symlink() {
if !cfg!(target_os = "macos") {
return;
}
let symlink_root = Path::new("/tmp");
let unique = format!("kache-pn-test-{}", std::process::id());
let real_dir = symlink_root.join(&unique);
let _ = fs::create_dir_all(&real_dir);
let n = PathNormalizer::from_env(Some(&real_dir));
let private_form = format!("/private/tmp/{unique}/target/release/build/foo/out");
let normalized = n.normalize(&private_form);
let _ = fs::remove_dir_all(&real_dir);
assert!(
normalized.starts_with("<WORKSPACE>"),
"expected <WORKSPACE> sentinel, got {normalized:?}"
);
}
#[test]
fn home_rule_normalizes_paths_inside_home() {
let n = PathNormalizer::from_env(None);
if let Some(home) = dirs::home_dir().and_then(|p| p.canonicalize().ok()) {
let input = format!("{}/some/thing", home.display());
let out = n.normalize(&input);
assert!(
out.starts_with('<'),
"expected a sentinel prefix, got {out:?}"
);
}
}
#[test]
fn empty_prefix_does_not_corrupt_input() {
let n = PathNormalizer {
rules: vec![Rule {
prefix: String::new(),
sentinel: "<NEVER>",
}],
};
assert_eq!(n.normalize("hello world"), "hello world");
}
#[test]
fn unmatched_input_passes_through_unchanged() {
let n = PathNormalizer::from_env(None);
let input = "this/path/does/not/match/anything/local";
assert_eq!(n.normalize(input), input);
}
#[test]
fn from_env_includes_rustup_home_rule_when_dir_exists() {
let rustup_dir = dirs::home_dir().map(|h| h.join(".rustup"));
let Some(dir) = rustup_dir else {
return;
};
if !dir.exists() {
return;
}
let n = PathNormalizer::from_env(None);
let canonical = dir.canonicalize().unwrap();
let input = format!("{}/toolchains/stable/bin/rustc", canonical.display());
let out = n.normalize(&input);
assert!(
out.contains("<RUSTUP_HOME>") || out.contains("<HOME>"),
"expected rustup or home sentinel in {out:?}"
);
}
#[test]
fn from_env_includes_tempdir_rule() {
let n = PathNormalizer::from_env(None);
let temp = std::env::temp_dir().canonicalize().unwrap();
let input = format!("{}/some-build-artifact", temp.display());
let out = n.normalize(&input);
assert!(
out.contains("<TMPDIR>") || out.contains("<HOME>") || out.starts_with('<'),
"expected a sentinel for tempdir-based path, got {out:?}"
);
}
#[test]
fn from_env_includes_cwd_rule_for_comp_dir() {
let cwd = std::env::current_dir().unwrap();
let canonical = canonical_string(&cwd).expect("cwd must canonicalize");
let n = PathNormalizer::from_env(None);
let normalized = n.normalize(format!("{canonical}/src/main.rs"));
assert!(
normalized.starts_with('<'),
"CWD path should normalize to a sentinel, got {normalized:?}"
);
let remaps = n.remap_args();
assert!(
remaps
.iter()
.any(|a| a == &format!("--remap-path-prefix={canonical}=<WORKSPACE>")),
"from_env must emit a CWD remap arg; got {remaps:?}"
);
}
#[test]
fn remap_args_emits_one_flag_per_rule_in_declaration_order() {
let dir = TempDir::new().unwrap();
let n = PathNormalizer::from_env(Some(dir.path()));
let args = n.remap_args();
for arg in &args {
assert!(
arg.starts_with("--remap-path-prefix="),
"unexpected arg shape: {arg:?}"
);
let body = arg.strip_prefix("--remap-path-prefix=").unwrap();
assert!(
body.contains('='),
"missing `=PREFIX=SENTINEL` shape: {arg:?}"
);
}
if let (Some(ws_idx), Some(home_idx)) = (
args.iter().position(|a| a.contains("<WORKSPACE>")),
args.iter().position(|a| a.contains("<HOME>")),
) {
assert!(
home_idx < ws_idx,
"HOME must come before WORKSPACE so rustc's last-match-wins \
lets WORKSPACE override; got:\n{args:#?}"
);
}
}
#[test]
fn remap_args_skips_empty_prefixes() {
let n = PathNormalizer {
rules: vec![Rule {
prefix: String::new(),
sentinel: "<NEVER>",
}],
};
assert!(n.remap_args().is_empty());
}
fn rules_for(n: &PathNormalizer) -> Vec<(String, &'static str)> {
n.rules
.iter()
.map(|r| (r.prefix.clone(), r.sentinel))
.collect()
}
#[test]
fn lowercase_drive_letter_only_touches_first_byte() {
assert_eq!(
lowercase_drive_letter("C:\\Users\\Alice"),
Some("c:\\Users\\Alice".to_string())
);
assert_eq!(
lowercase_drive_letter("D:/Projects/Foo"),
Some("d:/Projects/Foo".to_string())
);
}
#[test]
fn lowercase_drive_letter_returns_none_for_non_drive_paths() {
assert_eq!(lowercase_drive_letter("/unix/path"), None);
assert_eq!(lowercase_drive_letter("c:\\already\\lower"), None);
assert_eq!(lowercase_drive_letter("C"), None);
assert_eq!(lowercase_drive_letter(""), None);
assert_eq!(lowercase_drive_letter("CD"), None); assert_eq!(lowercase_drive_letter("1:\\foo"), None); }
#[test]
fn push_rule_with_variants_adds_only_canonical_form_on_unix() {
if cfg!(windows) {
return;
}
let mut rules = Vec::new();
push_rule_with_variants(
&mut rules,
Some("/Users/alice/.cargo".to_string()),
"<CARGO_HOME>",
);
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].prefix, "/Users/alice/.cargo");
assert_eq!(rules[0].sentinel, "<CARGO_HOME>");
}
#[test]
fn push_rule_with_variants_expands_on_windows() {
if !cfg!(windows) {
return;
}
let mut rules = Vec::new();
push_rule_with_variants(
&mut rules,
Some("C:\\Users\\Alice\\.cargo".to_string()),
"<CARGO_HOME>",
);
let prefixes: Vec<&str> = rules.iter().map(|r| r.prefix.as_str()).collect();
assert!(prefixes.contains(&"C:\\Users\\Alice\\.cargo"));
assert!(prefixes.contains(&"C:/Users/Alice/.cargo"));
assert!(prefixes.contains(&"c:\\Users\\Alice\\.cargo"));
assert!(prefixes.contains(&"c:/Users/Alice/.cargo"));
assert!(rules.iter().all(|r| r.sentinel == "<CARGO_HOME>"));
}
#[cfg(windows)]
#[test]
fn push_rule_with_variants_adds_8dot3_short_name() {
let tmp = TempDir::new().unwrap();
let long_dir = tmp.path().join("Long Program Dir");
fs::create_dir(&long_dir).unwrap();
let canonical = canonical_string(&long_dir).expect("canonicalize long dir");
let Some(short) = short_path_name(&canonical) else {
return;
};
if short == canonical {
return;
}
let mut rules = Vec::new();
push_rule_with_variants(&mut rules, Some(canonical), "<BASE_DIR>");
let n = PathNormalizer { rules };
let input = format!("{short}\\src\\main.rs");
let out = n.normalize(&input);
assert!(
out.contains("<BASE_DIR>"),
"8.3 short-name input {input:?} should normalize via the short variant, got {out:?}"
);
}
#[test]
fn push_rule_with_variants_skips_empty_and_none() {
let mut rules = Vec::new();
push_rule_with_variants(&mut rules, None, "<NEVER>");
push_rule_with_variants(&mut rules, Some(String::new()), "<NEVER>");
assert!(rules.is_empty());
}
#[test]
fn normalize_matches_any_variant_form() {
let mut rules = Vec::new();
push_rule_with_variants(
&mut rules,
Some("C:\\Users\\Alice\\.cargo".to_string()),
"<CARGO_HOME>",
);
let n = PathNormalizer { rules };
let inputs_and_expectations: &[(&str, bool)] = &[
("C:\\Users\\Alice\\.cargo\\registry\\foo", true), ("C:/Users/Alice/.cargo/registry/foo", cfg!(windows)),
("c:\\Users\\Alice\\.cargo\\registry\\foo", cfg!(windows)),
("c:/Users/Alice/.cargo/registry/foo", cfg!(windows)),
("/Users/alice/.cargo/registry/foo", false), ];
for (input, should_match) in inputs_and_expectations {
let out = n.normalize(input);
let matched = out.contains("<CARGO_HOME>");
assert_eq!(
matched, *should_match,
"input {input:?}: expected match={should_match}, got out={out:?}"
);
}
}
#[test]
fn normalize_does_not_transform_unmatched_input() {
let n = PathNormalizer::empty();
let weird_inputs = &[
"C:\\Users\\foo",
"/unix/with\\backslash/mixed",
r"\\?\C:\extended\length",
"\\//\\/", "no path here at all",
];
for input in weird_inputs {
assert_eq!(
n.normalize(input),
*input,
"unmatched input {input:?} must pass through unchanged"
);
}
}
#[test]
fn rules_dedup_keeps_first_for_identical_canonicalization() {
let mut rules = Vec::new();
push_rule_with_variants(&mut rules, Some("/same/path".to_string()), "<FIRST>");
push_rule_with_variants(&mut rules, Some("/same/path".to_string()), "<SECOND>");
rules.dedup_by(|a, b| a.prefix == b.prefix);
let names: Vec<_> = rules_for(&PathNormalizer { rules }).into_iter().collect();
assert_eq!(names.len(), 1);
assert_eq!(names[0].1, "<FIRST>");
}
#[test]
fn leak_detector_does_not_panic_on_unsentineled_paths() {
let n = PathNormalizer::empty();
let _ = n.normalize("/Users/alice/leaked/path");
let _ = n.normalize("/home/bob/leaked/path");
let _ = n.normalize("C:\\Users\\charlie\\leaked");
}
fn pn_with_rules(rules: Vec<(&str, &'static str)>) -> PathNormalizer {
PathNormalizer {
rules: rules
.into_iter()
.map(|(p, s)| Rule {
prefix: p.to_string(),
sentinel: s,
})
.collect(),
}
}
#[test]
fn normalize_replaces_all_occurrences_of_same_prefix() {
let n = pn_with_rules(vec![("/ws", "<W>")]);
let input = "-L /ws/lib -L /ws/build/deps -L /ws/extra";
let out = n.normalize(input);
assert_eq!(out, "-L <W>/lib -L <W>/build/deps -L <W>/extra");
}
#[test]
fn normalize_handles_input_equal_to_prefix() {
let n = pn_with_rules(vec![("/ws", "<W>")]);
assert_eq!(n.normalize("/ws"), "<W>");
}
#[test]
fn normalize_chains_multiple_distinct_prefixes_in_one_input() {
let n = pn_with_rules(vec![("/ws", "<W>"), ("/home/u/.cargo", "<C>")]);
let input = "-L /ws/lib -L /home/u/.cargo/registry/src/foo";
let out = n.normalize(input);
assert_eq!(out, "-L <W>/lib -L <C>/registry/src/foo");
}
#[test]
fn most_specific_prefix_wins_when_nested() {
let n = pn_with_rules(vec![("/home/u/projects/ws", "<W>"), ("/home/u", "<H>")]);
let input = "/home/u/projects/ws/src/lib.rs";
let out = n.normalize(input);
assert_eq!(out, "<W>/src/lib.rs");
let sibling = "/home/u/other/foo.rs";
assert_eq!(n.normalize(sibling), "<H>/other/foo.rs");
}
#[test]
fn normalize_is_idempotent_on_already_sentinelized_input() {
let n = pn_with_rules(vec![("/home/u", "<HOME>"), ("/workspace", "<WORKSPACE>")]);
let input = "/home/u/projects/foo /workspace/src/main.rs";
let once = n.normalize(input);
let twice = n.normalize(&once);
assert_eq!(once, twice, "normalize is not idempotent");
assert!(once.contains("<HOME>"));
assert!(once.contains("<WORKSPACE>"));
}
#[test]
fn normalize_substring_match_is_documented_limitation() {
let n = pn_with_rules(vec![("/home/u", "<H>")]);
assert_eq!(n.normalize("/home/usr/foo"), "<H>sr/foo");
assert_eq!(n.normalize("/home/u/foo"), "<H>/foo");
}
#[test]
fn normalize_handles_realistic_out_dir_value() {
let n = pn_with_rules(vec![("/Users/alice/projects/myrepo", "<WORKSPACE>")]);
let out_dir =
"/Users/alice/projects/myrepo/target/release/build/serde-65d43fa14511931c/out";
assert_eq!(
n.normalize(out_dir),
"<WORKSPACE>/target/release/build/serde-65d43fa14511931c/out"
);
}
#[test]
fn normalize_handles_realistic_rustflags_value() {
let n = pn_with_rules(vec![
("/Users/alice/.cargo", "<CARGO_HOME>"),
("/Users/alice/projects/myrepo", "<WORKSPACE>"),
]);
let flags = "-L /Users/alice/.cargo/registry/cache/foo \
-L /Users/alice/projects/myrepo/target/release/deps \
-C link-arg=-Wl,-rpath,/system/lib";
let out = n.normalize(flags);
assert!(out.contains("<CARGO_HOME>/registry/cache/foo"));
assert!(out.contains("<WORKSPACE>/target/release/deps"));
assert!(out.contains("/system/lib"));
}
#[test]
fn lowercase_drive_letter_handles_drive_root_alone() {
assert_eq!(lowercase_drive_letter("C:\\"), Some("c:\\".to_string()));
assert_eq!(lowercase_drive_letter("C:"), Some("c:".to_string()));
assert_eq!(lowercase_drive_letter("D:/"), Some("d:/".to_string()));
}
#[test]
fn windows_variants_are_distinct_for_distinct_canonical_forms() {
if !cfg!(windows) {
return;
}
let mut rules = Vec::new();
push_rule_with_variants(
&mut rules,
Some("c:/users/alice/.cargo".to_string()),
"<CARGO_HOME>",
);
let prefixes: Vec<&str> = rules.iter().map(|r| r.prefix.as_str()).collect();
assert!(prefixes.contains(&"c:/users/alice/.cargo"));
assert!(prefixes.contains(&"c:\\users\\alice\\.cargo"));
assert!(!prefixes.iter().any(|p| p.starts_with("C:")));
}
#[test]
fn remap_args_emits_all_windows_variants_with_same_sentinel() {
if !cfg!(windows) {
return;
}
let mut rules = Vec::new();
push_rule_with_variants(
&mut rules,
Some("C:\\Users\\Alice\\.cargo".to_string()),
"<CARGO_HOME>",
);
let n = PathNormalizer { rules };
let args = n.remap_args();
assert_eq!(args.len(), 4);
for arg in &args {
assert!(
arg.ends_with("=<CARGO_HOME>"),
"every variant maps to the same sentinel; got {arg:?}"
);
}
}
#[test]
fn lowercase_drive_letter_does_not_panic_on_multibyte_first_char() {
assert_eq!(lowercase_drive_letter("É:foo"), None);
assert_eq!(lowercase_drive_letter("日本:"), None);
assert_eq!(lowercase_drive_letter("é"), None); assert_eq!(lowercase_drive_letter("C:foo"), Some("c:foo".to_string()));
}
#[test]
fn normalize_handles_unicode_paths_correctly() {
let n = pn_with_rules(vec![("/Users/José/.cargo", "<CARGO_HOME>")]);
let input = "/Users/José/.cargo/registry/src/foo";
assert_eq!(n.normalize(input), "<CARGO_HOME>/registry/src/foo");
}
#[test]
fn normalize_matches_across_nfc_nfd_unicode_normalization_forms() {
let nfc_prefix = "/Users/Jos\u{00E9}/.cargo"; let nfd_input = "/Users/Jos\u{0065}\u{0301}/.cargo/registry/foo";
assert_ne!(
nfc_prefix.as_bytes(),
&nfd_input.as_bytes()[..nfc_prefix.len()]
);
let n = pn_with_rules(vec![(nfc_prefix, "<CARGO_HOME>")]);
let out = n.normalize(nfd_input);
assert_eq!(out, "<CARGO_HOME>/registry/foo");
let nfc_input = "/Users/Jos\u{00E9}/.cargo/registry/bar";
assert_eq!(n.normalize(nfc_input), "<CARGO_HOME>/registry/bar");
}
#[test]
fn strip_verbatim_prefix_removes_extended_length_marker() {
assert_eq!(strip_verbatim_prefix(r"\\?\C:\proj\out"), r"C:\proj\out");
assert_eq!(
strip_verbatim_prefix(r"\\?\UNC\server\share\x"),
r"\\server\share\x"
);
assert_eq!(strip_verbatim_prefix(r"C:\proj\out"), r"C:\proj\out");
assert_eq!(strip_verbatim_prefix("/home/u/proj"), "/home/u/proj");
}
#[test]
fn canonical_string_normalizes_to_nfc() {
let dir = TempDir::new().unwrap();
let nfc_name = "Jos\u{00E9}";
let subdir = dir.path().join(nfc_name);
std::fs::create_dir(&subdir).unwrap();
let result = canonical_string(&subdir).expect("canonicalize should succeed");
let renormalized: String = result.nfc().collect();
assert_eq!(
result, renormalized,
"canonical_string output must be in NFC form, got {result:?}"
);
}
#[test]
fn normalize_preserves_unicode_outside_matched_prefix() {
let n = pn_with_rules(vec![("/ws", "<W>")]);
let input = "/ws/José/中文/файл.rs"; let out = n.normalize(input);
assert_eq!(out, "<W>/José/中文/файл.rs");
}
#[test]
fn from_env_construction_is_deterministic() {
let dir = TempDir::new().unwrap();
let n1 = PathNormalizer::from_env(Some(dir.path()));
let n2 = PathNormalizer::from_env(Some(dir.path()));
let p1: Vec<(String, &str)> = n1
.rules
.iter()
.map(|r| (r.prefix.clone(), r.sentinel))
.collect();
let p2: Vec<(String, &str)> = n2
.rules
.iter()
.map(|r| (r.prefix.clone(), r.sentinel))
.collect();
assert_eq!(p1, p2);
}
}