From 499557bd9652aba0bf5687112207026f13a2549b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:56:41 +0000 Subject: [PATCH 1/3] node:zlib: honour rejectGarbageAfterEnd `options.rejectGarbageAfterEnd` was accepted and ignored. As in Node.js v26.12.1: - ZlibBase validates the option as a boolean and stores it. - processChunkSync and processCallback raise ERR_TRAILING_JUNK_AFTER_STREAM_END when a native write leaves input and has output space. Every decoder goes through one of the two. - NativeZlib takes the flag at init. With the flag it does not read a second gzip member. No result changes for a caller that does not set the option. The zstd decoder keeps no frame state across writes yet. With the option it refuses what one write leaves. Bytes that come in a later write still give ZSTD_error_prefix_unknown, and 1 to 3 bytes of a frame magic number are still accepted. --- src/js/builtins.d.ts | 1 + src/js/node/zlib.ts | 42 +- src/js/private.d.ts | 1 + src/runtime/node/node_zlib_binding.bind.ts | 1 + src/runtime/node/zlib/NativeZlib.rs | 14 +- .../zlib/reject-garbage-after-end.test.ts | 432 ++++++++++++++++++ 6 files changed, 486 insertions(+), 5 deletions(-) create mode 100644 test/js/node/zlib/reject-garbage-after-end.test.ts diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index c6f2aff28d89..a2f61ffa7c30 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -584,6 +584,7 @@ declare function $ERR_STREAM_CANNOT_PIPE(): Error; declare function $ERR_STREAM_WRITE_AFTER_END(): Error; declare function $ERR_STREAM_UNSHIFT_AFTER_END_EVENT(): Error; declare function $ERR_STREAM_PUSH_AFTER_EOF(): Error; +declare function $ERR_TRAILING_JUNK_AFTER_STREAM_END(): TypeError; declare function $ERR_STREAM_UNABLE_TO_PIPE(): Error; declare function $ERR_ILLEGAL_CONSTRUCTOR(): TypeError; declare function $ERR_SERVER_ALREADY_LISTEN(): Error; diff --git a/src/js/node/zlib.ts b/src/js/node/zlib.ts index 026a64098641..b5cb336eb56c 100644 --- a/src/js/node/zlib.ts +++ b/src/js/node/zlib.ts @@ -26,7 +26,12 @@ const kMaxLength = $requireMap.$get("buffer")?.exports.kMaxLength ?? BufferModul const { Transform, finished } = require("node:stream"); const owner_symbol = Symbol("owner_symbol"); -const { checkRangesOrGetDefault, validateFunction, validateFiniteNumber } = require("internal/validators"); +const { + checkRangesOrGetDefault, + validateBoolean, + validateFunction, + validateFiniteNumber, +} = require("internal/validators"); const kFlushFlag = Symbol("kFlushFlag"); const kAfterFlush = Symbol("kAfterFlush"); @@ -173,7 +178,7 @@ interface ZlibHandle extends $Bindgen.CompressionStream { [owner_symbol]: $Node.ZlibBase; /** Set only while a write is on the threadpool. */ buffer: (View & { [kAfterFlush]?: () => void }) | null; - cb: () => void; + cb: (error?: Error) => void; afterWrite?: (() => void) | null; availOutBefore: number; availInBefore: number; @@ -199,6 +204,7 @@ interface ZlibOptions extends Omit<$Node.TransformOptions, "flush" | "encoding"> dictionary?: View | ArrayBuffer | SharedArrayBuffer; info?: boolean; maxOutputLength?: number; + rejectGarbageAfterEnd?: boolean; params?: Record; pledgedSrcSize?: number; } @@ -219,6 +225,7 @@ declare global { _defaultFullFlushFlag: number; _info: boolean | undefined; _maxOutputLength: number; + _rejectGarbageAfterEnd: boolean; reset(): void; flush(kind?: number | (() => void), callback?: () => void): void; close(callback?: () => void): void; @@ -280,6 +287,10 @@ function ZlibBase( // prettier-ignore maxOutputLength = checkRangesOrGetDefault(opts.maxOutputLength, "options.maxOutputLength", 1, kMaxLength, kMaxLength); + if (opts.rejectGarbageAfterEnd !== undefined) { + validateBoolean(opts.rejectGarbageAfterEnd, "options.rejectGarbageAfterEnd"); + } + if (opts.encoding || opts.objectMode || opts.writableObjectMode) { opts = { ...opts }; opts.encoding = null; @@ -304,6 +315,8 @@ function ZlibBase( this._defaultFullFlushFlag = fullFlush; this._info = opts && opts.info; this._maxOutputLength = maxOutputLength; + + this._rejectGarbageAfterEnd = opts?.rejectGarbageAfterEnd === true; } $toClass(ZlibBase, "ZlibBase", Transform); ZlibBase.prototype[kFlushBound] = FLUSH_BOUND[FLUSH_BOUND_IDX_NORMAL]; @@ -518,6 +531,11 @@ function processChunkSync(self: $Node.ZlibBase, chunk: View, flushFlag: number): } } + if (availInAfter > 0 && self._rejectGarbageAfterEnd) { + _close(self); + throw $ERR_TRAILING_JUNK_AFTER_STREAM_END(); + } + self.bytesWritten = inputRead; _close(self); @@ -647,6 +665,15 @@ function processCallback(this: ZlibHandle) { // This applies to streams where we don't check data past the end of // what was consumed; that is, everything except Gunzip/Unzip. + if (self._rejectGarbageAfterEnd) { + const err = $ERR_TRAILING_JUNK_AFTER_STREAM_END(); + self.destroy(err); + // Node.js leaves the chunk on the handle here. + this.buffer = null; + this.cb(err); + return; + } + self.push(null); } @@ -718,7 +745,16 @@ function Zlib(this: $Node.Zlib, opts: ZlibOptions | undefined, mode: number) { const handle = new NativeZlib(mode); this._writeState = new Uint32Array(2); - handle.init(windowBits, level, memLevel, strategy, this._writeState, processCallback, dictionary); + handle.init( + windowBits, + level, + memLevel, + strategy, + this._writeState, + processCallback, + dictionary, + opts?.rejectGarbageAfterEnd === true, + ); ZlibBase.$apply(this, [opts, mode, handle, zlibDefaultOpts]); diff --git a/src/js/private.d.ts b/src/js/private.d.ts index 9805a13b7c82..643a3eb85b01 100644 --- a/src/js/private.d.ts +++ b/src/js/private.d.ts @@ -2127,6 +2127,7 @@ declare namespace $Bindgen { writeResult: Uint32Array, writeCallback: () => void, dictionary: $Node.AnyView | undefined, + rejectGarbageAfterEnd?: boolean, ): void; } interface NativeBrotli extends CompressionStream { diff --git a/src/runtime/node/node_zlib_binding.bind.ts b/src/runtime/node/node_zlib_binding.bind.ts index fb361dbe5819..2f78658ef524 100644 --- a/src/runtime/node/node_zlib_binding.bind.ts +++ b/src/runtime/node/node_zlib_binding.bind.ts @@ -63,6 +63,7 @@ export const NativeZlib = compressionStream( writeState: b.any, processCallback: b.any, dictionary: b.any, + rejectGarbageAfterEnd: b.bool.default(false), }, }, { ...method, args: { level: int32, strategy: int32 } }, diff --git a/src/runtime/node/zlib/NativeZlib.rs b/src/runtime/node/zlib/NativeZlib.rs index 4f9f5618bb77..62d591e11598 100644 --- a/src/runtime/node/zlib/NativeZlib.rs +++ b/src/runtime/node/zlib/NativeZlib.rs @@ -88,6 +88,7 @@ mod _impl { write_state: JsLocal<'_>, process_callback: JsLocal<'_>, dictionary: JsLocal<'_>, + reject_garbage_after_end: bool, ) -> JsResult<()> { let global = cx.global(); let this_value = this_value.to_js(); @@ -162,8 +163,10 @@ mod _impl { write_callback.with_async_context_if_needed(global), ); - self.stream - .with_mut(|s| s.init(level, window_bits, mem_level, strategy, dictionary)); + self.stream.with_mut(|s| { + s.reject_garbage_after_end = reject_garbage_after_end; + s.init(level, window_bits, mem_level, strategy, dictionary) + }); // `Context::init` leaves `mode` at `NONE` when `deflateInit2_` / // `inflateInit2_` rejects its arguments; mark the wrapper closed so // the next operation is rejected instead of re-entering `NONE`. @@ -218,8 +221,13 @@ pub(crate) struct Context { pub flush: c::FlushValue, pub dictionary: Vec, pub(crate) gzip_id_bytes_read: u8, + /// `options.rejectGarbageAfterEnd`: input after the first gzip member stays unconsumed, and `zlib.ts` refuses it. + pub(crate) reject_garbage_after_end: bool, } +// The flag sits in the padding after `gzip_id_bytes_read`. +const _: () = assert!(core::mem::size_of::() == 48); + impl Default for Context { fn default() -> Self { Self { @@ -229,6 +237,7 @@ impl Default for Context { flush: c::FlushValue::NoFlush, dictionary: Vec::new(), gzip_id_bytes_read: 0, + reject_garbage_after_end: false, } } } @@ -506,6 +515,7 @@ impl Context { while self.state.avail_in() > 0 && self.mode == c::NodeMode::GUNZIP && self.err == c::ReturnCode::StreamEnd + && !self.reject_garbage_after_end // SAFETY: avail_in > 0 ⇒ next_in points to ≥1 readable byte. && unsafe { *self.state.next_in() } != 0 { diff --git a/test/js/node/zlib/reject-garbage-after-end.test.ts b/test/js/node/zlib/reject-garbage-after-end.test.ts new file mode 100644 index 000000000000..1a95fa7a5950 --- /dev/null +++ b/test/js/node/zlib/reject-garbage-after-end.test.ts @@ -0,0 +1,432 @@ +// The `rejectGarbageAfterEnd` option of node:zlib. +// This file uses node:test and node:assert only, so Node.js runs it too: +// node --test test/js/node/zlib/reject-garbage-after-end.test.ts +import assert from "node:assert"; +import { describe, test } from "node:test"; +import zlib from "node:zlib"; + +const trailingJunk = { + name: "TypeError", + code: "ERR_TRAILING_JUNK_AFTER_STREAM_END", + message: "Trailing junk found after the end of the compressed stream", +}; +const junk = Buffer.from("JUNK"); + +// The validation cases pass what the declarations of node:zlib do not allow, so the options have their own type here. +interface Options { + rejectGarbageAfterEnd?: unknown; + chunkSize?: number; + finishFlush?: number; + info?: boolean; + maxOutputLength?: number; + windowBits?: number; +} +const reject: Options = { rejectGarbageAfterEnd: true }; +/** The native handle of a stream, with what `zlib.ts` keeps on it. */ +interface Handle { + constructor: new (mode: number) => Handle; + buffer?: Buffer | null; + init(...args: unknown[]): void; + writeSync( + flush: number, + input: Buffer, + inOff: number, + inLen: number, + out: Buffer, + outOff: number, + outLen: number, + ): void; + close(): void; +} +type Stream = zlib.Inflate & { + _handle: Handle | null; + _rejectGarbageAfterEnd: boolean; + _finishFlushFlag: number; + _processChunk(chunk: Buffer, flushFlag: number): Buffer; +}; +interface Decoder { + name: string; + compress(input: string, options?: Options): Buffer; + sync(input: Buffer, options?: Options): Buffer; + callback(input: Buffer, options: Options, callback: (error: Error | null, result: Buffer) => void): void; + create(options?: Options): Stream; + /** True for gzip input: one more member is more of the same stream, unless the option is set. */ + members?: boolean; + /** True for zstd: one more frame is more of the same stream, with the option too. */ + frames?: boolean; +} + +// prettier-ignore +const decoders = [ + { name: "inflate", compress: zlib.deflateSync, sync: zlib.inflateSync, callback: zlib.inflate, create: zlib.createInflate }, + { name: "inflateRaw", compress: zlib.deflateRawSync, sync: zlib.inflateRawSync, callback: zlib.inflateRaw, create: zlib.createInflateRaw }, + { name: "gunzip", compress: zlib.gzipSync, sync: zlib.gunzipSync, callback: zlib.gunzip, create: zlib.createGunzip, members: true }, + { name: "unzip of gzip", compress: zlib.gzipSync, sync: zlib.unzipSync, callback: zlib.unzip, create: zlib.createUnzip, members: true }, + { name: "unzip of deflate", compress: zlib.deflateSync, sync: zlib.unzipSync, callback: zlib.unzip, create: zlib.createUnzip }, + { name: "brotliDecompress", compress: zlib.brotliCompressSync, sync: zlib.brotliDecompressSync, callback: zlib.brotliDecompress, create: zlib.createBrotliDecompress }, + { name: "zstdDecompress", compress: zlib.zstdCompressSync, sync: zlib.zstdDecompressSync, callback: zlib.zstdDecompress, create: zlib.createZstdDecompress, frames: true }, +] as unknown as Decoder[]; + +function viaCallback(decoder: Decoder, input: Buffer, options: Options): Promise { + return new Promise((resolve, reject) => { + decoder.callback(input, options, (error, result) => (error ? reject(error) : resolve(result))); + }); +} + +interface Seen { + output: string; + /** "end", or the code of the error. */ + events: string[]; + /** For each write in turn: "ok", the code of the error its callback got, or "not called". */ + callbacks: string[]; + bytesWritten: number; +} + +/** Gives each input to the stream as one write, ends it with the last, and resolves after 'close' with what the stream did. */ +function viaStream(stream: Stream, inputs: Buffer[]): Promise { + return new Promise(resolve => { + const chunks: Buffer[] = []; + const events: string[] = []; + const callbacks = inputs.map(() => "not called"); + stream.on("data", chunk => chunks.push(chunk)); + stream.on("end", () => events.push("end")); + stream.on("error", (error: NodeJS.ErrnoException) => events.push(String(error.code))); + stream.on("close", () => { + // A write callback that is still to come runs before this. + setImmediate(() => { + resolve({ output: Buffer.concat(chunks).toString(), events, callbacks, bytesWritten: stream.bytesWritten }); + }); + }); + inputs.forEach((input, i) => { + const callback = (error?: NodeJS.ErrnoException | null) => (callbacks[i] = error ? String(error.code) : "ok"); + if (i === inputs.length - 1) stream.end(input, callback); + else stream.write(input, callback); + }); + }); +} + +async function assertRefused(decoder: Decoder, input: Buffer, options: Options) { + assert.throws(() => decoder.sync(input, options), trailingJunk); + await assert.rejects(viaCallback(decoder, input, options), trailingJunk); + assert.deepStrictEqual((await viaStream(decoder.create(options), [input])).events, [trailingJunk.code]); +} + +async function assertDecodes(decoder: Decoder, input: Buffer, options: Options | undefined, expected: string) { + assert.strictEqual(decoder.sync(input, options).toString(), expected); + assert.strictEqual((await viaCallback(decoder, input, options ?? {})).toString(), expected); + const { output, events } = await viaStream(decoder.create(options), [input]); + assert.deepStrictEqual({ output, events }, { output: expected, events: ["end"] }); +} + +// Node.js finds trailing input for zstd in its native decoder, which keeps the state of the frames across writes. +// Bun's zstd decoder keeps none yet (#33413), so for zstd Bun refuses what one write leaves and no more. +// A `todo` below is a zstd case that needs that state. It asserts what Node.js v26.12.1 gives. +const needsFrameState = (decoder: Decoder) => (decoder.frames ? test.todo : test); + +describe("rejectGarbageAfterEnd: true", () => { + for (const decoder of decoders) { + const a = decoder.compress("a"); + const b = decoder.compress("b"); + + describe(decoder.name, () => { + test("decodes input that ends with the stream", async () => { + await assertDecodes(decoder, a, reject, "a"); + }); + + test("refuses bytes that are not a compressed stream", async () => { + await assertRefused(decoder, Buffer.concat([a, junk]), reject); + }); + + test("refuses zero padding", async () => { + await assertRefused(decoder, Buffer.concat([a, Buffer.alloc(4)]), reject); + }); + + test("refuses one more byte", async () => { + await assertRefused(decoder, Buffer.concat([a, Buffer.from([1])]), reject); + }); + + if (decoder.frames) { + test("decodes a second frame, and refuses bytes after it", async () => { + await assertDecodes(decoder, Buffer.concat([a, b]), reject, "ab"); + await assertRefused(decoder, Buffer.concat([a, b, junk]), reject); + }); + } else { + test("refuses a second compressed stream", async () => { + await assertRefused(decoder, Buffer.concat([a, b]), reject); + }); + } + + needsFrameState(decoder)("refuses trailing input after output that fills the chunk exactly", async () => { + const options = { ...reject, chunkSize: 64 }; + const text = Buffer.alloc(64, "z").toString(); + const input = Buffer.concat([decoder.compress(text), junk]); + assert.throws(() => decoder.sync(input, options), trailingJunk); + await assert.rejects(viaCallback(decoder, input, options), trailingJunk); + const { output, events } = await viaStream(decoder.create(options), [input]); + assert.deepStrictEqual({ output, events }, { output: text, events: [trailingJunk.code] }); + }); + + test("refuses trailing input with info: true", () => { + assert.throws(() => decoder.sync(Buffer.concat([a, junk]), { ...reject, info: true }), trailingJunk); + const result = decoder.sync(a, { ...reject, info: true }) as unknown as { buffer: Buffer; engine: Stream }; + assert.strictEqual(result.buffer.toString(), "a"); + assert.strictEqual(result.engine._rejectGarbageAfterEnd, true); + }); + + test("the sync form closes the handle before it throws", () => { + const engine = decoder.create(reject); + assert.throws(() => engine._processChunk(Buffer.concat([a, junk]), engine._finishFlushFlag), trailingJunk); + assert.deepStrictEqual( + { handle: engine._handle, bytesWritten: engine.bytesWritten }, + { handle: null, bytesWritten: 0 }, + ); + }); + + // The JS driver finds the trailing input for zlib and brotli, after it gave out what the write decoded. + // Node's zstd decoder finds it in the native write, and that write gives nothing: no output, no callback, no count. + needsFrameState(decoder)("a stream gives what it decoded, then the error, and no 'end'", async () => { + const inWrite = decoder.frames + ? { output: "", events: [trailingJunk.code], callbacks: ["not called"], bytesWritten: 0 } + : { output: "a", events: [trailingJunk.code], callbacks: [trailingJunk.code], bytesWritten: a.length }; + assert.deepStrictEqual(await viaStream(decoder.create(reject), [Buffer.concat([a, junk])]), inWrite); + + for (const later of [junk, Buffer.alloc(4)]) { + assert.deepStrictEqual(await viaStream(decoder.create(reject), [a, later]), { + output: "a", + events: [trailingJunk.code], + callbacks: ["ok", decoder.frames ? "not called" : trailingJunk.code], + bytesWritten: a.length, + }); + } + }); + + needsFrameState(decoder)("reset() keeps the option", async () => { + const stream = decoder.create(reject); + const chunks: Buffer[] = []; + const events: string[] = []; + stream.on("data", chunk => chunks.push(chunk)); + stream.on("end", () => events.push("end")); + stream.on("error", (error: NodeJS.ErrnoException) => events.push(String(error.code))); + const closed = new Promise(resolve => stream.on("close", resolve)); + await new Promise(resolve => stream.write(a, resolve)); + stream.reset(); + stream.end(Buffer.concat([a, junk])); + await closed; + assert.deepStrictEqual( + { output: Buffer.concat(chunks).toString(), events }, + { output: decoder.frames ? "a" : "aa", events: [trailingJunk.code] }, + ); + }); + }); + } + + // The check is "a write left input and had output space", so it also refuses valid input that a flush stops early. + test("a finishFlush that stops before the end of the input is refused too", () => { + const valid = zlib.deflateSync("hello"); + const stopsEarly = { finishFlush: zlib.constants.Z_BLOCK }; + assert.strictEqual(zlib.inflateSync(valid, stopsEarly).toString(), ""); + assert.throws(() => decoders[0].sync(valid, { ...stopsEarly, ...reject }), trailingJunk); + }); +}); + +describe("rejectGarbageAfterEnd unset or false", () => { + for (const [label, options] of [ + ["no options", undefined], + ["{}", {}], + ["undefined", { rejectGarbageAfterEnd: undefined }], + ["false", { rejectGarbageAfterEnd: false }], + ] as const) { + for (const decoder of decoders) { + const a = decoder.compress("a"); + if (decoder.members) { + test(`${decoder.name} decodes a second member and ignores zero padding (${label})`, async () => { + await assertDecodes(decoder, Buffer.concat([a, decoder.compress("b")]), options, "ab"); + await assertDecodes(decoder, Buffer.concat([a, Buffer.alloc(4)]), options, "a"); + assert.throws(() => decoder.sync(Buffer.concat([a, junk]), options), { code: "Z_DATA_ERROR" }); + }); + } else { + test(`${decoder.name} ignores trailing bytes (${label})`, async () => { + await assertDecodes(decoder, Buffer.concat([a, junk]), options, "a"); + }); + } + } + } +}); + +describe("zstd frames", () => { + const zstd = decoders.find(decoder => decoder.frames)!; + const a = zlib.zstdCompressSync("a"); + const b = zlib.zstdCompressSync("b"); + // A skippable frame: a magic number from 0x184D2A50 to 0x184D2A5F, the size of its content, and the content. + const skippable = (magic: number) => { + const frame = Buffer.alloc(12); + frame.writeUInt32LE(magic, 0); + frame.writeUInt32LE(4, 4); + frame.write("meta", 8); + return frame; + }; + + for (const options of [undefined, reject]) { + const label = options ? "with the option" : "without the option"; + + test(`a skippable frame after a frame is not trailing input (${label})`, async () => { + for (const magic of [0x184d2a50, 0x184d2a5a, 0x184d2a5f]) { + await assertDecodes(zstd, Buffer.concat([a, skippable(magic), b]), options, "ab"); + await assertDecodes(zstd, Buffer.concat([a, skippable(magic)]), options, "a"); + } + }); + + test(`a frame may begin in one write and go on in the next (${label})`, async () => { + for (let split = 1; split < b.length; split++) { + const { output, events } = await viaStream(zstd.create(options), [a, b.subarray(0, split), b.subarray(split)]); + assert.deepStrictEqual({ output, events }, { output: "ab", events: ["end"] }, `split at byte ${split}`); + } + }); + + test(`bytes before the first frame are a zstd error (${label})`, async () => { + assert.throws(() => zstd.sync(junk, options), { name: "Error", code: "ZSTD_error_prefix_unknown" }); + assert.deepStrictEqual((await viaStream(zstd.create(options), [junk, a])).events, ["ZSTD_error_prefix_unknown"]); + }); + } + + test.todo("1 to 3 bytes of a frame magic number after a frame are trailing input", async () => { + for (let length = 1; length <= 3; length++) { + const input = Buffer.concat([a, b.subarray(0, length)]); + await assertRefused(zstd, input, reject); + // The decoder can tell only at the end of the input, so the write before gave the data. + const { output, events } = await viaStream(zstd.create(reject), [a, b.subarray(0, length)]); + assert.deepStrictEqual({ output, events }, { output: "a", events: [trailingJunk.code] }); + } + }); + + test.todo("a frame magic number that a later write breaks is trailing input", async () => { + const { output, events } = await viaStream(zstd.create(reject), [a, b.subarray(0, 2), junk]); + assert.deepStrictEqual({ output, events }, { output: "a", events: [trailingJunk.code] }); + }); + + // Node.js refuses this with or without the option. + test.todo("a frame that is cut after its magic number is an error", async () => { + for (const options of [undefined, reject]) { + for (const length of [4, b.length - 1]) { + const input = Buffer.concat([a, b.subarray(0, length)]); + assert.throws(() => zstd.sync(input, options), { name: "Error", code: "Z_BUF_ERROR" }); + assert.deepStrictEqual((await viaStream(zstd.create(options), [input])).events, ["Z_BUF_ERROR"]); + } + } + }); +}); + +describe("validation", () => { + // prettier-ignore + const classes = [ + zlib.createDeflate, zlib.createInflate, zlib.createDeflateRaw, zlib.createInflateRaw, zlib.createGzip, zlib.createGunzip, + zlib.createUnzip, zlib.createBrotliCompress, zlib.createBrotliDecompress, zlib.createZstdCompress, zlib.createZstdDecompress, + ] as unknown as ((options: Options) => Stream)[]; + + for (const create of classes) { + test(`${create.name} takes a boolean or undefined only`, () => { + for (const [value, received] of [ + [1, "type number (1)"], + [0, "type number (0)"], + ["true", "type string ('true')"], + [null, "null"], + [{}, "an instance of Object"], + ] as const) { + assert.throws(() => create({ rejectGarbageAfterEnd: value }), { + name: "TypeError", + code: "ERR_INVALID_ARG_TYPE", + message: `The "options.rejectGarbageAfterEnd" property must be of type boolean. Received ${received}`, + }); + } + for (const value of [undefined, false, true]) { + const stream = create({ rejectGarbageAfterEnd: value }); + assert.deepStrictEqual(Object.getOwnPropertyDescriptor(stream, "_rejectGarbageAfterEnd"), { + value: value === true, + writable: true, + enumerable: true, + configurable: true, + }); + stream.destroy(); + } + }); + } + + test("the convenience methods validate it", () => { + const invalid = { name: "TypeError", code: "ERR_INVALID_ARG_TYPE" }; + for (const decoder of decoders) { + const a = decoder.compress("a"); + assert.throws(() => decoder.sync(a, { rejectGarbageAfterEnd: "yes" }), invalid); + assert.throws(() => decoder.callback(a, { rejectGarbageAfterEnd: "yes" }, () => {}), invalid); + assert.throws(() => decoder.compress("a", { rejectGarbageAfterEnd: 1 }), invalid); + } + }); + + test("the options checked before it fail first", () => { + const outOfRange = { name: "RangeError", code: "ERR_OUT_OF_RANGE" }; + const invalid: Options = { rejectGarbageAfterEnd: "yes" }; + for (const decoder of decoders) { + assert.throws(() => decoder.create({ ...invalid, chunkSize: 1 }), outOfRange); + assert.throws(() => decoder.create({ ...invalid, maxOutputLength: 0 }), outOfRange); + } + assert.throws(() => decoders[0].create({ ...invalid, windowBits: 99 }), outOfRange); + }); + + test("a compressor takes the option, and its output decodes to the input", () => { + for (const decoder of decoders) { + assert.strictEqual(decoder.sync(decoder.compress("a", reject)).toString(), "a"); + } + }); +}); + +// What Bun does where Node.js does something else, or nothing. Node.js skips these. +(process.versions.bun ? describe : describe.skip)("in Bun", () => { + for (const decoder of decoders.filter(decoder => !decoder.frames)) { + // Node.js leaves the chunk there. + test(`${decoder.name} holds no input on its handle after the error`, async () => { + const stream = decoder.create(reject); + const handle = stream._handle!; + const { events } = await viaStream(stream, [Buffer.concat([decoder.compress("a"), junk])]); + assert.deepStrictEqual({ events, buffer: handle.buffer }, { events: [trailingJunk.code], buffer: null }); + }); + } + + test("the last argument of the native init stops a gzip handle at the end of the first member", () => { + const gunzip = decoders.find(decoder => decoder.name === "gunzip")!.create(); + const NativeZlib = gunzip._handle!.constructor; + gunzip.destroy(); + const b = zlib.gzipSync("b"); + const input = Buffer.concat([zlib.gzipSync("a"), b]); + const decode = (...last: unknown[]) => { + const handle = new NativeZlib(zlib.constants.GUNZIP); + const writeState = new Uint32Array(2); + handle.init(15, 6, 8, 0, writeState, () => {}, undefined, ...last); + const out = Buffer.alloc(16); + handle.writeSync(zlib.constants.Z_FINISH, input, 0, input.length, out, 0, out.length); + handle.close(); + return { output: out.subarray(0, out.length - writeState[0]).toString(), inputLeft: writeState[1] }; + }; + const both = { output: "ab", inputLeft: 0 }; + assert.deepStrictEqual([decode(), decode(undefined), decode(false)], [both, both, both]); + assert.deepStrictEqual(decode(true), { output: "a", inputLeft: b.length }); + // Node.js aborts on a last argument that is no boolean. + for (const value of ["yes", 1, null, {}]) { + assert.throws(() => decode(value), { name: "TypeError", code: "ERR_INVALID_ARG_TYPE" }); + } + }); + + // Node.js refuses each of these (the `todo` cases above). + test("zstd input after a frame that the option does not catch yet", async () => { + const zstd = decoders.find(decoder => decoder.frames)!; + const a = zlib.zstdCompressSync("a"); + const b = zlib.zstdCompressSync("b"); + // Bytes that start no frame are refused with the code of zstd, if they come in a native write of their own. + const unknown = { name: "Error", code: "ZSTD_error_prefix_unknown" }; + assert.deepStrictEqual((await viaStream(zstd.create(reject), [a, junk])).events, [unknown.code]); + const fillsChunk = Buffer.concat([zlib.zstdCompressSync(Buffer.alloc(64, "z")), junk]); + assert.throws(() => zstd.sync(fillsChunk, { ...reject, chunkSize: 64 }), unknown); + // The first bytes of a frame with nothing after them are accepted. + for (const length of [1, 2, 3, 4]) { + await assertDecodes(zstd, Buffer.concat([a, b.subarray(0, length)]), reject, "a"); + } + }); +}); From 810d86515524542d345131a32e0e19d2e4797fce Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:24:07 +0000 Subject: [PATCH 2/3] node:zlib: read rejectGarbageAfterEnd once in ZlibBase ZlibBase read the option in a condition and again in the body of that condition. The lint rule no-duplicate-conditional-property-access does not allow that. ZlibBase now reads the option into a local, validates the local and stores it. --- src/js/node/zlib.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/js/node/zlib.ts b/src/js/node/zlib.ts index b5cb336eb56c..8558fdeeca69 100644 --- a/src/js/node/zlib.ts +++ b/src/js/node/zlib.ts @@ -259,6 +259,7 @@ function ZlibBase( ) { let chunkSize = Z_DEFAULT_CHUNK; let maxOutputLength = kMaxLength; + let rejectGarbageAfterEnd: boolean | undefined; // The ZlibBase class is not exported to user land, the mode should only be passed in by us. $assert(typeof mode === "number"); $assert(mode >= DEFLATE && mode <= ZSTD_DECOMPRESS); @@ -287,8 +288,9 @@ function ZlibBase( // prettier-ignore maxOutputLength = checkRangesOrGetDefault(opts.maxOutputLength, "options.maxOutputLength", 1, kMaxLength, kMaxLength); - if (opts.rejectGarbageAfterEnd !== undefined) { - validateBoolean(opts.rejectGarbageAfterEnd, "options.rejectGarbageAfterEnd"); + rejectGarbageAfterEnd = opts.rejectGarbageAfterEnd; + if (rejectGarbageAfterEnd !== undefined) { + validateBoolean(rejectGarbageAfterEnd, "options.rejectGarbageAfterEnd"); } if (opts.encoding || opts.objectMode || opts.writableObjectMode) { @@ -316,7 +318,7 @@ function ZlibBase( this._info = opts && opts.info; this._maxOutputLength = maxOutputLength; - this._rejectGarbageAfterEnd = opts?.rejectGarbageAfterEnd === true; + this._rejectGarbageAfterEnd = rejectGarbageAfterEnd === true; } $toClass(ZlibBase, "ZlibBase", Transform); ZlibBase.prototype[kFlushBound] = FLUSH_BOUND[FLUSH_BOUND_IDX_NORMAL]; From 36eab9765c45a8ce20d225e7eaee94ceeb5aed6c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:52:35 +0000 Subject: [PATCH 3/3] ci: retrigger