From 52f1ab3deead22b303d529b86fb13e2a93c6a9aa Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:41:08 +0000 Subject: [PATCH 1/3] fs: read offset, length and position as independent slots in write and writeSync The native argument parser for fs.write and fs.writeSync stopped at the first slot it could not use. An undefined offset or a non-number length left position unread, so the write went to the current file offset. Read the three slots independently with Node's defaults: a nullish offset is 0, a non-number length is the rest of the buffer, a non-number position is the current file offset. A numeric length goes through the checks of validateOffsetLengthWrite in Node's order, so a fractional or NaN length throws instead of truncating. In JS, writeSync sends a null offset down the options path like Node, so the trailing arguments are discarded. fs.write maps a callback in the offset slot to offset 0, as Node does. --- src/js/node/fs.ts | 11 ++- src/runtime/node/node_fs.rs | 149 +++++++++++++++---------------- test/js/node/fs/fs.test.ts | 173 ++++++++++++++++++++++++++++++++++++ 3 files changed, 254 insertions(+), 79 deletions(-) diff --git a/src/js/node/fs.ts b/src/js/node/fs.ts index 8503df3b2d45..c9c151f51994 100644 --- a/src/js/node/fs.ts +++ b/src/js/node/fs.ts @@ -308,7 +308,11 @@ var access = function access(path, mode, callback?) { offset: offsetOrOptions = 0, length = buffer.byteLength - offsetOrOptions, position = null, - } = offsetOrOptions ?? {}); + } = offsetOrOptions ?? kEmptyObject); + } + if (typeof offsetOrOptions === "function") { + // fs.write(fd, buffer, callback) + offsetOrOptions = 0; } fs.writeCb(onWritten, fd, buffer, offsetOrOptions, length, position); @@ -511,8 +515,9 @@ var access = function access(path, mode, callback?) { try { if (types.isArrayBufferView(buffer)) { let offset = offsetOrOptions; - if (typeof offset === "object" && offset !== null) { - ({ offset = 0, length = buffer.byteLength - offset, position = null } = offsetOrOptions); + // `null` is an empty options object, as in Node. + if (typeof offset === "object") { + ({ offset = 0, length = buffer.byteLength - offset, position = null } = offsetOrOptions ?? kEmptyObject); return fs.writeSync(fd, buffer, offset, length, position); } return arguments.length <= 2 ? fs.writeSync(fd, buffer) : fs.writeSync(fd, buffer, offset, length, position); diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index a542eb64cf3a..a9b4231b7870 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -3777,104 +3777,101 @@ pub(crate) mod args { ..Default::default() }; arguments.eat(); - 'parse: { - let Some(mut current) = arguments.next() else { - break 'parse; - }; - match &args.buffer { - // fs.write(fd, buffer[, offset[, length[, position]]], callback) - StringOrBuffer::Buffer(_) | StringOrBuffer::PinnedBuffer(_) => { - if current.is_undefined_or_null() || current.is_function() { - break 'parse; - } + match &args.buffer { + // fs.write(fd, buffer[, offset[, length[, position]]], callback) + // Each slot takes its default on its own, as in Node's writeSync. + StringOrBuffer::Buffer(_) | StringOrBuffer::PinnedBuffer(_) => 'buffer: { + let buf_len = args.buffer.slice().len() as u64; + let Some(offset) = arguments.next_eat() else { + break 'buffer; + }; + if !offset.is_undefined_or_null() { args.offset = u64::try_from(validators::validate_integer( ctx, - current, + offset, "offset", Some(0), - Some(9007199254740991), + Some(bun_jsc::MAX_SAFE_INTEGER), )?) .expect("infallible: validated range"); - arguments.eat(); - // Node bounds `offset` by the buffer whether or not a - // `length` follows (`length` defaults to the rest). - let buf_len = args.buffer.slice().len(); - let max_offset = buf_len as i64; - if args.offset as i64 > max_offset { + } + if args.offset > buf_len { + return Err(ctx.throw_range_error( + args.offset as f64, + bun_jsc::RangeErrorOptions { + field_name: b"offset", + max: buf_len as i64, + ..Default::default() + }, + )); + } + let Some(length) = arguments.next_eat() else { + break 'buffer; + }; + // validateOffsetLengthWrite, in Node's order. + if length.is_number() { + let length_float = length.as_number(); + let max_len = (buf_len - args.offset).min(i32::MAX as u64) as i64; + if length_float > max_len as f64 { return Err(ctx.throw_range_error( - args.offset as f64, + length_float, bun_jsc::RangeErrorOptions { - field_name: b"offset", - max: max_offset, + field_name: b"length", + max: max_len, ..Default::default() }, )); } - let Some(next) = arguments.next() else { - break 'parse; - }; - current = next; - if !(current.is_number() || current.is_big_int()) { - break 'parse; - } - let length = current.to_int64(); - let max_len = ((buf_len as u64 - args.offset) as i64).min(i32::MAX as i64); - if length > max_len || length < 0 { + if length_float < 0.0 { return Err(ctx.throw_range_error( - length as f64, + length_float, bun_jsc::RangeErrorOptions { field_name: b"length", min: 0, - max: max_len, ..Default::default() }, )); } - args.length = u64::try_from(length).expect("int cast"); - arguments.eat(); - let Some(next) = arguments.next() else { - break 'parse; - }; - current = next; - if !(current.is_number() || current.is_big_int()) { - break 'parse; - } - if let Some(position @ 0..) = i52::offset_from_js(current) { - args.position = Some(position); + if length_float % 1.0 != 0.0 { + return Err(ctx.throw_range_error( + length_float, + bun_jsc::RangeErrorOptions { + field_name: b"length", + msg: b"an integer", + ..Default::default() + }, + )); } - arguments.eat(); + args.length = length_float as u64; } - // fs.write(fd, string[, position[, encoding]], callback) - _ => { - if let Some(position @ 0..) = i52::offset_from_js(current) { - args.position = Some(position); - } - // Node consumes the position slot whatever its type - // (null, undefined, a non-number); the encoding is - // strictly the next argument. - arguments.eat(); - let Some(next) = arguments.next() else { - break 'parse; - }; - current = next; - if current.is_string() { - args.encoding = Encoding::assert(current, ctx, args.encoding)?; - arguments.eat(); - // `bv` was converted to UTF-8 before the encoding - // argument was parsed; re-encode it now. Node - // treats the "buffer" encoding name as UTF-8 here. - if !matches!(args.encoding, Encoding::Utf8 | Encoding::Buffer) { - if let Some(encoded) = - StringOrBuffer::from_js_with_encoding_maybe_async( - ctx, - bv, - args.encoding, - flavor, - StringObjects::Allow, - )? - { - args.buffer = encoded; - } + if let Some(position @ 0..) = arguments.next_eat().and_then(i52::offset_from_js) + { + args.position = Some(position); + } + } + // fs.write(fd, string[, position[, encoding]], callback) + _ => { + if let Some(position @ 0..) = arguments.next_eat().and_then(i52::offset_from_js) + { + args.position = Some(position); + } + let encoding = arguments.next_eat().unwrap_or(JSValue::UNDEFINED); + if encoding.is_string() { + args.encoding = Encoding::assert(encoding, ctx, args.encoding)?; + // `bv` was converted to UTF-8 before the encoding + // argument was parsed; re-encode it now. Node + // treats the "buffer" encoding name as UTF-8 here. + if !matches!(args.encoding, Encoding::Utf8 | Encoding::Buffer) { + if let Some(encoded) = + StringOrBuffer::from_js_with_encoding_maybe_async( + ctx, + bv, + args.encoding, + flavor, + StringObjects::Allow, + )? + { + args.buffer = encoded; } } } diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index 88c5dca21d6a..4ea2b900fd57 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -2479,7 +2479,112 @@ describe.concurrent("writev/readv with more than IOV_MAX buffers", () => { }); }); +// write(fd, buffer[, offset[, length[, position]]]) in all three forms (writeSync, +// callback write, filehandle.write). Each case runs against a file that already +// holds `positionalWriteHead` with the file offset at its end, so a write at +// position 0 and a write at the current position are distinguishable. +// Expectations are Node v26's. +const positionalWriteHead = "PPPPPPPPPPPP"; +const positionalWriteBuffer = Buffer.from("0123456789abcdef"); +const positionalWriteCases: [args: unknown[], written: number, file: string][] = [ + // A nullish offset is 0; the length and position after it still apply. + [[undefined, 8, 0], 8, "01234567PPPP"], + [[undefined, undefined, 0], 16, "0123456789abcdef"], + [[undefined, undefined, 2], 16, "PP0123456789abcdef"], + [[0, undefined, 2], 16, "PP0123456789abcdef"], + [[undefined, 8], 8, positionalWriteHead + "01234567"], + // A non-number length is the rest of the buffer; the position still applies. + [[undefined, "8", 0], 16, "0123456789abcdef"], + [[undefined, 8n, 0], 16, "0123456789abcdef"], + [[4, undefined, 0], 12, "456789abcdef"], + [[4, null, 0], 12, "456789abcdef"], + // A non-numeric position writes at the current file offset. + [[4, 8, undefined], 8, positionalWriteHead + "456789ab"], + [[4, 8, null], 8, positionalWriteHead + "456789ab"], + [[4, 8, "2"], 8, positionalWriteHead + "456789ab"], + [[4, 8, 2], 8, "PP456789abPP"], + // An offset equal to byteLength is allowed and writes nothing. + [[16], 0, positionalWriteHead], + // `null` and objects take the options form, which discards the trailing + // positional arguments. + [[null, 8, 0], 16, positionalWriteHead + "0123456789abcdef"], + [[{ offset: 4, length: 8, position: 0 }], 8, "456789abPPPP"], + // Inside the options object, a `null` offset is 0 and the other fields apply. + [[{ offset: null, length: 8, position: 0 }], 8, "01234567PPPP"], + [[{ offset: null, position: 0 }], 16, "0123456789abcdef"], +]; +// The offset and length are validated against the buffer even when the +// arguments after them are omitted. +const positionalWriteErrors: [args: unknown[], error: unknown][] = [ + [[17], outOfRange("offset", "<= 16", 17)], + [[1.5], outOfRange("offset", "an integer", 1.5)], + [[undefined, 17], outOfRange("length", "<= 16", 17)], + [[undefined, -1], outOfRange("length", ">= 0", -1)], + [[4, 13], outOfRange("length", "<= 12", 13)], + [[0, Infinity], outOfRange("length", "<= 16", Infinity)], + // A number that is not an integer is a length (it is not defaulted), and + // Node rejects it after the bounds checks. + [[undefined, NaN], outOfRange("length", "an integer", NaN)], + [[0, 1.5], outOfRange("length", "an integer", 1.5)], +]; +function outOfRange(argument: string, bound: string, received: number) { + return { + name: "RangeError", + code: "ERR_OUT_OF_RANGE", + message: `The value of "${argument}" is out of range. It must be ${bound}. Received ${received}`, + }; +} +/** The return value of `fn`, or the shape of what it threw. */ +function outcomeOf(fn: () => unknown): unknown { + try { + return fn(); + } catch (error: any) { + return { name: error.name, code: error.code, message: error.message }; + } +} + describe("writeSync", () => { + it("reads offset, length and position positionally", () => { + using dir = tempDir("fs-writeSync-positional", {}); + const dest = join(String(dir), "out.bin"); + for (const [args, written, file] of positionalWriteCases) { + const fd = openSync(dest, "w+"); + let outcome: unknown; + try { + writeSync(fd, positionalWriteHead); + outcome = outcomeOf(() => (writeSync as Function)(fd, positionalWriteBuffer, ...args)); + } finally { + closeSync(fd); + } + expect({ args, outcome, file: readFileSync(dest, "latin1") }).toEqual({ args, outcome: written, file }); + } + }); + + it("validates offset and length against the buffer even when the later arguments are omitted", () => { + using dir = tempDir("fs-writeSync-positional-errors", {}); + const dest = join(String(dir), "out.bin"); + const errors: [args: unknown[], error: unknown][] = [ + ...positionalWriteErrors, + // Unlike fs.write, writeSync takes no callback, so a function is not an offset. + [[() => {}, 8, 0], expect.objectContaining({ name: "TypeError", code: "ERR_INVALID_ARG_TYPE" })], + ]; + for (const [args, error] of errors) { + const fd = openSync(dest, "w+"); + let outcome: unknown; + try { + writeSync(fd, positionalWriteHead); + outcome = outcomeOf(() => (writeSync as Function)(fd, positionalWriteBuffer, ...args)); + } finally { + closeSync(fd); + } + expect({ args, outcome, file: readFileSync(dest, "latin1") }).toEqual({ + args, + outcome: error, + file: positionalWriteHead, + }); + } + }); + it("treats a bigint position as the current offset", () => { // Node's fs.write does `if (typeof position !== 'number') position = null` // for the buffer overload; GetOffset then returns -1. fs.read accepts @@ -5421,6 +5526,74 @@ describe("fs.write", () => { }); }); + it("reads offset, length and position positionally", async () => { + using dir = tempDir("fs-write-positional", {}); + const dest = join(String(dir), "out.bin"); + const cases: typeof positionalWriteCases = [ + ...positionalWriteCases, + // fs.write(fd, buffer, callback): the callback sits in the offset slot. + [[], 16, positionalWriteHead + "0123456789abcdef"], + // A function in `options.offset` is 0 as well. + [[{ offset: () => {}, length: 8, position: 0 }], 8, "01234567PPPP"], + ]; + for (const [args, written, file] of cases) { + const fd = openSync(dest, "w+"); + let outcome: unknown; + try { + writeSync(fd, positionalWriteHead); + const { promise, resolve, reject } = Promise.withResolvers(); + (fs.write as Function)(fd, positionalWriteBuffer, ...args, (err: unknown, bytesWritten: number) => + err ? reject(err) : resolve(bytesWritten), + ); + outcome = await promise; + } finally { + closeSync(fd); + } + expect({ args, outcome, file: readFileSync(dest, "latin1") }).toEqual({ args, outcome: written, file }); + } + + for (const [args, written, file] of positionalWriteCases) { + const handle = await promises.open(dest, "w+"); + let outcome: unknown; + try { + await handle.write(positionalWriteHead); + outcome = (await (handle.write as Function)(positionalWriteBuffer, ...args)).bytesWritten; + } finally { + await handle.close(); + } + expect({ args, outcome, file: readFileSync(dest, "latin1") }).toEqual({ args, outcome: written, file }); + } + }); + + it("throws synchronously for an offset or length outside the buffer", () => { + using dir = tempDir("fs-write-positional-errors", {}); + const dest = join(String(dir), "out.bin"); + function mustNotCall() { + throw new Error("fs.write must throw instead of calling back"); + } + const errors: [args: unknown[], error: unknown][] = [ + ...positionalWriteErrors, + // `options.offset` is 0 for a function, so `options.length` defaults to + // `byteLength - offset` with the function still in `offset`: NaN. + [[{ offset: () => {} }], outOfRange("length", "an integer", NaN)], + ]; + for (const [args, error] of errors) { + const fd = openSync(dest, "w+"); + let outcome: unknown; + try { + writeSync(fd, positionalWriteHead); + outcome = outcomeOf(() => (fs.write as Function)(fd, positionalWriteBuffer, ...args, mustNotCall)); + } finally { + closeSync(fd); + } + expect({ args, outcome, file: readFileSync(dest, "latin1") }).toEqual({ + args, + outcome: error, + file: positionalWriteHead, + }); + } + }); + it("should work with (fd, string, position, encoding, callback)", done => { const path = `${tmpdir()}/bun-fs-write-3-${Date.now()}.txt`; const fd = fs.openSync(path, "w"); From 94f820e946b78999ccb335afb6efa7ad7058b044 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:51:21 +0000 Subject: [PATCH 2/3] test: run the write error table through filehandle.write --- test/js/node/fs/fs.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index 4ea2b900fd57..98deb3dcab2d 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -5594,6 +5594,29 @@ describe("fs.write", () => { } }); + it("filehandle.write rejects an offset or length outside the buffer", async () => { + using dir = tempDir("fs-filehandle-write-positional-errors", {}); + const dest = join(String(dir), "out.bin"); + for (const [args, error] of positionalWriteErrors) { + const handle = await promises.open(dest, "w+"); + let outcome: unknown; + try { + await handle.write(positionalWriteHead); + outcome = await (handle.write as Function)(positionalWriteBuffer, ...args).then( + (result: { bytesWritten: number }) => result.bytesWritten, + (err: any) => ({ name: err.name, code: err.code, message: err.message }), + ); + } finally { + await handle.close(); + } + expect({ args, outcome, file: readFileSync(dest, "latin1") }).toEqual({ + args, + outcome: error, + file: positionalWriteHead, + }); + } + }); + it("should work with (fd, string, position, encoding, callback)", done => { const path = `${tmpdir()}/bun-fs-write-3-${Date.now()}.txt`; const fd = fs.openSync(path, "w"); From 50fd5cd80846e1c7d0d18f7862e800075eaf96b6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:40:31 +0000 Subject: [PATCH 3/3] ci: retrigger