Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
68 changes: 64 additions & 4 deletions src/runtime/cli/test/Scanner.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::collections::VecDeque;
use std::collections::{HashSet, VecDeque};

Check failure on line 1 in src/runtime/cli/test/Scanner.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

use of a disallowed type `std::collections::HashSet`
use std::rc::Rc;

use bun_alloc::AllocError;
Expand Down Expand Up @@ -32,6 +32,12 @@
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.
Comment thread
robobun marked this conversation as resolved.
Resolved
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.
Comment thread
robobun marked this conversation as resolved.
Resolved
visited_dirs: HashSet<(u64, u64)>,

Check failure on line 40 in src/runtime/cli/test/Scanner.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

use of a disallowed type `std::collections::HashSet`
/// The directory being iterated; its fd closes once every child `ScanEntry` has been opened.
current_dir: Option<Rc<Dir>>,
}
Expand Down Expand Up @@ -88,10 +94,32 @@
open_dir_buf: PathBuffer::uninit(),
has_iterated: false,
search_count: 0,
unreadable_dirs: 0,
visited_dirs: HashSet::new(),

Check failure on line 98 in src/runtime/cli/test/Scanner.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

use of a disallowed type `std::collections::HashSet`
current_dir: None,
})
}

fn report_unreadable_dir(&mut self, path: &[u8], err: &bun_sys::Error) {
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)
);
}

/// Records `fd`'s directory identity. Returns `false` when that directory
/// was scanned before (reached again through a symlink).
Comment thread
robobun marked this conversation as resolved.
Resolved
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,
}
}
Comment thread
robobun marked this conversation as resolved.
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 +186,21 @@
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,
),
);
}
}
} 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 +247,17 @@
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.
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 +266,17 @@
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_int(
errno as core::ffi::c_int,
bun_sys::Tag::scandir,
),
);
}
}
Comment thread
robobun marked this conversation as resolved.
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
68 changes: 67 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,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"));
Comment thread
robobun marked this conversation as resolved.
Resolved

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