Skip to content
Closed
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
48 changes: 37 additions & 11 deletions src/runtime/cli/test/Scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@ use std::collections::VecDeque;
use bun_alloc::AllocError;
use bun_bundler::Transpiler;
use bun_bundler::options::BundleOptions;
#[cfg(not(windows))]
use bun_core::ZStr;
use bun_core::err;
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 @@ -121,8 +120,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 @@ -136,7 +135,11 @@ impl<'a> Scanner<'a> {
// reshaped for borrowck — abs_buf's return keeps a &mut borrow
// of scan_dir_buf alive across the &mut self calls below. Capture only the
// length, then reconstruct a detached slice from the raw buffer pointer.
let path_len = self.fs().abs_buf(&parts, &mut self.scan_dir_buf).len();
let path_len = match self.fs().abs_buf_checked(&parts, &mut self.scan_dir_buf) {
Some(p) => p.len(),
// A path longer than MAX_PATH_BYTES cannot be opened.
None => return Err(ScanError::DoesNotExist),
};
// SAFETY: scan_dir_buf is not written again for the remainder of this
// function — read_dir_with_name/next() only touch open_dir_buf — so the
// bytes at [0, path_len) remain valid while `path` is live.
Expand Down Expand Up @@ -208,7 +211,16 @@ 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);
// Reserve one byte for the NUL written below. Skip this entry if the
// joined path exceeds MAX_PATH_BYTES; openat would fail with
// ENAMETOOLONG anyway and the unchecked join panics on overflow.
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 @@ -238,7 +250,16 @@ 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 buf_len = self.open_dir_buf.len();
let Some(path2_slice) =
fs.abs_buf_checked(&parts2, &mut self.open_dir_buf[..buf_len - 1])
else {
continue;
};
let path2_len = path2_slice.len();
self.open_dir_buf[path2_len] = 0;
// SAFETY: NUL written at path2_len above.
let path2 = unsafe { ZStr::from_raw(self.open_dir_buf.as_ptr(), path2_len) };
let Ok(child_fd) = bun_sys::open_dir_no_renaming_or_deleting_windows(
Fd::INVALID,
path2.as_bytes(),
Expand Down Expand Up @@ -393,12 +414,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 @@ -430,9 +453,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
53 changes: 52 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, isMusl, isWindows, tempDir, tempDirWithFiles, tmpdirSync } from "harness";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";

Expand Down Expand Up @@ -1556,4 +1556,55 @@ describe.concurrent("test file discovery (scanner)", () => {
expect(stderr).toContain(" 1 pass");
expect(exitCode).toBe(0);
});

// https://github.com/oven-sh/bun/issues/sentry-BUN-3JDD
// On Windows, MAX_PATH_BYTES is ~98 KB (32767 * 3 + 1), which is impractical
// to exercise here; the POSIX path (1024 macOS / 4096 Linux) is what users hit.
test.skipIf(isWindows)(
"skips directories whose absolute path exceeds MAX_PATH_BYTES instead of panicking",
async () => {
using dir = tempDir("scanner-deep-tree", {
"shallow.test.ts": `import { test } from "bun:test"; test("shallow", () => { console.log("RAN shallow"); });`,
});

// Build a directory chain whose absolute path exceeds the platform's
// MAX_PATH_BYTES. We cannot pass the full path to mkdir (ENAMETOOLONG),
// so create it one segment at a time via cd in a child shell.
const seg = Buffer.alloc(200, "a").toString();
const maxPathBytes = process.platform === "darwin" ? 1024 : 4096;
const depth = Math.ceil((maxPathBytes + 200 - String(dir).length) / (seg.length + 1));
let script = `set -e; cd ${JSON.stringify(String(dir))}\n`;
for (let i = 0; i < depth; i++) {
script += `mkdir -p ${seg} && cd ${seg}\n`;
}
await using setup = Bun.spawn({
cmd: ["bash", "-c", script],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [, setupErr, setupExit] = await Promise.all([setup.stdout.text(), setup.stderr.text(), setup.exited]);
// On musl, getcwd(3) fails once the cumulative path exceeds PATH_MAX. bash
// prints a cd warning for each subsequent level but mkdir/cd still succeed,
// so filter that one known warning out before asserting stderr is clean.
const filteredSetupErr = setupErr
.split("\n")
.filter(line => !(isMusl && line.startsWith("cd: error retrieving current directory: getcwd:")))
.join("\n");
expect({ setupErr: filteredSetupErr, setupExit }).toEqual({ setupErr: "", setupExit: 0 });

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

expect(stderr).toContain(" 1 pass");
expect(stdout).toContain("RAN shallow");
expect(exitCode).toBe(0);
},
);
});
Loading