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
138 changes: 138 additions & 0 deletions src/paths/resolve_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1402,6 +1402,38 @@ pub fn join_abs_string_spill<'a, P: PlatformT>(
join_abs_string_buf::<P>(cwd, &mut spill[..], parts)
}

/// Node's `path.resolve(cwd, path)`: [`join_abs_string_spill`] minus a trailing separator.
pub fn resolve_spill<'a, P: PlatformT>(
cwd: &'a [u8],
spill: &'a mut Vec<u8>,
path: &[u8],
) -> &'a [u8] {
let is_windows = P::P == Platform::Windows || (cfg!(windows) && P::P == Platform::Loose);
// The Windows join finds no volume in `\\.\C:\x` and answers `C:\C:\x`.
if is_windows
&& path.len() >= 4
&& is_sep_any(path[0])
&& is_sep_any(path[1])
&& path[2] == b'.'
&& is_sep_any(path[3])
{
spill.clear();
spill.extend_from_slice(path);
return &spill[..];
}
let resolved = join_abs_string_spill::<P>(cwd, spill, &[path]);
let root_len = if is_windows {
windows_filesystem_root(resolved).len()
} else {
Comment thread
robobun marked this conversation as resolved.
1
};
let mut len = resolved.len();
while len > root_len && P::P.is_separator(resolved[len - 1]) {
len -= 1;
}
&resolved[..len]
}

/// Convert parts of potentially invalid file paths into a single valid filpeath
/// without querying the filesystem
/// This is the equivalent of path.resolve
Expand Down Expand Up @@ -2628,6 +2660,112 @@ mod tests {
assert_eq!(out, b"/work/sub");
}

fn assert_resolve_spill<P: PlatformT>(cwd: &[u8], rows: &[(&[u8], &[u8])]) {
for &(path, expected) in rows {
let mut spill = Vec::new();
assert_eq!(
bstr::BStr::new(resolve_spill::<P>(cwd, &mut spill, path)),
bstr::BStr::new(expected),
"path: {}",
bstr::BStr::new(path)
);
}
}

#[test]
fn resolve_spill_matches_path_posix_resolve() {
// (path, `path.posix.resolve("/work/dir", path)`)
let rows: &[(&[u8], &[u8])] = &[
(b"main.js", b"/work/dir/main.js"),
(b"./pkg/", b"/work/dir/pkg"),
(b"pkg//", b"/work/dir/pkg"),
(b"pkg/./", b"/work/dir/pkg"),
(b"sub/../main.js", b"/work/dir/main.js"),
(b".", b"/work/dir"),
(b"./", b"/work/dir"),
(b"", b"/work/dir"),
(b"../..", b"/"),
(b"../../..", b"/"),
(b"/abs/./sub/../main.js", b"/abs/main.js"),
(b"/abs//main.js", b"/abs/main.js"),
(b"//abs/main.js", b"/abs/main.js"),
(b"/abs/pkg///", b"/abs/pkg"),
(b"/", b"/"),
(b"//", b"/"),
];
assert_resolve_spill::<platform::Posix>(b"/work/dir", rows);
if cfg!(not(windows)) {
assert_resolve_spill::<platform::Loose>(b"/work/dir", rows);
}
}

#[test]
fn resolve_spill_matches_path_win32_resolve() {
// (path, `path.win32.resolve("C:\\work\\dir", path)`)
let rows: &[(&[u8], &[u8])] = &[
(b"main.js", b"C:\\work\\dir\\main.js"),
(b"./pkg/", b"C:\\work\\dir\\pkg"),
(b".\\pkg\\", b"C:\\work\\dir\\pkg"),
(b"pkg//", b"C:\\work\\dir\\pkg"),
(b"sub/../main.js", b"C:\\work\\dir\\main.js"),
(b".", b"C:\\work\\dir"),
(b"", b"C:\\work\\dir"),
(b"../../..", b"C:\\"),
(b"C:/proj/main.mjs", b"C:\\proj\\main.mjs"),
(b"C:\\proj\\.\\sub\\..\\main.mjs", b"C:\\proj\\main.mjs"),
(b"C:/proj//main.mjs", b"C:\\proj\\main.mjs"),
(b"C:/proj/", b"C:\\proj"),
(b"C:\\proj\\", b"C:\\proj"),
(b"C:/", b"C:\\"),
(b"C:\\", b"C:\\"),
(b"C:main.js", b"C:\\work\\dir\\main.js"),
(b"D:/other/main.js", b"D:\\other\\main.js"),
(b"D:\\", b"D:\\"),
(b"/proj/main.js", b"C:\\proj\\main.js"),
(b"\\proj\\", b"C:\\proj"),
(b"/", b"C:\\"),
(
b"//server/share/dir/main.js",
b"\\\\server\\share\\dir\\main.js",
),
(b"\\\\server\\share\\dir\\", b"\\\\server\\share\\dir"),
(b"\\\\server\\share\\", b"\\\\server\\share\\"),
(b"\\\\server\\share", b"\\\\server\\share\\"),
(b"\\\\?\\C:\\proj\\main.js", b"\\\\?\\C:\\proj\\main.js"),
(b"\\\\.\\C:\\proj\\main.js", b"\\\\.\\C:\\proj\\main.js"),
(b"\\\\.\\pipe\\name", b"\\\\.\\pipe\\name"),
];
assert_resolve_spill::<platform::Windows>(b"C:\\work\\dir", rows);
}

#[test]
fn resolve_spill_differs_from_path_win32_resolve() {
assert_resolve_spill::<platform::Windows>(
b"C:\\work\\dir",
&[
// The join writes an uppercase drive letter. Node keeps `c:`.
(b"c:/proj/main.mjs", b"C:\\proj\\main.mjs"),
// A `\\.\` device path is returned as given. Node normalizes it.
(b"//./C:/proj/../main.js", b"//./C:/proj/../main.js"),
],
);
}

#[test]
fn resolve_spill_strips_a_result_that_spills() {
let name = vec![b'a'; PARSER_JOIN_INPUT_BUFFER_LEN + 1];
let mut path = name.clone();
path.extend_from_slice(b"//");
let mut expected = b"/work/".to_vec();
expected.extend_from_slice(&name);

let mut spill = Vec::new();
assert_eq!(
resolve_spill::<platform::Posix>(b"/work", &mut spill, &path),
&expected[..]
);
}

#[test]
fn normalize_string_spill_accounts_for_outputs_that_grow_by_one_byte() {
// A bare UNC volume exactly as long as the thread-local buffer
Expand Down
40 changes: 17 additions & 23 deletions src/runtime/cli/run_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2968,29 +2968,7 @@ impl RunCommand {
Self::exec_as_if_node_missing_script();
}

// borrowck — `boot_and_handle_error` takes `&mut ctx`, so
// dupe the positional out before the call.
let filename: Box<[u8]> = ctx.positionals[0].clone();

let normalized: Box<[u8]> = if paths::is_absolute(&filename) {
filename
} else {
// Note: write
// `cwd_buf[cwd_len] = b'/'` (always `/`, NOT the
// platform separator) and then run the result through
// `join_abs_string_buf::<Loose>` to collapse `.`/`..`.
let mut cwd_buf = bun_paths::path_buffer_pool::get();
let cwd = bun_core::getcwd_or_exe_dir(&mut cwd_buf);
let cwd_len = cwd.as_bytes().len();
cwd_buf[cwd_len] = b'/';
let mut out_buf = bun_paths::path_buffer_pool::get();
let joined = paths::resolve_path::join_abs_string_buf::<paths::platform::Loose>(
&cwd_buf[..cwd_len + 1],
&mut out_buf.0,
&[&filename],
);
joined.to_vec().into_boxed_slice()
};
let normalized = Self::node_script_entry_path(&ctx.positionals[0]);

// This arm calls `Run::boot`
// directly — NOT `boot_and_handle_error` — so it (a) does not call
Expand All @@ -3003,6 +2981,22 @@ impl RunCommand {
Ok(())
}

/// `path.resolve(script)` for `process.argv[1]`. The module loader resolves
/// the entry from the same path, so a trailing separator stays.
Comment thread
robobun marked this conversation as resolved.
fn node_script_entry_path(script: &[u8]) -> Box<[u8]> {
let mut cwd_buf = bun_paths::path_buffer_pool::get();
let cwd = bun_core::getcwd_or_exe_dir(&mut cwd_buf).as_bytes();
let mut spill = Vec::new();
let mut entry =
paths::resolve_path::resolve_spill::<paths::platform::Loose>(cwd, &mut spill, script)
Comment thread
robobun marked this conversation as resolved.
.to_vec();
let names_directory = |path: &[u8]| path.last().is_some_and(|&c| paths::is_sep_any(c));
if names_directory(script) && !names_directory(&entry) {
entry.push(SEP);
}
entry.into_boxed_slice()
}

#[cold]
#[inline(never)]
#[cfg_attr(
Expand Down
52 changes: 51 additions & 1 deletion test/cli/run/as-node.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { join } from "path";
import { join, sep } from "path";
import { bunEnv, bunExe, fakeNodeRun, tempDir } from "../../harness";

describe("fake node cli", () => {
Expand Down Expand Up @@ -97,6 +97,56 @@ describe("fake node cli", () => {
);
});

// Node sets `process.argv[1]` to `path.resolve()` of the script argument.
describe("process.argv[1] is the normalized path of the script", () => {
const scripts: [name: string, script: (temp: string) => string, normalized: (temp: string) => string][] = [
["an absolute path with `.` and `..`", temp => `${temp}/./pkg/../index.js`, temp => join(temp, "index.js")],
["an absolute path with a repeated separator", temp => `${temp}//index.js`, temp => join(temp, "index.js")],
[
"an absolute path with `/` separators",
temp => join(temp, "index.js").replaceAll("\\", "/"),
temp => join(temp, "index.js"),
],
// Unlike `path.resolve()`, a trailing separator stays: see the directory test below.
["a directory with a trailing separator", temp => `${temp}//pkg/`, temp => join(temp, "pkg") + sep],
];
test.each(scripts)("%s", (_, script, normalized) => {
using temp = tempDir("fake-node", {
"index.js": "console.log(process.argv[1])",
"pkg/index.js": "console.log(process.argv[1])",
});
expect(fakeNodeRun(temp, script(String(temp))).stdout).toBe(normalized(String(temp)));
});

const mainModules: [name: string, script: (temp: string) => string][] = [
// The launcher that pnpm writes to `node_modules/.bin` runs `node "$basedir/../pkg/cli.mjs"`.
["through `node_modules/.bin/..`", temp => `${temp}/node_modules/.bin/../pkg/cli.mjs`],
// Git Bash and MSYS2 pass a Windows path with `/` separators.
["with `/` separators", temp => join(temp, "node_modules", "pkg", "cli.mjs").replaceAll("\\", "/")],
];
test.each(mainModules)("an ES module that runs %s finds that it is the main module", (_, script) => {
using temp = tempDir("fake-node", {
"node_modules/.bin/pkg": "",
"node_modules/pkg/cli.mjs": `
import { fileURLToPath } from "node:url";
console.log(process.argv[1] === fileURLToPath(import.meta.url));
`,
});
expect(fakeNodeRun(temp, script(String(temp))).stdout).toBe("true");
});

// The module loader resolves the entry from the same path. Bun tries `pkg.ts` before the directory
// `pkg` (see "entrypoint file extension picking"), so only the separator keeps `node ./pkg/` on
// `pkg/index.js`, which is what Node runs beside a `pkg.ts`.
test.each(["pkg.js", "pkg.ts"])("a trailing separator selects the directory over %s", sibling => {
using temp = tempDir("fake-node", {
[sibling]: "console.log('sibling')",
"pkg/index.js": "console.log('directory')",
});
expect([fakeNodeRun(temp, "./pkg").stdout, fakeNodeRun(temp, "./pkg/").stdout]).toEqual(["sibling", "directory"]);
});
});

// Bare `node` now matches Node.js: a TTY stdin enters the REPL, a
// non-TTY stdin (pipe) prints "Missing script". fakeNodeRun's default
// stdin is platform-dependent (Windows may inherit a console), so pin
Expand Down
Loading