Skip to content

Commit 2520778

Browse files
committed
🐛 Normalize Windows symlink path separators
Windows CreateSymbolicLinkW requires native backslash separators. Normalize forward slashes in symlink targets and link paths before calling symlink_dir/symlink_file to prevent invalid symlink creation.
1 parent 43b88e7 commit 2520778

1 file changed

Lines changed: 108 additions & 5 deletions

File tree

pna/src/fs.rs

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,22 +28,23 @@ pub fn symlink<P: AsRef<Path>, Q: AsRef<Path>>(original: P, link: Q) -> io::Resu
2828
}
2929
#[cfg(windows)]
3030
fn inner(original: &Path, link: &Path) -> io::Result<()> {
31-
use std::borrow::Cow;
31+
let original = normalize_windows_separators(original);
32+
let link = normalize_windows_separators(link);
3233
// Symlink targets are resolved relative to the link's parent directory,
3334
// not the current working directory. Resolve before checking is_dir()
3435
// so that relative targets pick the correct symlink type.
3536
let is_dir = if original.is_relative() {
3637
link.parent()
37-
.map(|p| Cow::Owned(p.join(original)))
38-
.unwrap_or(Cow::Borrowed(original))
38+
.map(|p| p.join(original.as_ref()))
39+
.unwrap_or_else(|| original.as_ref().to_path_buf())
3940
.is_dir()
4041
} else {
4142
original.is_dir()
4243
};
4344
if is_dir {
44-
os::windows::fs::symlink_dir(original, link)
45+
os::windows::fs::symlink_dir(original.as_ref(), link.as_ref())
4546
} else {
46-
os::windows::fs::symlink_file(original, link)
47+
os::windows::fs::symlink_file(original.as_ref(), link.as_ref())
4748
}
4849
}
4950
#[cfg(target_os = "wasi")]
@@ -53,6 +54,37 @@ pub fn symlink<P: AsRef<Path>, Q: AsRef<Path>>(original: P, link: Q) -> io::Resu
5354
inner(original.as_ref(), link.as_ref())
5455
}
5556

57+
/// Replaces forward-slash separators with backslashes for Windows path APIs.
58+
///
59+
/// Windows symlink reparse points store the target verbatim; non-canonical
60+
/// `/` separators break resolution under `\\?\` extended-length paths and
61+
/// confuse downstream tools that read the reparse buffer (e.g. bsdtar,
62+
/// GNU tar, 7-Zip all normalize on extract). Goes through UTF-16 to preserve
63+
/// non-UTF-8 OsString sequences (WTF-16) byte-for-byte.
64+
#[cfg(windows)]
65+
fn normalize_windows_separators(path: &Path) -> std::borrow::Cow<'_, Path> {
66+
use std::borrow::Cow;
67+
use std::ffi::OsString;
68+
use std::os::windows::ffi::{OsStrExt, OsStringExt};
69+
use std::path::PathBuf;
70+
71+
let wide: Vec<u16> = path.as_os_str().encode_wide().collect();
72+
if !wide.iter().any(|&unit| unit == u16::from(b'/')) {
73+
return Cow::Borrowed(path);
74+
}
75+
let normalized = wide
76+
.into_iter()
77+
.map(|unit| {
78+
if unit == u16::from(b'/') {
79+
u16::from(b'\\')
80+
} else {
81+
unit
82+
}
83+
})
84+
.collect::<Vec<_>>();
85+
Cow::Owned(PathBuf::from(OsString::from_wide(&normalized)))
86+
}
87+
5688
/// Removes a path by dispatching based on file type.
5789
///
5890
/// - Symlinks: removed via `remove_file` (or `remove_dir` for directory symlinks on Windows)
@@ -146,3 +178,74 @@ pub fn remove_path_all<P: AsRef<Path>>(path: P) -> io::Result<()> {
146178
pub fn remove_path<P: AsRef<Path>>(path: P) -> io::Result<()> {
147179
remove_path_with(path.as_ref(), fs::remove_dir)
148180
}
181+
182+
#[cfg(all(test, windows))]
183+
mod windows_tests {
184+
use super::normalize_windows_separators;
185+
use std::borrow::Cow;
186+
use std::ffi::OsString;
187+
use std::os::windows::ffi::{OsStrExt, OsStringExt};
188+
use std::path::{Path, PathBuf};
189+
190+
fn wide_units_of(path: &Path) -> Vec<u16> {
191+
path.as_os_str().encode_wide().collect()
192+
}
193+
194+
#[test]
195+
fn returns_borrowed_when_no_forward_slash() {
196+
let input = Path::new(r"foo\bar\baz");
197+
let result = normalize_windows_separators(input);
198+
assert!(matches!(result, Cow::Borrowed(_)));
199+
assert_eq!(result.as_ref(), input);
200+
}
201+
202+
#[test]
203+
fn converts_basic_forward_slash_to_backslash() {
204+
let result = normalize_windows_separators(Path::new("foo/bar"));
205+
assert!(matches!(result, Cow::Owned(_)));
206+
assert_eq!(result.as_ref(), Path::new(r"foo\bar"));
207+
}
208+
209+
#[test]
210+
fn preserves_existing_backslashes_in_mixed_input() {
211+
let result = normalize_windows_separators(Path::new(r"a/b\c/d"));
212+
assert_eq!(result.as_ref(), Path::new(r"a\b\c\d"));
213+
}
214+
215+
#[test]
216+
fn empty_path_returns_borrowed() {
217+
let input = Path::new("");
218+
let result = normalize_windows_separators(input);
219+
assert!(matches!(result, Cow::Borrowed(_)));
220+
assert_eq!(result.as_ref(), input);
221+
}
222+
223+
#[test]
224+
fn single_forward_slash_is_converted() {
225+
let result = normalize_windows_separators(Path::new("/"));
226+
assert_eq!(result.as_ref(), Path::new(r"\"));
227+
}
228+
229+
#[test]
230+
fn extended_length_path_with_forward_slashes_is_normalized() {
231+
let result = normalize_windows_separators(Path::new(r"\\?\C:/foo/bar"));
232+
assert_eq!(result.as_ref(), Path::new(r"\\?\C:\foo\bar"));
233+
}
234+
235+
#[test]
236+
fn lone_surrogate_is_preserved_while_slash_is_converted() {
237+
let units: [u16; 3] = [0xD800, u16::from(b'/'), u16::from(b'a')];
238+
let input = PathBuf::from(OsString::from_wide(&units));
239+
let result = normalize_windows_separators(&input);
240+
assert_eq!(
241+
wide_units_of(result.as_ref()),
242+
vec![0xD800, u16::from(b'\\'), u16::from(b'a')]
243+
);
244+
}
245+
246+
#[test]
247+
fn unicode_characters_are_preserved() {
248+
let result = normalize_windows_separators(Path::new("日本語/フォルダ"));
249+
assert_eq!(result.as_ref(), Path::new(r"日本語\フォルダ"));
250+
}
251+
}

0 commit comments

Comments
 (0)