From 257681d5f0c1025faa178925321f1834058a42d3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:23:05 +0000 Subject: [PATCH 1/6] bun test: report directories the scanner cannot read, scan each directory once The scanner skipped a directory it could not open or read without a word, so a suite under an unreadable directory was silently absent and the run exited 0. Each such directory is now reported with its errno, a summary line names the count, and the run exits 1. The scanner also followed symlinks back into the tree until the path buffer overflowed, which ran the same files many times. It now records (st_dev, st_ino) of every directory it opens and skips one it has seen. --- src/runtime/cli/test/Scanner.rs | 62 ++++++++++++++++++++++++++++-- src/runtime/cli/test_command.rs | 9 +++++ test/cli/test/bun-test.test.ts | 68 ++++++++++++++++++++++++++++++++- 3 files changed, 134 insertions(+), 5 deletions(-) diff --git a/src/runtime/cli/test/Scanner.rs b/src/runtime/cli/test/Scanner.rs index 30698f15555e..0b2a882f181a 100644 --- a/src/runtime/cli/test/Scanner.rs +++ b/src/runtime/cli/test/Scanner.rs @@ -1,4 +1,4 @@ -use std::collections::VecDeque; +use std::collections::{HashSet, VecDeque}; use std::rc::Rc; use bun_alloc::AllocError; @@ -32,6 +32,12 @@ pub struct Scanner<'a> { pub(crate) options: &'a BundleOptions<'a>, pub(crate) has_iterated: bool, pub(crate) search_count: usize, + /// Directories that could not be opened or read. Each one is reported as + /// it happens; the count makes the run exit non-zero. + pub(crate) unreadable_dirs: usize, + /// `(st_dev, st_ino)` of every directory scanned so far. A symlink that + /// points back into the tree is skipped instead of walked again. + visited_dirs: HashSet<(u64, u64)>, /// The directory being iterated; its fd closes once every child `ScanEntry` has been opened. current_dir: Option>, } @@ -88,10 +94,32 @@ impl<'a> Scanner<'a> { open_dir_buf: PathBuffer::uninit(), has_iterated: false, search_count: 0, + unreadable_dirs: 0, + visited_dirs: HashSet::new(), current_dir: None, }) } + fn report_unreadable_dir(&mut self, path: &[u8], err: &bun_sys::Error) { + self.unreadable_dirs += 1; + bun_core::pretty_errorln!( + "error: could not scan {} for tests\n{}", + bun_core::fmt::quote(path), + err.with_path(path) + ); + } + + /// Records `fd`'s directory identity. Returns `false` when that directory + /// was scanned before (reached again through a symlink). + fn mark_visited(&mut self, fd: Fd) -> bool { + match bun_sys::fstat(fd) { + Ok(st) => self + .visited_dirs + .insert((st.st_dev as u64, st.st_ino as u64)), + Err(_) => true, + } + } + #[inline] pub(crate) fn fs(&self) -> &'static FileSystem { // SAFETY: process-singleton; no `&mut` to it is live outside the iterator callback. @@ -158,6 +186,18 @@ impl<'a> Scanner<'a> { bstr::BStr::new(path), root_err.original_err.name() ); + if let bun_resolver::Error::Sys(errno) = e { + self.report_unreadable_dir( + path, + &bun_sys::Error::from_code(errno, bun_sys::Tag::open), + ); + } + } + } else { + let zpath = bun_core::ZBox::from_bytes(path); + if let Ok(st) = bun_sys::fstatat(Fd::cwd(), &zpath) { + self.visited_dirs + .insert((st.st_dev as u64, st.st_ino as u64)); } } @@ -204,10 +244,17 @@ impl<'a> Scanner<'a> { let opened = bun_sys::open_dir_no_renaming_or_deleting_windows(parent, rel_path); // Dropping `entry` releases the parent fd once its last child is opened. drop(entry); - let Ok(child_fd) = opened else { - continue; + let child_fd = match opened { + Ok(fd) => fd, + Err(err) => { + self.report_unreadable_dir(path2, &err); + continue; + } }; let child_dir = Rc::new(Dir::from_fd(child_fd)); + if !self.mark_visited(child_dir.fd) { + continue; + } let path2 = self .fs() .dirname_store @@ -216,7 +263,14 @@ impl<'a> Scanner<'a> { self.current_dir = Some(Rc::clone(&child_dir)); let result = self.read_dir_with_name(path2, Some(child_dir.fd)); self.current_dir = None; - result.map_err(|_| ScanError::OutOfMemory)?; + if let EntriesOption::Err(dir_err) = result.map_err(|_| ScanError::OutOfMemory)? { + if let bun_resolver::Error::Sys(errno) = dir_err.original_err { + self.report_unreadable_dir( + path2, + &bun_sys::Error::from_code(errno, bun_sys::Tag::scandir), + ); + } + } } Ok(()) diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index fbf7dd9fc7c9..ac8d6e32c571 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -2167,6 +2167,7 @@ impl TestCommand { // so the watcher-enable check below can read it without reborrowing. let all_test_files_count = all_test_files.len(); let search_count = scanner.search_count; + let unreadable_dirs = scanner.unreadable_dirs; drop(scanner); // When --changed or --shard filters the discovered test files @@ -2642,6 +2643,13 @@ impl TestCommand { } } + if unreadable_dirs > 0 { + pretty_error!( + "error: {} director{} could not be scanned for tests\n", + unreadable_dirs, + if unreadable_dirs == 1 { "y" } else { "ies" } + ); + } pretty_error!("\n"); Output::flush(); @@ -2668,6 +2676,7 @@ impl TestCommand { && coverage_options.fail_on_low_coverage) || !write_snapshots_success || reporter.jest.unhandled_errors_between_tests > 0 + || unreadable_dirs > 0 { vm.exit_handler.exit_code = 1; } diff --git a/test/cli/test/bun-test.test.ts b/test/cli/test/bun-test.test.ts index 49a4905e9b14..bf14964f4351 100644 --- a/test/cli/test/bun-test.test.ts +++ b/test/cli/test/bun-test.test.ts @@ -1,7 +1,7 @@ import { spawnSync } from "bun"; import { beforeAll, describe, expect, it, test } from "bun:test"; import { bunEnv, bunExe, isLinux, isWindows, tempDir, tempDirWithFiles, tmpdirSync } from "harness"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { basename, dirname, join, resolve, sep } from "node:path"; describe("bun test", () => { @@ -2091,4 +2091,70 @@ describe.concurrent("test file discovery (scanner)", () => { expect(stderr).toContain(" 1 pass"); expect(exitCode).toBe(0); }); + + test.skipIf(isWindows)("a symlink that points back into the tree is scanned once", async () => { + using dir = tempDir("scanner-symlink-loop", { + "sub/a.test.ts": `import { test } from "bun:test"; test("a", () => { console.log("RAN a"); });`, + "sub/deeper/b.test.ts": `import { test } from "bun:test"; test("b", () => { console.log("RAN b"); });`, + }); + // sub/loop -> the scan root, sub/deeper/self -> sub/deeper + symlinkSync("..", join(String(dir), "sub", "loop")); + symlinkSync(".", join(String(dir), "sub", "deeper", "self")); + + 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(stdout.match(/RAN a/g)).toHaveLength(1); + expect(stdout.match(/RAN b/g)).toHaveLength(1); + expect(stderr).toContain("Ran 2 tests across 2 files."); + expect(exitCode).toBe(0); + }); + + // root bypasses permission checks, so the unreadable directory is only + // unreadable for a different user. + const canUseRunuser = + isLinux && + typeof process.getuid === "function" && + process.getuid() === 0 && + !!Bun.which("runuser") && + /^nobody:/m.test(readFileSync("/etc/passwd", "utf8")); + + test.skipIf(!canUseRunuser)("a directory that cannot be read is reported and fails the run", async () => { + using dir = tempDir("scanner-unreadable", { + "ok/a.test.ts": `import { test } from "bun:test"; test("a", () => { console.log("RAN a"); });`, + "locked/b.test.ts": `import { test } from "bun:test"; test("b", () => { console.log("RAN b"); });`, + }); + const root = String(dir); + chmodSync(root, 0o755); + chmodSync(join(root, "ok"), 0o755); + chmodSync(join(root, "ok", "a.test.ts"), 0o644); + chmodSync(join(root, "locked"), 0o000); + + try { + await using proc = Bun.spawn({ + cmd: ["runuser", "-m", "-u", "nobody", "--", bunExe(), "test"], + env: bunEnv, + cwd: root, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // The readable part of the tree still runs. + expect(stdout).toContain("RAN a"); + expect(stderr).toContain(" 1 pass"); + expect(stderr).toContain(`could not scan "${join(root, "locked")}" for tests`); + expect(stderr).toContain("EACCES"); + expect(stderr).toContain("1 directory could not be scanned for tests"); + expect(exitCode).toBe(1); + } finally { + chmodSync(join(root, "locked"), 0o755); + } + }); }); From dee0cabbb22c844f3687aa8a5ef16d5af0a7fd1d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:34:23 +0000 Subject: [PATCH 2/6] scanner: build the errno error with from_code_int so it compiles on Windows --- src/runtime/cli/test/Scanner.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/runtime/cli/test/Scanner.rs b/src/runtime/cli/test/Scanner.rs index 0b2a882f181a..759b620b312c 100644 --- a/src/runtime/cli/test/Scanner.rs +++ b/src/runtime/cli/test/Scanner.rs @@ -189,7 +189,10 @@ impl<'a> Scanner<'a> { if let bun_resolver::Error::Sys(errno) = e { self.report_unreadable_dir( path, - &bun_sys::Error::from_code(errno, bun_sys::Tag::open), + &bun_sys::Error::from_code_int( + errno as core::ffi::c_int, + bun_sys::Tag::open, + ), ); } } @@ -267,7 +270,10 @@ impl<'a> Scanner<'a> { if let bun_resolver::Error::Sys(errno) = dir_err.original_err { self.report_unreadable_dir( path2, - &bun_sys::Error::from_code(errno, bun_sys::Tag::scandir), + &bun_sys::Error::from_code_int( + errno as core::ffi::c_int, + bun_sys::Tag::scandir, + ), ); } } From 49526fc217f88adeec6e42fd59209bbc3154802b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:36:12 +0000 Subject: [PATCH 3/6] shorten doc comments --- src/runtime/cli/test/Scanner.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/runtime/cli/test/Scanner.rs b/src/runtime/cli/test/Scanner.rs index 759b620b312c..44e9db5814a2 100644 --- a/src/runtime/cli/test/Scanner.rs +++ b/src/runtime/cli/test/Scanner.rs @@ -32,11 +32,9 @@ pub struct Scanner<'a> { pub(crate) options: &'a BundleOptions<'a>, pub(crate) has_iterated: bool, pub(crate) search_count: usize, - /// Directories that could not be opened or read. Each one is reported as - /// it happens; the count makes the run exit non-zero. + /// Directories that could not be opened or read; non-zero fails the run. pub(crate) unreadable_dirs: usize, - /// `(st_dev, st_ino)` of every directory scanned so far. A symlink that - /// points back into the tree is skipped instead of walked again. + /// `(st_dev, st_ino)` of every directory scanned so far. visited_dirs: HashSet<(u64, u64)>, /// The directory being iterated; its fd closes once every child `ScanEntry` has been opened. current_dir: Option>, @@ -109,8 +107,7 @@ impl<'a> Scanner<'a> { ); } - /// Records `fd`'s directory identity. Returns `false` when that directory - /// was scanned before (reached again through a symlink). + /// Returns `false` when `fd`'s directory was scanned before. fn mark_visited(&mut self, fd: Fd) -> bool { match bun_sys::fstat(fd) { Ok(st) => self From 3fc9c3b8358749ef7fed57fca5a032f1ec56e893 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:49:54 +0000 Subject: [PATCH 4/6] scanner: report every directory read error, skip ENOENT, no dedup without an inode number --- src/runtime/cli/test/Scanner.rs | 49 +++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/src/runtime/cli/test/Scanner.rs b/src/runtime/cli/test/Scanner.rs index 44e9db5814a2..ed703cbd7154 100644 --- a/src/runtime/cli/test/Scanner.rs +++ b/src/runtime/cli/test/Scanner.rs @@ -98,7 +98,12 @@ impl<'a> Scanner<'a> { }) } + /// A directory that vanished between readdir and open (or a dangling link + /// to one) is not an error; anything else is reported and fails the run. fn report_unreadable_dir(&mut self, path: &[u8], err: &bun_sys::Error) { + if err.get_errno() == bun_sys::E::ENOENT { + return; + } self.unreadable_dirs += 1; bun_core::pretty_errorln!( "error: could not scan {} for tests\n{}", @@ -107,13 +112,31 @@ impl<'a> Scanner<'a> { ); } - /// Returns `false` when `fd`'s directory was scanned before. + fn report_dir_read_error(&mut self, path: &[u8], err: bun_resolver::Error, tag: bun_sys::Tag) { + match err { + bun_resolver::Error::Sys(errno) => self.report_unreadable_dir( + path, + &bun_sys::Error::from_code_int(errno as core::ffi::c_int, tag), + ), + other => { + self.unreadable_dirs += 1; + bun_core::pretty_errorln!( + "error: could not scan {} for tests\n{}", + bun_core::fmt::quote(path), + other + ); + } + } + } + + /// Returns `false` when `fd`'s directory was scanned before. A filesystem + /// that reports no inode number (`st_ino == 0`) gets no deduplication. fn mark_visited(&mut self, fd: Fd) -> bool { match bun_sys::fstat(fd) { - Ok(st) => self + Ok(st) if st.st_ino != 0 => self .visited_dirs .insert((st.st_dev as u64, st.st_ino as u64)), - Err(_) => true, + _ => true, } } @@ -183,15 +206,7 @@ impl<'a> Scanner<'a> { bstr::BStr::new(path), root_err.original_err.name() ); - if let bun_resolver::Error::Sys(errno) = e { - self.report_unreadable_dir( - path, - &bun_sys::Error::from_code_int( - errno as core::ffi::c_int, - bun_sys::Tag::open, - ), - ); - } + self.report_dir_read_error(path, e, bun_sys::Tag::open); } } else { let zpath = bun_core::ZBox::from_bytes(path); @@ -264,15 +279,7 @@ impl<'a> Scanner<'a> { let result = self.read_dir_with_name(path2, Some(child_dir.fd)); self.current_dir = None; if let EntriesOption::Err(dir_err) = result.map_err(|_| ScanError::OutOfMemory)? { - if let bun_resolver::Error::Sys(errno) = dir_err.original_err { - self.report_unreadable_dir( - path2, - &bun_sys::Error::from_code_int( - errno as core::ffi::c_int, - bun_sys::Tag::scandir, - ), - ); - } + self.report_dir_read_error(path2, dir_err.original_err, bun_sys::Tag::scandir); } } From 59cea25b079237a891cfa15a17c78a4892090961 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:51:45 +0000 Subject: [PATCH 5/6] test: guard the /etc/passwd read --- test/cli/test/bun-test.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/cli/test/bun-test.test.ts b/test/cli/test/bun-test.test.ts index bf14964f4351..751f177cc594 100644 --- a/test/cli/test/bun-test.test.ts +++ b/test/cli/test/bun-test.test.ts @@ -2118,12 +2118,19 @@ describe.concurrent("test file discovery (scanner)", () => { // root bypasses permission checks, so the unreadable directory is only // unreadable for a different user. + function hasNobodyUser(): boolean { + try { + return /^nobody:/m.test(readFileSync("/etc/passwd", "utf8")); + } catch { + return false; + } + } const canUseRunuser = isLinux && typeof process.getuid === "function" && process.getuid() === 0 && !!Bun.which("runuser") && - /^nobody:/m.test(readFileSync("/etc/passwd", "utf8")); + hasNobodyUser(); test.skipIf(!canUseRunuser)("a directory that cannot be read is reported and fails the run", async () => { using dir = tempDir("scanner-unreadable", { From 3ce764607a1987f2be8779d20835f970d9b39e78 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:11:44 +0000 Subject: [PATCH 6/6] scanner: use the wyhash hashbrown set for visited directories --- src/runtime/cli/test/Scanner.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/runtime/cli/test/Scanner.rs b/src/runtime/cli/test/Scanner.rs index ed703cbd7154..5a3a6aa2ad96 100644 --- a/src/runtime/cli/test/Scanner.rs +++ b/src/runtime/cli/test/Scanner.rs @@ -1,4 +1,4 @@ -use std::collections::{HashSet, VecDeque}; +use std::collections::VecDeque; use std::rc::Rc; use bun_alloc::AllocError; @@ -35,11 +35,13 @@ pub struct Scanner<'a> { /// Directories that could not be opened or read; non-zero fails the run. pub(crate) unreadable_dirs: usize, /// `(st_dev, st_ino)` of every directory scanned so far. - visited_dirs: HashSet<(u64, u64)>, + visited_dirs: VisitedDirs, /// The directory being iterated; its fd closes once every child `ScanEntry` has been opened. current_dir: Option>, } +type VisitedDirs = bun_collections::hashbrown::HashSet<(u64, u64), bun_wyhash::BuildHasher>; + // FIFO queue of scan entries (pop_front / push_back). pub(crate) type Fifo = VecDeque; @@ -93,7 +95,7 @@ impl<'a> Scanner<'a> { has_iterated: false, search_count: 0, unreadable_dirs: 0, - visited_dirs: HashSet::new(), + visited_dirs: VisitedDirs::default(), current_dir: None, }) }