From bf6c7862dfb7e239665c70a095611912e182f482 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:35:16 +0000 Subject: [PATCH 1/3] test scanner: skip paths exceeding MAX_PATH_BYTES instead of panicking The test file scanner walks the directory tree by repeatedly joining parent directory paths with entry names into a fixed-size PathBuffer (MAX_PATH_BYTES: 1024 macOS, 4096 Linux). When a tree is deep enough that the absolute path exceeds this limit, the unchecked join panics in normalize_string_generic_tz with a slice index error. Use the existing checked join variant at each join site in the scanner and skip entries whose joined path would overflow. The OS would reject such paths with ENAMETOOLONG anyway, so skipping is the correct behavior rather than aborting the entire test run. Fixes Sentry BUN-3JDD. --- src/runtime/cli/test/Scanner.rs | 49 +++++++++++++++++++++++++-------- test/cli/test/bun-test.test.ts | 43 ++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/src/runtime/cli/test/Scanner.rs b/src/runtime/cli/test/Scanner.rs index 1be66233f18a..201d4c82e0c7 100644 --- a/src/runtime/cli/test/Scanner.rs +++ b/src/runtime/cli/test/Scanner.rs @@ -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}; @@ -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::(top_level_dir, buf, parts) + ) -> Option<&'b [u8]> { + join_abs_string_buf_checked::(top_level_dir, buf, parts) } /// Take the list of test files out of this scanner. Caller owns the returned @@ -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. @@ -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(); @@ -238,7 +250,17 @@ 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(), @@ -393,12 +415,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; @@ -430,9 +454,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) { diff --git a/test/cli/test/bun-test.test.ts b/test/cli/test/bun-test.test.ts index 22372761c509..2573987d9c48 100644 --- a/test/cli/test/bun-test.test.ts +++ b/test/cli/test/bun-test.test.ts @@ -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, isWindows, tempDir, tempDirWithFiles, tmpdirSync } from "harness"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; @@ -1556,4 +1556,45 @@ 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]); + expect({ setupErr, 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); + }); }); From 1c57c41ec9f12ae814c45102e8bf38fbdc0bb36d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:37:57 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- src/runtime/cli/test/Scanner.rs | 3 +- test/cli/test/bun-test.test.ts | 77 +++++++++++++++++---------------- 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/src/runtime/cli/test/Scanner.rs b/src/runtime/cli/test/Scanner.rs index 201d4c82e0c7..3beef2155811 100644 --- a/src/runtime/cli/test/Scanner.rs +++ b/src/runtime/cli/test/Scanner.rs @@ -259,8 +259,7 @@ impl<'a> Scanner<'a> { 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 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(), diff --git a/test/cli/test/bun-test.test.ts b/test/cli/test/bun-test.test.ts index 2573987d9c48..c2a7c338490d 100644 --- a/test/cli/test/bun-test.test.ts +++ b/test/cli/test/bun-test.test.ts @@ -1560,41 +1560,44 @@ describe.concurrent("test file discovery (scanner)", () => { // 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]); - expect({ setupErr, 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); - }); + 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]); + expect({ setupErr, 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); + }, + ); }); From f596e11ba7616727c772a2e44e84ad5f37529795 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:50:22 +0000 Subject: [PATCH 3/3] test: filter musl getcwd warnings from bash setup stderr On musl (Alpine), getcwd(3) fails once the cumulative path exceeds PATH_MAX. bash prints a cd warning for each subsequent level but mkdir/cd still succeed and exit 0. Filter the known warning before asserting stderr is clean, matching the existing handling in test/js/bun/glob/path-length.test.ts. --- test/cli/test/bun-test.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/cli/test/bun-test.test.ts b/test/cli/test/bun-test.test.ts index c2a7c338490d..173f6dfe490d 100644 --- a/test/cli/test/bun-test.test.ts +++ b/test/cli/test/bun-test.test.ts @@ -1,6 +1,6 @@ import { spawnSync } from "bun"; import { beforeAll, describe, expect, it, test } from "bun:test"; -import { bunEnv, bunExe, isWindows, 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"; @@ -1584,7 +1584,14 @@ describe.concurrent("test file discovery (scanner)", () => { stderr: "pipe", }); const [, setupErr, setupExit] = await Promise.all([setup.stdout.text(), setup.stderr.text(), setup.exited]); - expect({ setupErr, setupExit }).toEqual({ setupErr: "", setupExit: 0 }); + // 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"],