Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 0 additions & 7 deletions src/resolver/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,13 +350,6 @@ pub mod fs {
join_abs_string_buf_checked::<platform::Loose>(self.top_level_dir, buf, parts)
}

/// Like `abs_buf` but writes a
/// NUL sentinel and returns a `ZStr` borrowing `buf`.
pub fn abs_buf_z<'b>(&self, parts: &[&[u8]], buf: &'b mut [u8]) -> &'b ZStr {
use bun_paths::resolve_path::{join_abs_string_buf_z, platform};
join_abs_string_buf_z::<platform::Loose>(self.top_level_dir, buf, parts)
}

/// Normalizes `str` (separators, `.`/`..` segments) into `buf`.
pub fn normalize_buf<'b>(&self, buf: &'b mut [u8], str: &[u8]) -> &'b [u8] {
use bun_paths::resolve_path::{normalize_string_buf, platform};
Expand Down
50 changes: 33 additions & 17 deletions src/runtime/cli/test/Scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use bun_bundler::options::BundleOptions;
use bun_core::ZStr;
use bun_core::{StringOrTinyString, strings};
use bun_output::{declare_scope, scoped_log};
use bun_paths::resolve_path::{join_abs_string_buf, platform};
use bun_paths::resolve_path::{join_abs_string_buf_checked, platform};
use bun_paths::{self, PathBuffer};
use bun_ptr::Interned;
use bun_resolver::fs::{self as fs, DirEntryIterator, EntriesOption, FileSystem};
Expand Down Expand Up @@ -47,8 +47,9 @@ pub struct ScanEntry {

#[derive(thiserror::Error, Debug, strum::IntoStaticStr)]
pub enum ScanError {
/// Scan entrypoint file/directory does not exist. Not returned when
/// a subdirectory is scanned but does not exist.
/// Scan entrypoint file/directory does not exist, or its absolute path
/// does not fit in a `PathBuffer`. Not returned when a subdirectory is
/// scanned but does not exist.
Comment thread
robobun marked this conversation as resolved.
Outdated
#[error("DoesNotExist")]
DoesNotExist,
#[error("OutOfMemory")]
Expand Down Expand Up @@ -117,8 +118,8 @@ impl<'a> Scanner<'a> {
top_level_dir: &'static [u8],
parts: &[&[u8]],
buf: &'b mut [u8],
) -> &'b [u8] {
join_abs_string_buf::<platform::Loose>(top_level_dir, buf, parts)
) -> Option<&'b [u8]> {
join_abs_string_buf_checked::<platform::Loose>(top_level_dir, buf, parts)
}

/// Take the list of test files out of this scanner. Caller owns the returned
Expand All @@ -130,7 +131,10 @@ impl<'a> Scanner<'a> {
pub(crate) fn scan(&mut self, path_literal: &[u8]) -> Result<(), ScanError> {
let mut scan_dir_buf = PathBuffer::uninit();
let parts: [&[u8]; 2] = [self.top_level_dir(), path_literal];
let path: &[u8] = Self::abs_buf_projected(self.top_level_dir(), &parts, &mut scan_dir_buf);
let Some(path) = Self::abs_buf_projected(self.top_level_dir(), &parts, &mut scan_dir_buf)
else {
return Err(ScanError::DoesNotExist);
};

let root = self
.read_dir_with_name(path, None)
Expand Down Expand Up @@ -197,7 +201,13 @@ impl<'a> Scanner<'a> {
let dir = entry.relative_dir;

let parts2: [&[u8]; 2] = [entry.dir_path, entry.name.slice()];
let path2 = self.fs().abs_buf(&parts2, &mut self.open_dir_buf);
let buf_len = self.open_dir_buf.len();
let Some(path2) = self
.fs()
.abs_buf_checked(&parts2, &mut self.open_dir_buf[..buf_len - 1])
else {
continue;
};
let path2_len = path2.len();
self.open_dir_buf[path2_len] = 0;
let name_len = entry.name.slice().len();
Expand Down Expand Up @@ -227,17 +237,18 @@ impl<'a> Scanner<'a> {
{
let fs = self.fs();
let parts2: [&[u8]; 2] = [entry.dir_path, entry.name.slice()];
let path2 = fs.abs_buf_z(&parts2, &mut self.open_dir_buf);
let Ok(child_fd) = bun_sys::open_dir_no_renaming_or_deleting_windows(
Fd::INVALID,
path2.as_bytes(),
) else {
let Some(path2) = fs.abs_buf_checked(&parts2, &mut self.open_dir_buf) else {
continue;
};
let Ok(child_fd) =
bun_sys::open_dir_no_renaming_or_deleting_windows(Fd::INVALID, path2)
else {
continue;
};
let child_dir = bun_sys::Dir::from_fd(child_fd);
let stored = fs
.dirname_store
.append_slice(path2.as_bytes())
.append_slice(path2)
.map_err(|_| ScanError::OutOfMemory)?;
let _ = self
.read_dir_with_name(stored, Some(child_dir))
Expand Down Expand Up @@ -380,12 +391,14 @@ impl<'a> Scanner<'a> {
// reshaped for borrowck — drop the &mut borrow from
// abs_buf and reborrow open_dir_buf immutably so &self methods
// can be called with the slice.
let dir_path_len = Self::abs_buf_projected(
let Some(dir_path_len) = Self::abs_buf_projected(
self.top_level_dir(),
&parts,
&mut self.open_dir_buf,
)
.len();
.map(<[u8]>::len) else {
return;
};
let dir_path = &self.open_dir_buf[..dir_path_len];
if self.matches_path_ignore_pattern(dir_path) {
return;
Expand Down Expand Up @@ -417,9 +430,12 @@ impl<'a> Scanner<'a> {
// reshaped for borrowck — drop the &mut borrow from
// abs_buf and reborrow open_dir_buf immutably so &self methods
// below can be called with the slice.
let path_len =
let Some(path_len) =
Self::abs_buf_projected(self.top_level_dir(), &parts, &mut self.open_dir_buf)
.len();
.map(<[u8]>::len)
else {
return;
};
let path = &self.open_dir_buf[..path_len];

if !self.does_absolute_path_match_filter(path) {
Expand Down
113 changes: 112 additions & 1 deletion test/cli/test/bun-test.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { spawnSync } from "bun";
import { beforeAll, describe, expect, it, test } from "bun:test";
import { bunEnv, bunExe, tempDir, tempDirWithFiles, tmpdirSync } from "harness";
import { bunEnv, bunExe, isMacOS, isWindows, tempDir, tempDirWithFiles, tmpdirSync } from "harness";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";

Expand Down Expand Up @@ -1882,4 +1882,115 @@
expect(stderr).toContain(" 1 pass");
expect(exitCode).toBe(0);
});

// The scanner builds every absolute path in a PathBuffer of MAX_PATH_BYTES:
// 4096 on Linux, 1024 on macOS. On Windows it is 32767*3+1 bytes, more than
// a command line or an NT path can hold, so the overflow is unreachable there.
const maxPathBytes = isMacOS ? 1024 : 4096;

Check warning on line 1889 in test/cli/test/bun-test.test.ts

View check run for this annotation

Claude / Claude Code Review

maxPathBytes constant does not match source on non-Linux/non-macOS POSIX (FreeBSD)

`isMacOS ? 1024 : 4096` doesn't mirror `MAX_PATH_BYTES` in `src/bun_core/util.rs`: the source uses 4096 only under `cfg!(any(target_os = "linux", target_os = "android"))` and 1024 on every other POSIX (FreeBSD/OpenBSD/NetBSD/DragonFly/Solaris), not just macOS. FreeBSD is a shipped build target, and there the deep-tree test would compute `fitDepth ≈ 15` instead of ~3 — its assertions still pass, but it doesn't exercise the boundary its comments describe. Consider `isLinux ? 4096 : 1024` (importin
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
const existsTest = `import { test } from "bun:test"; test("exists", () => {});`;

for (const [kind, prefix, len] of [
["absolute", "/", 5000],
["absolute", "/", 100_000],
["relative", "./", 5000],
] as const) {
test.skipIf(isWindows)(
`${kind} path argument of ${len} bytes (longer than MAX_PATH_BYTES) reports no match instead of panicking`,
async () => {
using dir = tempDir("scanner-long-arg", { "exists.test.ts": existsTest });
const longArg = prefix + Buffer.alloc(len, "a").toString() + ".test.ts";

await using proc = Bun.spawn({
cmd: [bunExe(), "test", longArg],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);

expect(stderr).toContain("had no matches");
expect(exitCode).toBe(1);
},
);
}

test.skipIf(isWindows)(
"a path argument longer than MAX_PATH_BYTES is skipped like a missing path when other arguments match",
async () => {
using dir = tempDir("scanner-long-arg-mixed", { "exists.test.ts": existsTest });
const longArg = "/" + Buffer.alloc(5000, "a").toString() + ".test.ts";

await using proc = Bun.spawn({
cmd: [bunExe(), "test", "./exists.test.ts", longArg],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);

expect(stderr).toContain("Ran 1 test across 1 file.");
expect(exitCode).toBe(0);
},
);

// The directory walk joins parent + entry name for every directory it
// descends into and every candidate test file. With pathIgnorePatterns
// configured it additionally joins each directory before queueing it, so the
// same tree is scanned once per configuration to reach both code paths.
for (const [config, files] of [
["no bunfig", {}],
["pathIgnorePatterns configured", { "bunfig.toml": `[test]\npathIgnorePatterns = ["unrelated/**"]\n` }],
] as const) {
test.skipIf(isWindows)(
`entries whose absolute path exceeds MAX_PATH_BYTES are skipped during the directory walk (${config})`,
async () => {
using dir = tempDir("scanner-deep-tree", {
...files,
"shallow.test.ts": `import { test } from "bun:test"; test("shallow", () => {});`,
});
const root = String(dir);
// Each level adds "/" + segment = 255 bytes. The deepest directory the
// scanner can still open is the last one whose path is at most
// maxPathBytes - 1 (it reserves one byte for the NUL); the directory
// below it is skipped. A 255-byte file name in the deepest directory
// overflows too: that directory is at most 254 bytes short of the limit
// and the name adds 256.
const segment = Buffer.alloc(254, "d").toString();
const longTestFile = Buffer.alloc(247, "f").toString() + ".test.ts";
const fitDepth = Math.floor((maxPathBytes - 1 - root.length) / (segment.length + 1));
expect(longTestFile).toHaveLength(255);
expect(root.length + fitDepth * 255 + 256).toBeGreaterThan(maxPathBytes);

// The two over-long entries cannot be addressed by absolute path
// (ENAMETOOLONG), so walk down the chain and create them relative to
// the deepest directory.
const script = ["set -e"];
for (let level = 1; level <= fitDepth; level++) {
script.push(`mkdir ${segment} && cd ${segment}`);
}
script.push(`touch ${longTestFile}`, `mkdir ${segment}`);
await using setup = Bun.spawn({
cmd: ["bash", "-c", script.join("\n")],
env: bunEnv,
cwd: root,
stderr: "pipe",
});
const [setupStderr, setupExitCode] = await Promise.all([setup.stderr.text(), setup.exited]);
expect(setupStderr).toBe("");
expect(setupExitCode).toBe(0);

await using proc = Bun.spawn({
cmd: [bunExe(), "test"],
env: bunEnv,
cwd: root,
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);

expect(stderr).toContain("shallow.test.ts:");
expect(stderr).toContain("Ran 1 test across 1 file.");
expect(exitCode).toBe(0);
},
);
}
});
Loading