diff --git a/src/runtime/cli/test/Scanner.rs b/src/runtime/cli/test/Scanner.rs index 30698f15555e..5a3a6aa2ad96 100644 --- a/src/runtime/cli/test/Scanner.rs +++ b/src/runtime/cli/test/Scanner.rs @@ -32,10 +32,16 @@ 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; non-zero fails the run. + pub(crate) unreadable_dirs: usize, + /// `(st_dev, st_ino)` of every directory scanned so far. + 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; @@ -88,10 +94,54 @@ impl<'a> Scanner<'a> { open_dir_buf: PathBuffer::uninit(), has_iterated: false, search_count: 0, + unreadable_dirs: 0, + visited_dirs: VisitedDirs::default(), current_dir: None, }) } + /// 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{}", + bun_core::fmt::quote(path), + err.with_path(path) + ); + } + + 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) if st.st_ino != 0 => self + .visited_dirs + .insert((st.st_dev as u64, st.st_ino as u64)), + _ => true, + } + } + #[inline] pub(crate) fn fs(&self) -> &'static FileSystem { // SAFETY: process-singleton; no `&mut` to it is live outside the iterator callback. @@ -158,6 +208,13 @@ impl<'a> Scanner<'a> { bstr::BStr::new(path), root_err.original_err.name() ); + self.report_dir_read_error(path, e, 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 +261,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 +280,9 @@ 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)? { + self.report_dir_read_error(path2, dir_err.original_err, 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..751f177cc594 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,77 @@ 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. + 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") && + hasNobodyUser(); + + 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); + } + }); });