@@ -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