From 96bd8170f91d4ad590221239c14452bbab3d2c88 Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 3 May 2026 00:43:37 +0000 Subject: [PATCH 1/4] resolver: check union tag before reading cached_entry.entries in dirInfoCachedMaybeLog EntriesOption is a tagged union of .entries (*DirEntry) and .err (DirEntry.Err). When readDirectory() fails with a non-ENOENT error (EACCES, EMFILE, ...) it stores .err in rfs.entries. If the directory later becomes openable and dirInfoCachedMaybeLog processes it as queue slot [0], the cached .err was read as .entries without a tag check, reinterpreting two anyerror values as a *DirEntry pointer and dereferencing it for .generation. dirInfoForResolution already had the correct guard; apply the same here. When the cached entry is .err, fall through with needs_iter=true so the directory is re-read now that it can be opened. --- src/resolver/resolver.zig | 12 ++-- test/js/bun/resolve/resolve.test.ts | 92 ++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/src/resolver/resolver.zig b/src/resolver/resolver.zig index c7c58fa0877..eb690cb631b 100644 --- a/src/resolver/resolver.zig +++ b/src/resolver/resolver.zig @@ -2970,11 +2970,13 @@ pub const Resolver = struct { var in_place: ?*Fs.FileSystem.DirEntry = null; if (rfs.entries.atIndex(cached_dir_entry_result.index)) |cached_entry| { - if (cached_entry.entries.generation >= r.generation) { - dir_entries_option = cached_entry; - needs_iter = false; - } else { - in_place = cached_entry.entries; + if (cached_entry.* == .entries) { + if (cached_entry.entries.generation >= r.generation) { + dir_entries_option = cached_entry; + needs_iter = false; + } else { + in_place = cached_entry.entries; + } } } diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index 102d5e6cd9b..af13f9f6db0 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -1,7 +1,7 @@ import { pathToFileURL } from "bun"; import { describe, expect, it } from "bun:test"; -import { mkdirSync, writeFileSync } from "fs"; -import { bunEnv, bunExe, bunRun, isWindows, joinP, tempDir, tempDirWithFiles } from "harness"; +import { chmodSync, chownSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { bunEnv, bunExe, bunRun, isLinux, isWindows, joinP, tempDir, tempDirWithFiles } from "harness"; import { join, resolve, sep } from "path"; const fixture = (...segs: string[]) => resolve(import.meta.dir, "fixtures", ...segs); @@ -484,3 +484,91 @@ it.skipIf(isWindows)("browser map resolution handles relative paths longer than expect(stdout).toContain("42"); expect(exitCode).toBe(0); }); + +// dirInfoCachedMaybeLog reads the rfs.entries cache without checking the union +// tag. If readDirectory() previously failed with a non-ENOENT error (e.g. +// EACCES), a `.err` variant is stored there; re-resolving the directory after +// the error condition clears would then reinterpret the two `anyerror` values +// as a *DirEntry pointer and dereference it. +{ + // Root bypasses DAC, so chmod 0 won't yield EACCES. When running as root on + // Linux we drop to `nobody` via runuser (and chown the temp dir so the + // fixture can chmod it back). Otherwise we run the fixture directly. + const isRoot = !isWindows && process.getuid?.() === 0; + const hasNobody = (() => { + try { + return /^nobody:/m.test(readFileSync("/etc/passwd", "utf8")); + } catch { + return false; + } + })(); + const canUseRunuser = isLinux && isRoot && !!Bun.which("runuser") && hasNobody; + const canTriggerEACCES = !isWindows && (!isRoot || canUseRunuser); + + it.skipIf(!canTriggerEACCES)("resolving a directory whose entries cache holds .err does not crash", async () => { + const fixture = ` + const { chmodSync } = require("fs"); + const { join } = require("path"); + const root = process.argv[2]; + const bad = join(root, "bad"); + + // 1) Make "bad" unreadable. loadAsFile -> readDirectory(bad) fails with + // EACCES, which stores EntriesOption{ .err = ... } in rfs.entries. + chmodSync(bad, 0o000); + let threw = false; + try { Bun.resolveSync("./bad/index.js", root); } catch { threw = true; } + + // 2) Restore permissions so the dir is openable again. + chmodSync(bad, 0o755); + + // 3) Resolve "bad" as a directory. dirInfoCachedMaybeLog now opens it + // successfully, finds the cached .err, and must not read + // cached_entry.entries.generation on the inactive union field. + const resolved = Bun.resolveSync("./bad", root); + + if (!threw) throw new Error("expected EACCES resolving ./bad/index.js"); + if (!resolved.endsWith(join("bad", "index.js"))) + throw new Error("expected ./bad to resolve to bad/index.js, got: " + resolved); + console.log("OK"); + `; + + using dir = tempDir("resolver-cached-err", { + "fixture.js": fixture, + "bad/index.js": "module.exports = 1;\n", + }); + const root = String(dir); + + let cmd: string[]; + if (canUseRunuser) { + // Give `nobody` ownership so the fixture's chmodSync calls succeed, and + // open up perms so `nobody` can traverse/read everything it needs. + for (const p of [root, join(root, "fixture.js"), join(root, "bad"), join(root, "bad", "index.js")]) { + chmodSync(p, 0o777); + chownSync(p, 65534, 65534); + } + cmd = ["runuser", "-u", "nobody", "--", bunExe(), join(root, "fixture.js"), root]; + } else { + cmd = [bunExe(), join(root, "fixture.js"), root]; + } + + try { + await using proc = Bun.spawn({ + cmd, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout).toBe("OK\n"); + expect(exitCode).toBe(0); + } finally { + // Ensure tempDir cleanup can remove the directory even if the fixture + // crashed between the two chmod calls. + try { + chmodSync(join(root, "bad"), 0o755); + } catch {} + } + }, 30_000); +} From 548c8dda3592cb700f49e662dd78bafc8c53e840 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 00:46:43 +0000 Subject: [PATCH 2/4] [autofix.ci] apply automated fixes --- test/js/bun/resolve/resolve.test.ts | 80 +++++++++++++++-------------- 1 file changed, 42 insertions(+), 38 deletions(-) diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index af13f9f6db0..d4b7999805e 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -505,8 +505,10 @@ it.skipIf(isWindows)("browser map resolution handles relative paths longer than const canUseRunuser = isLinux && isRoot && !!Bun.which("runuser") && hasNobody; const canTriggerEACCES = !isWindows && (!isRoot || canUseRunuser); - it.skipIf(!canTriggerEACCES)("resolving a directory whose entries cache holds .err does not crash", async () => { - const fixture = ` + it.skipIf(!canTriggerEACCES)( + "resolving a directory whose entries cache holds .err does not crash", + async () => { + const fixture = ` const { chmodSync } = require("fs"); const { join } = require("path"); const root = process.argv[2]; @@ -532,43 +534,45 @@ it.skipIf(isWindows)("browser map resolution handles relative paths longer than console.log("OK"); `; - using dir = tempDir("resolver-cached-err", { - "fixture.js": fixture, - "bad/index.js": "module.exports = 1;\n", - }); - const root = String(dir); - - let cmd: string[]; - if (canUseRunuser) { - // Give `nobody` ownership so the fixture's chmodSync calls succeed, and - // open up perms so `nobody` can traverse/read everything it needs. - for (const p of [root, join(root, "fixture.js"), join(root, "bad"), join(root, "bad", "index.js")]) { - chmodSync(p, 0o777); - chownSync(p, 65534, 65534); + using dir = tempDir("resolver-cached-err", { + "fixture.js": fixture, + "bad/index.js": "module.exports = 1;\n", + }); + const root = String(dir); + + let cmd: string[]; + if (canUseRunuser) { + // Give `nobody` ownership so the fixture's chmodSync calls succeed, and + // open up perms so `nobody` can traverse/read everything it needs. + for (const p of [root, join(root, "fixture.js"), join(root, "bad"), join(root, "bad", "index.js")]) { + chmodSync(p, 0o777); + chownSync(p, 65534, 65534); + } + cmd = ["runuser", "-u", "nobody", "--", bunExe(), join(root, "fixture.js"), root]; + } else { + cmd = [bunExe(), join(root, "fixture.js"), root]; } - cmd = ["runuser", "-u", "nobody", "--", bunExe(), join(root, "fixture.js"), root]; - } else { - cmd = [bunExe(), join(root, "fixture.js"), root]; - } - try { - await using proc = Bun.spawn({ - cmd, - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - expect(stderr).toBe(""); - expect(stdout).toBe("OK\n"); - expect(exitCode).toBe(0); - } finally { - // Ensure tempDir cleanup can remove the directory even if the fixture - // crashed between the two chmod calls. try { - chmodSync(join(root, "bad"), 0o755); - } catch {} - } - }, 30_000); + await using proc = Bun.spawn({ + cmd, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout).toBe("OK\n"); + expect(exitCode).toBe(0); + } finally { + // Ensure tempDir cleanup can remove the directory even if the fixture + // crashed between the two chmod calls. + try { + chmodSync(join(root, "bad"), 0o755); + } catch {} + } + }, + 30_000, + ); } From 5425fe5378dac1ee67fc87032a89a183fac88738 Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 3 May 2026 02:09:05 +0000 Subject: [PATCH 3/4] test: parse nobody uid/gid from /etc/passwd; drop explicit timeout --- test/js/bun/resolve/resolve.test.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index d4b7999805e..3fd1512c504 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -495,14 +495,21 @@ it.skipIf(isWindows)("browser map resolution handles relative paths longer than // Linux we drop to `nobody` via runuser (and chown the temp dir so the // fixture can chmod it back). Otherwise we run the fixture directly. const isRoot = !isWindows && process.getuid?.() === 0; - const hasNobody = (() => { + const nobody = (() => { try { - return /^nobody:/m.test(readFileSync("/etc/passwd", "utf8")); + // /etc/passwd format: name:x:uid:gid:gecos:home:shell + const line = readFileSync("/etc/passwd", "utf8") + .split("\n") + .find(l => l.startsWith("nobody:")); + if (!line) return null; + const [, , uid, gid] = line.split(":"); + if (!Number.isInteger(+uid) || !Number.isInteger(+gid)) return null; + return { uid: +uid, gid: +gid }; } catch { - return false; + return null; } })(); - const canUseRunuser = isLinux && isRoot && !!Bun.which("runuser") && hasNobody; + const canUseRunuser = isLinux && isRoot && !!Bun.which("runuser") && nobody !== null; const canTriggerEACCES = !isWindows && (!isRoot || canUseRunuser); it.skipIf(!canTriggerEACCES)( @@ -546,7 +553,7 @@ it.skipIf(isWindows)("browser map resolution handles relative paths longer than // open up perms so `nobody` can traverse/read everything it needs. for (const p of [root, join(root, "fixture.js"), join(root, "bad"), join(root, "bad", "index.js")]) { chmodSync(p, 0o777); - chownSync(p, 65534, 65534); + chownSync(p, nobody!.uid, nobody!.gid); } cmd = ["runuser", "-u", "nobody", "--", bunExe(), join(root, "fixture.js"), root]; } else { @@ -573,6 +580,5 @@ it.skipIf(isWindows)("browser map resolution handles relative paths longer than } catch {} } }, - 30_000, ); } From befbdc46be5d32b8327cb437de458ab2a5d90092 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 02:11:15 +0000 Subject: [PATCH 4/4] [autofix.ci] apply automated fixes --- test/js/bun/resolve/resolve.test.ts | 79 ++++++++++++++--------------- 1 file changed, 38 insertions(+), 41 deletions(-) diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index 3fd1512c504..8a09744063b 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -512,10 +512,8 @@ it.skipIf(isWindows)("browser map resolution handles relative paths longer than const canUseRunuser = isLinux && isRoot && !!Bun.which("runuser") && nobody !== null; const canTriggerEACCES = !isWindows && (!isRoot || canUseRunuser); - it.skipIf(!canTriggerEACCES)( - "resolving a directory whose entries cache holds .err does not crash", - async () => { - const fixture = ` + it.skipIf(!canTriggerEACCES)("resolving a directory whose entries cache holds .err does not crash", async () => { + const fixture = ` const { chmodSync } = require("fs"); const { join } = require("path"); const root = process.argv[2]; @@ -541,44 +539,43 @@ it.skipIf(isWindows)("browser map resolution handles relative paths longer than console.log("OK"); `; - using dir = tempDir("resolver-cached-err", { - "fixture.js": fixture, - "bad/index.js": "module.exports = 1;\n", - }); - const root = String(dir); - - let cmd: string[]; - if (canUseRunuser) { - // Give `nobody` ownership so the fixture's chmodSync calls succeed, and - // open up perms so `nobody` can traverse/read everything it needs. - for (const p of [root, join(root, "fixture.js"), join(root, "bad"), join(root, "bad", "index.js")]) { - chmodSync(p, 0o777); - chownSync(p, nobody!.uid, nobody!.gid); - } - cmd = ["runuser", "-u", "nobody", "--", bunExe(), join(root, "fixture.js"), root]; - } else { - cmd = [bunExe(), join(root, "fixture.js"), root]; + using dir = tempDir("resolver-cached-err", { + "fixture.js": fixture, + "bad/index.js": "module.exports = 1;\n", + }); + const root = String(dir); + + let cmd: string[]; + if (canUseRunuser) { + // Give `nobody` ownership so the fixture's chmodSync calls succeed, and + // open up perms so `nobody` can traverse/read everything it needs. + for (const p of [root, join(root, "fixture.js"), join(root, "bad"), join(root, "bad", "index.js")]) { + chmodSync(p, 0o777); + chownSync(p, nobody!.uid, nobody!.gid); } + cmd = ["runuser", "-u", "nobody", "--", bunExe(), join(root, "fixture.js"), root]; + } else { + cmd = [bunExe(), join(root, "fixture.js"), root]; + } + try { + await using proc = Bun.spawn({ + cmd, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout).toBe("OK\n"); + expect(exitCode).toBe(0); + } finally { + // Ensure tempDir cleanup can remove the directory even if the fixture + // crashed between the two chmod calls. try { - await using proc = Bun.spawn({ - cmd, - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - expect(stderr).toBe(""); - expect(stdout).toBe("OK\n"); - expect(exitCode).toBe(0); - } finally { - // Ensure tempDir cleanup can remove the directory even if the fixture - // crashed between the two chmod calls. - try { - chmodSync(join(root, "bad"), 0o755); - } catch {} - } - }, - ); + chmodSync(join(root, "bad"), 0o755); + } catch {} + } + }); }