Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
70 changes: 56 additions & 14 deletions src/bun_core/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4053,23 +4053,14 @@ fn getcwd_len(buf: &mut PathBuffer) -> crate::CrateResult<usize> {
}
#[cfg(windows)]
{
// Windows: wrap
// `kernel32.GetCurrentDirectoryW` and transcode WTF-16 → WTF-8.
unsafe extern "system" {
fn GetCurrentDirectoryW(nBufferLength: u32, lpBuffer: *mut u16) -> u32;
}
let mut wbuf = WPathBuffer::ZEROED;
// SAFETY: `wbuf` has `PATH_MAX_WIDE` writable u16 units.
let n = unsafe { GetCurrentDirectoryW(wbuf.0.len() as u32, wbuf.0.as_mut_ptr()) } as usize;
if n == 0 {
return Err(crate::CrateError::Unexpected);
}
if n >= wbuf.0.len() {
return Err(crate::CrateError::NameTooLong);
}
let src: &[u16] = match getcwd_w(&mut wbuf.0) {
Ok(cwd) => cwd,
Err(GetcwdError::Os) => return Err(crate::CrateError::Unexpected),
Err(GetcwdError::NameTooLong) => return Err(crate::CrateError::NameTooLong),
};
// WTF-16 → WTF-8 into the caller's `PathBuffer`. Surrogate pairs are
// combined; unpaired surrogates are encoded as 3-byte WTF-8.
let src = &wbuf.0[..n];
let out = &mut buf.0;
let mut wi = 0usize;
let mut bi = 0usize;
Expand All @@ -4094,6 +4085,57 @@ fn getcwd_len(buf: &mut PathBuffer) -> crate::CrateResult<usize> {
}
}

/// Why [`getcwd_w`] has no working directory to give.
#[cfg(windows)]
pub enum GetcwdError {
/// `GetCurrentDirectoryW` failed, and `GetLastError` still says why.
Os,
NameTooLong,
}

/// The working directory, in the spelling of [`without_verbatim_prefix_w`]. Windows keeps a `\\?\` prefix from a start in, or a chdir into, `\\?\C:\dir`, and `uv_cwd` gives that prefix back.
#[cfg(windows)]
pub fn getcwd_w(buf: &mut [u16]) -> Result<&mut [u16], GetcwdError> {
unsafe extern "system" {
fn GetCurrentDirectoryW(nBufferLength: u32, lpBuffer: *mut u16) -> u32;
}
// SAFETY: `buf` has `buf.len()` writable u16 units.
let len = unsafe { GetCurrentDirectoryW(buf.len() as u32, buf.as_mut_ptr()) } as usize;
if len == 0 {
return Err(GetcwdError::Os);
}
// A buffer that is too small gets the length it needs, NUL included.
if len >= buf.len() {
return Err(GetcwdError::NameTooLong);
}
Ok(without_verbatim_prefix_w(&mut buf[..len]))
Comment thread
robobun marked this conversation as resolved.
Resolved
}

/// `\\?\C:\dir` is `C:\dir` and `\\?\UNC\server\share` is `\\server\share`, in place. Any other path comes back whole.
pub fn without_verbatim_prefix_w(path: &mut [u16]) -> &mut [u16] {
const SEP: u16 = b'\\' as u16;
const QUESTION: u16 = b'?' as u16;
const COLON: u16 = b':' as u16;
let is = |unit: u16, letter: u8| {
u8::try_from(unit).is_ok_and(|unit| unit.eq_ignore_ascii_case(&letter))
};
match *path {
[SEP, SEP, QUESTION, SEP, drive, COLON, SEP, ..]
if u8::try_from(drive).is_ok_and(|drive| drive.is_ascii_alphabetic()) =>
{
&mut path[4..]
Comment thread
robobun marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
[SEP, SEP, QUESTION, SEP, u, n, c, SEP, server, ..]
if is(u, b'U') && is(n, b'N') && is(c, b'C') && server != SEP =>
{
// The `C` of `UNC` becomes the second `\` of `\\server`.
path[6] = SEP;
&mut path[6..]
}
_ => path,
}
}

// ── which ─────────────────────────────────────────────────────────────────
// `bun.which` lives in the `bun_which` crate (tier-2, full Windows PATHEXT
// support). bun_core cannot re-export it (bun_which → bun_core dep cycle), so
Expand Down
47 changes: 47 additions & 0 deletions src/paths/string_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -705,4 +705,51 @@ mod tests {
assert!(fits_in_wide_path_buffer(&vec![0x80u8; 32758]));
assert!(fits_in_wide_path_buffer(&vec![0x80u8; 32757]));
}

#[test]
fn without_verbatim_prefix_w_gives_the_plain_spelling() {
let plain = |path: &str| {
let mut wide: Vec<u16> = path.encode_utf16().collect();
String::from_utf16(bun_core::without_verbatim_prefix_w(&mut wide)).unwrap()
};
for (path, spelled) in [
(r"\\?\C:\", r"C:\"),
(r"\\?\c:\dir\file.ts", r"c:\dir\file.ts"),
(r"\\?\UNC\server\share", r"\\server\share"),
(r"\\?\unc\server\share\dir", r"\\server\share\dir"),
] {
assert_eq!(plain(path), spelled);
}
for whole in [
// Plain already.
"",
r"C:\dir",
r"\\server\share\dir",
// No plain spelling.
r"\\?\Volume{b75e2c83-0000-0000-0000-602f00000000}\dir",
r"\\?\GLOBALROOT\Device\HarddiskVolume1\dir",
r"\\.\C:\dir",
r"\??\C:\dir",
// Not a directory of a drive or of a share.
r"\\?\",
r"\\?\C:",
r"\\?\C:dir",
r"\\?\1:\dir",
r"\\?\UNC",
r"\\?\UNC\",
r"\\?\UNC\\share",
r"\\?\UNCserver\share",
r"\\?\XNC\server\share",
r"\\?\UXC\server\share",
r"\\?\UNX\server\share",
// A unit above U+00FF whose low byte is `C`, and three whose low bytes are `UNC`.
"\\\\?\\\u{0143}:\\dir",
"\\\\?\\\u{0155}\u{014E}\u{0143}\\server\\share",
// Only `\` is a separator after the prefix.
"//?/C:/dir",
r"\\?\C:/dir",
] {
assert_eq!(plain(whole), whole);
}
}
}
6 changes: 5 additions & 1 deletion src/runtime/node/node_fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8449,8 +8449,12 @@ impl NodeFS {
&[dir, target_path],
)
.len();
self.sync_error_buf[0..4].copy_from_slice(&paths::windows::LONG_PATH_PREFIX_U8);
self.sync_error_buf[4 + target_len] = 0;
// A working directory with no plain spelling (a volume GUID) keeps its `\\?\`.
if self.sync_error_buf[4..].starts_with(&paths::windows::LONG_PATH_PREFIX_U8) {
break 'target ZStr::from_buf(&self.sync_error_buf[4..], target_len);
}
self.sync_error_buf[0..4].copy_from_slice(&paths::windows::LONG_PATH_PREFIX_U8);
Comment thread
robobun marked this conversation as resolved.
Resolved
break 'target ZStr::from_buf(&self.sync_error_buf[..], 4 + target_len);
}
if paths::is_absolute(target_path) {
Expand Down
1 change: 1 addition & 0 deletions src/runtime/node/node_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@ mod _impl {
}
// Real syscall (not the resolver's cached top_level_dir): Node's
// process.cwd() calls uv_cwd() so a deleted cwd must surface here.
// Unlike uv_cwd(), it gives `\\?\C:\dir` as `C:\dir` and `\\?\UNC\server\share` as `\\server\share` (`bun_core::getcwd_w`).
let mut buf = bun_paths::path_buffer_pool::get();
match bun_sys::getcwd(&mut buf[..]) {
bun_sys::Result::Ok(len) => {
Expand Down
29 changes: 14 additions & 15 deletions src/sys/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4108,22 +4108,21 @@ mod windows_impl {
Ok(Fd::from_native(target as _))
}
pub fn getcwd(buf: &mut [u8]) -> Maybe<usize> {
// GetCurrentDirectoryW + WTF16→UTF8.
let mut wbuf = bun_paths::w_path_buffer_pool::get();
let len =
unsafe { w::kernel32::GetCurrentDirectoryW(wbuf.len() as u32, wbuf.as_mut_ptr()) };
if len == 0 {
return Err(Error::from_win32(w::Win32Error::get(), Tag::getcwd));
}
// MSDN: when `nBufferLength` is too small `GetCurrentDirectoryW`
// returns the *required* size (incl. NUL), which can exceed
// `wbuf.len()` under long-path opt-in. Guard so the slice below
// surfaces ENAMETOOLONG instead of panicking on OOB.
if len as usize > wbuf.len() {
return Err(Error::new(E::ENAMETOOLONG, Tag::getcwd));
}
let utf8 = bun_paths::string_paths::from_w_path(buf, &wbuf[..len as usize]);
Ok(utf8.len())
let cwd = match bun_core::getcwd_w(&mut wbuf[..]) {
Ok(cwd) => cwd,
Err(bun_core::GetcwdError::Os) => {
return Err(Error::from_win32(w::Win32Error::get(), Tag::getcwd));
}
Err(bun_core::GetcwdError::NameTooLong) => {
return Err(Error::new(E::ENAMETOOLONG, Tag::getcwd));
}
};
// Not `from_w_path`: it also drops the `\\?\` of a path that has no plain spelling.
let last = buf.len() - 1;
let written = bun_core::strings::copy_utf16_into_utf8(&mut buf[..last], cwd).written;
Comment thread
robobun marked this conversation as resolved.
Resolved
buf[written] = 0;
Ok(written)
}
pub fn mkdirat(dir: impl AsFd, path: &ZStr, _mode: Mode) -> Maybe<()> {
let dir = dir.as_fd();
Expand Down
33 changes: 33 additions & 0 deletions test/bake/dev/production.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,39 @@ export default function () {
expect(exitCode).toBe(0);
});

// Windows keeps a `\\?\` prefix in the working directory of a process that starts in a directory spelled with one.
// https://github.com/oven-sh/bun/issues/11953
test.skipIf(!isWindows)("builds from a working directory spelled with the \\\\?\\ prefix", async () => {
using dir = tempDir("bake-production-verbatim-cwd", {
"server.ts": `
export function render() {
return new Response("");
}
export async function prerender(meta) {
return { files: { "index.html": meta.pageModule.default() } };
}
`,
"routes/index.ts": `export default () => "homepage";`,
"app.ts": `
const fsr = { root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts" };
export default { app: { framework: { fileSystemRouterTypes: [fsr] } } };
`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "build", "--app", "./app.ts"],
env: bunEnv,
cwd: "\\\\?\\" + String(dir),
stdout: "ignore",
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
const page = await Bun.file(path.join(String(dir), "dist", "index.html"))
.text()
.catch(() => null);
expect({ page, stderr, exitCode }).toEqual({ page: "homepage", stderr: expect.any(String), exitCode: 0 });
});

test("fails the build for a route file with an invalid name", async () => {
using dir = tempDir("bake-production-invalid-route", {
"app.ts": `export default { app: { framework: ${JSON.stringify(minimalFramework)} } };`,
Expand Down
40 changes: 40 additions & 0 deletions test/cli/install/bun-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9715,6 +9715,46 @@ describe.concurrent("bun-install", () => {
});
});

// https://github.com/oven-sh/bun/issues/11953
//
// Windows keeps a `\\?\` prefix in the working directory of a process that starts in a directory spelled with one.
// Rust's `fs::canonicalize` gives that spelling, and `Command::current_dir` of Rust before 1.88 starts `bun install`
// in it.
describe.skipIf(!isWindows)("project in a directory spelled with the \\\\?\\ prefix", () => {
it.each(["hoisted", "isolated"] as const)("bun install with the %s linker", async linker => {
await withContext({ linker }, async ctx => {
setContextHandler(ctx, dummyRegistryForContext(ctx, []));
await writeFile(
join(ctx.package_dir, "package.json"),
JSON.stringify({
name: "foo",
version: "0.0.1",
dependencies: { bar: "^0" },
scripts: { postinstall: "echo INIT_CWD=$INIT_CWD" },
}),
);

await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: "\\\\?\\" + ctx.package_dir,
stdout: "pipe",
stdin: "ignore",
stderr: "pipe",
env,
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toContain("Saved lockfile");
expect(stdout).toContain("+ bar@0.0.2");
expect(stdout.split(/\r?\n/)).toContain(`INIT_CWD=${ctx.package_dir}`);
expect(await file(join(ctx.package_dir, "node_modules", "bar", "package.json")).json()).toEqual({
name: "bar",
version: "0.0.2",
});
expect(exitCode).toBe(0);
});
});
});

// https://github.com/oven-sh/bun/issues/19088
//
// Workspace package.jsons are parsed without the root's duplicate check, so a name listed in
Expand Down
41 changes: 41 additions & 0 deletions test/internal/source-lints/windows-cwd-readers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Glob } from "bun";
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import path from "node:path";

// Windows keeps a `\\?\` prefix in the working directory of a process that starts in, or changes into, a directory
// spelled with one. `bun_core::getcwd_w` reads the working directory without that prefix, and `bun_core::getcwd` and
// `bun_sys::getcwd` both read through it. A second reader of `GetCurrentDirectoryW` gives one process two spellings of
// its working directory. A module path made from the spelling with the prefix is cut at the `?`
// (https://github.com/oven-sh/bun/issues/11953).
//
// Read the working directory with `bun_core::getcwd` or `bun_sys::getcwd`.
test("the Windows working directory has one reader", async () => {
const repoRoot = path.resolve(import.meta.dir, "..", "..", "..");
const call = /(?<!\bfn\s+)\bGetCurrentDirectoryW\s*\(/g;

const known = new Map([
// `getcwd_w`.
["src/bun_core/util.rs", 1],
// The `.bunx` fast path of `bun run <bin>`. Its path does not open with the prefix, and the slower path, which
// starts the program in the directory as Bun names it, takes over.
["src/runtime/cli/run_command.rs", 1],
// `process.report()` says what the OS says.
["src/jsc/bindings/BunProcessReportObjectWindows.cpp", 1],
]);

const found = new Map<string, number>();
let scanned = 0;
for await (const rel of new Glob("**/*.{rs,c,cpp,h}").scan({ cwd: path.join(repoRoot, "src") })) {
const file = path.join("src", rel).replaceAll("\\", "/");
scanned++;
const source = readFileSync(path.join(repoRoot, file), "utf8");
if (!source.includes("GetCurrentDirectoryW")) continue;
const code = source.split("\n").filter(line => !/^\s*\/\//.test(line));
const count = code.join("\n").match(call)?.length ?? 0;
if (count > 0) found.set(file, count);
}
// Guard against repoRoot resolving wrong, which would make the lint pass vacuously.
expect(scanned).toBeGreaterThan(1000);
expect(Object.fromEntries([...found].sort())).toEqual(Object.fromEntries([...known].sort()));
});
19 changes: 19 additions & 0 deletions test/js/node/fs/fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3688,6 +3688,25 @@ it.if(isWindows)("symlink on windows with forward slashes", async () => {
expect(await fs.promises.readlink(join(r, "file-sym.txt"))).toBe("files\\2024\\123.txt");
});

// `fs.symlink` puts the `\\?\` prefix in front of the target of a junction. A target that has the prefix already keeps
// it, as `path.toNamespacedPath()` keeps it. A relative target is joined to the directory of the link.
describe.skipIf(!isWindows)("junction on windows", () => {
it.each([
["a plain target", (real: string, link: string) => [real, link]],
["a target that has the \\\\?\\ prefix", (real: string, link: string) => ["\\\\?\\" + real, link]],
[
"a link path that has the \\\\?\\ prefix, and a relative target",
(_: string, link: string) => ["real", "\\\\?\\" + link],
],
])("with %s", (_, spell) => {
using dir = tempDir("fs-junction-prefix", { "real/inner.txt": "through the junction" });
const link = join(String(dir), "link");
const [target, linkPath] = spell(join(String(dir), "real"), link);
symlinkSync(target, linkPath, "junction");
expect(readFileSync(join(link, "inner.txt"), "utf8")).toBe("through the junction");
});
});

it("realpath async", async () => {
const actual = join(tmpdirSync(), "fs-realpath.txt");
try {
Expand Down
Loading
Loading