Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions doc/api/zlib.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
40 changes: 39 additions & 1 deletion src/node_zlib.cc
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,15 @@ class ZlibContext final : public MemoryRetainer {
unsigned int gzip_id_bytes_read_ = 0;
std::vector<unsigned char> 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_;
};

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand All @@ -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();
}

Expand Down Expand Up @@ -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;
Expand Down
154 changes: 154 additions & 0 deletions test/parallel/test-zlib-reset-incomplete-output.js
Original file line number Diff line number Diff line change
@@ -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',
);
});
Loading