node:fs: validate rm options before the ERR_FS_EISDIR check - #39338
node:fs: validate rm options before the ERR_FS_EISDIR check#39338robobun wants to merge 1 commit into
Conversation
fs.rm, fs.rmSync and fs.promises.rm lstat'ed the target and threw
ERR_FS_EISDIR for a directory before the native binding got to validate
the options, so fs.rmSync(dir, { force: "x" }) reported EISDIR where node
reports ERR_INVALID_ARG_TYPE. Port node's validateRmOptions into the JS
wrappers (options, then lstat, then the native rm) and share it between
the three entry points. The callback form now also throws synchronously
on invalid options, as node's does.
|
Warning Review limit reached
Next review available in: 2 minutes 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. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
|
Status: reproduced, fixed, CI green (Buildkite build 99329); ready for review.
|
There was a problem hiding this comment.
LGTM — focused Node-compat fix that reorders rm option validation ahead of the ERR_FS_EISDIR lstat check.
What was reviewed
validateRmOptionsmatches node's order (validateObject→ spread over defaults → force/recursive/retryDelay/maxRetries); confirmedvalidateObjectrejects null, arrays, and callables (NodeValidator.cpp:641).- Callback
fs.rmstill validates the callback before options, and the 2-arg overload (options = undefined) hits the early-return default path. - Internal callers (
mkdtempDisposable/mkdtempDisposableSync) call the native binding directly with literals and bypass the new validator. - Tests: 12 option cases × 3 entry points with exact message assertions, plus positive coverage that valid options still remove, ENOENT still reports
syscall: "lstat", andERR_FS_EISDIRstill fires.
Extended reasoning...
Overview
This PR fixes error-reporting order in fs.rm/fs.rmSync/fs.promises.rm: node validates the options bag before lstat'ing the target, so an invalid option should produce ERR_INVALID_ARG_TYPE/ERR_OUT_OF_RANGE even when the target is a directory. Bun was doing the lstat first and reporting ERR_FS_EISDIR instead. The fix ports node's validateRmOptions into fs.promises.ts, factors the two duplicated lstat/EISDIR blocks into shared helpers (validateRmOptionsSync, rmValidated, rmEisdirError), and shares them with fs.ts via the existing private $data symbol. A secondary fix: callback fs.rm now throws invalid-option errors synchronously (as node does) rather than routing them through the callback.
Security risks
None. This is argument-validation ordering in a Node-compat wrapper; no new syscalls, no path handling changes, no privilege boundaries. The lstat and native rm calls are the same ones the previous code made.
Level of scrutiny
Medium. It touches the JS-side wrappers for a filesystem-mutating API across three entry points, and there are user-visible behavior changes (null/array/function options and out-of-int32 retryDelay now reject; callback form throws synchronously). All changes move Bun toward node's documented behavior, are backed by a 240-case matrix diff against node v26.3.0, and the vendored test-fs-rm.js / test-fs-rmdir-*.js / test-fs-promises.js still pass per the description. I verified jsFunction_validateObject rejects null/arrays/callables so the validateObject(options, "options") call behaves as the tests assert, and that the two internal fs.rm/fs.rmSync call sites go through the native binding directly and are unaffected.
Other factors
- The refactor removes duplication (two copies of the EISDIR block collapse into one
rmEisdirError+ two thin flavor helpers) rather than adding it, and reuses the established$dataprivate-symbol channel already used forFileHandle. - Test coverage is strong per the repo's review guidance: exact error name/code/message across all three entry points, ordering cases (
forcebeforerecursive,retryDelaybeforemaxRetries), the own-recursive: undefinededge case, and a positive case confirming valid options still remove files, ENOENT still carriessyscall: "lstat", and directories still hitERR_FS_EISDIR. Tests usetempDir, no network, no sleeps. - No CODEOWNERS cover these files. The PR notes overlap with #39328 (native message text) and #33366 (callback dispatch rewrite), and both compose cleanly with this change.
|
To confirm the behavior changes the review lists, since they are the user-visible part of this: an array or function as the options bag, and |
Problem
fs.rmSync(dir, { force: "x" })throwsERR_FS_EISDIR(Path is a directory: rm returned EISDIR (is a directory) <dir>). Node v26.3.0 throwsERR_INVALID_ARG_TYPE(The "options.force" property must be of type boolean. Received type string ('x')). Same for any other invalid option (recursive: 0, an ownrecursive: undefined,maxRetries: "x",retryDelay: -1, ...) and for a non-objectoptions(rmSync(dir, "x")), and through all three entry points:fs.rmdelivers the EISDIR to the callback andfs.promises.rmrejects with it.rmSyncinsrc/js/node/fs.ts,rminsrc/js/node/fs.promises.ts, which the callbackfs.rmalso went through) lstat the target and throwERR_FS_EISDIRfirst, and only then call the nativefs.rm, which is where the options were validated (args::Rm::from_jsinsrc/runtime/node/node_fs.rs). Node'svalidateRmOptionsvalidates the options before its lstat, so with a directory target the option error wins there. With a file target the native validation was reached and Bun already reported the type error, so this is purely the wrappers' ordering.fs.rm(path, badOptions, cb)throws synchronously; Bun handed the error to the callback becausefs.rmwas implemented aspromises.rm(...).then(...).Fix
fs.promises.ts: port node'svalidateRmOptions(validateObject, spread over the defaults, thenforce,recursive,retryDelayas int32 >= 0,maxRetriesas uint32, in node's order) and run it before the lstat /ERR_FS_EISDIRcheck in all three flavors:validateRmOptionsSync(used byrmSync) andrmValidated(the async lstat + native call, used bypromises.rmand callbackrm). The two copies of the EISDIR check infs.tsandfs.promises.tscollapse into these; they are shared withfs.tsthrough the module's existing private$dataexport. The fixing change is the order: options first, lstat second.fs.ts:rmSyncbecomesfs.rmSync(path, validateRmOptionsSync(path, options)); callbackrmcallsvalidateRmOptionssynchronously (so invalid options throw, as in node) and thenrmValidated.node:fsnode's behavior is the contract, and this is node's own structure (options, then lstat, then remove), using the shared validators that already render node's messages. Accepted inputs do not change except in the node direction: the native parser already rejected everythingvalidateRmOptionsrejects, apart from an array or function as the options bag andretryDelayabove int32 range, which node rejects too. The normalized object handed to the native binding is the same set of own keys the wrappers passed before (the old{ ...options }spread), so the native parser,fs.rmdir, and the internal callers that pass literals to the binding are unaffected. The native validation and itstest-fs-rm.jshook are left in place: the binding still validates its own input, and node:fs: report mkdir/readdir/rm boolean option type errors like node #39328 is fixing its messages; the native rm also still produces node'sforce/ENOENT(syscall: "lstat") behavior, which is why an lstat failure in the wrappers is still left for it to report.test/js/node/fs/promises.test.js, newrm option validation runs before the ERR_FS_EISDIR checkblock: 12 option cases (each checked throughrmSync, callbackrmthrowing synchronously, andpromises.rm, asserting error class, code and node's message text, and that the directory survives), plus a case that valid options still remove files, still reportENOENT/lstatand still reportERR_FS_EISDIR. The 12 cases fail on the released build, all pass with this change; the whole file passes.rmSyncmatches node: options first). Excerpt below.test-fs-rm.js,test-fs-rmSync-special-char.js,test-fs-rmdir-*.js,test-fs-promises.js,test-fs-error-messages.jspass;test/js/node/fs/fs.test.ts(512 tests) and the rest oftest/js/node/fspass, exceptabort-signal-leak-read-write-file.test.ts, whose 100k-iteration fixture needs about 7 minutes on this debug+ASAN container against a 5 minute budget and does not involve rm.rmwrapper for a different reason (callback dispatch); the overlap is the few lines of that wrapper, and its rm arm would keep thevalidateRmOptionscall up front.Background
ERR_FS_EISDIRis node's JS-level refusal torma directory withoutrecursive: true. It is not produced by the OS: node'svalidateRmOptions(lib/internal/fs/utils.js) lstat's the path and throws it itself, after validating the options; the removal only runs afterwards. Bun's nativermdoes not perform this check, which is why the wrappers do it in JS.{ ...defaults, ...options }is what makes an own key holdingundefinedan error (rmSync(p, { recursive: undefined })is rejected, an absent key is not). Bun's native parser mirrors that with own-property lookups, and the normalized object the wrappers now pass has all four keys as own properties.$dataon thenode:fs/promisesexport object is a private-symbol property (invisible to user code) already used to handFileHandleand its symbols tointernal/fs/streamsandnode:http2;node:fsrequiresnode:fs/promisesat load, so sharing the helpers this way loads nothing extra on the rm path.Matrix excerpt (directory target; node v26.3.0 / before / after)
The
fs.rmcallback andfs.promises.rmrows for the directory target are the same as thermSyncrows above, before and after.