Repository navigation
Conversation
fs.read tested its callback with `!callback`, and fs.fstat did not test
it. A truthy value that is not a function (5, "x", {}, true) passed.
guardCallback wrapped it in a function, so the native callback arm saw a
function, ran the read or the stat on the thread pool, and the completion
failed with an uncaught `TypeError: callback is not a function`.
fs.fstat now calls ensureCallback, as fs.stat and fs.lstat do. fs.read
tests `$isCallable(callback)` before the native call. Both throw
ERR_INVALID_ARG_TYPE for "cb" from the call, as Node.js does.
Node.js checks fd and buffer before the callback in fs.read. The native
parser checks them after this test, so the throw path of fs.read repeats
those two checks first (throwInvalidReadCallback). A call with two
invalid arguments names the argument that Node.js names.
test-fs-read.js and test-fs-read-type.js are the upstream v26.3.0 files
again. Two of the seven restored message assertions need this change.
|
Status: the fix is pushed and waits for review. How I reproduced it // r.mjs
import fs from "node:fs";
const fd = fs.openSync(import.meta.filename, "r"), b = Buffer.alloc(4, 46);
process.on("uncaughtException", e => console.log("uncaught", e.code || e.name, "buffer=" + JSON.stringify(b.toString())));
try { fs.read(fd, b, 0, 4, 0, 5); console.log("returned"); } catch (e) { console.log("threw", e.code); }
With CI (build 137919, commit
PR: #47995 |
|
No actionable comments were generated in the recent review. Walkthrough
ChangesFilesystem callback validation
Suggested reviewers: Merge Risk:
|
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline finding, I also checked the remaining bare wrapFsCallback callers in src/js/node/fs.ts (close, the 4-argument writeFile overload, opendir) for the same unchecked-callback bug — each gates on $isCallable/validateFunction first, so fs.read and fs.fstat were the only two. The fstat callback-before-fd order and the read fd -> buffer -> cb order in throwInvalidReadCallback match Node's makeCallback/getValidatedFd/validateBuffer sequence, so the new ordering assertions certify Node's behavior, not a Bun quirk.
Extended reasoning...
The change adds a 7-line throwInvalidReadCallback helper and two one-line call-site edits in src/js/node/fs.ts, plus ~230 lines of node:test-style tests and re-enabled message assertions in two ported upstream tests; it touches no security-sensitive surface. The only confirmed finding is a pre-existing options-vs-fd ordering gap that the PR narrows but does not close. The re-enabled message assertions in test-fs-read-type.js exercise the native parser (readSync, valid-callback read) rather than the JS edits, and I could not run them, so a human should confirm CI on those files.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟣
src/js/node/fs.ts— pre-existing: callers passing a bad fd together with a bad options argument still get the "options" error from fs.read, where Node reports the fd first. The new throwInvalidReadCallback only orders fd before buffer and cb; the options throws at src/js/node/fs.ts:273 and :294 still run before any fd check, since fd validation is deferred to native. Fix: on every synchronous error path of fs.read, including the two options throws, run validateInteger(fd, "fd", 0, 2147483647) before throwing, so doubly-invalid calls name "fd" as Node does while valid calls keep skipping JS fd validation.Why this was flagged
A user calls fs.read(-1, buffer, 0, 5) or fs.read(-1, buffer, "x", cb) (argc 4, non-object options). At src/js/node/fs.ts:268-274 params is set from offsetOrOptions and at :293-295 the typeof check throws ERR_INVALID_ARG_TYPE for "options". Node runs getValidatedFd(fd) first (the PR's own cited lib/fs.js#L600) and throws ERR_OUT_OF_RANGE for "fd". The base branch behaves the same, and the PR description lists this under Downsides as not fixed; it is reported because the PR adds fd-before-X ordering for the cb path at :299 and :49-55 but leaves the sibling options path in the same function with the wrong winner. The fix is cheap because it only runs on the error path, like throwInvalidReadCallback.
Verification: pre-existing. A caller passes an out-of-range fd with a non-object options argument, e.g. fs.read(-1, buffer, 0, 5). In /home/claude/bun/src/js/node/fs.ts, line 294 throws the options TypeError before any fd check; throwInvalidReadCallback (lines 48-55) is reached only from line 299, after line 294 has thrown. Base branch has identical lines, so the base already fails by the same route.
The options test of fs.read runs in JS, and the fd test is in the native parser. A call with an invalid fd and an invalid options argument named "options". Node.js checks the fd first. The options throw path now checks the fd first, as the callback throw path does. A valid call does not run the check.
|
The finding on the
Two rows in the
#44476 changes both. |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline finding, I also checked two things and ruled them out: the edits to the two vendored tests in test/js/node/test/parallel/ only uncomment the upstream message: assertions (the text matches nodejs/node verbatim, and the readSync messages they re-enable come from the shared native formatter in src/jsc/JSGlobalObject.rs, so they are not new assertions); and validateInteger(fd, "fd", 0, 2147483647) in throwInvalidReadCallback is equivalent to Node's getValidatedFd (validateInt32(fd, 'fd', 0)), including -0 passing and the an integer vs range message split.
Extended reasoning...
The change tightens callback validation in fs.read and fs.fstat in src/js/node/fs.ts and adds tests; it touches no security-sensitive surface. The inline finding is a pre-existing dead params check the diff leaves in place, and the items above are the additional checks this run ruled out.
Problem
fs.read(fd, buffer, 0, 4, 0, 5)returns, reads intobuffer, then raises an uncaughtTypeError: callback is not a function. Node.js throwsERR_INVALID_ARG_TYPEfrom the call.fs.fstathas the same fault.fs.readtests!callback(src/js/node/fs.ts:290) andfs.fstattests nothing (:167).guardCallbackthen wraps the value in a function.fs.readin a wider change and node:fs: honor options.signal in the async stat family #47310 has thefs.fstatline. This PR is the narrow part of both.Fix
fs.fstatcallsensureCallback, asfs.statdoes.fs.readtests$isCallable(callback)before the native call.fs.readneeds more: Node.js checks fd,optionsand buffer first. Each JS throw path repeats the earlier checks (throwInvalidReadCallback).fs-read-buffer-before-offset.test.ts(18 of 20 new tests fail on main, all pass undernode --test).test-fs-read.jsandtest-fs-read-type.jsequal upstream again and need this fix. Self-reviewed: 6 concerns raised, 6 addressed.Background
fs.readandfs.fstatare JS adapters over a native, thread-pool binding.guardCallbackroutes a throw in the callback touncaughtException. It wraps any value, so only the adapter sees the user's value.fs.ts. fix(fs): match Node read and write argument defaults #44476's read block then replacesthrowInvalidReadCallback.Downsides
fs.read(fd, b, undefined, cb)reads where Node.js throws foroptions. Sofs.read(fd, b, undefined, 5)namescb.fs.readabout +90 interpreter instructions (oneis_callable),fs.fstat+385 (oneensureCallbackcall). The JIT hides both. The binary grows 332 bytes.Notes
Where this comes from. No user reported it. An automated comparison with Node.js v26.3.0 and v26.12.1 found it. The report asked for a fix or for the number of the open PR that has it. The answer to the second part is #44476 for
fs.readand #47310 forfs.fstat. Neither has a human review. #12412 addedensureCallbackto 35 adapters and left these two out. #43728 lists the gap under "Not in this PR".Repro.
node r.mjsprintsthrew ERR_INVALID_ARG_TYPE. Debug and release builds of mainb1007f1177printreturned, thenuncaught TypeError buffer="impo". The full text isTypeError: callback is not a function. (In 'callback(a, b, c)', 'callback' is 5).Probe rows. Each row is a child process. Node.js v26.3.0 and this PR agree in every row.
b1007f1177fs.read(fd, b, 0, 4, 0, X), X is5,"x",{},trueTypeError, buffer filledERR_INVALID_ARG_TYPE, buffer untouchedfs.read(fd, { buffer: b }, 5),fs.read(fd, b, { length: 4 }, 5),fs.read(fd, 5)TypeErrorfs.fstat(fd),fs.fstat(fd, 5)TypeErrorfs.read(fd, b, 0, 4, 0, null),fs.read(fd, b, 0, 4, 0)"callback""cb"fs.read(fd, b, 0, 4, 0, () => {})Siblings. I put 8 values that are not functions (
5,"x",{},[],true,null,undefined,0) into 81 callback slots ofnode:fs(every callback API and overload). Onlyfs.read(4 slots) andfs.fstat(2 slots) start the operation where Node.js throws.guardCallbackhas 16 callers (9 infs.ts, 7 indns.ts). The other 14 test the value first.Why the test is in the JS adapter. The native arm has its own test (
split_callback,src/runtime/node/node_fs_binding.rs:60-73). It receives the function thatguardCallbackmade, so it cannot fail. It also runs before the native parser reads fd and buffer, so a test there would namecbfirst too. Node.js has the test inlib/fs.js(read: line 634, after fd at 600 and buffer at 633.fstat: line 1579, first).Order of the checks in
fs.read. Node.js: fd,options, buffer, callback, offset, length, position. Bun:optionsand the callback in JS, then fd, buffer, offset, length and position in the native parser (Read::from_js,src/runtime/node/node_fs.rs). So each JS throw path repeats the checks that Node.js makes earlier. The callback path callsthrowInvalidReadCallback, which runsvalidateInteger(fd, "fd", 0, 2147483647)and theisArrayBufferViewtest first. Theoptionspath runs the same fd check before it throws (second commit, from a review finding). A valid call runs none of this.options, buffer, callback, offset, length and position). All 41 give node's code, class and message. Main gives it in 18.options, withcallback = ensureCallback(callback)infs.read(bundled text patched in a private mount): 12 give node's error, and the 16 that match on main all namecb.validateBuffer. Bun'svalidateBufferacceptsundefined(src/jsc/bindings/NodeValidatorBodies.h:322), and Node.js does not. The test "an undefined buffer gets the buffer error" fails with that substitution.ArrayBufferand aSharedArrayBufferas the buffer. Node.js does not. Not changed here. With an invalid callback both now get node's buffer error.undefined: 1. Without the fd check before theoptionserror: 1. With!callbackinfs.read: 13. WithwrapFsCallbackinfs.fstat: 3.Cost (main
b1007f1177against this PR, Linux x64, release builds from one tree, except where a debug build is named).fs.read: 31 to 32 (oneis_callable).fs.fstat: its own 18 do not change, andensureCallbackadds 7 and one call.fs.statandfs.lstatpay the same today.BUN_JSC_useJIT=0). The call has a valid callback and an offset or an fd that the native parser refuses, so the adapter and the parser run and nothing is queued. Slope between 100,000 and 300,000 calls, least of 3, two rounds.fs.read, three rounds: 10,632, 10,666 and 10,636 on main, 10,721, 10,739 and 10,746 here (+90 by the means).fs.fstat: 8,548 and 8,548 on main, 8,933 and 8,930 here (+385).fs.read: 2,868 and 2,923 on main, 2,907 and 2,915 here.fs.fstat: 2,257 and 2,258 on main, 2,257 and 2,262 here. The difference is below the spread of one binary.heapStats, 1,000 pending calls):fs.read2 Function and 2 scopes,fs.fstat1 and 1, 0 Promise. The same as main.node:fs: +3 heap cells (one Function, one FunctionExecutable and one UnlinkedFunctionExecutable forthrowInvalidReadCallback) and +308 bytes of heap. The bytecode of the helper is generated only when a call is refused. Instructions to runrequire("node:fs"), least of 15, two rounds: 12,717,101 and 12,712,643 on main, 12,713,718 and 12,718,154 here.node/fs.js30,978 to 31,310 bytes (+332). Binarytext(size): 140,290,352 to 140,290,684 (+332).dataandbssdo not change. No host function and no binding member is added.fs.fstatstays at 80 bytes. The DFG inline limit (maximumFunctionForCallInlineCandidateBytecodeCostForDFG) is 80.fs.readgoes from 613 to 648, all of it on the throw paths.fs.fstat, an inlineif (!$isCallable(callback)) throw ...beforewrapFsCallback, is the statement that node:fs: honor options.signal in the async stat family #47310 has. I built it too, before the second commit: +98 instructions per call with the JIT off (against +385), no difference with the JIT on, 123 bytes of bytecode (over the DFG limit) and 93 more bytes of text. This PR usesensureCallbackbecause the other stat adapters do./proc/self/io) around 100 refused calls: 8, with 6 for no call (main: 108). Around 1,000: 9 (main: 1,008). Event-loop references that one refusedfs.readand one refusedfs.fstattake: 0 (main: 2).node.fs.asynctrace events for the two: 0 (main: 2 begin and 2 end, Node.js: 0).valgrindandperfare not installed on this machine. The instruction counts come fromperf_event_openon the first thread of the process.Not in this PR. Each row differs from Node.js before and after it.
undefinedin theoptionsslot offs.read. The test atfs.ts:263(on main) looks atparams, which is stillnullthere, sofs.read(fd, buffer, undefined, cb)reads where Node.js throws foroptions. A program can depend on that read today, so this PR leaves it. fix(fs): match Node read and write argument defaults #44476 changes it.fs.readSync(fd, "x", 5)namesoptionswhere Node.js namesbuffer. It is the same order fault in the sync function. fix(fs): match Node read and write argument defaults #44476 changes it.fs.open(p, "r", {})namescb, Node.js namesmode). Both throw from the call.fs.globnames"callback"where Node.js names"cb".fs.rm(p, {}, 5)throws where Node.js runs the removal.Dir.read(null)andDir.close(null)return a promise where Node.js throws.fs.readqueues a job. Node.js calls back fromprocess.nextTickand does not touch the fd.wrapFsCallbackandguardCallbackstill wrap any value. Each of their callers now tests first.fs.readallocates. That change touches the binding files that bindgen: deleteb.anyand.variadic#46789 and Remove libuv on Windows #47776 rewrite.Other open PRs on these lines (
git merge-treeagainst mainb1007f1177and against this branch).d3552fcce4) merges cleanly on main and conflicts with this branch insrc/js/node/fs.ts. It validates the arguments offs.readin JS before the native call. I ran its JS on main: the 7fs.readrows throw, and thefs.fstatrows do not. A validfs.readmakes 4 host calls there where main makes 1 (validateInt32,validateBuffer,validateIntegerfor the offset, then the binding), and 5 with a numeric position. If it lands after this PR, its read block replacesthrowInvalidReadCallback, and thefs.fstatline and the tests of this PR stay.1620afcd7b) merges cleanly on main and conflicts with this branch insrc/js/node/fs.ts, in thefs.fstathunk. It needs its inline test there, because it readsoptions.signalbetween the test and the wrap. It is my PR. I rebase it when this one lands.fs.write()ignoringposition, and match Node.js range and type errors #47036 has context lines next to thefs.readtest. It conflicts with main insrc/jsc/JSGlobalObject.rsand not with this branch.fs.readvandfs.writev(throwInvalidVectorCallback).Tests.
test/js/node/fs/fs-read-buffer-before-offset.test.tsusesnode:testandnode:assert, so the same file runs under Node.js. It had 5 tests. It now has 25.fs.readwith the 8 values (code, class and message, then a read at the current position to show that nothing moved it), the argument count,length.valueOfnot called, an async function, a bound function and aProxystill accepted,fs.fstatwith 2 and 3 arguments, the callback before the fd infs.fstat, andutil.promisifyof both (a rejection where main never settles).optionserror.test-fs-read.jsandtest-fs-read-type.jshad 7message:assertions commented out since node fs compat pr #2 #16422. All 7 are restored, and both files are byte for byte the files of nodejs/node v26.3.0 (blobs966185c5and81ad7ecb). Both exit 1 on main and 0 here. 2 of the 7 need this change (fs.read(null, buffer, 0, 1, 0)namesfd, andfs.read(fd, 4, 0, "utf-8", fn)namesbuffer). The other 5 already pass on main.test/js/node/fs/fs.test.ts(633 pass, 10 skip, 0 fail), 10 other files oftest/js/node/fs/(179 pass),test/internal/source-lints/(194 pass), and 397 files of Node'stest/parallel(test-fs-*,test-util-promisify*,test-filehandle*,test-trace-events-fs*,test-http2-respond-file*,test-stream-pipeline*,test-readline*,test-file-*), all pass.