diff --git a/doc/api/zlib.md b/doc/api/zlib.md index 656331d9b08e..e142ef7d899f 100644 --- a/doc/api/zlib.md +++ b/doc/api/zlib.md @@ -2198,6 +2198,15 @@ 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 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`. ## Class: `ZstdOptions` diff --git a/src/node_zlib.cc b/src/node_zlib.cc index 810df1c779fd..a9d2146cc0c2 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. 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 gzip stream with an incomplete member; end the " + "stream or start a new gzip compressor", + "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..84190b32d528 --- /dev/null +++ b/test/parallel/test-zlib-reset-incomplete-output.js @@ -0,0 +1,154 @@ +'use strict'; + +// Tests that reset() refuses to run on gzip once an incomplete member has +// already emitted output. +// +// deflateReset() is equivalent to deflateEnd + deflateInit. gzip writes a +// header on the first write, so those bytes cannot be taken back. +// +// 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'); +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(); + } + }); + }); +} + +test('Gzip reset throws when write has emitted wrapper output', async () => { + const stream = zlib.createGzip(); + 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('Gzip reset throws when flush has emitted incomplete 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); + + stream.reset(); + stream.end(Buffer.from('world')); + + 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 () => { + 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', + ); +});