Skip to content

Commit 41855f5

Browse files
committed
fix: canonicalize cache key separators+trailing slash, fix RwLock comment (PR #457)
1 parent 00702ee commit 41855f5

1 file changed

Lines changed: 72 additions & 18 deletions

File tree

crates/pet-fs/src/path.rs

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,13 @@ pub fn strip_trailing_separator<P: AsRef<Path>>(path: P) -> PathBuf {
114114
/// - On Windows, this function uses `GetLongPathNameW` which **preserves junction paths**
115115
/// unlike `fs::canonicalize` which would resolve them to their target.
116116
/// - For symlink resolution, use `resolve_symlink()` instead.
117-
/// - On Windows, results are memoized in a per-process cache keyed on the
118-
/// computed *absolute* path (so relative inputs remain correct across
119-
/// CWD changes). Only successful normalizations are cached, so paths
120-
/// that do not yet exist on disk can still be normalized later. The
121-
/// cache is never invalidated for the lifetime of the process.
117+
/// - On Windows, results are memoized in a per-process cache. The key is
118+
/// the computed *absolute* path with separators canonicalized (forward
119+
/// → backslash) and trailing separators stripped, so the same logical
120+
/// path is cached only once even when callers vary in style. Only
121+
/// successful normalizations are cached, so paths that do not yet exist
122+
/// on disk can still be normalized later. The cache is never invalidated
123+
/// for the lifetime of the process.
122124
///
123125
/// # Related
124126
/// - `strip_trailing_separator()` - Just removes trailing separators
@@ -149,13 +151,19 @@ pub fn norm_case<P: AsRef<Path>>(path: P) -> PathBuf {
149151
path.as_ref().to_path_buf()
150152
};
151153

154+
// Apply a cheap, deterministic pre-normalization (slash style + trailing
155+
// separator) to the cache key so the same logical path is not cached under
156+
// multiple keys (e.g. `C:\Windows`, `C:\Windows\`, and `C:/Windows`).
157+
// This is purely an in-memory string transform — no syscalls.
158+
let cache_key = cache_key_for(&absolute_path);
159+
152160
// Fast path: cache hit. Read lock is held only while we copy the
153161
// already-normalized PathBuf out. We treat lock poisoning as a
154162
// soft-miss (fall through to recompute) because `norm_case` is
155-
// hot and side-effect-free; a poisoned mutex from one bad caller
163+
// hot and side-effect-free; a poisoned RwLock from one bad caller
156164
// shouldn't permanently break path normalization for the process.
157165
if let Ok(cache) = NORM_CASE_CACHE.read() {
158-
if let Some(cached) = cache.get(&absolute_path) {
166+
if let Some(cached) = cache.get(&cache_key) {
159167
return cached.clone();
160168
}
161169
}
@@ -169,7 +177,7 @@ pub fn norm_case<P: AsRef<Path>>(path: P) -> PathBuf {
169177
// poison the cache with a non-normalized fallback for paths
170178
// that may be created later in the process.
171179
if let Ok(mut cache) = NORM_CASE_CACHE.write() {
172-
cache.insert(absolute_path, normalized.clone());
180+
cache.insert(cache_key, normalized.clone());
173181
}
174182
normalized
175183
}
@@ -178,6 +186,24 @@ pub fn norm_case<P: AsRef<Path>>(path: P) -> PathBuf {
178186
}
179187
}
180188

189+
/// Builds a deterministic cache key for `norm_case` from an absolute path.
190+
///
191+
/// We canonicalize separators (forward → backslash) and strip trailing
192+
/// separators (preserving roots) so that, for example, `C:\Windows`,
193+
/// `C:\Windows\`, and `C:/Windows` all collapse to the same key. This is
194+
/// purely an in-memory string transform — no syscalls — and exists only
195+
/// to improve cache hit rate, not to change the result of `norm_case`.
196+
#[cfg(windows)]
197+
fn cache_key_for(absolute_path: &Path) -> PathBuf {
198+
let s = absolute_path.to_string_lossy();
199+
let with_backslashes = if s.contains('/') {
200+
PathBuf::from(s.replace('/', "\\"))
201+
} else {
202+
absolute_path.to_path_buf()
203+
};
204+
strip_trailing_separator(&with_backslashes)
205+
}
206+
181207
/// Windows-specific path case normalization using GetLongPathNameW.
182208
/// This normalizes the case of path components but does NOT resolve junctions or symlinks.
183209
/// Note: GetLongPathNameW requires the path to exist on the filesystem to normalize it.
@@ -678,15 +704,17 @@ mod tests {
678704
fn test_norm_case_windows_memoizes_existing_path() {
679705
// Successful normalizations should be cached so subsequent calls
680706
// for the same input do not re-issue GetLongPathNameW.
681-
// The cache is keyed on the computed absolute path, so we pass an
682-
// absolute path here to make the assertion deterministic.
707+
// The cache is keyed on the canonicalized absolute path, so we
708+
// pass an already-canonical absolute path here to make the
709+
// assertion deterministic.
683710
let path = PathBuf::from("C:\\Windows\\System32");
684711
let first = norm_case(&path);
685-
// Cache must contain the exact (absolute) input we passed in.
712+
// Cache must contain the canonicalized form of the input.
713+
let key = cache_key_for(&path);
686714
let cached = NORM_CASE_CACHE
687715
.read()
688716
.expect("norm_case cache poisoned")
689-
.get(&path)
717+
.get(&key)
690718
.cloned();
691719
assert_eq!(
692720
cached.as_ref(),
@@ -705,18 +733,19 @@ mod tests {
705733
// those, otherwise a path that gets created later in the process
706734
// would forever return its non-normalized fallback form.
707735
let nonexistent = PathBuf::from("C:\\pet_test_norm_case_no_cache_xyz_zzz_zzz");
736+
let key = cache_key_for(&nonexistent);
708737
// Make sure we start from a clean slate for this key.
709738
NORM_CASE_CACHE
710739
.write()
711740
.expect("norm_case cache poisoned")
712-
.remove(&nonexistent);
741+
.remove(&key);
713742

714743
let _ = norm_case(&nonexistent);
715744

716745
let has_entry = NORM_CASE_CACHE
717746
.read()
718747
.expect("norm_case cache poisoned")
719-
.contains_key(&nonexistent);
748+
.contains_key(&key);
720749
assert!(
721750
!has_entry,
722751
"failed normalizations must not be inserted into the cache"
@@ -742,18 +771,43 @@ mod tests {
742771
!cache.contains_key(&relative),
743772
"cache must not be keyed on the caller-supplied relative path"
744773
);
745-
// The absolute form must be present (assuming the CWD exists,
746-
// which it always does for a running test process).
774+
// The canonicalized absolute form must be present (assuming the
775+
// CWD exists, which it always does for a running test process).
747776
let abs = std::env::current_dir()
748777
.expect("cwd must exist")
749778
.join(&relative);
779+
let key = cache_key_for(&abs);
750780
assert_eq!(
751-
cache.get(&abs).cloned(),
781+
cache.get(&key).cloned(),
752782
Some(result),
753-
"cache must be keyed on the computed absolute path"
783+
"cache must be keyed on the canonicalized absolute path"
754784
);
755785
}
756786

787+
#[test]
788+
#[cfg(windows)]
789+
fn test_norm_case_windows_cache_key_canonicalizes_separators_and_trailing_slash() {
790+
// Variant inputs (mixed separators, trailing slash) should all share
791+
// a single cache entry once one of them populates it.
792+
let canonical = PathBuf::from("C:\\Windows\\System32");
793+
let with_forward_slashes = PathBuf::from("C:/Windows/System32");
794+
let with_trailing_slash = PathBuf::from("C:\\Windows\\System32\\");
795+
796+
// Pre-populate by calling norm_case on the canonical form.
797+
let expected = norm_case(&canonical);
798+
799+
// All variants compute to the same cache key.
800+
let key_canonical = cache_key_for(&canonical);
801+
let key_forward = cache_key_for(&with_forward_slashes);
802+
let key_trailing = cache_key_for(&with_trailing_slash);
803+
assert_eq!(key_canonical, key_forward);
804+
assert_eq!(key_canonical, key_trailing);
805+
806+
// And subsequent calls for the variants return the same cached result.
807+
assert_eq!(norm_case(&with_forward_slashes), expected);
808+
assert_eq!(norm_case(&with_trailing_slash), expected);
809+
}
810+
757811
// ==================== resolve_any_symlink tests ====================
758812

759813
#[test]

0 commit comments

Comments
 (0)