Skip to content

test: add a harness isRoot and skip the chmod-based ls and Bun.file cases as root - #39232

Open
robobun wants to merge 3 commits into
mainfrom
farm/6c0fa8b1/ls-test-skip-permission-cases-as-root
Open

test: add a harness isRoot and skip the chmod-based ls and Bun.file cases as root#39232
robobun wants to merge 3 commits into
mainfrom
farm/6c0fa8b1/ls-test-skip-permission-cases-as-root

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Run as uid 0, two test files fail on main with both the released binary and a debug build, and are green in CI only because the CI agents are unprivileged:
    • test/js/bun/shell/commands/ls.test.ts: bunshell ls > errors > permission denied directory and ... permission denied directory recursive (27 pass / 2 fail; Expected to contain: "Permission denied", Received: ""). Both chmod 000 a directory and expect the ls builtin to report EACCES (ls.test.ts:306 and :322 on main).
    • test/js/web/fetch/fetch.test.ts: the four Bun.file > bad permissions throws cases (fetch.test.ts:1115 on main), which chmod a file to 000 and expect every read method to reject with permission denied (as root the reads succeed; the json case fails with Failed to parse JSON because it read the file).
  • Root bypasses mode bits, so a chmod 000 fixture cannot produce EACCES for it. These tests run in the test process itself (the shell ones through TestBuilder, which calls Bun.$ directly), so there is no child process to run as another user; the only correct outcome as root is to skip them.
  • The uid 0 check these gates need already exists in the tree, but as 16 separate copies in 14 test files (const isRoot = process.getuid?.() === 0, or the same comparison inline in a skipIf), while test/harness.ts exports every other shared predicate (isWindows, isPosix, isMusl, ...) and has no isRoot. Four open shell PRs (test(shell): make TestBuilder fail on expectations that cannot assert anything #37737, shell: keep OutputTask boxed through the vtable and output queues #37688, shell: treat a lone - as an operand in the builtin option parsers #39214, test(shell): stop the ls/rm/bunshell tests from running bun install against the registry #39231) each list the two ls failures as known local noise.

Fix

  • test/harness.ts: export isRoot = process.getuid?.() === 0 next to isPosix/isWindows. It is always false on Windows: process.getuid is only installed under #if !OS(WINDOWS) (src/jsc/bindings/BunProcess.cpp:4919), so the optional call yields undefined.
  • ls.test.ts: the two cases become test.if(isPosix && !isRoot). fetch.test.ts: describe.skipIf(isWindows) on bad permissions throws becomes describe.skipIf(isWindows || isRoot).
  • The 16 existing uid 0 checks now use the export (bunshell, commands/mv, resolve, resolver-permission-denied-ancestor, spawn, spawnSync, spawn-cgroup, child_process, process.test.js x2, fs.watch x2, bun-add-filter, bun-prune, no-orphans, env). Each keeps its condition as it was, only spelled through isRoot (getuid?.() === 0 -> isRoot, getuid?.() !== 0 -> !isRoot, env.test.ts's typeof getuid === "function" && getuid() === 0 -> isRoot); about half of them are root-only tests (setuid/setgid, cgroups) rather than skips, which is why the harness comment describes both uses. The larger hunks in process.test.js and bun-prune.test.ts are prettier reflowing the shortened conditions and the longer import line.
  • Why this is right: the property the six newly gated cases check (the runtime surfaces EACCES from the open) is unreachable as uid 0, so skipping there removes a guaranteed false failure and nothing else; every unprivileged run, which includes CI, still executes them, so coverage of the error paths is unchanged. Putting the predicate in the harness and pointing the existing sites at it leaves the check with one spelling, so the next chmod-based test imports it instead of adding another copy.
  • Intentionally left alone:
  • Verified as root with the debug build: bun bd test test/js/bun/shell/commands/ls.test.ts 27 pass / 2 skip (was 27 pass / 2 fail); bun bd test test/js/web/fetch/fetch.test.ts -t "bad permissions throws" 4 skip (was 4 fail); a full run of fetch.test.ts on this branch and on main differs only in those four cases going from fail to skip.
  • Verified as an unprivileged user (runuser -u nobody, debug and release builds): both ls cases and all four fetch cases still run and pass.
  • Verified the 16 converted sites as root with the debug build: every file loads, the root-only tests still run and the non-root tests still skip, with the same statuses as main (details below).
  • Test-only change; no runtime code touched.

Background

  • On POSIX systems chmod 000 removes every permission bit from a file or directory, and opening or listing it fails with EACCES for an ordinary user. The kernel does not apply these checks to uid 0 (on Linux this is the CAP_DAC_OVERRIDE capability root holds), so for root the same path opens normally. Any test that relies on chmod to provoke EACCES is therefore only meaningful when run as a non-root user; conversely, tests that setuid/setgid a child can only run as root, which is the other way isRoot is used.
  • test/harness.ts is preloaded into every test file and is where the shared platform predicates live; test.if(cond) and describe.skipIf(cond) register the affected cases as skipped instead of running them.
Per-site results as root (debug build, this branch; identical statuses on main)
  • resolve.test.ts: the two canTriggerEACCES tests run via runuser and pass
  • resolver-permission-denied-ancestor.test.ts: 2 skip
  • bunshell.test.ts: glob over an unreadable directory, cd with EACCES skip
  • commands/mv.test.ts: unreadable directory across devices skip
  • spawn.test.ts uid/gid: 2 pass, throws EPERM skip
  • spawnSync.test.ts uid/gid: 3 pass, throws EPERM skip
  • spawn-cgroup.test.ts: 8 pass / 5 skip (no writable cgroupfs here), same as main
  • child_process.test.ts uid/gid options: 3 pass, EPERM case skip
  • process.test.js: seteuid under GC pressure runs and passes, initgroups ... unknown string user skip
  • fs.watch.test.ts: the two no permission to watch cases skip, the inotify_add_watch case runs and passes
  • bun-add-filter.test.ts, bun-prune.test.ts: the unwritable/undeletable cases skip
  • env.test.ts: process.env is preserved when cwd lacks read permission runs via runuser and passes
  • no-orphans.test.ts: the uid/gid case runs as before; it fails here on main as well, because uid 0 in this container has no CAP_KILL so the test's kill(pid, 0) liveness check gets EPERM for the nobody grandchild. Unrelated to this change and reported separately.
Earlier revisions of this PR

The first push only added a file-local const isRoot to ls.test.ts and gated the two shell cases. Self-review pointed out that this was one more copy of a predicate the harness should own and that fetch.test.ts had the same class of failure, so the second revision added the export and the fetch gate. Review of that revision asked for the pre-existing local copies to move to the export as well, which the third revision does.


no test proof · iteration 0 · Platform-specific test-only change; deferring to CI.

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 59 seconds

Limit details: You’ve used all 5 included reviews currently available under your plan.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f78fde2d-945e-4d7d-b75d-cc108d67b561

📥 Commits

Reviewing files that changed from the base of the PR and between a42889a and 17da809.

📒 Files selected for processing (17)
  • test/cli/install/bun-add-filter.test.ts
  • test/cli/install/bun-prune.test.ts
  • test/cli/run/env.test.ts
  • test/cli/run/no-orphans.test.ts
  • test/harness.ts
  • test/js/bun/resolve/resolve.test.ts
  • test/js/bun/resolve/resolver-permission-denied-ancestor.test.ts
  • test/js/bun/shell/bunshell.test.ts
  • test/js/bun/shell/commands/ls.test.ts
  • test/js/bun/shell/commands/mv.test.ts
  • test/js/bun/spawn/spawn-cgroup.test.ts
  • test/js/bun/spawn/spawn.test.ts
  • test/js/bun/spawn/spawnSync.test.ts
  • test/js/node/child_process/child_process.test.ts
  • test/js/node/process/process.test.js
  • test/js/node/watch/fs.watch.test.ts
  • test/js/web/fetch/fetch.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:39 PM PT - Aug 15th, 2026

@robobun, your commit 17da809a84569ab4e24080ad96960833a59d0056 passed in Build #98988! 🎉


🧪   To try this PR locally:

bunx bun-pr 39232

That installs a local version of the PR into your bun-39232 executable, so you can run:

bun-39232 --bun

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on main as root with both the released binary and a debug build: test/js/bun/shell/commands/ls.test.ts ends 27 pass / 2 fail (the two chmod 000 cases, Expected to contain: "Permission denied", Received: ""), and the four Bun.file > bad permissions throws cases in test/js/web/fetch/fetch.test.ts fail the same way.
  • Current revision (17da809): isRoot exported from test/harness.ts, used to skip the two ls cases and the fetch.test.ts block, and the 16 pre-existing uid 0 checks in 14 test files now import it instead of spelling it locally (review request from the previous revision). As root: ls 27 pass / 2 skip, fetch block 4 skip, every converted site keeps the same run/skip status as main. As an unprivileged user (runuser -u nobody): all six newly gated cases still run and pass.
  • Test-only change. CI is green on 17da809 (build 98988, all jobs passed); ready for review.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — test-only skip guard matching the existing pattern in neighboring shell tests.

What was reviewed:

  • Confirmed const isRoot = process.getuid?.() === 0 and test.if(isPosix && !isRoot) match the identical guards already in bunshell.test.ts and commands/mv.test.ts.
  • The two gated tests rely on chmod 000 to provoke EACCES, which root bypasses — skipping under uid 0 is correct and CI (unprivileged) still exercises them.
  • No runtime code touched; no other chmod 000-based cases in this file were left unguarded.
Extended reasoning...

Overview

This PR adds a single isRoot constant to test/js/bun/shell/commands/ls.test.ts and changes two test.if(isPosix) guards to test.if(isPosix && !isRoot). The two affected tests create a directory, chmod 000 it, and assert that the ls builtin reports "Permission denied". When run as root, the kernel bypasses DAC mode bits (CAP_DAC_OVERRIDE / CAP_DAC_READ_SEARCH), so the open succeeds and the tests fail spuriously. Test-only change; no runtime code modified.

Security risks

None. This only adds a skip condition to two test cases in the test suite.

Level of scrutiny

Low. This is a mechanical, 4-line test-gating change that copies an established pattern verbatim from sibling files. I verified via grep that bunshell.test.ts:57 and commands/mv.test.ts:23 define the same isRoot constant, and bunshell.test.ts:941 / :1168 use the identical test.if(isPosix && !isRoot) guard for the same reason (chmod-based EACCES fixtures). The repo review guidance explicitly allows skipping with a stated reason, and the added comment ("Root bypasses directory mode bits, so chmod 000 cannot produce EACCES for it.") documents why.

Other factors

  • CI agents run unprivileged, so the EACCES error-path coverage is unchanged in CI — this only affects local runs as root.
  • The process.getuid?.() optional-chaining handles Windows (where getuid is undefined) correctly, yielding undefined === 0false, so !isRoot is true and the existing isPosix gate remains the effective condition there.
  • Checked that no other tests in this file depend on chmod-based permission denial; only these two do.
  • No prior review comments to address; the bug-hunting system found nothing.

@robobun robobun changed the title test(shell): skip the ls permission-denied cases when running as root test: add isRoot to the harness and skip the chmod-based ls and Bun.file cases as root Aug 16, 2026
@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Reshaped in aee03a8 after self-review: the uid 0 predicate now lives in test/harness.ts as isRoot instead of being another file-local copy, and the same gate is applied to the Bun.file > bad permissions throws block in fetch.test.ts, which was the only other chmod-based case still failing as root. The PR description has been updated to describe the current shape, including what was intentionally left alone.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — test-only skip guard for chmod-based EACCES fixtures under uid 0, matching the pattern already used in neighbouring shell tests. The inline nit about migrating the pre-existing local isRoot copies is a fair follow-up but not blocking.

Extended reasoning...

Overview

Test-only change touching three files: exports isRoot = process.getuid?.() === 0 from test/harness.ts, gates two chmod 000 permission-denied cases in test/js/bun/shell/commands/ls.test.ts on isPosix && !isRoot, and applies the same guard to the bad permissions throws describe in test/js/web/fetch/fetch.test.ts. No runtime code touched.

Security risks

None. Test infrastructure only; the harness constant reads process.getuid() and is used purely to decide whether to register a test.

Level of scrutiny

Low. This is a mechanical skip guard for a well-understood POSIX property (root bypasses DAC via CAP_DAC_OVERRIDE / CAP_DAC_READ_SEARCH, so chmod 000 cannot provoke EACCES for uid 0). The identical pattern already exists in bunshell.test.ts and mv.test.ts. CI agents run unprivileged, so the EACCES error-path coverage is unchanged where it matters; this only removes false failures for developers running the suite as root locally.

Other factors

  • Verified both directions per the description: 27 pass / 2 skip as root, both cases still run and pass as an unprivileged user.
  • The one inline nit (migrate the six pre-existing local const isRoot copies to the new harness export) is a consistency cleanup. REVIEW.md's "don't ride file-wide standardization on a focused bugfix" cuts the other way here, so leaving those for a follow-up is reasonable — the change as-is moves in the right direction by promoting to harness rather than adding a seventh local copy.
  • The fetch.test.ts hunk correctly extends the existing skipIf(isWindows) to skipIf(isWindows || isRoot) for the same reason (chmod 0o000 on a file cannot deny read to root).

Comment thread test/harness.ts
Root bypasses directory mode bits, so the chmod 000 setup in these two
tests never produces EACCES and the builtin lists the directory instead
of printing the expected error. Gate them on not running as uid 0, the
same way bunshell.test.ts and mv.test.ts gate their chmod-based cases.
…ases on it too

Move the uid 0 predicate into test/harness.ts next to the other is*
helpers so ls.test.ts imports it instead of defining its own copy, and
use it to gate the "bad permissions throws" block in fetch.test.ts,
which chmods a file to 000 and fails the same way as root.
Replaces the file-local `const isRoot = process.getuid?.() === 0` copies
and the inline getuid() comparisons used to gate tests with the harness
export, so the uid 0 check has one spelling. No gating changes: every
site keeps the same condition, only spelled through isRoot.
@robobun
robobun force-pushed the farm/6c0fa8b1/ls-test-skip-permission-cases-as-root branch from aee03a8 to 17da809 Compare August 16, 2026 02:05
@robobun robobun changed the title test: add isRoot to the harness and skip the chmod-based ls and Bun.file cases as root test: add isRoot to the harness, use it at every uid 0 check, and skip the chmod-based ls and Bun.file cases as root Aug 16, 2026
@robobun robobun changed the title test: add isRoot to the harness, use it at every uid 0 check, and skip the chmod-based ls and Bun.file cases as root test: add a harness isRoot and skip the chmod-based ls and Bun.file cases as root Aug 16, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — thanks for the follow-up sweep in 17da809; all the local isRoot copies and inline process.getuid?.() === 0 checks now use the harness export.

What was reviewed:

  • Verified every migrated site is semantically identical to the harness definition (including resolve.test.ts, whose old !isWindows && guard is subsumed by process.getuid being undefined on Windows).
  • Confirmed the remaining process.getuid() === 0 hits are all in vendored test/js/node/test/parallel/ upstream tests, correctly left untouched.
  • Checked the new skip gates on ls.test.ts and fetch.test.ts only narrow when running as uid 0; unprivileged CI coverage is unchanged.
Extended reasoning...

Overview

Test-only PR that (1) exports isRoot = process.getuid?.() === 0 from test/harness.ts, (2) gates six chmod-000-based test cases in ls.test.ts and fetch.test.ts on !isRoot so they skip instead of falsely failing when run as uid 0, and (3) — in response to my earlier review — sweeps every existing file-local const isRoot and inline process.getuid?.() === 0 check across 15 test files onto the new harness export. No src/ code is touched.

Security risks

None. This only changes which tests run under uid 0, a configuration CI does not use. No production code paths, no auth/crypto/permissions logic.

Level of scrutiny

Low. Every hunk is one of two mechanical shapes: add isRoot to an existing from "harness" import and delete a byte-identical local definition, or replace an inline process.getuid?.() === 0 with isRoot. The harness expression is character-for-character the same as the removed locals, so behaviour is provably identical. The two spots that differed in spelling (resolve.test.ts's !isWindows && prefix, env.test.ts's typeof process.getuid === "function" && prefix) are equivalent because process.getuid is undefined on Windows and the optional-chain returns undefined !== 0.

Other factors

  • My prior review's only ask (migrate the local copies) is fully addressed and then some — the sweep covers all 14 non-vendored call sites.
  • Grepped for stragglers: the only remaining process.getuid()-based root checks are in test/js/node/test/parallel/, which are vendored upstream Node tests and should not import Bun's harness.
  • The process.test.js hunk that looks large is just prettier reflowing an it.skipIf(...) back onto one line after its condition shortened; the test body is unchanged.
  • CI runs unprivileged, so the new skipIf(... || isRoot) gates do not reduce CI coverage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant