Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions doc/api/zlib.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
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. 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");
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
138 changes: 138 additions & 0 deletions test/parallel/test-zlib-reset-incomplete-output.js
Original file line number Diff line number Diff line change
@@ -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',
);
});
Loading