Skip to content

Commit cd50017

Browse files
committed
fix: ensure consistent normalization of UNC paths with trailing separators and \\
1 parent 7c1aec1 commit cd50017

File tree

5 files changed

+101
-86
lines changed

5 files changed

+101
-86
lines changed

src/impl_sugar_path.rs

Lines changed: 99 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use std::{
22
borrow::Cow,
3+
ffi::OsString,
4+
iter::Peekable,
35
ops::Deref,
46
path::{Component, Path, PathBuf},
57
};
@@ -9,17 +11,14 @@ use smallvec::SmallVec;
911

1012
use crate::{
1113
SugarPath,
12-
utils::{ComponentVec, IntoCowPath, get_current_dir, to_normalized_components},
14+
utils::{IntoCowPath, get_current_dir},
1315
};
1416

1517
type StrVec<'a> = SmallVec<[&'a str; 8]>;
1618

1719
impl SugarPath for Path {
1820
fn normalize(&self) -> PathBuf {
19-
let peekable = self.components().peekable();
20-
let mut components = to_normalized_components(peekable);
21-
22-
normalize_inner(&mut components)
21+
normalize_inner(self.components().peekable(), self.as_os_str().len())
2322
}
2423

2524
fn absolutize(&self) -> PathBuf {
@@ -49,10 +48,9 @@ impl SugarPath for Path {
4948
// absolute path, get cwd for that drive, or the process cwd if
5049
// the drive cwd is not available. We're sure the device is not
5150
// a UNC path at this points, because UNC paths are always absolute.
52-
let mut components: ComponentVec = components.collect();
51+
let mut components: SmallVec<[Component; 8]> = components.collect();
5352
components.insert(1, Component::RootDir);
54-
let mut components = to_normalized_components(components.into_iter().peekable());
55-
normalize_inner(&mut components)
53+
normalize_inner(components.into_iter().peekable(), self.as_os_str().len())
5654
} else {
5755
base.to_mut().push(self);
5856
base.normalize()
@@ -107,7 +105,7 @@ impl SugarPath for Path {
107105
matches!(com, Component::Normal(_) | Component::Prefix(_) | Component::RootDir)
108106
};
109107

110-
// Collect components using SmallVec to avoid heap allocation for typical paths
108+
// Iterate components without intermediate allocation
111109
let base_components = base.components().filter(filter_fn);
112110
let target_components = target.components().filter(filter_fn);
113111

@@ -167,19 +165,103 @@ impl SugarPath for Path {
167165
}
168166

169167
#[inline]
170-
fn normalize_inner(components: &mut ComponentVec) -> PathBuf {
171-
if components.is_empty() {
168+
fn normalize_inner<'a>(
169+
mut components: Peekable<impl Iterator<Item = Component<'a>>>,
170+
hint_cap: usize,
171+
) -> PathBuf {
172+
let sep_byte = std::path::MAIN_SEPARATOR as u8;
173+
let mut buf: Vec<u8> = Vec::with_capacity(hint_cap);
174+
let mut has_root = false;
175+
let mut depth: usize = 0; // count of Normal segments currently in buf
176+
let mut need_sep = false;
177+
178+
// --- Prefix (Windows only) ---
179+
#[cfg(target_family = "windows")]
180+
let prefix_len: usize;
181+
#[cfg(target_family = "windows")]
182+
{
183+
if let Some(Component::Prefix(p)) = components.peek() {
184+
if let std::path::Prefix::UNC(server, share) = p.kind() {
185+
buf.extend_from_slice(b"\\\\");
186+
buf.extend_from_slice(server.as_encoded_bytes());
187+
buf.push(b'\\');
188+
buf.extend_from_slice(share.as_encoded_bytes());
189+
} else {
190+
buf.extend_from_slice(p.as_os_str().as_encoded_bytes());
191+
}
192+
components.next();
193+
}
194+
prefix_len = buf.len();
195+
}
196+
197+
// --- RootDir ---
198+
if matches!(components.peek(), Some(Component::RootDir)) {
199+
buf.push(sep_byte);
200+
has_root = true;
201+
components.next();
202+
}
203+
204+
let root_end = buf.len();
205+
206+
// --- Remaining components ---
207+
for component in components {
208+
match component {
209+
Component::Prefix(prefix) => unreachable!("Unexpected prefix for {:?}", prefix),
210+
Component::RootDir => unreachable!("Unexpected RootDir after initial position"),
211+
Component::CurDir => {}
212+
Component::ParentDir => {
213+
if depth > 0 {
214+
// Roll back the last Normal segment using memrchr.
215+
let search_region = &buf[root_end..];
216+
if let Some(pos) = memrchr(sep_byte, search_region) {
217+
buf.truncate(root_end + pos);
218+
} else {
219+
buf.truncate(root_end);
220+
}
221+
depth -= 1;
222+
need_sep = buf.len() > root_end;
223+
} else if !has_root {
224+
// Relative path going above start: write ".." literally
225+
if need_sep {
226+
buf.push(sep_byte);
227+
}
228+
buf.extend_from_slice(b"..");
229+
need_sep = true;
230+
}
231+
// else: has_root && depth == 0 → ignore (can't go above root)
232+
}
233+
Component::Normal(s) => {
234+
if need_sep {
235+
buf.push(sep_byte);
236+
}
237+
buf.extend_from_slice(s.as_encoded_bytes());
238+
depth += 1;
239+
need_sep = true;
240+
}
241+
}
242+
}
243+
244+
// --- Empty result → "." ---
245+
if buf.is_empty() {
172246
return PathBuf::from(".");
173247
}
174248

175-
if cfg!(target_family = "windows")
176-
&& components.len() == 1
177-
&& matches!(components[0], Component::Prefix(_))
178-
{
179-
components.push(Component::CurDir)
249+
// --- Prefix-only: append trailing separator or CurDir ---
250+
#[cfg(target_family = "windows")]
251+
if buf.len() == prefix_len && prefix_len > 0 {
252+
// Determine if the prefix is UNC by checking for leading "\\"
253+
if buf.len() >= 2 && buf[0] == b'\\' && buf[1] == b'\\' {
254+
buf.push(b'\\');
255+
} else {
256+
buf.push(b'.');
257+
}
180258
}
181259

182-
components.iter().collect()
260+
// SAFETY: `buf` was built entirely from:
261+
// - encoded bytes of OsStr components (valid platform encoding)
262+
// - ASCII separator bytes and ASCII '.' characters
263+
// This preserves the encoding invariants required by OsString.
264+
PathBuf::from(unsafe { OsString::from_encoded_bytes_unchecked(buf) })
183265
}
184266

185267
impl<T: Deref<Target = str>> SugarPath for T {

src/utils.rs

Lines changed: 1 addition & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
use std::{
22
borrow::Cow,
3-
iter::Peekable,
4-
path::{Component, Path, PathBuf},
3+
path::{Path, PathBuf},
54
sync::OnceLock,
65
};
76

8-
use smallvec::SmallVec;
9-
107
static CURRENT_DIR: OnceLock<PathBuf> = OnceLock::new();
118

129
pub fn get_current_dir() -> Cow<'static, Path> {
@@ -75,60 +72,3 @@ impl<'a> IntoCowPath<'a> for Cow<'a, str> {
7572
}
7673
}
7774
}
78-
79-
// Type alias for SmallVec with stack capacity of 8 (typical path depth)
80-
pub type ComponentVec<'a> = SmallVec<[Component<'a>; 8]>;
81-
82-
pub fn to_normalized_components<'a>(
83-
mut components: Peekable<impl Iterator<Item = Component<'a>>>,
84-
) -> ComponentVec<'a> {
85-
let mut ret = SmallVec::with_capacity(components.size_hint().1.unwrap_or(8));
86-
if let Some(c @ Component::Prefix(..)) = components.peek() {
87-
ret.push(*c);
88-
components.next();
89-
};
90-
91-
for component in components {
92-
match component {
93-
Component::Prefix(prefix) => unreachable!("Unexpected prefix for {:?}", prefix),
94-
Component::RootDir => {
95-
ret.push(component);
96-
}
97-
Component::CurDir => {
98-
// ignore
99-
}
100-
c @ Component::ParentDir => {
101-
// So we hit a `..` here. If the previous path segment looks like
102-
// - `c:`
103-
// - `c:../..`
104-
// - `../..`
105-
// - ``
106-
// We should preserve the `..`
107-
108-
let need_to_preserve =
109-
matches!(ret.last(), None | Some(Component::Prefix(_)) | Some(Component::ParentDir));
110-
if need_to_preserve {
111-
ret.push(c);
112-
} else {
113-
let is_last_root_dir = matches!(ret.last(), Some(Component::RootDir));
114-
if is_last_root_dir {
115-
// If the previous path segment looks like
116-
// - `c:/`
117-
// - `/`
118-
// We need to ignore the `..`
119-
} else {
120-
// This branch means the previous path segment looks like
121-
// - `c:/a/b`
122-
// - `/a/b`
123-
ret.pop();
124-
}
125-
}
126-
}
127-
c @ Component::Normal(_) => {
128-
ret.push(c);
129-
}
130-
}
131-
}
132-
133-
ret
134-
}

tests/absolutize_with.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,6 @@ fn windows_absolutize_with_unc_paths() {
9090
"..\\other".absolutize_with("\\\\server\\share\\folder"),
9191
"\\\\server\\share\\other"
9292
);
93-
// TODO: should be "\\\\other\\share" — normalize() on a UNC root produces
94-
// [Prefix, RootDir] which reconstructs with a trailing `\`.
9593
assert_eq_str!("\\\\other\\share".absolutize_with("\\\\server\\share"), "\\\\other\\share\\");
9694
}
9795

tests/normalize.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,7 @@ fn windows() {
4040
assert_eq_str!(p!("/foo/../../../bar").normalize(), "\\bar");
4141
assert_eq_str!(p!("a//b//../b").normalize(), "a\\b");
4242
assert_eq_str!(p!("a//b//./c").normalize(), "a\\b\\c");
43-
// TODO: should be "\\\\server\\share\\dir\\file.ext" — the UNC prefix preserves
44-
// original forward slashes from the input instead of normalizing to backslashes.
45-
assert_eq_str!(p!("//server/share/dir/file.ext").normalize(), "//server/share\\dir\\file.ext");
43+
assert_eq_str!(p!("//server/share/dir/file.ext").normalize(), "\\\\server\\share\\dir\\file.ext");
4644
assert_eq_str!(p!("/foo/../../../bar").normalize(), "\\bar");
4745
assert_eq_str!(p!("/a/b/c/../../../x/y/z").normalize(), "\\x\\y\\z");
4846
assert_eq_str!(p!("C:").normalize(), "C:.");

tests/relative.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,6 @@ fn windows_unc() {
7373
let cases = [
7474
("\\\\foo\\bar", "\\\\foo\\bar\\baz", "baz"),
7575
("\\\\foo\\bar\\baz-quux", "\\\\foo\\bar\\baz", "..\\baz"),
76-
// TODO: should be "\\\\foo\\baz" — relative() for cross-root UNC paths
77-
// returns the absolute target via normalize(), which produces [Prefix, RootDir]
78-
// and reconstructs with a trailing `\`.
7976
("\\\\foo\\baz-quux", "\\\\foo\\baz", "\\\\foo\\baz\\"),
8077
("\\\\foo\\bar\\baz", "\\\\foo\\bar\\baz-quux", "..\\baz-quux"),
8178
("\\\\foo\\bar\\baz", "\\\\foo\\bar", ".."),

0 commit comments

Comments
 (0)