diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 208c1678127e..9e5c9c02fef2 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -350,13 +350,6 @@ pub mod fs { join_abs_string_buf_checked::(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::(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}; @@ -1373,7 +1366,7 @@ pub mod fs { existing_fd: Fd, store_fd: bool, ) -> crate::CrateResult { - 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}; @@ -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::(self.cwd, &mut outpath[..], &combo).len(); + let join_capacity = outpath.len() - 2; + let Some(entry_path) = join_abs_string_buf_checked::( + 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; diff --git a/src/runtime/cli/test/Scanner.rs b/src/runtime/cli/test/Scanner.rs index 5182755e3614..f8a19647aff9 100644 --- a/src/runtime/cli/test/Scanner.rs +++ b/src/runtime/cli/test/Scanner.rs @@ -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}; @@ -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")] @@ -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::(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 @@ -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) @@ -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(); @@ -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)) @@ -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; @@ -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) { diff --git a/test/cli/test/bun-test.test.ts b/test/cli/test/bun-test.test.ts index f582b871d8f5..566279d3e877 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, isLinux, isWindows, tempDir, tempDirWithFiles, tmpdirSync } from "harness"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; @@ -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); + }, + ); + } });