From 09e909043ebfbfd3033cf2cc39d303a05ea69d9c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:42:28 +0000 Subject: [PATCH] Handle an unavailable statx, faccessat2 or prlimit64 the way node does - fs.stat: report ctime as birthtime when stat(2) has to stand in for statx(2), like libuv's uv__to_stat. It was the epoch. - exists_at/faccessat on Linux: issue the flag-less faccessat syscall directly. glibc 2.33+ faccessat() tries faccessat2 first and falls back on ENOSYS only, so a seccomp filter that answers faccessat2 with EPERM made a hoisted install report a cached package as missing. - RealFS::init: a failed getrlimit(RLIMIT_NOFILE) is an unknown fd budget, not a panic. - process.report: leave out a limit that getrlimit cannot read instead of printing an uninitialized struct rlimit. --- src/jsc/bindings/BunProcess.cpp | 6 +- src/resolver/lib.rs | 18 +- src/sys/PosixStat.rs | 10 +- src/sys/lib.rs | 19 +- src/sys/linux_syscall.rs | 15 + test/js/node/fs/fs-stat-seccomp-linux.test.ts | 277 +++++++++++++++++- 6 files changed, 308 insertions(+), 37 deletions(-) diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index b0067425958f..4deedb2a8b8f 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -2385,10 +2385,12 @@ __attribute__((minsize)) static JSValue constructReportObjectComplete(VM& vm, Zi }; for (size_t i = 0; i < std::size(resourceLimits); i++) { + // Node leaves out a limit it cannot read. + struct rlimit limit; + if (getrlimit(resourceLimits[i], &limit) != 0) + continue; JSC::JSObject* limitObject = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), 2); RETURN_IF_EXCEPTION(scope, {}); - struct rlimit limit; - getrlimit(resourceLimits[i], &limit); JSValue soft = limit.rlim_cur == RLIM_INFINITY ? JSC::jsString(vm, String("unlimited"_s)) : JSC::jsNumber(limit.rlim_cur); diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 883137b80f80..273ca6723195 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -1063,7 +1063,7 @@ pub mod fs { /// record the resulting fd budget so `need_to_close_files` can decide /// whether to cache directory fds. pub(crate) fn init(cwd: &'static [u8]) -> RealFS { - let file_limit = Self::adjust_ulimit().expect("unreachable"); + let file_limit = Self::adjust_ulimit(); #[cfg(windows)] let _ = file_limit; RealFS { @@ -1076,16 +1076,22 @@ pub mod fs { } /// Port of `RealFS.adjustUlimit` — always try to max out how many - /// files we can keep open. - pub(crate) fn adjust_ulimit() -> crate::CrateResult { + /// files we can keep open. Returns 0 when the limit cannot be read: + /// `need_to_close_files` is then always true and no fd stays cached. + /// `--watch`/`--hot` register an imported module through its cached + /// fd, so they then only see the entry point. + pub(crate) fn adjust_ulimit() -> usize { #[cfg(not(unix))] { - Ok(usize::MAX) + usize::MAX } #[cfg(unix)] { let resource = bun_sys::posix::RlimitResource::NOFILE; - let mut lim = bun_sys::posix::getrlimit(resource)?; + // Node ignores a failed getrlimit(RLIMIT_NOFILE) too (`PlatformInit`). + let Ok(mut lim) = bun_sys::posix::getrlimit(resource) else { + return 0; + }; // Cap at 1<<20 to match Node.js. On macOS the hard limit defaults to // RLIM_INFINITY; raising soft anywhere near INT_MAX breaks child processes @@ -1108,7 +1114,7 @@ pub mod fs { lim.cur = raised.cur; } } - Ok(usize::try_from(lim.cur).expect("int cast")) + usize::try_from(lim.cur).expect("int cast") } } diff --git a/src/sys/PosixStat.rs b/src/sys/PosixStat.rs index ed2c48128eac..8abe19a4333b 100644 --- a/src/sys/PosixStat.rs +++ b/src/sys/PosixStat.rs @@ -126,9 +126,10 @@ pub fn stat_ctime(s: &Stat) -> Timespec { } #[inline] pub fn stat_birthtime(s: &Stat) -> Timespec { - // Linux gets the epoch arm; everything else reads the real birthtime. - // Windows `Stat` is `uv_stat_t` and libuv fills `birthtim` from NTFS - // CreationTime, so it must NOT fall into the epoch arm. + // Linux `struct stat` has no birthtime (only `statx` does), so it gets the + // ctime arm like libuv's `uv__to_stat`; everything else reads the real + // birthtime. Windows `Stat` is `uv_stat_t` and libuv fills `birthtim` from + // NTFS CreationTime, so it must NOT fall into the ctime arm. #[cfg(windows)] { Timespec { @@ -160,8 +161,7 @@ pub fn stat_birthtime(s: &Stat) -> Timespec { target_os = "dragonfly" )))] { - let _ = s; - Timespec::EPOCH + stat_ctime(s) } } diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 6701a0b46b1e..dddbb5965b97 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -2871,11 +2871,7 @@ mod posix_impl { } /// Never errors; any non-zero rc → `Ok(false)`. pub fn faccessat(dir: impl AsFd, sub: &ZStr) -> Maybe { - let dir = dir.as_fd(); - // SAFETY: `dir` is a live fd (or AT_FDCWD); `ZStr::as_ptr()` is a - // valid NUL-terminated C string. - let rc = unsafe { libc::faccessat(dir.native(), sub.as_ptr(), libc::F_OK, 0) }; - Ok(rc == 0) + Ok(exists_at(dir, sub)) } pub fn futimens(fd: Fd, atime: TimeLike, mtime: TimeLike) -> Maybe<()> { let ts = [atime.to_timespec(), mtime.to_timespec()]; @@ -2923,9 +2919,16 @@ mod posix_impl { } pub fn exists_at(dir: impl AsFd, sub: &ZStr) -> bool { let dir = dir.as_fd(); - // SAFETY: `dir` is a live fd (or AT_FDCWD); `ZStr::as_ptr()` is a - // valid NUL-terminated C string. - unsafe { libc::faccessat(dir.native(), sub.as_ptr(), libc::F_OK, 0) == 0 } + #[cfg(any(target_os = "linux", target_os = "android"))] + { + super::linux_syscall::faccessat(dir, sub, libc::F_OK).is_ok() + } + #[cfg(not(any(target_os = "linux", target_os = "android")))] + { + // SAFETY: `dir` is a live fd (or AT_FDCWD); `ZStr::as_ptr()` is a + // valid NUL-terminated C string. + unsafe { libc::faccessat(dir.native(), sub.as_ptr(), libc::F_OK, 0) == 0 } + } } /// Calls extern C `is_executable_file` (c-bindings.cpp:72-89) via FFI. pub fn is_executable_file_path(path: &ZStr) -> bool { diff --git a/src/sys/linux_syscall.rs b/src/sys/linux_syscall.rs index ae5cc0041722..374ac6342229 100644 --- a/src/sys/linux_syscall.rs +++ b/src/sys/linux_syscall.rs @@ -201,6 +201,21 @@ pub(crate) fn fstatat(dir: i32, path: &ZStr, flags: i32) -> Result Result<(), i32> { + // `Access` is `c_uint` bits on rustix's linux_raw backend, `c_int` on its + // libc backend (Android, where bionic's `faccessat` is already flag-less). + let access = rustix::fs::Access::from_bits_retain(mode as _); + let dir = dir.as_borrowed_fd(); + retry(|| rustix::fs::accessat(dir, path.as_cstr(), access, rustix::fs::AtFlags::empty())) +} + /// Map rustix's kernel `struct stat` → `libc::stat`. /// /// On Bun's tier-1 Linux targets (x86_64, aarch64 — gnu/musl/bionic alike), diff --git a/test/js/node/fs/fs-stat-seccomp-linux.test.ts b/test/js/node/fs/fs-stat-seccomp-linux.test.ts index f159c897f3cb..40044b34569d 100644 --- a/test/js/node/fs/fs-stat-seccomp-linux.test.ts +++ b/test/js/node/fs/fs-stat-seccomp-linux.test.ts @@ -1,11 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isLinux, tempDir, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, isASAN, isLinux, tempDir, tempDirWithFiles } from "harness"; import { spawnSync } from "node:child_process"; -import { existsSync, symlinkSync } from "node:fs"; +import { existsSync, rmSync, symlinkSync } from "node:fs"; import { join } from "node:path"; // Seccomp helper: installs a filter that makes one syscall (`BLOCK_SYSCALL`, // a `-D` define) fail with the errno given in argv[1], then execs argv[2..]. +// With `-DBLOCK_ARG1=value` only calls whose second argument is `value` fail. // Shared by the describe blocks below. const helperSrc = ` #define _GNU_SOURCE @@ -17,6 +18,7 @@ const helperSrc = ` #include #include #include +#include #include #include @@ -28,6 +30,18 @@ const helperSrc = ` #define MY_AUDIT_ARCH 0 #endif +/* Linux 5.8; the same number on every architecture. */ +#ifndef __NR_faccessat2 + #define __NR_faccessat2 439 +#endif + +/* instructions between the syscall-nr test and the errno return */ +#ifdef BLOCK_ARG1 + #define ARG1_TEST_LEN 2 +#else + #define ARG1_TEST_LEN 0 +#endif + /* usage: block [args...] */ int main(int argc, char **argv) { if (argc < 3) return 2; @@ -42,7 +56,12 @@ int main(int argc, char **argv) { /* load syscall nr */ BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), /* if nr == BLOCK_SYSCALL → return the requested errno */ - BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, BLOCK_SYSCALL, 0, 1), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, BLOCK_SYSCALL, 0, 1 + ARG1_TEST_LEN), +#ifdef BLOCK_ARG1 + /* ... but only if the low word of args[1] == BLOCK_ARG1 (little-endian) */ + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[1])), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, BLOCK_ARG1, 0, 1), +#endif BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (err & SECCOMP_RET_DATA)), /* else → allow */ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), @@ -70,21 +89,25 @@ int main(int argc, char **argv) { // Linux errno values (identical on x86_64 and aarch64). const EPERM = 1; const EACCES = 13; +const EINVAL = 22; +const ENOSYS = 38; // Driver-internal code that leaks to userspace; above EHWPOISON (133), the // last errno bun's SystemErrno table declares. const ENOTSUPP = 524; -// Compile the seccomp helper for one syscall. Returns the binary path, or -// null if the host genuinely can't build it (no cc, missing kernel headers). -// Any other compile failure throws so a source regression isn't silently -// hidden as a skip. -function tryBuildHelper(syscall: string): string | null { +// Compile the seccomp helper for one syscall (and, with `arg1`, one value of +// its second argument). Returns the binary path, or null if the host +// genuinely can't build it (no cc, missing kernel headers). Any other compile +// failure throws so a source regression isn't silently hidden as a skip. +function tryBuildHelper(syscall: string, arg1?: string): string | null { const dir = tempDirWithFiles("seccomp-helper", { "block.c": helperSrc, }); const src = join(dir, "block.c"); const bin = join(dir, "block"); - const compile = spawnSync("cc", ["-O0", `-DBLOCK_SYSCALL=${syscall}`, "-o", bin, src], { stdio: "pipe" }); + const defines = [`-DBLOCK_SYSCALL=${syscall}`]; + if (arg1 !== undefined) defines.push(`-DBLOCK_ARG1=${arg1}`); + const compile = spawnSync("cc", ["-O0", ...defines, "-o", bin, src], { stdio: "pipe" }); // compiler not on PATH — expected skip if ((compile.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return null; @@ -101,14 +124,19 @@ function tryBuildHelper(syscall: string): string | null { return bin; } -// Run `bun -e snippet args...` under the seccomp helper, with the blocked -// syscall failing with `errno`. Returns { stdout, stderr, exitCode } on -// success, or null if the environment refused to install the seccomp filter -// (skip). -async function runUnderSeccomp(bin: string, errno: number, snippet: string, args: string[] = []) { +// Run `bun argv...` under the seccomp helper, with the blocked syscall failing +// with `errno`. Returns { stdout, stderr, exitCode } on success, or null if +// the environment refused to install the seccomp filter (skip). +async function runBunUnderSeccomp( + bin: string, + errno: number, + argv: string[], + options: { cwd?: string; env?: Record } = {}, +) { await using proc = Bun.spawn({ - cmd: [bin, String(errno), bunExe(), "-e", snippet, ...args], - env: bunEnv, + cmd: [bin, String(errno), bunExe(), ...argv], + env: options.env ?? bunEnv, + cwd: options.cwd, stdout: "pipe", stderr: "pipe", }); @@ -117,6 +145,11 @@ async function runUnderSeccomp(bin: string, errno: number, snippet: string, args return { stdout, stderr, exitCode }; } +// Run `bun -e snippet args...` under the seccomp helper. +function runUnderSeccomp(bin: string, errno: number, snippet: string, args: string[] = []) { + return runBunUnderSeccomp(bin, errno, ["-e", snippet, ...args]); +} + // Reproduces the seccomp class of failures documented in libuv's // deps/uv/src/unix/fs.c: statx under a seccomp filter that does not // whitelist it returns EPERM (libseccomp < 2.3.3, docker < 18.04, various @@ -203,6 +236,218 @@ describe.skipIf(!isLinux)("fs.stat seccomp statx fallback", () => { expect(out.exitCode).toBe(0); }); } + + // `struct stat` has no birthtime. libuv (so node) reports ctime for it when + // statx is unavailable (a kernel older than 4.11, or a seccomp profile + // written before the call existed). The first call goes through the statx + // fallback, the later ones skip statx altogether. + const birthtimeSnippet = ` + const fs = require("node:fs"); + const path = process.argv[1]; + const fd = fs.openSync(path, "r"); + const stats = { + statSync: fs.statSync(path), + lstatSync: fs.lstatSync(path), + fstatSync: fs.fstatSync(fd), + bigint: fs.statSync(path, { bigint: true }), + promises: await fs.promises.stat(path), + }; + fs.closeSync(fd); + const result = {}; + for (const [name, s] of Object.entries(stats)) { + result[name] = s.birthtimeMs === s.ctimeMs ? "ctime" : Number(s.birthtimeMs) === 0 ? "epoch" : "other"; + } + console.log(JSON.stringify(result)); + `; + + for (const [name, errno] of [ + ["ENOSYS", ENOSYS], + ["EPERM", EPERM], + ["EINVAL", EINVAL], + ] as const) { + test.concurrent(`birthtime is ctime when statx fails with ${name}`, async () => { + if (helperBin == null) { + console.warn("SKIP birthtime seccomp: cc or seccomp headers not available"); + return; + } + using targetDir = tempDir("stat-seccomp-birthtime", { "file.txt": "hello" }); + const out = await runUnderSeccomp(helperBin, errno, birthtimeSnippet, [join(String(targetDir), "file.txt")]); + if (out == null) { + console.warn("SKIP birthtime seccomp: seccomp not permitted in this environment"); + return; + } + expect({ stdout: out.stdout.trim(), exitCode: out.exitCode }).toEqual({ + stdout: JSON.stringify({ + statSync: "ctime", + lstatSync: "ctime", + fstatSync: "ctime", + bigint: "ctime", + promises: "ctime", + }), + exitCode: 0, + }); + }); + } +}); + +// glibc 2.33+ faccessat() issues faccessat2 first, even with no flags, and +// falls back to faccessat on ENOSYS only. When a seccomp filter answers +// faccessat2 with EPERM or EINVAL, every "does this exist" check made through +// the libc wrapper says no: a hoisted install could not see a package that +// was in the cache. bun 1.3 made the flag-less syscall itself and passed. +describe.skipIf(!isLinux)("bun install when faccessat2 is blocked by seccomp", () => { + const helperBin = tryBuildHelper("__NR_faccessat2"); + + for (const [name, errno] of [ + ["EPERM", EPERM], + ["EINVAL", EINVAL], + ] as const) { + test.concurrent(`hoisted install finds a package in a warm cache (${name})`, async () => { + if (helperBin == null) { + console.warn("SKIP faccessat2 seccomp: cc or seccomp headers not available"); + return; + } + + const tarball = await new Bun.Archive( + { + "package/package.json": JSON.stringify({ name: "dep", version: "1.0.0" }), + "package/index.js": "module.exports = 1;", + }, + { compress: "gzip" }, + ).bytes(); + await using registry = Bun.serve({ + port: 0, + fetch(request) { + const { origin, pathname } = new URL(request.url); + if (pathname === "/dep-1.0.0.tgz") return new Response(tarball); + if (pathname !== "/dep") return new Response("not found", { status: 404 }); + return Response.json({ + name: "dep", + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { name: "dep", version: "1.0.0", dist: { tarball: `${origin}/dep-1.0.0.tgz` } } }, + }); + }, + }); + + using dir = tempDir("faccessat2-seccomp", { + "package.json": JSON.stringify({ name: "app", version: "1.0.0", dependencies: { dep: "1.0.0" } }), + "bunfig.toml": `[install]\nregistry = "${registry.url.href}"\nlinker = "hoisted"\n`, + }); + const cwd = String(dir); + const env = { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(cwd, ".bun-cache") }; + const installed = join(cwd, "node_modules", "dep", "package.json"); + + // Cold: downloads and extracts into the cache. + const cold = await runBunUnderSeccomp(helperBin, errno, ["install"], { cwd, env }); + if (cold == null) { + console.warn("SKIP faccessat2 seccomp: seccomp not permitted in this environment"); + return; + } + expect({ stderr: cold.stderr, installed: existsSync(installed), exitCode: cold.exitCode }).toEqual({ + stderr: expect.not.stringContaining("error:"), + installed: true, + exitCode: 0, + }); + + // Warm: node_modules is gone, the package is in the cache. + rmSync(join(cwd, "node_modules"), { recursive: true, force: true }); + const warm = await runBunUnderSeccomp(helperBin, errno, ["install"], { cwd, env }); + expect({ stderr: warm!.stderr, installed: existsSync(installed), exitCode: warm!.exitCode }).toEqual({ + stderr: expect.not.stringContaining("error:"), + installed: true, + exitCode: 0, + }); + }); + } +}); + +// glibc implements getrlimit() with prlimit64. bun raises RLIMIT_NOFILE at +// startup and used to panic ("unreachable: Sys(EPERM)") when it could not read +// the limit. Node ignores that failure. This filter matches RLIMIT_NOFILE +// only, so that JavaScriptCore and the ASAN runtime can still read +// RLIMIT_STACK. The next block denies the whole syscall. +describe.skipIf(!isLinux)("startup when getrlimit(RLIMIT_NOFILE) fails", () => { + const helperBin = tryBuildHelper("__NR_prlimit64", "RLIMIT_NOFILE"); + + for (const [name, errno] of [ + ["ENOSYS", ENOSYS], + ["EPERM", EPERM], + ] as const) { + test.concurrent(`bun runs a script (${name})`, async () => { + if (helperBin == null) { + console.warn("SKIP prlimit64 seccomp: cc or seccomp headers not available"); + return; + } + // The relative import makes the resolver read the directory, which is + // where the fd budget from RLIMIT_NOFILE is used. + using dir = tempDir("prlimit64-seccomp", { + "index.js": `import { message } from "./message.js";\nconsole.log(message);`, + "message.js": `export const message = "bun ok";`, + }); + const out = await runBunUnderSeccomp(helperBin, errno, ["index.js"], { cwd: String(dir) }); + if (out == null) { + console.warn("SKIP prlimit64 seccomp: seccomp not permitted in this environment"); + return; + } + expect({ stdout: out.stdout.trim(), exitCode: out.exitCode }).toEqual({ stdout: "bun ok", exitCode: 0 }); + }); + } + + // It used to print whatever was in the uninitialized struct rlimit. + test.concurrent("process.report leaves out the limit it cannot read, like node", async () => { + if (helperBin == null) { + console.warn("SKIP prlimit64 seccomp: cc or seccomp headers not available"); + return; + } + const out = await runUnderSeccomp( + helperBin, + EPERM, + `const { userLimits } = process.report.getReport(); + console.log(JSON.stringify({ open_files: "open_files" in userLimits, stack_size_bytes: "stack_size_bytes" in userLimits }));`, + ); + if (out == null) { + console.warn("SKIP prlimit64 seccomp: seccomp not permitted in this environment"); + return; + } + expect({ stdout: out.stdout.trim(), exitCode: out.exitCode }).toEqual({ + stdout: JSON.stringify({ open_files: false, stack_size_bytes: true }), + exitCode: 0, + }); + }); +}); + +// With the whole syscall denied, JavaScriptCore still cannot start: glibc's +// pthread_getattr_np needs getrlimit(RLIMIT_STACK) to tell it the main +// thread's stack bounds. Commands that do not run JavaScript work. Not under +// ASAN: its runtime aborts on its own when getrlimit fails. +describe.skipIf(!isLinux || isASAN)("bun build when prlimit64 is blocked by seccomp", () => { + const helperBin = tryBuildHelper("__NR_prlimit64"); + + for (const [name, errno] of [ + ["ENOSYS", ENOSYS], + ["EPERM", EPERM], + ] as const) { + test.concurrent(`bundles a file (${name})`, async () => { + if (helperBin == null) { + console.warn("SKIP prlimit64 seccomp: cc or seccomp headers not available"); + return; + } + using dir = tempDir("prlimit64-seccomp-build", { + "index.js": `import { message } from "./message.js";\nconsole.log(message);`, + "message.js": `export const message = "bun ok";`, + }); + const cwd = String(dir); + const out = await runBunUnderSeccomp(helperBin, errno, ["build", "index.js", "--outfile=out.js"], { cwd }); + if (out == null) { + console.warn("SKIP prlimit64 seccomp: seccomp not permitted in this environment"); + return; + } + expect({ built: existsSync(join(cwd, "out.js")), exitCode: out.exitCode }).toEqual({ + built: true, + exitCode: 0, + }); + }); + } }); // The kernel is not bound to the errno table bun knows: FUSE filesystems and