From 6a09d5455b8befe00baaae36efec282815524b79 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:24:15 +0000 Subject: [PATCH 1/5] node:zlib: accept zstd pledgedSrcSize values of 4 GiB and larger The option was validated as a uint32, so pledging a source size of 2**32 or more threw ERR_OUT_OF_RANGE even though ZSTD_CCtx_setPledgedSrcSize takes a u64 and the frame header's content-size field holds 8 bytes. Validate it as a non-negative safe integer instead and pass it through unmodified. --- src/runtime/node/zlib/NativeZstd.rs | 12 ++-- .../node/zlib/zstd-pledged-src-size.test.ts | 72 +++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 test/js/node/zlib/zstd-pledged-src-size.test.ts diff --git a/src/runtime/node/zlib/NativeZstd.rs b/src/runtime/node/zlib/NativeZstd.rs index 8a56248734c2..274a9415454b 100644 --- a/src/runtime/node/zlib/NativeZstd.rs +++ b/src/runtime/node/zlib/NativeZstd.rs @@ -201,14 +201,18 @@ mod _impl { write_js_callback.with_async_context_if_needed(global), ); + // `u64::MAX` is `ZSTD_CONTENTSIZE_UNKNOWN`. `ZSTD_CCtx_setPledgedSrcSize` + // takes a u64, so the only bound is what a JS number represents exactly. let mut pledged_src_size: u64 = u64::MAX; if pledged_src_size_value.is_number() { - pledged_src_size = u64::from(validators::validate_uint32( + pledged_src_size = u64::try_from(validators::validate_integer( global, pledged_src_size_value, - format_args!("pledgedSrcSize"), - false, - )?); + "pledgedSrcSize", + Some(0), + Some(jsc::MAX_SAFE_INTEGER), + )?) + .expect("infallible: validated range"); } // Bind the ArrayBuffer view to a local so the borrowed byte_slice() diff --git a/test/js/node/zlib/zstd-pledged-src-size.test.ts b/test/js/node/zlib/zstd-pledged-src-size.test.ts new file mode 100644 index 000000000000..a7d61f021e7d --- /dev/null +++ b/test/js/node/zlib/zstd-pledged-src-size.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test"; +import { promisify } from "node:util"; +import * as zlib from "node:zlib"; + +// https://github.com/facebook/zstd/blob/dev/doc/zstd_compression_format.md#frame_header +// magic(4) | frame header descriptor(1) | [window descriptor(1)] | [dictionary id] | [frame content size] +function frameContentSize(frame: Buffer): bigint | null { + expect(frame.readUInt32LE(0)).toBe(0xfd2fb528); + const descriptor = frame[4]; + const singleSegment = (descriptor >> 5) & 1; + const offset = 5 + (singleSegment ? 0 : 1) + [0, 1, 2, 4][descriptor & 0b11]; + switch (descriptor >> 6) { + case 0: + return singleSegment ? BigInt(frame[offset]) : null; + case 1: + return BigInt(frame.readUInt16LE(offset) + 256); + case 2: + return BigInt(frame.readUInt32LE(offset)); + default: + return frame.readBigUInt64LE(offset); + } +} + +// Finishing with ZSTD_e_flush instead of ZSTD_e_end means zstd never checks the pledged size +// against what was actually written, so these can pledge 4 GiB without feeding it 4 GiB. +// The pledge still goes into the frame header. +const input = Buffer.alloc(64, 0x61); +const flushOnly = (pledgedSrcSize: number) => ({ pledgedSrcSize, finishFlush: zlib.constants.ZSTD_e_flush }); + +describe("zstd pledgedSrcSize", () => { + it("is written to the frame header unmodified when below 4 GiB", () => { + expect(frameContentSize(zlib.zstdCompressSync(input, flushOnly(1000)))).toBe(1000n); + expect(frameContentSize(zlib.zstdCompressSync(input, flushOnly(2 ** 32 - 1)))).toBe(4294967295n); + }); + + it("accepts 4 GiB and larger", () => { + expect(frameContentSize(zlib.zstdCompressSync(input, flushOnly(2 ** 32)))).toBe(4294967296n); + expect(frameContentSize(zlib.zstdCompressSync(input, flushOnly(2 ** 33 + 1)))).toBe(8589934593n); + expect(frameContentSize(zlib.zstdCompressSync(input, flushOnly(Number.MAX_SAFE_INTEGER)))).toBe( + 9007199254740991n, + ); + }); + + it("accepts 4 GiB and larger when compressing asynchronously", async () => { + const compressed = await promisify(zlib.zstdCompress)(input, flushOnly(2 ** 32)); + expect(frameContentSize(compressed)).toBe(4294967296n); + }); + + it("accepts 4 GiB and larger when streaming", async () => { + const encoder = zlib.createZstdCompress({ pledgedSrcSize: 2 ** 32 }); + const chunks: Buffer[] = []; + const { promise, resolve, reject } = Promise.withResolvers(); + encoder.on("data", chunk => chunks.push(chunk)); + encoder.on("error", reject); + encoder.write(input, err => err && reject(err)); + encoder.flush(() => resolve()); + await promise; + encoder.destroy(); + expect(frameContentSize(Buffer.concat(chunks))).toBe(4294967296n); + }); + + it("rejects values that are not a non-negative safe integer", () => { + for (const pledgedSrcSize of [Number.MAX_SAFE_INTEGER + 1, -1, 1.5, Infinity]) { + expect(() => zlib.createZstdCompress({ pledgedSrcSize })).toThrow( + expect.objectContaining({ + code: "ERR_OUT_OF_RANGE", + message: expect.stringContaining('The value of "pledgedSrcSize" is out of range.'), + }), + ); + } + }); +}); From 570f78869b44d08d018f37faa3f256fc71fb08a6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:27:29 +0000 Subject: [PATCH 2/5] [autofix.ci] apply automated fixes --- test/js/node/zlib/zstd-pledged-src-size.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/js/node/zlib/zstd-pledged-src-size.test.ts b/test/js/node/zlib/zstd-pledged-src-size.test.ts index a7d61f021e7d..cff0aa864d9e 100644 --- a/test/js/node/zlib/zstd-pledged-src-size.test.ts +++ b/test/js/node/zlib/zstd-pledged-src-size.test.ts @@ -36,9 +36,7 @@ describe("zstd pledgedSrcSize", () => { it("accepts 4 GiB and larger", () => { expect(frameContentSize(zlib.zstdCompressSync(input, flushOnly(2 ** 32)))).toBe(4294967296n); expect(frameContentSize(zlib.zstdCompressSync(input, flushOnly(2 ** 33 + 1)))).toBe(8589934593n); - expect(frameContentSize(zlib.zstdCompressSync(input, flushOnly(Number.MAX_SAFE_INTEGER)))).toBe( - 9007199254740991n, - ); + expect(frameContentSize(zlib.zstdCompressSync(input, flushOnly(Number.MAX_SAFE_INTEGER)))).toBe(9007199254740991n); }); it("accepts 4 GiB and larger when compressing asynchronously", async () => { From 28ce99c2a98c46b0e16e976bdbb542cba2b8fc1e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:46:29 +0000 Subject: [PATCH 3/5] Harden the streaming pledgedSrcSize test and cover NaN Wire close to the rejection path and destroy the encoder in a finally, so a premature close fails with a diagnostic instead of hanging and the handle is released on both paths. Split the rejected values into it.each cases and add NaN, which only the native validator rejects. --- .../node/zlib/zstd-pledged-src-size.test.ts | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/test/js/node/zlib/zstd-pledged-src-size.test.ts b/test/js/node/zlib/zstd-pledged-src-size.test.ts index cff0aa864d9e..32cb04c4fd13 100644 --- a/test/js/node/zlib/zstd-pledged-src-size.test.ts +++ b/test/js/node/zlib/zstd-pledged-src-size.test.ts @@ -46,25 +46,27 @@ describe("zstd pledgedSrcSize", () => { it("accepts 4 GiB and larger when streaming", async () => { const encoder = zlib.createZstdCompress({ pledgedSrcSize: 2 ** 32 }); - const chunks: Buffer[] = []; - const { promise, resolve, reject } = Promise.withResolvers(); - encoder.on("data", chunk => chunks.push(chunk)); - encoder.on("error", reject); - encoder.write(input, err => err && reject(err)); - encoder.flush(() => resolve()); - await promise; - encoder.destroy(); - expect(frameContentSize(Buffer.concat(chunks))).toBe(4294967296n); + try { + const chunks: Buffer[] = []; + const { promise, resolve, reject } = Promise.withResolvers(); + encoder.on("data", chunk => chunks.push(chunk)); + encoder.on("error", reject); + encoder.on("close", () => reject(new Error("encoder closed before the flush completed"))); + encoder.write(input, err => err && reject(err)); + encoder.flush(() => resolve()); + await promise; + expect(frameContentSize(Buffer.concat(chunks))).toBe(4294967296n); + } finally { + encoder.destroy(); + } }); - it("rejects values that are not a non-negative safe integer", () => { - for (const pledgedSrcSize of [Number.MAX_SAFE_INTEGER + 1, -1, 1.5, Infinity]) { - expect(() => zlib.createZstdCompress({ pledgedSrcSize })).toThrow( - expect.objectContaining({ - code: "ERR_OUT_OF_RANGE", - message: expect.stringContaining('The value of "pledgedSrcSize" is out of range.'), - }), - ); - } + it.each([Number.MAX_SAFE_INTEGER + 1, -1, 1.5, Infinity, NaN])("rejects %p as pledgedSrcSize", pledgedSrcSize => { + expect(() => zlib.createZstdCompress({ pledgedSrcSize })).toThrow( + expect.objectContaining({ + code: "ERR_OUT_OF_RANGE", + message: expect.stringContaining('The value of "pledgedSrcSize" is out of range.'), + }), + ); }); }); From 82dbeaf1f957e6d8fbee61434fb58283463a1f15 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:34:49 +0000 Subject: [PATCH 4/5] Run the zstd pledgedSrcSize test on Bun and on Node.js Write the test with node:test and node:assert, and run the same file under Node.js from its last test. A rejected value asserts the error name and code only, on the runtimes that validate the option: Bun, and Node.js from v26.7.0 (nodejs/node de6eeece8f). --- .../node/zlib/zstd-pledged-src-size.test.ts | 87 ++++++++++++++----- 1 file changed, 65 insertions(+), 22 deletions(-) diff --git a/test/js/node/zlib/zstd-pledged-src-size.test.ts b/test/js/node/zlib/zstd-pledged-src-size.test.ts index 32cb04c4fd13..bc2bdc6c3ab1 100644 --- a/test/js/node/zlib/zstd-pledged-src-size.test.ts +++ b/test/js/node/zlib/zstd-pledged-src-size.test.ts @@ -1,11 +1,31 @@ -import { describe, expect, it } from "bun:test"; +/** + * All tests in this file run in both Bun and Node.js: `bun test` runs them + * here, and the last test runs this same file under Node.js. + * + * zstd writes `pledgedSrcSize` into the frame header as the Frame_Content_Size, + * a field of up to 8 bytes, so a size of 4 GiB or more is valid. The expected + * values below were recorded from Node v26.3.0 and v26.10.0. + */ +import assert from "node:assert"; +import { describe, test } from "node:test"; +import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import * as zlib from "node:zlib"; +import zlib from "node:zlib"; + +// Node validates the option as a non-negative safe integer since nodejs/node +// de6eeece8f (v26.7.0). An older Node truncates or clamps such a value, so the +// reject cases only describe it from that version on. Bun always runs them. +const runtimeValidates = (() => { + if (process.versions.bun) return true; + const [major, minor] = process.versions.node.split(".").map(Number); + return major > 26 || (major === 26 && minor >= 7); +})(); +const rejectTest = runtimeValidates ? test : test.skip; // https://github.com/facebook/zstd/blob/dev/doc/zstd_compression_format.md#frame_header // magic(4) | frame header descriptor(1) | [window descriptor(1)] | [dictionary id] | [frame content size] function frameContentSize(frame: Buffer): bigint | null { - expect(frame.readUInt32LE(0)).toBe(0xfd2fb528); + assert.strictEqual(frame.readUInt32LE(0), 0xfd2fb528); const descriptor = frame[4]; const singleSegment = (descriptor >> 5) & 1; const offset = 5 + (singleSegment ? 0 : 1) + [0, 1, 2, 4][descriptor & 0b11]; @@ -28,23 +48,26 @@ const input = Buffer.alloc(64, 0x61); const flushOnly = (pledgedSrcSize: number) => ({ pledgedSrcSize, finishFlush: zlib.constants.ZSTD_e_flush }); describe("zstd pledgedSrcSize", () => { - it("is written to the frame header unmodified when below 4 GiB", () => { - expect(frameContentSize(zlib.zstdCompressSync(input, flushOnly(1000)))).toBe(1000n); - expect(frameContentSize(zlib.zstdCompressSync(input, flushOnly(2 ** 32 - 1)))).toBe(4294967295n); + test("is written to the frame header unmodified when below 4 GiB", () => { + assert.strictEqual(frameContentSize(zlib.zstdCompressSync(input, flushOnly(1000))), 1000n); + assert.strictEqual(frameContentSize(zlib.zstdCompressSync(input, flushOnly(2 ** 32 - 1))), 4294967295n); }); - it("accepts 4 GiB and larger", () => { - expect(frameContentSize(zlib.zstdCompressSync(input, flushOnly(2 ** 32)))).toBe(4294967296n); - expect(frameContentSize(zlib.zstdCompressSync(input, flushOnly(2 ** 33 + 1)))).toBe(8589934593n); - expect(frameContentSize(zlib.zstdCompressSync(input, flushOnly(Number.MAX_SAFE_INTEGER)))).toBe(9007199254740991n); + test("accepts 4 GiB and larger", () => { + assert.strictEqual(frameContentSize(zlib.zstdCompressSync(input, flushOnly(2 ** 32))), 4294967296n); + assert.strictEqual(frameContentSize(zlib.zstdCompressSync(input, flushOnly(2 ** 33 + 1))), 8589934593n); + assert.strictEqual( + frameContentSize(zlib.zstdCompressSync(input, flushOnly(Number.MAX_SAFE_INTEGER))), + 9007199254740991n, + ); }); - it("accepts 4 GiB and larger when compressing asynchronously", async () => { + test("accepts 4 GiB and larger when compressing asynchronously", async () => { const compressed = await promisify(zlib.zstdCompress)(input, flushOnly(2 ** 32)); - expect(frameContentSize(compressed)).toBe(4294967296n); + assert.strictEqual(frameContentSize(compressed), 4294967296n); }); - it("accepts 4 GiB and larger when streaming", async () => { + test("accepts 4 GiB and larger when streaming", async () => { const encoder = zlib.createZstdCompress({ pledgedSrcSize: 2 ** 32 }); try { const chunks: Buffer[] = []; @@ -55,18 +78,38 @@ describe("zstd pledgedSrcSize", () => { encoder.write(input, err => err && reject(err)); encoder.flush(() => resolve()); await promise; - expect(frameContentSize(Buffer.concat(chunks))).toBe(4294967296n); + assert.strictEqual(frameContentSize(Buffer.concat(chunks)), 4294967296n); } finally { encoder.destroy(); } }); - it.each([Number.MAX_SAFE_INTEGER + 1, -1, 1.5, Infinity, NaN])("rejects %p as pledgedSrcSize", pledgedSrcSize => { - expect(() => zlib.createZstdCompress({ pledgedSrcSize })).toThrow( - expect.objectContaining({ - code: "ERR_OUT_OF_RANGE", - message: expect.stringContaining('The value of "pledgedSrcSize" is out of range.'), - }), - ); - }); + for (const pledgedSrcSize of [Number.MAX_SAFE_INTEGER + 1, -1, 1.9, Infinity, -Infinity, NaN]) { + rejectTest(`rejects ${pledgedSrcSize}`, () => { + const expected = { name: "RangeError", code: "ERR_OUT_OF_RANGE" }; + assert.throws(() => zlib.createZstdCompress({ pledgedSrcSize }), expected); + assert.throws(() => zlib.zstdCompressSync(input, { pledgedSrcSize }), expected); + }); + } }); + +// Only in Bun: when Node.js runs this file it must not spawn itself again. +if (typeof Bun !== "undefined") { + const { bunEnv, nodeExe } = await import("harness"); + const node = nodeExe(); + + describe("Node.js compatibility", () => { + (node ? test : test.skip)("all tests pass in Node.js", async () => { + // A direct run, not `node --test`: the runner mode forks a second node + // process per file. node:test still exits non-zero on any failure. + await using proc = Bun.spawn({ + cmd: [node!, "--v8-pool-size=1", fileURLToPath(import.meta.url)], + env: { ...bunEnv, UV_THREADPOOL_SIZE: "2" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + assert.deepStrictEqual({ exitCode, output: exitCode === 0 ? "" : stdout + stderr }, { exitCode: 0, output: "" }); + }); + }); +} From 69770768d01cae7f8ab34c1e325135c00c971718 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:52:46 +0000 Subject: [PATCH 5/5] Remove the comment above the pledgedSrcSize validator --- src/runtime/node/zlib/NativeZstd.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/runtime/node/zlib/NativeZstd.rs b/src/runtime/node/zlib/NativeZstd.rs index 274a9415454b..25186b131380 100644 --- a/src/runtime/node/zlib/NativeZstd.rs +++ b/src/runtime/node/zlib/NativeZstd.rs @@ -201,8 +201,6 @@ mod _impl { write_js_callback.with_async_context_if_needed(global), ); - // `u64::MAX` is `ZSTD_CONTENTSIZE_UNKNOWN`. `ZSTD_CCtx_setPledgedSrcSize` - // takes a u64, so the only bound is what a JS number represents exactly. let mut pledged_src_size: u64 = u64::MAX; if pledged_src_size_value.is_number() { pledged_src_size = u64::try_from(validators::validate_integer(