node:fs: accept the readdir recursive/withFileTypes values node accepts - #39330
node:fs: accept the readdir recursive/withFileTypes values node accepts#39330robobun wants to merge 3 commits into
Conversation
readdir and readdirSync only validate options.recursive when it is not nullish, and no readdir entry point validates withFileTypes (node passes !!options.withFileTypes). fs.promises.readdir does not validate recursive at all and only tests it for truthiness. The shared native parser rejected every non-boolean value for both options in all three forms.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Included review availability: Your plan includes up to 5 reviews per rolling hour; 0 remain after this review. Walkthrough
Changesreaddir option compatibility
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Reproduced on bun 1.4.0 and on main (aec33f5) by running each of With this branch the three forms match node for every value probed. The new cases in Related: #39328 changes the wording of the error that remains for a bad |
There was a problem hiding this comment.
LGTM — small, well-scoped Node compat fix with a thorough test matrix. The inline nit (spread dropping inherited options) needs both an inherited withFileTypes/encoding and a non-boolean recursive to trigger, and that input threw before this PR anyway; fine to take or leave.
Extended reasoning...
Overview
Loosens fs.readdir option parsing to match Node v26.3.0: args::Readdir::from_js in src/runtime/node/node_fs.rs now skips a null recursive (was strict-boolean → ERR_INVALID_ARG_TYPE) and reads withFileTypes via get_boolean_loose (JS truthiness, matching Node's !!options.withFileTypes). src/js/node/fs.promises.ts replaces the asyncWrap wrapper for readdir with one that coerces a non-nullish, non-boolean recursive to !!recursive before calling the native binding — the promise form is the only one where Node accepts truthy non-booleans, and the shared native parser can't special-case one caller. New describe block in test/js/node/fs/fs.test.ts covers 12 accepted option bags across all three forms, 7 rejected recursive values in sync/callback that resolve as truthiness in the promise form, and a no-mutation check.
Security risks
None. This only relaxes type validation on two option fields to match Node; the resulting values are still bool on the Rust side. No new user-controlled data reaches a syscall path differently than before.
Level of scrutiny
Low-to-medium. ~15 lines of production code, all in the argument-parsing prelude before any I/O. validators::validate_boolean and get_boolean_loose are existing helpers already used throughout node_fs.rs / JSValue.rs. The JS wrapper follows the same shape as rm a few lines below. Each change is line-for-line traceable to a cited Node source line, and the PR body includes an empirical Node-vs-Bun comparison table.
Other factors
validators::is already in scope innode_fs.rs(used at 2682, 2756, etc.), so no import change needed.val.getreturnsNonefor missing/undefined, so theis_null()guard correctly leavesrecursive = falsefor both nullish spellings while still routing0/"x"/{}throughvalidate_boolean— the sync/callback rejection set is unchanged.- The promise wrapper only allocates a new options object on the exotic path (non-nullish, non-boolean
recursive); booleans andnullpass the caller's object straight through, and the caller's object is never mutated (test pins this). - Tests use
tempDir,it.each,jest.fn()for the callback-not-called assertion, and assertcode: 'ERR_INVALID_ARG_TYPE'rather than baretoThrow(). 17/27 cases fail on the unfixed build per the PR body, so the suite is load-bearing. - The one inline nit is real but requires inherited
withFileTypes/encodingcombined with a non-booleanrecursive; that same input threwERR_INVALID_ARG_TYPEbefore this PR, so it's not a regression from a working state. The suggested{ __proto__: options, ... }fix is a one-liner if the author wants it. - The PR body flags the overlap with #39328 (error-message wording) and how the two combine; nothing to do here beyond whichever lands second reconciling.
There was a problem hiding this comment.
LGTM — the earlier nit about { ...options } dropping inherited options is addressed (now { __proto__: options, recursive: !!recursive }) with a covering test, and the comment-cop feedback is resolved.
What was reviewed:
Readdir::from_js:val.getreturnsNonefor missing/undefined so the added!r.is_null()guard exactly matches Node'soptions.recursive != null;get_boolean_loose→to_boolean()matches!!options.withFileTypes.- Confirmed
validators::validate_booleanis in scope (use super::util::validatorsat node_fs.rs:187) and accepts&strfor the name arg. - The
__proto__-shadow wrapper: verifiedJSValue::getwalks the prototype (viagetIfPropertyExistsImpl), soencoding/withFileTypeson the original object are still seen and the caller's object is never mutated. - Tests: all referenced imports (
Dirent,inspect,promisify,jest,tempDir) are already in the file; the callback-form rejection test asserts the callback is never invoked.
Extended reasoning...
Overview
Loosens readdir option parsing to match Node v26.3.0: recursive is only validated when non-nullish (sync/callback forms), withFileTypes is truthiness-coerced everywhere, and fs.promises.readdir skips recursive validation entirely (truthiness only). Touches src/runtime/node/node_fs.rs (~8 lines in Readdir::from_js), src/js/node/fs.promises.ts (a 9-line wrapper replacing asyncWrap), and adds a ~100-line test block to test/js/node/fs/fs.test.ts.
Security risks
None. This only loosens type validation on two boolean-ish option flags to match Node's coercion rules; no paths, buffers, or privileged operations are affected.
Level of scrutiny
Low-to-medium. This is a focused Node-compat fix in a well-understood parser. The Rust change swaps get_boolean_strict for existing helpers (get + is_null + validators::validate_boolean, and get_boolean_loose) whose semantics I verified against source: JSValue::get returns None for missing/undefined and Some for everything else including null, so the !r.is_null() guard is exactly Node's != null check; get_boolean_loose calls to_boolean(), i.e. JS truthiness. The JS wrapper only fires on the exotic case (non-nullish, non-boolean recursive) and passes the common case straight through.
Other factors
- My prior review's nit (spread dropping inherited options) was addressed in 9f6275d with the
__proto__-shadow approach and a regression test that fails on the spread version. All comment-cop flags were resolved in 0949c4e by collapsing the multi-line comments to single lines with node permalinks. - Test coverage is thorough: 12 accepted option bags × 3 API forms, 7 non-boolean
recursivevalues × (sync+callback rejection, promise truthiness), plus no-mutation and inherited-option checks. robobun confirmed 18/28 fail on the unfixed build and all pass with the fix. - The PR body documents the interaction with #39328 (which changes the remaining error's wording); whichever lands second needs a small reconciliation but that's a merge-order concern, not a correctness issue here.
validatorsis already imported atnode_fs.rs:187andvalidate_boolean'sname: impl fmt::Displayaccepts the bare"recursive"string literal — the change compiles as written.
Problem
fs.readdirSync(dir, { recursive: null })throwsERR_INVALID_ARG_TYPE(The "recursive" property must be of type boolean, got object); node returns the flat listing. Same forfs.readdir(callback) andfs.promises.readdir.{ withFileTypes: 1 },{ withFileTypes: "x" },{ withFileTypes: null },{ withFileTypes: 0 }throw the same way in all three forms; node returns Dirents for the truthy values and names for the falsy ones.fs.promises.readdir(dir, { recursive: "x" })/{ recursive: 1 }reject; node returns the recursive listing ({ recursive: 0 }the flat one).args::Readdir::from_js(src/runtime/node/node_fs.rs:3488-3493) reads both options withget_boolean_strict, which rejects anything present that is notundefinedor a boolean. It is the one parser behindreaddirSync,readdirandpromises.readdir.readdir/readdirSyncrunvalidateBooleanonly whenoptions.recursive != null(lib/fs.js#L1546-L1548); no entry point validateswithFileTypes, they all pass!!options.withFileTypes(lib/fs.js#L1557);fs.promises.readdirnever validatesrecursiveand just branches on it (lib/internal/fs/promises.js#L1601).getOptional(..., bool)withgetBooleanStrict, the parser treatednullas absent and accepted numbers, so{ recursive: null }and{ withFileTypes: 1 }used to work in Bun too.Fix
Readdir::from_js: anullrecursiveis skipped (staysfalse); any other non-boolean still goes throughvalidators::validate_boolean, soreaddirSync/readdirkeep throwingERR_INVALID_ARG_TYPEfor0,"x",{}, ... with the message unchanged.withFileTypesis read withget_boolean_loose, i.e. JS truthiness, like!!options.withFileTypes.fs.promises.readdir(src/js/node/fs.promises.ts) gets a small wrapper in place ofasyncWrap: whenrecursiveis present, non-nullish and not a boolean, it passes the native binding{ __proto__: options, recursive: !!recursive }, so the parser still readsencoding/withFileTypesoff the caller's object (own or inherited, exactly as on the pass-through path) and the caller's object is never mutated. Booleans,nullandundefinedgo through untouched, so the common case does no extra work. This is the only form whose node behavior differs from the shared parser, and the same file already adaptsrm/rmdirthis way. (Side effect:promises.readdir.lengthis now 2, as in node; it was 3.)node:*, and every loosened value here is one node accepts; the values node rejects (non-nullish, non-booleanrecursivein the sync and callback forms) are still rejected, andmkdir'srecursive, which node validates strictly, is untouched.withFileTypes: "x"/withFileTypes: 1/withFileTypes: nullthrow and thatpromises.readdir({ recursive: "x" })rejects no longer hold once this lands.test/js/node/fs/fs.test.ts,describe("readdir accepts the recursive/withFileTypes values node accepts"). 12 accepted option bags checked through all three forms, 7 non-booleanrecursivevalues checked to still throw synchronously in the sync and callback forms (callback never called) and to act as truthiness in the promise form, plus a no-mutation check and an inherited-withFileTypescheck for the coercion path. 18 of the 28 cases fail on the unfixed build (USE_SYSTEM_BUN=1), all pass with the fix; the rest pin what must keep rejecting.fs.test.ts(539 pass),promises.test.js,dir.test.ts, and the portedtest-fs-readdir*.js/test-fs-promises.js.Background
node:fsin Bun has one native argument parser per operation (args::*::from_jsinnode_fs.rs). The sync binding and the async binding both call it, andfs.readdir(callback) andfs.promises.readdirboth call the same async binding, so a rule that only applies to the promise form has to live infs.promises.ts.JSValue::getreturnsNonefor a missing orundefinedproperty, so{ recursive: undefined }was already treated as absent;nullis the value that reaches the check.get_boolean_looseis the existing helper for "missing means default, otherwise JS truthiness";get_boolean_strictis the one for "must be a boolean".validators::validate_booleanis the Rust port of node'svalidateBoolean; it throws the sameERR_INVALID_ARG_TYPEget_boolean_strictthrew before, so only the accepted set changes in this PR.node 26.3.0 vs bun 1.4.0 vs this branch
Each option was probed through
readdirSync,readdir(callback) andpromises.readdiron a directory containinga.txtandsub/b.txt.THROWis node'sERR_INVALID_ARG_TYPE, thrown synchronously in the callback form as well (the callback is not invoked); this branch does the same.undefined,trueandfalsebehave identically in all three runtimes.