Skip to content
Draft
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
72 changes: 69 additions & 3 deletions src/runtime/cli/test/Scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Rc<Dir>>,
}

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<ScanEntry>;

Expand Down Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
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!(
"<r><red>error<r>: 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!(
"<r><red>error<r>: 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.
Comment thread
robobun marked this conversation as resolved.
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,
}
}
Comment thread
robobun marked this conversation as resolved.

#[inline]
pub(crate) fn fs(&self) -> &'static FileSystem {
// SAFETY: process-singleton; no `&mut` to it is live outside the iterator callback.
Expand Down Expand Up @@ -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));
}
}

Expand Down Expand Up @@ -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;
}
Comment thread
robobun marked this conversation as resolved.
};
let child_dir = Rc::new(Dir::from_fd(child_fd));
if !self.mark_visited(child_dir.fd) {
continue;
}
let path2 = self
.fs()
.dirname_store
Expand All @@ -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);
}
Comment thread
robobun marked this conversation as resolved.
}

Ok(())
Expand Down
9 changes: 9 additions & 0 deletions src/runtime/cli/test_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2642,6 +2643,13 @@ impl TestCommand {
}
}

if unreadable_dirs > 0 {
pretty_error!(
"<r><red>error<r>: {} director{} could not be scanned for tests\n",
unreadable_dirs,
if unreadable_dirs == 1 { "y" } else { "ies" }
);
}
pretty_error!("\n");
Output::flush();

Expand All @@ -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;
}
Expand Down
75 changes: 74 additions & 1 deletion test/cli/test/bun-test.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
}
});
});
Loading