Skip to content

node:fs: reject a non-string, non-object options argument like node - #41928

Open
robobun wants to merge 3 commits into
mainfrom
robobun/4f904684/fs-options-arg-type
Open

robobun wants to merge 3 commits into
mainfrom
robobun/4f904684/fs-options-arg-type

Conversation

@robobun

@robobun robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • fs.writeFileSync(path, "KEY", 0o600) writes a 0o644 file. Bun treats any options value that is not a string or an object as "no options". Node throws ERR_INVALID_ARG_TYPE: The "options" argument must be one of type string or object. Received type number (384) from getOptions() (lib/internal/fs/utils.js).
  • The same hole exists in every fs entry point that takes options: string | object: readFile, writeFile, appendFile, readdir, readlink, realpath, mkdtemp, watch (sync, callback and fs.promises forms) and FileHandle#readFile/writeFile/appendFile. The native parsers in src/runtime/node/node_fs.rs (parse_encoding_arg, Readdir::from_js, ReadFile::from_js, WriteFile::from_js_with_default_flag) had no else branch for a non-object, and the JS wrappers in fs.promises.ts, internal/fs/watch.ts and the win32 realpath port swallowed it too.

Fix

  • Each of those parsers now throws ERR_INVALID_ARG_TYPE with node's message when options is not a string, an object, null/undefined, or a function. A function stays accepted because the callback wrappers (fs.readFile(path, cb)) leave the callback in that slot, as node's getOptions() does. opendir/opendirSync/Dir get the same contract: they already threw, but with a different message, and rejected a function.
  • fs.promises.writeFile with iterable data and a bad options now rejects with the same error instead of Unknown encoding: undefined.
  • Self-reviewed: 2 concerns raised, 2 addressed (the opendir contract above, and the notes below no longer claim it already matched).
  • Verified: test/js/node/fs/fs.test.ts (fs options argument, two tests, both fail on canary). Also the rest of fs.test.ts, promises.test.js, dir.test.ts, fs.watch.test.ts, fs-promises-writeFile-async-iterator.test.ts and 54 test-fs-* node parallel tests.

Background

  • Node routes the options argument of these functions through one helper, getOptions(options, defaults). It returns the defaults for null, undefined or a function, turns a string into { encoding }, uses an object as is, and throws for anything else. So fs.mkdirSync(p, 0o700) and fs.openSync(p, "w", 0o600) take a positional mode, but writeFileSync(p, data, 0o600) does not. Under Bun that last call silently produced a world-readable file.
  • Bun already enforced this contract on rm, cp, createReadStream/createWriteStream (getStreamOptions) and glob. The functions above were the remaining lenient ones.
  • In node_fs.rs, JSValue::is_object() is true for functions (JSType >= Object), so the new else branches only see primitives: numbers, booleans, symbols and bigints.
Notes

Repro matrix (bun canary f42e98025 before, this branch after, node v26.3.0):

writeFileSync(p,'KEY',0o600)        before: ACCEPTED, mode 644   after/node: ERR_INVALID_ARG_TYPE
appendFileSync(p,'KEY',0o600)       before: ACCEPTED, mode 644   after/node: ERR_INVALID_ARG_TYPE
promises.writeFile(p,'KEY',0o600)   before: ACCEPTED, mode 644   after/node: ERR_INVALID_ARG_TYPE (rejection)
promises.appendFile(p,'KEY',0o600)  before: ACCEPTED, mode 644   after/node: ERR_INVALID_ARG_TYPE (rejection)
writeFile(p,'KEY',0o600,cb)         before: ACCEPTED, mode 644   after/node: ERR_INVALID_ARG_TYPE (sync throw)
appendFile(p,'KEY',0o600,cb)        before: ACCEPTED, mode 644   after/node: ERR_INVALID_ARG_TYPE (sync throw)
writeFileSync(p,'KEY',true|Symbol()|5n)  before: ACCEPTED        after/node: ERR_INVALID_ARG_TYPE
writeFileSync(p,'KEY',null|undefined|'utf8'|()=>{})  ACCEPTED everywhere
readFileSync(p|fd, 5), readFile(p, 5, cb), promises.readFile(p, 5)  before: Buffer  after/node: ERR_INVALID_ARG_TYPE
fh.readFile(5), fh.writeFile('X', 5), fh.appendFile('X', 5)          before: ok      after/node: ERR_INVALID_ARG_TYPE
readdirSync(D, 5|true), realpathSync(D, 5), mkdtempSync(D+'/x-', 5)  before: ok      after/node: ERR_INVALID_ARG_TYPE
readlinkSync(f, 5)                  before: EINVAL               after/node: ERR_INVALID_ARG_TYPE
watch(D, 5)                         before: TypeError 'Expected "listener" callback to be a function'  after/node: ERR_INVALID_ARG_TYPE
opendirSync(D, 5)                   before: ERR_INVALID_ARG_TYPE 'must be of type object'  after/node: 'must be one of type string or object'
opendirSync(D, ()=>{})              before: ERR_INVALID_ARG_TYPE  after/node: accepted (defaults)

Not changed: fs.promises.watch. Node uses validateObject there (a string is rejected too), which is a different contract from getOptions; Bun throws a TypeError about the listener for a number today. createReadStream/createWriteStream already match node through getStreamOptions.

The JS side now has the getOptions discrimination in a handful of places (getStreamOptions, FSWatcher, Dir, the two FileHandle writers, the iterable writeFile path, the win32 realpath helper). Folding them into one getOptions(options, defaults) in internal/validators is a reasonable follow-up. It is not in this PR to keep the diff to the behavior change.

Two pre-existing slow tests in fs.test.ts (stat > async calls do not keep a Buffer path alive, readdirSync(path, {recursive: true}) x 100) time out under the full debug+ASAN run in this container and pass in isolation. abort-signal-leak-read-write-file.test.ts (100k iterations) also exceeds its 300 s budget under debug+ASAN here; a 5k-iteration copy of the fixture passes.

readFile, writeFile, appendFile, readdir, readlink, realpath, mkdtemp,
watch and the FileHandle readFile/writeFile/appendFile methods treated
any options value that was not a string or an object as "no options".
`fs.writeFileSync(path, secret, 0o600)` wrote a 0o644 file. Node's
getOptions() throws ERR_INVALID_ARG_TYPE for these values. Do the same.
@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:04 AM PT - Sep 8th, 2026

❌ @robobun, your commit 95c62c4 has 2 failures in Build #112921 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 41928

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

bun-41928 --bun

@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on canary f42e98025 with the repro from the report: fs.writeFileSync(p, 'KEY', 0o600), appendFileSync, promises.writeFile/appendFile, and writeFile(p, 'KEY', 0o600, cb) all wrote a mode 644 file, and readFileSync(p, 5) returned a Buffer. Node v26.3.0 throws ERR_INVALID_ARG_TYPE for each. With this branch the debug build matches node on all of them (full matrix in the PR notes).

Test: bun bd test test/js/node/fs/fs.test.ts -t "fs options argument" (fails on canary, passes here).

CI: build 112657 passed every lane except test/js/node/test/parallel/test-crypto-dh-leak.js on debian x64-asan, which fails on main too. Build 112921 (after the opendir follow-up) is the same, plus a Windows 2019 x64 test agent that failed to provision (infra, the lane was canceled, it passed on 112657).

@coderabbitai

coderabbitai Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 12 days. After that, they cost $0.25 per reviewed file.

Or wait 12 seconds for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 48b609cb-2d00-416b-8e40-8a83e4cdfd39

📥 Commits

Reviewing files that changed from the base of the PR and between 4f8b824 and 95c62c4.

📒 Files selected for processing (3)
  • src/js/node/fs.ts
  • src/runtime/node/node_fs.rs
  • test/js/node/fs/fs.test.ts
ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 18cd4316-e556-49db-be82-43a363c09ab7

📥 Commits

Reviewing files that changed from the base of the PR and between b52d3e5 and 4f8b824.

📒 Files selected for processing (5)
  • src/js/internal/fs/watch.ts
  • src/js/node/fs.promises.ts
  • src/js/node/fs.ts
  • src/runtime/node/node_fs.rs
  • test/js/node/fs/fs.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

Filesystem APIs now reject invalid options values with ERR_INVALID_ARG_TYPE. Supported strings, objects, null, and undefined retain their existing behavior. Validation covers JavaScript and Rust implementations, with synchronous, callback, promise, and FileHandle tests.

Changes

Filesystem options validation

Layer / File(s) Summary
Runtime option parsing
src/runtime/node/node_fs.rs
Shared filesystem parsers now reject invalid option types while preserving null and undefined defaults.
JavaScript option validation
src/js/internal/fs/watch.ts, src/js/node/fs.promises.ts, src/js/node/fs.ts
Watch, file-write, async-iterator, and Windows realpath APIs now validate option types and throw ERR_INVALID_ARG_TYPE for unsupported values.
Filesystem validation tests
test/js/node/fs/fs.test.ts
Tests cover invalid values across synchronous, callback, promise, and FileHandle APIs, plus supported option forms and file preservation.

Merge Risk: ⚪ Minimal · up to 4f8b8

Filesystem APIs now consistently reject unsupported options values while retaining supported option forms. No merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: rejecting invalid non-string, non-object options in Node-compatible fs APIs.
Description check ✅ Passed The description explains the problem, the affected APIs, the implementation approach, compatibility behavior, and verification results. It does not use the template headings exactly, but it provides t…

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.


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

@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.

I reviewed this PR and didn't find any bugs. Because it tightens user-facing validation across a dozen node:fs entry points (previously-accepted calls will now throw) and the motivating case is security-adjacent (a mode argument being silently dropped), a human look would still be worthwhile.

What was reviewed:

  • Traced each new else guard in node_fs.rs and the JS wrappers — functions still fall through as is_object()/typeof === "function" so fs.readFile(path, cb) is unaffected.
  • Checked the win32 realpathEncodingOption extraction — the ?? → ??= fix now actually caches the validator, and the callback overload peels the callback before calling the helper.
  • Compared the Rust throw_invalid_options_type message against the JS $ERR_INVALID_ARG_TYPE(["string", "Object"]) path — both render the same string the test asserts.
  • Test coverage: sync/callback/promises/FileHandle matrix, negative contract (valid forms still work, no leaked writes), isWindows branch on the mode assertion.
Extended reasoning...

Overview

This PR closes a Node.js compat gap where Bun silently ignored a non-string, non-object options argument across the node:fs surface. The motivating case — fs.writeFileSync(p, "KEY", 0o600) writing a 0o644 file instead of throwing — is a real footgun. The fix adds a shared throw_invalid_options_type helper on the Rust side wired into four arg parsers (parse_encoding_arg, Readdir, ReadFile, WriteFile), matching guards in the JS-side fs.promises writeFile/appendFile/readFile paths (including the async-iterator branch and FileHandle methods) and fs.watch, and extracts a realpathEncodingOption helper for the win32 JS realpath ports that also fixes a pre-existing ?? (should have been ??=) caching bug. Tests in fs.test.ts sweep six bad primitive values across ~30 entry-point/form combinations plus the negative contract.

Security risks

The change is security-positive: the bug it fixes could cause a caller who thought they were passing a restrictive mode to writeFileSync to get a default-permission file instead. The new behavior throws rather than silently mis-applying, which is strictly safer. No new attack surface is introduced — validation is added, not relaxed. The one thing worth a human eye is that this is a behavior change: code that previously "worked" (with wrong semantics) will now throw, which could surface in downstream projects.

Level of scrutiny

Medium-high. This is a Node compat change, and REVIEW.md is explicit that the full error contract (code, message text, delivery channel, check ordering) must match Node exactly, and that tightened validation must enumerate every legitimate input class and prove each still passes. The PR does this well — functions are still accepted (matching Node's getOptions()), null/undefined/string/object all still work, and the tests assert exact code + message. The message-wording concern (Rust helper vs $ERR_INVALID_ARG_TYPE(["string", "Object"])) was raised twice during the hunt and ruled out: both paths produce must be one of type string or object, which is what the test asserts across both native and JS entry points. A human confirming that against a live Node build would be the last mile.

Other factors

The change follows the repo's own review rules closely: it fixes the whole bug class in one PR (all sibling entry points, sync/async/promises/FileHandle, the win32 branch), deduplicates within its own diff (two extracted helpers), uses the centralized $ERR_* machinery rather than hand-rolled errors, and the test uses tempDir with using, awaits all .rejects, asserts side-effect absence, and branches the mode-bit assertion on isWindows. The ?? → ??= fix in the win32 realpath is a genuine drive-by correctness improvement. The bug hunt ran to dry_streak with no findings. I'm deferring rather than approving only because behavior-changing Node-compat validation across native + JS with a security-adjacent motivation is exactly the category where a maintainer sign-off is appropriate.

Comment thread src/runtime/node/node_fs.rs Outdated

@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.

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
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.

2 participants