From 5e511b4d114748b17818ff605e0ad325edf408e5 Mon Sep 17 00:00:00 2001 From: Xia Chao Date: Mon, 21 Sep 2026 20:02:30 +0800 Subject: [PATCH 1/2] zlib: reject reset after gzip/deflate emitted incomplete output deflateReset starts a new member. Bytes already written out cannot be taken back, so gunzip/inflate see a truncated member followed by a new header. Refuse reset in that case. Raw deflate has no wrapper header and is unchanged. Signed-off-by: Xia Chao --- doc/api/zlib.md | 8 + src/node_zlib.cc | 40 ++++- .../test-zlib-reset-incomplete-output.js | 138 ++++++++++++++++++ 3 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-zlib-reset-incomplete-output.js diff --git a/doc/api/zlib.md b/doc/api/zlib.md index 656331d9b08e..559ecc91d129 100644 --- a/doc/api/zlib.md +++ b/doc/api/zlib.md @@ -2198,6 +2198,14 @@ For Zstd streams, cancel the current frame and start a new session while preserving the configured parameters and dictionary. If `pledgedSrcSize` was configured for a Zstd compressor, it applies again to the next frame. +Resetting a gzip or zlib-wrapped deflate stream after it has emitted output +for an incomplete member causes the stream to error with +`ERR_ZLIB_INCOMPLETE_FRAME`. Resetting at that point would discard the +member state while the bytes already written out remain at the start of the +output stream, leaving it undecodable. Call `.end()`, or start over with a +new stream, instead. Raw deflate has no wrapper header, so `reset()` after a +flush still concatenates. + Calling `reset()` while a write is in progress throws an `Error`. ## Class: `ZstdOptions` diff --git a/src/node_zlib.cc b/src/node_zlib.cc index 810df1c779fd..f7aa984b4d71 100644 --- a/src/node_zlib.cc +++ b/src/node_zlib.cc @@ -229,6 +229,15 @@ class ZlibContext final : public MemoryRetainer { unsigned int gzip_id_bytes_read_ = 0; std::vector dictionary_; + // gzip and zlib-wrapped deflate emit a header on the first deflate() call. + // Resetting after those bytes have left the compressor starts a new member + // while the fragment remains, so gunzip/inflate fail with Z_DATA_ERROR. + // Raw deflate has no header; Z_FULL_FLUSH + reset still concatenates. + // A member is complete once deflate() has been called with Z_FINISH and + // returned Z_STREAM_END. + bool stream_complete_ = true; + bool output_emitted_ = false; + z_stream strm_; }; @@ -1088,9 +1097,20 @@ void ZlibContext::DoThreadPoolWork() { switch (mode_) { case DEFLATE: case GZIP: - case DEFLATERAW: + case DEFLATERAW: { + const unsigned out_before = strm_.avail_out; err_ = deflate(&strm_, flush_); + if (out_before > strm_.avail_out) { + output_emitted_ = true; + } + if (err_ == Z_STREAM_END) { + stream_complete_ = true; + output_emitted_ = false; + } else if (err_ == Z_OK || err_ == Z_BUF_ERROR) { + stream_complete_ = false; + } break; + } case UNZIP: if (strm_.avail_in > 0) { next_expected_header_byte = strm_.next_in; @@ -1233,6 +1253,20 @@ CompressionError ZlibContext::GetErrorInfo() const { CompressionError ZlibContext::ResetStream() { + // deflateReset() is deflateEnd + deflateInit: a new stream. Bytes already + // written out cannot be taken back, so refuse reset on wrapper formats + // (gzip / zlib deflate) once an incomplete member has emitted output. + // Unflushed internal state alone is cancelled by deflateReset; raw deflate + // has no wrapper header, so flush+reset still concatenates. + if ((mode_ == GZIP || mode_ == DEFLATE) && !stream_complete_ && + output_emitted_) { + return CompressionError( + "Cannot reset a zlib stream with an incomplete member; end the " + "stream or discard the output produced so far", + "ERR_ZLIB_INCOMPLETE_FRAME", + Z_STREAM_ERROR); + } + bool first_init_call = InitZlib(); if (first_init_call && err_ != Z_OK) { return ErrorForMessage("Failed to init stream before reset"); @@ -1258,6 +1292,8 @@ CompressionError ZlibContext::ResetStream() { if (err_ != Z_OK) return ErrorForMessage("Failed to reset stream"); + stream_complete_ = true; + output_emitted_ = false; return SetDictionary(); } @@ -1302,6 +1338,8 @@ void ZlibContext::Init(int level, flush_ = Z_NO_FLUSH; err_ = Z_OK; + stream_complete_ = true; + output_emitted_ = false; if (mode_ == GZIP || mode_ == GUNZIP) { window_bits_ += 16; diff --git a/test/parallel/test-zlib-reset-incomplete-output.js b/test/parallel/test-zlib-reset-incomplete-output.js new file mode 100644 index 000000000000..f0b846ebf48f --- /dev/null +++ b/test/parallel/test-zlib-reset-incomplete-output.js @@ -0,0 +1,138 @@ +'use strict'; + +// Tests that reset() refuses to run on gzip / zlib-wrapped deflate once an +// incomplete member has already emitted output. +// +// deflateReset() is equivalent to deflateEnd + deflateInit. Bytes that have +// already been written out cannot be taken back, so the next member would be +// appended to that fragment and gunzip/inflate fail with Z_DATA_ERROR. +// +// gzip and zlib deflate emit a header on the first write, so write-then-reset +// is already unsafe. Raw deflate has no wrapper header: a small write may emit +// nothing, and flush+reset still concatenates. + +require('../common'); +const assert = require('assert'); +const { finished } = require('stream/promises'); +const test = require('node:test'); +const zlib = require('zlib'); + +async function writeHello(stream) { + await new Promise((resolve, reject) => { + stream.write(Buffer.from('hello'), (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); +} + +for (const [name, create, decompress] of [ + ['Gzip', zlib.createGzip, zlib.gunzipSync], + ['Deflate', zlib.createDeflate, zlib.inflateSync], +]) { + test(`${name} reset throws when write has emitted wrapper output`, + async () => { + const stream = create(); + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + + await writeHello(stream); + assert.ok(Buffer.concat(chunks).length > 0); + + stream.reset(); + stream.end(Buffer.from('world')); + + await assert.rejects(finished(stream), { + code: 'ERR_ZLIB_INCOMPLETE_FRAME', + }); + }); + + test(`${name} reset throws when flush has emitted incomplete output`, + async () => { + const stream = create(); + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + + stream.write(Buffer.from('hello')); + await new Promise((resolve) => stream.flush(resolve)); + assert.ok(Buffer.concat(chunks).length > 0); + + stream.reset(); + stream.end(Buffer.from('world')); + + await assert.rejects(finished(stream), { + code: 'ERR_ZLIB_INCOMPLETE_FRAME', + }); + }); + + test(`${name} flush followed by end still produces a valid stream`, + async () => { + const stream = create(); + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + + stream.write(Buffer.from('hello')); + await new Promise((resolve) => stream.flush(resolve)); + stream.end(Buffer.from('world')); + await finished(stream); + + assert.strictEqual( + decompress(Buffer.concat(chunks)).toString(), + 'helloworld', + ); + }); + + test(`${name} reset before any write still works`, async () => { + const stream = create(); + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + + stream.reset(); + stream.end(Buffer.from('hello')); + await finished(stream); + + assert.strictEqual( + decompress(Buffer.concat(chunks)).toString(), + 'hello', + ); + }); +} + +test('DeflateRaw reset after write without emitted output still works', + async () => { + const stream = zlib.createDeflateRaw(); + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + + await writeHello(stream); + assert.strictEqual(Buffer.concat(chunks).length, 0); + + stream.reset(); + stream.end(Buffer.from('world')); + await finished(stream); + + assert.strictEqual( + zlib.inflateRawSync(Buffer.concat(chunks)).toString(), + 'world', + ); + }); + +test('DeflateRaw flush followed by reset still concatenates', async () => { + const stream = zlib.createDeflateRaw(); + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + + stream.write(Buffer.from('hello')); + await new Promise((resolve) => stream.flush(resolve)); + stream.reset(); + stream.end(Buffer.from('world')); + await finished(stream); + + assert.strictEqual( + zlib.inflateRawSync(Buffer.concat(chunks)).toString(), + 'helloworld', + ); +}); From 96593b0f7bcc30155298eea3d6f81d3c407dabdd Mon Sep 17 00:00:00 2001 From: Xia Chao Date: Mon, 21 Sep 2026 20:49:27 +0800 Subject: [PATCH 2/2] zlib: refuse incomplete-member reset only for gzip zlib-wrapped deflate still allows reset after flush. The dictionary test discards the first member and reuses the compressor. Signed-off-by: Xia Chao --- doc/api/zlib.md | 15 +- src/node_zlib.cc | 18 +- .../test-zlib-reset-incomplete-output.js | 156 ++++++++++-------- 3 files changed, 103 insertions(+), 86 deletions(-) diff --git a/doc/api/zlib.md b/doc/api/zlib.md index 559ecc91d129..e142ef7d899f 100644 --- a/doc/api/zlib.md +++ b/doc/api/zlib.md @@ -2198,13 +2198,14 @@ For Zstd streams, cancel the current frame and start a new session while preserving the configured parameters and dictionary. If `pledgedSrcSize` was configured for a Zstd compressor, it applies again to the next frame. -Resetting a gzip or zlib-wrapped deflate stream after it has emitted output -for an incomplete member causes the stream to error with -`ERR_ZLIB_INCOMPLETE_FRAME`. Resetting at that point would discard the -member state while the bytes already written out remain at the start of the -output stream, leaving it undecodable. Call `.end()`, or start over with a -new stream, instead. Raw deflate has no wrapper header, so `reset()` after a -flush still concatenates. +Resetting a gzip stream after it has emitted output for an incomplete member +causes the stream to error with `ERR_ZLIB_INCOMPLETE_FRAME`. Resetting at +that point would discard the member state while the bytes already written out +remain at the start of the output stream, leaving it undecodable. Call +`.end()`, or start over with a new gzip stream, instead. +zlib-wrapped deflate may still `reset()` after a flush; callers that reuse +the compressor discard the first output. Raw deflate has no wrapper header, +so `reset()` after a flush still concatenates. Calling `reset()` while a write is in progress throws an `Error`. diff --git a/src/node_zlib.cc b/src/node_zlib.cc index f7aa984b4d71..a9d2146cc0c2 100644 --- a/src/node_zlib.cc +++ b/src/node_zlib.cc @@ -1253,16 +1253,16 @@ CompressionError ZlibContext::GetErrorInfo() const { CompressionError ZlibContext::ResetStream() { - // deflateReset() is deflateEnd + deflateInit: a new stream. Bytes already - // written out cannot be taken back, so refuse reset on wrapper formats - // (gzip / zlib deflate) once an incomplete member has emitted output. - // Unflushed internal state alone is cancelled by deflateReset; raw deflate - // has no wrapper header, so flush+reset still concatenates. - if ((mode_ == GZIP || mode_ == DEFLATE) && !stream_complete_ && - output_emitted_) { + // deflateReset() is deflateEnd + deflateInit: a new stream. gzip emits a + // wrapper header on the first write; those bytes cannot be taken back, so + // refuse reset once an incomplete gzip member has emitted output. + // zlib-wrapped deflate still allows reset after flush: callers discard the + // first member (test-zlib-dictionary.js). Raw deflate has no wrapper header, + // so flush+reset still concatenates. + if (mode_ == GZIP && !stream_complete_ && output_emitted_) { return CompressionError( - "Cannot reset a zlib stream with an incomplete member; end the " - "stream or discard the output produced so far", + "Cannot reset a gzip stream with an incomplete member; end the " + "stream or start a new gzip compressor", "ERR_ZLIB_INCOMPLETE_FRAME", Z_STREAM_ERROR); } diff --git a/test/parallel/test-zlib-reset-incomplete-output.js b/test/parallel/test-zlib-reset-incomplete-output.js index f0b846ebf48f..84190b32d528 100644 --- a/test/parallel/test-zlib-reset-incomplete-output.js +++ b/test/parallel/test-zlib-reset-incomplete-output.js @@ -1,14 +1,14 @@ 'use strict'; -// Tests that reset() refuses to run on gzip / zlib-wrapped deflate once an -// incomplete member has already emitted output. +// Tests that reset() refuses to run on gzip once an incomplete member has +// already emitted output. // -// deflateReset() is equivalent to deflateEnd + deflateInit. Bytes that have -// already been written out cannot be taken back, so the next member would be -// appended to that fragment and gunzip/inflate fail with Z_DATA_ERROR. +// deflateReset() is equivalent to deflateEnd + deflateInit. gzip writes a +// header on the first write, so those bytes cannot be taken back. // -// gzip and zlib deflate emit a header on the first write, so write-then-reset -// is already unsafe. Raw deflate has no wrapper header: a small write may emit +// zlib-wrapped deflate still allows reset after flush: official +// test-zlib-dictionary.js discards the first member and reuses the +// compressor. Raw deflate has no wrapper header: a small write may emit // nothing, and flush+reset still concatenates. require('../common'); @@ -29,77 +29,93 @@ async function writeHello(stream) { }); } -for (const [name, create, decompress] of [ - ['Gzip', zlib.createGzip, zlib.gunzipSync], - ['Deflate', zlib.createDeflate, zlib.inflateSync], -]) { - test(`${name} reset throws when write has emitted wrapper output`, - async () => { - const stream = create(); - const chunks = []; - stream.on('data', (chunk) => chunks.push(chunk)); - - await writeHello(stream); - assert.ok(Buffer.concat(chunks).length > 0); - - stream.reset(); - stream.end(Buffer.from('world')); - - await assert.rejects(finished(stream), { - code: 'ERR_ZLIB_INCOMPLETE_FRAME', - }); - }); - - test(`${name} reset throws when flush has emitted incomplete output`, - async () => { - const stream = create(); - const chunks = []; - stream.on('data', (chunk) => chunks.push(chunk)); +test('Gzip reset throws when write has emitted wrapper output', async () => { + const stream = zlib.createGzip(); + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); - stream.write(Buffer.from('hello')); - await new Promise((resolve) => stream.flush(resolve)); - assert.ok(Buffer.concat(chunks).length > 0); + await writeHello(stream); + assert.ok(Buffer.concat(chunks).length > 0); - stream.reset(); - stream.end(Buffer.from('world')); + stream.reset(); + stream.end(Buffer.from('world')); - await assert.rejects(finished(stream), { - code: 'ERR_ZLIB_INCOMPLETE_FRAME', - }); - }); + await assert.rejects(finished(stream), { + code: 'ERR_ZLIB_INCOMPLETE_FRAME', + }); +}); - test(`${name} flush followed by end still produces a valid stream`, - async () => { - const stream = create(); - const chunks = []; - stream.on('data', (chunk) => chunks.push(chunk)); - - stream.write(Buffer.from('hello')); - await new Promise((resolve) => stream.flush(resolve)); - stream.end(Buffer.from('world')); - await finished(stream); - - assert.strictEqual( - decompress(Buffer.concat(chunks)).toString(), - 'helloworld', - ); - }); +test('Gzip reset throws when flush has emitted incomplete output', async () => { + const stream = zlib.createGzip(); + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); - test(`${name} reset before any write still works`, async () => { - const stream = create(); - const chunks = []; - stream.on('data', (chunk) => chunks.push(chunk)); + stream.write(Buffer.from('hello')); + await new Promise((resolve) => stream.flush(resolve)); + assert.ok(Buffer.concat(chunks).length > 0); - stream.reset(); - stream.end(Buffer.from('hello')); - await finished(stream); + stream.reset(); + stream.end(Buffer.from('world')); - assert.strictEqual( - decompress(Buffer.concat(chunks)).toString(), - 'hello', - ); + await assert.rejects(finished(stream), { + code: 'ERR_ZLIB_INCOMPLETE_FRAME', }); -} +}); + +test('Gzip flush followed by end still produces a valid stream', async () => { + const stream = zlib.createGzip(); + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + + stream.write(Buffer.from('hello')); + await new Promise((resolve) => stream.flush(resolve)); + stream.end(Buffer.from('world')); + await finished(stream); + + assert.strictEqual( + zlib.gunzipSync(Buffer.concat(chunks)).toString(), + 'helloworld', + ); +}); + +test('Gzip reset before any write still works', async () => { + const stream = zlib.createGzip(); + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + + stream.reset(); + stream.end(Buffer.from('hello')); + await finished(stream); + + assert.strictEqual( + zlib.gunzipSync(Buffer.concat(chunks)).toString(), + 'hello', + ); +}); + +test('Deflate reset after flush still works when first output is discarded', + async () => { + const stream = zlib.createDeflate(); + const chunks = []; + let take = false; + stream.on('data', (chunk) => { + if (take) { + chunks.push(chunk); + } + }); + + stream.write(Buffer.from('hello')); + await new Promise((resolve) => stream.flush(resolve)); + stream.reset(); + take = true; + stream.end(Buffer.from('world')); + await finished(stream); + + assert.strictEqual( + zlib.inflateSync(Buffer.concat(chunks)).toString(), + 'world', + ); + }); test('DeflateRaw reset after write without emitted output still works', async () => {