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
20 changes: 10 additions & 10 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 Expand Up @@ -1373,7 +1366,7 @@ pub mod fs {
existing_fd: Fd,
store_fd: bool,
) -> crate::CrateResult<EntryCache> {
use bun_paths::resolve_path::{join_abs_string_buf, platform};
use bun_paths::resolve_path::{join_abs_string_buf_checked, platform};
#[cfg(not(windows))]
use bun_sys::{FileKind, kind_from_mode};

Expand All @@ -1385,8 +1378,15 @@ pub mod fs {

let combo: [&[u8]; 2] = [dir_, base];
let mut outpath = bun_paths::PathBuffer::uninit();
let entry_path_len =
join_abs_string_buf::<platform::Auto>(self.cwd, &mut outpath[..], &combo).len();
let join_capacity = outpath.len() - 2;
let Some(entry_path) = join_abs_string_buf_checked::<platform::Auto>(
self.cwd,
&mut outpath[..join_capacity],
&combo,
) else {
return Err(crate::Error::Sys(bun_errno::SystemErrno::ENAMETOOLONG));
};
let entry_path_len = entry_path.len();

outpath[entry_path_len + 1] = 0;
outpath[entry_path_len] = 0;
Expand Down
48 changes: 31 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,7 @@ 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.
/// The entrypoint does not exist or does not fit a `PathBuffer`; never returned for subdirectories.
#[error("DoesNotExist")]
DoesNotExist,
#[error("OutOfMemory")]
Expand Down Expand Up @@ -117,8 +116,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 +129,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 +199,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 +235,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 +389,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 +428,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
119 changes: 118 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, isLinux, 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,121 @@ describe.concurrent("test file discovery (scanner)", () => {
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 every other POSIX (src/bun_core/util.rs). 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 = isLinux ? 4096 : 1024;
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 name in the deepest directory
// overflows too: that directory is at most 254 bytes short of the limit
// and the name adds 256. The deepest directory gets three such entries:
// a test file, a subdirectory, and a symlink. readdir does not report a
// symlink's kind, so the scanner stats it first, and that stat builds
// the path through the resolver (RealFS::kind) rather than through the
// scanner's own joins.
const segment = Buffer.alloc(254, "d").toString();
const longTestFile = Buffer.alloc(247, "f").toString() + ".test.ts";
const longTestLink = Buffer.alloc(247, "l").toString() + ".test.ts";
const fitDepth = Math.floor((maxPathBytes - 1 - root.length) / (segment.length + 1));
expect([longTestFile.length, longTestLink.length]).toEqual([255, 255]);
expect(root.length + fitDepth * 255 + 256).toBeGreaterThan(maxPathBytes);

// The 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}`, `ln -s ${longTestFile} ${longTestLink}`, `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