Skip to content

Commit f6cd7c6

Browse files
author
bun-unsafe
committed
zlib: reject reset while a zstd frame is incomplete
Resetting a ZstdCompress stream while a frame is still in progress dropped the frame state, but any bytes already written out stayed at the start of the output stream. The next frame was then appended to that fragment, so the resulting stream could not be decompressed. The failure was silent: the compressor reported no error at all. zstd requires internal buffers to be fully flushed before a new compression job starts. Track whether the current frame has completed and throw ERR_ZLIB_INCOMPLETE_FRAME from reset() otherwise. Signed-off-by: bun-unsafe <bun-unsafe@users.noreply.github.com>
1 parent 99f0dde commit f6cd7c6

4 files changed

Lines changed: 108 additions & 8 deletions

File tree

‎doc/api/zlib.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2200,6 +2200,12 @@ configured for a Zstd compressor, it applies again to the next frame.
22002200

22012201
Calling `reset()` while a write is in progress throws an `Error`.
22022202

2203+
Calling `reset()` on a Zstd compressor while a frame is still in progress throws
2204+
an `Error` with the code `ERR_ZLIB_INCOMPLETE_FRAME`. Resetting at that point
2205+
would discard the frame state while the bytes already written out remain at the
2206+
start of the output stream, leaving it undecodable. Call `.flush()` and
2207+
`.end()`, or start over with a new stream, instead.
2208+
22032209
## Class: `ZstdOptions`
22042210

22052211
> Stability: 1 - Experimental

‎src/node_errors.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ void OOMErrorHandler(const char* location, const v8::OOMDetails& details);
133133
V(ERR_VM_MODULE_CACHED_DATA_REJECTED, Error) \
134134
V(ERR_VM_MODULE_LINK_FAILURE, Error) \
135135
V(ERR_WASI_NOT_STARTED, Error) \
136+
V(ERR_ZLIB_INCOMPLETE_FRAME, Error) \
136137
V(ERR_ZLIB_INITIALIZATION_FAILED, Error) \
137138
V(ERR_WORKER_INIT_FAILED, Error) \
138139
V(ERR_PROTO_ACCESS, Error)

‎src/node_zlib.cc‎

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,13 @@ class ZstdCompressContext final : public ZstdContext {
352352

353353
uint64_t pledged_src_size_ = ZSTD_CONTENTSIZE_UNKNOWN;
354354
std::optional<uint64_t> consumed_src_size_;
355+
356+
// Tracks whether the current frame has been fully flushed. A frame is only
357+
// complete once ZSTD_compressStream2() has been called with ZSTD_e_end and
358+
// has returned 0. Resetting while a frame is still in progress silently
359+
// discards the frame state, so any bytes already written out remain as an
360+
// unusable fragment at the start of the output stream.
361+
bool frame_complete_ = true;
355362
};
356363

357364
class ZstdDecompressContext final : public ZstdContext {
@@ -1678,6 +1685,7 @@ CompressionError ZstdCompressContext::Init(uint64_t pledged_src_size,
16781685
std::string_view dictionary,
16791686
bool) {
16801687
pledged_src_size_ = pledged_src_size;
1688+
frame_complete_ = true;
16811689
if (pledged_src_size == ZSTD_CONTENTSIZE_UNKNOWN) {
16821690
consumed_src_size_.reset();
16831691
} else {
@@ -1718,6 +1726,20 @@ CompressionError ZstdCompressContext::Init(uint64_t pledged_src_size,
17181726
}
17191727

17201728
CompressionError ZstdCompressContext::ResetStream() {
1729+
// Resetting drops the state of the frame currently being compressed. If that
1730+
// frame was already partially written out (for example by an earlier
1731+
// flush()), those bytes cannot be discarded and the next frame will be
1732+
// appended to an incomplete frame, producing an unreadable stream. zstd
1733+
// requires internal buffers to be fully flushed before a new compression job
1734+
// starts, so refuse instead of silently corrupting the output.
1735+
if (!frame_complete_) {
1736+
return CompressionError(
1737+
"Cannot reset a zstd stream with an incomplete frame; end the frame "
1738+
"or discard the output produced so far",
1739+
"ERR_ZLIB_INCOMPLETE_FRAME",
1740+
ZSTD_error_stage_wrong);
1741+
}
1742+
17211743
size_t result = ZSTD_CCtx_reset(cctx_.get(), ZSTD_reset_session_only);
17221744
if (ZSTD_isError(result)) {
17231745
const ZSTD_ErrorCode error = ZSTD_getErrorCode(result);
@@ -1755,15 +1777,20 @@ void ZstdCompressContext::DoThreadPoolWork() {
17551777
error_ = ZSTD_getErrorCode(remaining);
17561778
error_code_string_ = ZstdStrerror(error_);
17571779
error_string_ = ZSTD_getErrorString(error_);
1758-
} else if (remaining == 0 && flush_ == ZSTD_e_end &&
1759-
consumed_src_size_.has_value()) {
1760-
uint64_t const consumed_src_size = *consumed_src_size_;
1761-
consumed_src_size_.reset();
1762-
if (consumed_src_size != pledged_src_size_) {
1763-
error_ = ZSTD_error_srcSize_wrong;
1764-
error_code_string_ = ZstdStrerror(error_);
1765-
error_string_ = ZSTD_getErrorString(error_);
1780+
frame_complete_ = false;
1781+
} else if (remaining == 0 && flush_ == ZSTD_e_end) {
1782+
frame_complete_ = true;
1783+
if (consumed_src_size_.has_value()) {
1784+
uint64_t const consumed_src_size = *consumed_src_size_;
1785+
consumed_src_size_.reset();
1786+
if (consumed_src_size != pledged_src_size_) {
1787+
error_ = ZSTD_error_srcSize_wrong;
1788+
error_code_string_ = ZstdStrerror(error_);
1789+
error_string_ = ZSTD_getErrorString(error_);
1790+
}
17661791
}
1792+
} else {
1793+
frame_complete_ = false;
17671794
}
17681795
}
17691796

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
'use strict';
2+
3+
// Tests that reset() refuses to run while a zstd frame is still in progress.
4+
//
5+
// A frame is only complete once ZSTD_compressStream2() has been called with
6+
// ZSTD_e_end and returned 0. Resetting earlier dropped the frame state, and any
7+
// bytes already written out (for example by flush()) stayed in the output
8+
// stream as an unusable fragment. The next frame was then appended to that
9+
// fragment, so the resulting stream could not be decompressed.
10+
11+
require('../common');
12+
const assert = require('assert');
13+
const { finished } = require('stream/promises');
14+
const test = require('node:test');
15+
const zlib = require('zlib');
16+
17+
test('ZstdCompress reset throws when a frame is incomplete', async () => {
18+
const stream = zlib.createZstdCompress();
19+
const chunks = [];
20+
stream.on('data', (chunk) => chunks.push(chunk));
21+
22+
stream.write(Buffer.from('hello'));
23+
await new Promise((resolve) => stream.flush(resolve));
24+
25+
// The frame started by write() is still open, so reset() must refuse
26+
// instead of silently producing a stream that cannot be decoded.
27+
stream.reset();
28+
stream.end(Buffer.from('world'));
29+
30+
await assert.rejects(finished(stream), {
31+
code: 'ERR_ZLIB_INCOMPLETE_FRAME',
32+
});
33+
});
34+
35+
test('ZstdCompress flush followed by end still produces a valid stream',
36+
async () => {
37+
const stream = zlib.createZstdCompress();
38+
const chunks = [];
39+
stream.on('data', (chunk) => chunks.push(chunk));
40+
41+
stream.write(Buffer.from('hello'));
42+
await new Promise((resolve) => stream.flush(resolve));
43+
stream.end(Buffer.from('world'));
44+
await finished(stream);
45+
46+
assert.strictEqual(
47+
zlib.zstdDecompressSync(Buffer.concat(chunks)).toString(),
48+
'helloworld',
49+
);
50+
});
51+
52+
test('ZstdCompress reset before any write still works', async () => {
53+
const stream = zlib.createZstdCompress();
54+
const chunks = [];
55+
stream.on('data', (chunk) => chunks.push(chunk));
56+
57+
// No frame has been started yet, so reset() is allowed.
58+
stream.reset();
59+
stream.end(Buffer.from('hello'));
60+
await finished(stream);
61+
62+
assert.strictEqual(
63+
zlib.zstdDecompressSync(Buffer.concat(chunks)).toString(),
64+
'hello',
65+
);
66+
});

0 commit comments

Comments
 (0)