Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ede3e2c
windows: recognize reserved DOS device names and strip trailing dots/…
robobun Jul 20, 2026
d82ede4
fix test cleanup
robobun Jul 20, 2026
0aee1f5
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 20, 2026
4c9b3b2
exempt UNC, \\.\, \\?\UNC and \??\ paths from DOS device translation
robobun Jul 20, 2026
32c16fb
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 20, 2026
91e448c
emit \??\ for \\.\ inputs on the NT path; fix unit test assertions; r…
robobun Jul 20, 2026
a58ae23
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 20, 2026
30c0fd1
bundler: strip trailing dot from rendered output paths so an empty [e…
robobun Jul 20, 2026
abffb95
take FD_TEST_LOCK in the new normalize_path_windows unit tests
robobun Jul 20, 2026
30981ef
use comptime_string_map for the reserved DOS device name table
robobun Jul 20, 2026
4922b21
doc: cite RtlIsDosDeviceName_U / MS naming rules for the reserved-nam…
robobun Jul 20, 2026
95649a4
simplify the trailing-trim in windows_reserved_device_name_t
robobun Jul 20, 2026
768f14c
test.concurrent for the device-name suite; strip trailing space from …
robobun Jul 20, 2026
ff1f852
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 20, 2026
2b44001
remove now-dead \\.\ -> CreateFileW fallback from open_dir_at_windows…
robobun Jul 20, 2026
c485fe9
trim comments to three lines
robobun Jul 20, 2026
44a349e
drop the trailing-dot/space strip (regresses drive-absolute write/sta…
robobun Jul 20, 2026
7a6d51d
trim remaining comment blocks in normalize_path_windows_opts to three…
robobun Jul 20, 2026
f9472a9
drop the stale 'and our NT open path' clause from the bundler trim co…
robobun Jul 20, 2026
53a19fb
revert bundler trailing-dot trim (no longer needed now the normalize …
robobun Jul 20, 2026
de663fc
trim test file header comment to three lines
robobun Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions src/paths/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,133 @@ pub fn is_absolute_windows_wtf16(p: &[u16]) -> bool {
is_absolute_windows_t::<u16>(p)
}

/// Recognise a reserved Win32 DOS device name (`NUL`, `CON`, `PRN`, `AUX`,
/// `COM1`-`COM9`, `LPT1`-`LPT9`) in a single path component and return its
/// canonical uppercase spelling, or `None`. Mirrors `RtlIsDosDeviceName_U`
/// enough that every spelling Win32's `CreateFileW` would redirect is caught:
/// trailing `.`/` ` are ignored and the match is ASCII-case-insensitive.
/// Unlike the Win32 routine no extension/stream suffix is stripped, so
/// `nul.txt` is not a device; modern Windows and Node agree on that.
pub fn windows_reserved_device_name_t<T: PathChar>(component: &[T]) -> Option<&'static [u8]> {
let mut end = component.len();
while end > 0 && (component[end - 1].eq_ascii(b'.') || component[end - 1].eq_ascii(b' ')) {
end -= 1;
}
let up = |i: usize| component[i].to_ascii_upper().to_ascii();
match end {
3 => match [up(0)?, up(1)?, up(2)?] {
[b'N', b'U', b'L'] => Some(b"NUL"),
[b'C', b'O', b'N'] => Some(b"CON"),
[b'P', b'R', b'N'] => Some(b"PRN"),
[b'A', b'U', b'X'] => Some(b"AUX"),
_ => None,
},
4 => {
let d = up(3)?;
if !d.is_ascii_digit() || d == b'0' {
return None;
}
const COM: [&[u8]; 9] = [
b"COM1", b"COM2", b"COM3", b"COM4", b"COM5", b"COM6", b"COM7", b"COM8", b"COM9",
];
const LPT: [&[u8]; 9] = [
b"LPT1", b"LPT2", b"LPT3", b"LPT4", b"LPT5", b"LPT6", b"LPT7", b"LPT8", b"LPT9",
];
let i = (d - b'1') as usize;
match [up(0)?, up(1)?, up(2)?] {
[b'C', b'O', b'M'] => Some(COM[i]),
[b'L', b'P', b'T'] => Some(LPT[i]),
_ => None,
}
}
_ => None,
}
}

#[cfg(test)]
mod windows_reserved_device_name_tests {
use super::windows_reserved_device_name_t as check;

#[track_caller]
fn both(s: &str, want: Option<&[u8]>) {
assert_eq!(check(s.as_bytes()), want, "{s:?} (u8)");
let w: Vec<u16> = s.encode_utf16().collect();
assert_eq!(check::<u16>(&w), want, "{s:?} (u16)");
}

#[test]
fn three_char_devices() {
for (s, want) in [
("nul", b"NUL" as &[u8]),
("NUL", b"NUL"),
("Nul", b"NUL"),
("nUl", b"NUL"),
("con", b"CON"),
("CoN", b"CON"),
("prn", b"PRN"),
("aux", b"AUX"),
("AUX", b"AUX"),
] {
both(s, Some(want));
}
}

#[test]
fn numbered_devices() {
both("com1", Some(b"COM1"));
both("COM9", Some(b"COM9"));
both("Com5", Some(b"COM5"));
both("lpt1", Some(b"LPT1"));
both("LpT9", Some(b"LPT9"));
both("com0", None);
both("lpt0", None);
both("com10", None);
both("comA", None);
both("coma", None);
}

#[test]
fn trailing_dots_and_spaces_ignored() {
both("nul.", Some(b"NUL"));
both("nul ", Some(b"NUL"));
both("nul. ", Some(b"NUL"));
both("nul .", Some(b"NUL"));
both("Nul .. ", Some(b"NUL"));
both("com1 ", Some(b"COM1"));
both("aux.", Some(b"AUX"));
}

#[test]
fn near_misses() {
for s in [
"",
"n",
"nu",
"null",
"nul1",
"nu1",
"nula",
"anul",
" nul",
".nul",
"con1",
"conn",
"nul.txt",
"nul:stream",
"aux1",
"co",
"com",
"lpt",
"c:nul",
] {
both(s, None);
}
// Non-ASCII in a would-be match position.
let w: Vec<u16> = "n\u{00fc}l".encode_utf16().collect();
assert_eq!(check::<u16>(&w), None);
}
}

/// Returns the leading drive
/// designator (e.g. `C:` or `\\server\share`) or empty.
///
Expand Down
193 changes: 151 additions & 42 deletions src/sys/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6644,56 +6644,96 @@
opts: NormalizePathWindowsOpts,
) -> Maybe<&'a bun_core::WStr> {
use bun_core::WStr;
use bun_paths::is_sep_any_t as is_sep;
let too_long = || Error::from_code(E::ENAMETOOLONG, Tag::open);

let mut path = path;

// `\\?\`, `\\.\`, `\??\` — namespaces Win32 does not apply DOS-path
// normalization to; exempt from the trailing-dot/space strip below.
let verbatim = path.len() >= 4
&& is_sep(path[0])
&& is_sep(path[3])
&& ((is_sep(path[1]) && (path[2] == b'?' as u16 || path[2] == b'.' as u16))
|| (path[1] == b'?' as u16 && path[2] == b'?' as u16));

// Locate the final path component: after the last separator, or after a
// leading `C:` when no separator is present.
let comp_start = path
.iter()
.rposition(|&c| is_sep(c))
.map(|i| i + 1)
.unwrap_or_else(|| {
if path.len() >= 2
&& bun_paths::resolve_path::is_drive_letter_t::<u16>(path[0])
&& path[1] == b':' as u16
{
2
} else {
0
}
});

// (a) Reserved DOS device names (`NUL`, `CON`, `PRN`, `AUX`, `COM1-9`,
// `LPT1-9`) name a device regardless of any directory prefix and
// case-insensitively; `NtCreateFile` does not know about them, so a bare
// `nul` otherwise creates a literal file that Explorer/cmd cannot remove.
if let Some(device) = bun_paths::windows_reserved_device_name_t(&path[comp_start..]) {
let prefix: &[u16] = if opts.add_nt_prefix {
bun_core::w!("\\??\\")
} else {
bun_core::w!("\\\\.\\")
};
let total = prefix.len() + device.len();
if buf.len() <= total {
return Err(too_long());
}
buf[..prefix.len()].copy_from_slice(prefix);
for (i, &b) in device.iter().enumerate() {
buf[prefix.len() + i] = b as u16;
}
buf[total] = 0;
return Ok(WStr::from_buf(&buf[..], total));
}
Comment thread
robobun marked this conversation as resolved.
Outdated

// (b) Win32 strips trailing `.` and ` ` from the final component; NT does
// not. Doing the strip here keeps both open paths agreeing on the same
// file and avoids creating names Explorer/cmd cannot address. Verbatim
// inputs keep their bytes. The strip only applies when the component has
// a non-`.`/` ` character to anchor on, so `.`/`..` reach the normalizer
// unchanged.
if !verbatim {
if let Some(last) = path[comp_start..]
.iter()
.rposition(|&c| c != b'.' as u16 && c != b' ' as u16)
{
path = &path[..comp_start + last + 1];
}
Comment thread
robobun marked this conversation as resolved.
}

Check warning on line 6712 in src/sys/lib.rs

View check run for this annotation

Claude / Claude Code Review

Trailing-dot strip runs on the raw last component, so foo.\. and foo. \ escape it

Minor completeness gap (not a regression): the trailing-dot/space strip inspects the raw `path[comp_start..]`, so an input whose last raw component is empty or all-dot — e.g. `"foo.\\."` or `"foo. \\"` — sees `rposition` return `None`, skips the strip, and then `normalize_string_generic_tz` resolves the `.`/trailing sep afterwards, leaving `foo.` / `foo. ` to reach `NtCreateFile`. Win32 canonicalizes first and strips second, so `CreateFileW` on the same inputs creates `foo`. Contrived enough not
Comment thread
robobun marked this conversation as resolved.
Outdated

if bun_paths::is_absolute_windows_wtf16(path) {
// Three special-cases that must run BEFORE
// Two special-cases that must run BEFORE
// `normalizeStringGenericTZ`, otherwise device paths get mangled:
if path.len() >= 4 {
// (a) `…\nul` / `…\NUL` → literal NT object path `\??\NUL`.
const BS_NUL_LO: [u16; 4] = [b'\\' as u16, b'n' as u16, b'u' as u16, b'l' as u16];
const BS_NUL_UP: [u16; 4] = [b'\\' as u16, b'N' as u16, b'U' as u16, b'L' as u16];
let tail = &path[path.len() - 4..];
if tail == BS_NUL_LO || tail == BS_NUL_UP {
const NT_NUL: [u16; 7] = [
b'\\' as u16,
b'?' as u16,
b'?' as u16,
b'\\' as u16,
b'N' as u16,
b'U' as u16,
b'L' as u16,
];
if buf.len() <= NT_NUL.len() {
if path.len() >= 4 && is_sep(path[1]) && is_sep(path[3]) {
// `\\.\…` device path → preserve verbatim so `\\.\pipe\foo`
// is not collapsed to `\pipe\foo` by the normalizer.
if path[2] == b'.' as u16 {
if path.len() >= buf.len() {
return Err(too_long());
}
buf[..NT_NUL.len()].copy_from_slice(&NT_NUL);
buf[NT_NUL.len()] = 0;
return Ok(WStr::from_buf(&buf[..], NT_NUL.len()));
buf[0] = b'\\' as u16;
buf[1] = b'\\' as u16;
buf[2] = b'.' as u16;
buf[3] = b'\\' as u16;
let rest = &path[4..];
buf[4..4 + rest.len()].copy_from_slice(rest);
buf[path.len()] = 0;
return Ok(WStr::from_buf(&buf[..], path.len()));
}
use bun_paths::is_sep_any_t as is_sep;
if is_sep(path[1]) && is_sep(path[3]) {
// (b) `\\.\…` device path → preserve verbatim so `\\.\pipe\foo`
// is not collapsed to `\pipe\foo` by the normalizer.
if path[2] == b'.' as u16 {
if path.len() >= buf.len() {
return Err(too_long());
}
buf[0] = b'\\' as u16;
buf[1] = b'\\' as u16;
buf[2] = b'.' as u16;
buf[3] = b'\\' as u16;
let rest = &path[4..];
buf[4..4 + rest.len()].copy_from_slice(rest);
buf[path.len()] = 0;
return Ok(WStr::from_buf(&buf[..], path.len()));
}
// (c) `\??\…` / `\\?\…` already prefixed → strip the 4-u16
// prefix before re-normalizing to avoid a double `\??\`.
if path[2] == b'?' as u16 {
path = &path[4..];
}
// `\??\…` / `\\?\…` already prefixed → strip the 4-u16
// prefix before re-normalizing to avoid a double `\??\`.
if path[2] == b'?' as u16 {
path = &path[4..];
}
}
if opts.add_nt_prefix {
Expand Down Expand Up @@ -9890,6 +9930,75 @@
}
}

#[test]
fn dos_device_names_resolve_to_nt_device() {
let cwd = Fd::cwd();
Comment thread
robobun marked this conversation as resolved.
// Bare, mixed case, relative, absolute, forward-slash, drive-relative,
// `\\?\`-prefixed (the prefix does not suppress device recognition
// here; node:fs hands every absolute path through with that prefix).
for input in [
"nul",
"NUL",
"Nul",
"nUl",
"nul.",
"nul ",
"nul. ",
"C:nul",
"sub\\nul",
"sub/nul",
"./nul",
"C:\\a\\Nul",
"C:\\a\\nul ",
"\\\\?\\C:\\a\\nul",
"\\\\?\\C:\\a\\NUL",
] {
assert_eq!(normalize(cwd, input), "\\??\\NUL", "{input:?}");
assert_eq!(normalize_opts(cwd, input, false), "\\\\.\\NUL", "{input:?}");
}
assert_eq!(normalize(cwd, "con"), "\\??\\CON");
assert_eq!(normalize(cwd, "pRn"), "\\??\\PRN");
assert_eq!(normalize(cwd, "Aux"), "\\??\\AUX");
assert_eq!(normalize(cwd, "com1"), "\\??\\COM1");
assert_eq!(normalize(cwd, "LPT9"), "\\??\\LPT9");
assert_eq!(normalize(cwd, "\\\\.\\nul"), "\\??\\NUL");
// Near-misses take the ordinary file path.
for input in ["nul.txt", "nula", "null", "com0", "com10", "nul\\x"] {
let got = normalize(cwd, input);
assert!(!got.starts_with("\\??\\NUL"), "{input:?} -> {got}");
assert!(!got.starts_with("\\??\\COM"), "{input:?} -> {got}");
}
}

#[test]
fn trailing_dots_and_spaces_stripped() {
let cwd = Fd::cwd();
// Bare relative: the stripped name is returned verbatim for
// `NtCreateFile` against `RootDirectory`.
for (input, want) in [
("foo.", "foo"),
("foo ", "foo"),
("foo. ", "foo"),
("foo..", "foo"),
("foo . ", "foo"),
(".foo.", ".foo"),
("foo.bar.", "foo.bar"),

Check failure on line 9985 in src/sys/lib.rs

View check run for this annotation

Claude / Claude Code Review

Windows unit test asserts wrong output for .foo. and foo.bar.

Two entries in this loop will fail `cargo test -p bun_sys` on Windows: `".foo."` and `"foo.bar."` strip to `.foo` / `foo.bar`, which still contain a `.`, so `classify_rel_t` sets `has_dot=true` and the bare-relative fast path at line 6802 is skipped — they resolve through `GetFinalPathNameByHandle(cwd)` and return `\Device\HarddiskVolumeN\<cwd>\.foo` / `...\foo.bar`, not the bare strings asserted here. Either drop these two entries from the verbatim loop, or assert them the way `relative_resolve
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
("foo", "foo"),
] {
assert_eq!(normalize(cwd, input), want, "{input:?}");
}
// `.` / `..` and all-dot/all-space names are left for the normalizer.
assert_ne!(normalize(cwd, ".."), ".");
assert!(normalize(cwd, ".").starts_with("\\Device\\"));
assert_eq!(normalize(cwd, "sub\\.."), normalize(cwd, "."));
// Absolute: only the final component is touched.
assert_eq!(normalize(cwd, "C:\\a\\b. "), "\\??\\C:\\a\\b");
assert_eq!(normalize(cwd, "C:\\a \\b"), "\\??\\C:\\a \\b");
// Verbatim prefixes keep their trailing characters.
assert_eq!(normalize(cwd, "\\\\?\\C:\\a\\b. "), "\\??\\C:\\a\\b. ");
assert_eq!(normalize(cwd, "\\\\.\\pipe\\name."), "\\\\.\\pipe\\name.");
}

#[test]
fn relative_resolves_to_nt_device_name() {
let _g = crate::file::tests::FD_TEST_LOCK.lock();
Expand Down
Loading
Loading