node:zlib: re-resolve writeState buffer on each write instead of storing raw pointer#30118
node:zlib: re-resolve writeState buffer on each write instead of storing raw pointer#30118robobun wants to merge 2 commits into
Conversation
WalkthroughReplace raw-pointer caching of native write-state buffers for zlib/Brotli/Zstd with a JS-cached ChangesWriteState Memory Safety
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
Updated 6:55 AM PT - May 24th, 2026
❌ @robobun, your commit aec7f30 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 30118That installs a local version of the PR into your bun-30118 --bun |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
|
Re the auto-linked issues above — not adding either to the PR description:
This PR fixes a distinct bug: the native handle writing through a stale pointer when user code reaches into |
There was a problem hiding this comment.
No issues found and the fix follows the existing cached-JSValue pattern (writeCallback/dictionary), but this is native memory-safety code on the hot path for every zlib/brotli/zstd write — worth a human pass on the JSC typed-array lifetime reasoning.
Extended reasoning...
Overview
This PR replaces a raw [*]u32 pointer (captured at init() time from the _writeState Uint32Array's backing store) with a GC-visited cached JSValue that is re-resolved to a slice on each write completion. It touches the codegen class definition (zlib.classes.ts), the shared CompressionStream mixin in node_zlib_binding.zig, and the three native handle structs (NativeZlib, NativeBrotli, NativeZstd). A new test file exercises detach-after-init for sync zlib, async zlib, and sync brotli.
Security risks
The PR removes a memory-safety hazard (write through a stale pointer after TypedArray vector migration or ArrayBuffer detachment) and adds input validation where there was previously an unconditional .? unwrap. The new updateWriteResult helper is only invoked on the JS thread (writeSync and runFromJSThread), so re-resolving the JSValue there is safe with respect to GC. I don't see any new risks introduced; the length check (slice.len < 2) makes detached/short buffers a no-op rather than an OOB write.
Level of scrutiny
High. This is native Zig ↔ JSC interop on a path that runs for every node:zlib write, and the correctness argument depends on JSC-specific behavior (FastTypedArray → WastefulTypedArray transitions, asArrayBuffer/asU32() returning empty on detach, codegen values[] GC-visiting semantics). The change is small and pattern-matches the existing writeCallback/errorCallback/dictionary cached values, but a maintainer familiar with Bun's bindings codegen should confirm the lifetime reasoning.
Other factors
The bug-hunting system found no issues. My earlier nit (use test.concurrent) was addressed in e063239. The author reports the full existing zlib test suite and Node compat test-zlib*.js suite still pass. No CODEOWNERS cover these paths. Given the guidelines to not auto-approve changes to critical/memory-safety code paths, I'm deferring rather than approving.
| }; | ||
| if (writeResult.asU32().len < 2) { | ||
| return globalThis.throwInvalidArgumentTypeValue("writeResult", "Uint32Array of length >= 2", arguments[4]); | ||
| } | ||
| const writeCallback = try validators.validateFunction(globalThis, "writeCallback", arguments[5]); | ||
| const dictionary = if (arguments[6].isUndefined()) null else arguments[6].asArrayBuffer(globalThis).?.byteSlice(); |
There was a problem hiding this comment.
🟣 Pre-existing, not introduced by this PR — but the dictionary argument one line down has the identical stale-pointer hazard this PR fixes for writeState: init() captures a raw byteSlice() into the typed array's backing store and stores it long-term in Context.dictionary, which setDictionary() (via reset()) and doWorkInflate() later dereference. js.dictionarySetCached keeps the JSValue GC-rooted but doesn't stop the backing store moving (FastTypedArray→WastefulTypedArray) or being detached via transfer()/postMessage, leaving inflateSetDictionary/deflateSetDictionary reading freed memory. Note the writeState-style fix won't suffice here since doWorkInflate runs on a WorkPool thread — the dictionary bytes need to be copied into native-owned memory at init time. Worth a follow-up since you're already in this function fixing this exact pattern.
Extended reasoning...
What the bug is
NativeZlib.init() at line 86 captures a raw byte slice into the user-supplied dictionary typed array's backing store:
const dictionary = if (arguments[6].isUndefined()) null else arguments[6].asArrayBuffer(globalThis).?.byteSlice();and Context.init() stores it long-term:
this.dictionary = dictionary orelse "";This []const u8 is then dereferenced well after init() returns:
setDictionary()(called fromreset()on the JS thread):c.deflateSetDictionary(&this.state, dict.ptr, @intCast(dict.len))/c.inflateSetDictionary(...).doWorkInflate()(called fromdoWork(), which for asyncwrite()runs on a WorkPool worker thread): when zlib returnsZ_NEED_DICT, it callsc.inflateSetDictionary(&this.state, this.dictionary.ptr, @intCast(this.dictionary.len)).
This is exactly the pattern this PR's description identifies as unsafe for writeState, applied to the adjacent dictionary argument in the same init() function.
Why dictionarySetCached doesn't help
js.dictionarySetCached(this_value, globalThis, arguments[6]) keeps the JSValue GC-rooted, so the typed-array object won't be collected. But, as the PR description explains, that does not stabilize the backing store:
- Accessing
.bufferon a small typed array transitions it FastTypedArray → WastefulTypedArray and moves the vector to a new heap allocation. ArrayBuffer.prototype.transfer()/structuredClone(..., {transfer})/postMessagetransfer detaches the buffer; the storage is now owned elsewhere (or freed once the new owner is collected).
After either, Context.dictionary is a dangling slice. The JS wrapper (src/js/node/zlib.ts) accepts any ArrayBufferView/ArrayBuffer for dictionary and passes it through without copying, so user code fully controls this buffer.
Step-by-step proof
const dict = Buffer.from("some-dictionary-bytes");const z = zlib.createInflate({ dictionary: dict });→NativeZlib.init()runs, capturesdict.byteSlice()intoContext.dictionary, and caches the JSValue viadictionarySetCached.dict.buffer.transfer();→ the ArrayBuffer backingdictis detached. The cached JSValue is still alive (length 0), butContext.dictionary.ptrnow points at freed/repurposed memory.z.write(deflatedDataRequiringDict)→ async path schedulesAsyncJob.runon the WorkPool.- On the worker thread,
doWork()→doWorkInflate()→c.inflate()returnsZ_NEED_DICT→c.inflateSetDictionary(&this.state, this.dictionary.ptr, this.dictionary.len)readsdict.lenbytes through a dangling pointer, off the JS thread.
The same dangling read also occurs on the JS thread via reset() → setDictionary().
Impact
Use-after-free read in zlib's inflateSetDictionary/deflateSetDictionary. Best case: garbage dictionary contents and bogus inflate output / Z_DATA_ERROR. Worst case: read of unmapped memory and a crash, potentially on a worker thread. Same severity class as the writeState bug this PR fixes (that one is a wild write; this one is a wild read).
Fix
The writeState-style fix (cache the JSValue, re-resolve at use time) is not sufficient here, because doWorkInflate() runs off the JS thread and cannot safely call asArrayBuffer(globalThis). Instead, the dictionary bytes should be copied into native-owned memory at init() time (e.g. bun.default_allocator.dupe(u8, ...)), stored in Context.dictionary, and freed in close()/deinit(). That makes the slice independent of the JS heap and removes the need for dictionarySetCached entirely.
This is pre-existing — the PR doesn't touch the dictionary line and doesn't make it worse. Flagging only because it's the same bug class, in the same init() function, with the same root cause the PR description articulates; a small follow-up would close the gap.
There was a problem hiding this comment.
Agreed — noted this while working on the writeState fix. Leaving it out of this PR because the shape of the fix is different: doWorkInflate dereferences dictionary on a WorkPool thread, so re-resolving the JSValue at use time (the approach here) is not available. The dictionary bytes need to be copied into a native-owned buffer in init() and freed in close()/deinit(), which is its own change with its own test. Will follow up separately.
| const writeResult = arguments[1].asArrayBuffer(globalThis) orelse { | ||
| return globalThis.throwInvalidArgumentTypeValue("writeResult", "Uint32Array", arguments[1]); | ||
| }; | ||
| if (writeResult.asU32().len < 2) { | ||
| return globalThis.throwInvalidArgumentTypeValue("writeResult", "Uint32Array of length >= 2", arguments[1]); | ||
| } |
There was a problem hiding this comment.
🟣 Pre-existing, non-blocking: while you're here, there are two more unconditional .? unwraps in these same init() functions — arguments[0].asArrayBuffer(globalThis).?.asU32() for the brotli params (NativeBrotli.zig:92) and arguments[6].asArrayBuffer(globalThis).?.byteSlice() for the zlib dictionary (NativeZlib.zig:86, only undefined is guarded). Both panic on a non-buffer value. They're only reachable via direct _handle.init() misuse (the JS wrapper always passes the right types), so feel free to leave for a follow-up.
Extended reasoning...
What this is
This PR replaces the unconditional .? unwrap on writeResult with proper orelse validation, and the description notes "add the missing argument validation in init() (previously an unconditional .? unwrap)". A few lines below the diff hunk in the same functions, two more unconditional unwraps remain:
- NativeBrotli.zig:92 —
const params_ = arguments[0].asArrayBuffer(globalThis).?.asU32(); - NativeZlib.zig:86 —
const dictionary = if (arguments[6].isUndefined()) null else arguments[6].asArrayBuffer(globalThis).?.byteSlice();(guardsundefinedbut notnull/number/string/etc.)
How it triggers
asArrayBuffer returns null when the value is not an ArrayBufferView, so .? panics on the optional unwrap. Concretely:
const z = require('zlib').createBrotliCompress();
z._handle.init(123, new Uint32Array(2), () => {});
// -> Zig panic: attempt to use null value (params unwrap)
const d = require('zlib').createDeflate();
d._handle.init(15, -1, 8, 0, new Uint32Array(2), () => {}, 123);
// -> Zig panic: attempt to use null value (dictionary unwrap)In safe builds this is a process abort; in release-fast it's UB.
Why this is pre-existing and non-blocking
These lines are not in the diff — the PR neither introduces them nor adds new callers that could pass bad types. _handle.init() is an internal binding called only by Bun's own node:zlib JS wrapper, which always passes a Uint32Array for params and either undefined or a Buffer for dictionary. Triggering this requires deliberately reaching past the public API into _handle and passing garbage; Node's own native bindings also abort on that kind of misuse. So this is hardening of an internal surface, not a user-facing bug, and the PR's actual fix (the stale writeState pointer) is correct and complete on its own.
Addressing the "out of scope" objection
One reasonable view is that this is "while you're here" scope creep: the PR description's "an unconditional .? unwrap" is singular and clearly refers to the specific writeResult line being rewritten, not a general validation pass. That's fair, and is exactly why this is filed as pre-existing / non-blocking rather than a change request — it's just flagging that two siblings of the line you fixed have the same shape, since you've already got the orelse return globalThis.throwInvalidArgumentTypeValue(...) pattern at hand. Perfectly fine to defer to a separate cleanup PR.
Suggested fix (if you choose to take it)
Mirror the new writeResult handling:
// NativeBrotli.zig
const params_buf = arguments[0].asArrayBuffer(globalThis) orelse {
return globalThis.throwInvalidArgumentTypeValue("params", "Uint32Array", arguments[0]);
};
const params_ = params_buf.asU32();
// NativeZlib.zig
const dictionary = if (arguments[6].isUndefinedOrNull()) null else (arguments[6].asArrayBuffer(globalThis) orelse {
return globalThis.throwInvalidArgumentTypeValue("dictionary", "Buffer, TypedArray, or DataView", arguments[6]);
}).byteSlice();NativeZstd.zig already validates its initParamsArray with orelse, so it's only these two.
There was a problem hiding this comment.
Ack — both are pre-existing and only reachable via direct _handle.init() misuse (the JS wrapper always passes correct types). Leaving for a follow-up to keep this change scoped to the writeState stale-pointer fix; the zlib dictionary one will get proper validation as part of the copy-into-native fix mentioned in the other thread.
|
Status (build #57561 @ aec7f30, rebased onto the Rust runtime after #30412):
This needs a maintainer to review/merge — the remaining CI red is an unrelated flake on a lane this PR doesn't touch. |
428b933 to
cf48dc3
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/runtime/node/zlib/NativeBrotli.zig (1)
92-92:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winUnconditional
.?unwrap onparamscan panic if argument is invalid.The
writeResultargument now has proper validation withorelse, butparams_on line 92 still uses.?which will panic ifarguments[0]is not an ArrayBuffer. This is inconsistent with the improved validation pattern.🛡️ Proposed fix to add validation
- const params_ = arguments[0].asArrayBuffer(globalThis).?.asU32(); + const params_ = (arguments[0].asArrayBuffer(globalThis) orelse { + return globalThis.throwInvalidArgumentTypeValue("params", "Uint32Array", arguments[0]); + }).asU32();🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/runtime/node/zlib/NativeBrotli.zig` at line 92, params_ is unconditionally unwrapped with .? which can panic if arguments[0] is not an ArrayBuffer; update the extraction of params_ to mirror the validated pattern used for writeResult by using orelse to return or propagate a clear error (or handle the invalid argument) instead of unwrapping—specifically change the call chain involving arguments[0].asArrayBuffer(globalThis).?.asU32() to use asArrayBuffer(...).orelse(...) and then asU32() (or validate the asU32 result) so the code safely handles invalid or missing arguments without panicking.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/node/zlib/zlib-writestate-detached.test.ts`:
- Around line 112-153: Add a parallel zstd test mirroring the brotli fixture:
create a zstdFixture that uses zlib.createZstdCompress(), captures z._handle,
calls handle.init(params, state, () => {}), performs handle.writeSync(...),
transfers state.buffer and mutates the stolen Uint32Array, then calls writeSync
again and asserts the transferred buffer was not written through; add a
test.concurrent titled like "zstd: writeSync does not write through stale
writeState pointer after detach" that spawns Bun with the zstdFixture and
asserts stdout/stderr/exitCode the same way the brotli test does. Ensure you
reference createZstdCompress, handle.init, handle.writeSync, and
state.buffer.transfer() in the new fixture so it covers NativeZstd the same way
the existing test covers NativeBrotli.
---
Outside diff comments:
In `@src/runtime/node/zlib/NativeBrotli.zig`:
- Line 92: params_ is unconditionally unwrapped with .? which can panic if
arguments[0] is not an ArrayBuffer; update the extraction of params_ to mirror
the validated pattern used for writeResult by using orelse to return or
propagate a clear error (or handle the invalid argument) instead of
unwrapping—specifically change the call chain involving
arguments[0].asArrayBuffer(globalThis).?.asU32() to use
asArrayBuffer(...).orelse(...) and then asU32() (or validate the asU32 result)
so the code safely handles invalid or missing arguments without panicking.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 5705aa2c-c542-40a3-ba33-e9fad654958a
📒 Files selected for processing (6)
src/runtime/api/zlib.classes.tssrc/runtime/node/node_zlib_binding.zigsrc/runtime/node/zlib/NativeBrotli.zigsrc/runtime/node/zlib/NativeZlib.zigsrc/runtime/node/zlib/NativeZstd.zigtest/js/node/zlib/zlib-writestate-detached.test.ts
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/node/zlib/zlib-writestate-detached.test.ts`:
- Around line 124-129: Add the same pre-detach sanity check used in the zlib
sync fixture to the Brotli and Zstd tests: after calling handle.writeSync(0,
inBuf, 0, inBuf.length, outBuf, 0, outBuf.length) but before calling
state.buffer.transfer(), read the shared buffer (e.g., const sanity = new
Uint32Array(state.buffer);) and assert that the writeState was updated (e.g.,
sanity.some(v => v !== 0) or a specific expected value) so the test fails if
writeSync never modified the buffer; apply this insertion for both the Brotli
case around the inBuf/outBuf/writeSync block and the Zstd case at the
corresponding location mentioned in the comment.
- Around line 78-90: The onerror handler currently swallows native errors and
the console.log+process.exit pattern can lose buffered output; update the empty
handle.onerror to immediately write an error message and exit (e.g., write
details about the error and call process.exit(1)), and replace the success
console.log("OK") + process.exit(0) and any other console.log usages in the
async callbacks with process.stdout.write calls that use the callback to call
process.exit only after the write is flushed; keep the existing stolen[]
corruption check inside handle.init's callback and ensure both success and error
paths flush stdout/stderr before exiting.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 71f13417-d1f6-4af9-b1ab-2ab9ad5a5f0a
📒 Files selected for processing (1)
test/js/node/zlib/zlib-writestate-detached.test.ts
There was a problem hiding this comment.
No issues found — the fix looks correct and follows the existing values[] codegen pattern, but since this is native memory-safety code at the JS/native boundary I'd like a human to sign off.
Extended reasoning...
Overview
This PR removes the raw write_result: ?[*]u32 pointer field from NativeZlib/NativeBrotli/NativeZstd and replaces it with a cached JSValue (via the codegen values[] mechanism, alongside the existing writeCallback/errorCallback/dictionary slots). A new updateWriteResult helper in CompressionStream re-resolves the typed array's backing store on each write completion (both runFromJSThread and writeSync, always on the JS thread) and length-checks before writing, turning a detached/migrated buffer into a safe no-op instead of a wild 8-byte write. Each init() gains orelse-based validation in place of the old unconditional .? unwrap. Four new subprocess tests cover zlib sync/async, brotli sync, and zstd sync.
Security risks
The PR fixes a memory-safety hazard (write through a stale pointer into freed/transferred memory). The new code only dereferences the typed-array vector after re-resolving it via asArrayBuffer and checking asU32().len >= 2, and only on the JS thread, so I don't see new UAF or OOB surface introduced. The added writeState entry in values[] is GC-visited by the same generated machinery that already handles writeCallback etc., so it shouldn't introduce a GC hazard.
Level of scrutiny
This is native Zig code at the JavaScriptCore interop boundary, changing how a hot path (every write/writeSync) accesses a JS-owned buffer. The pattern is established and the diff is small and mechanical, but correctness here depends on subtleties of JSC typed-array storage semantics and the codegen's GC-visiting of cached values — the kind of thing a maintainer familiar with Bun's native bindings should confirm.
Other factors
All prior review feedback has been addressed: the test.concurrent nit was applied, zstd test coverage was added, and the two pre-existing issues I flagged (dictionary stale-pointer, remaining .? unwraps) are explicitly out of scope with follow-ups acknowledged. No bugs were found by the bug-hunting system on the current revision. CI's only failure is a known-flaky bake/HMR test unrelated to zlib. None of the touched files have a CODEOWNER.
There was a problem hiding this comment.
I didn't find any issues — the fix is well-scoped, follows the existing values[]/*SetCached pattern, and has solid fail-before/pass-after coverage across all three backends. Deferring only because this is a native memory-safety change on the JSC↔Zig boundary in a hot path (every zlib/brotli/zstd write completion), which is worth a human sign-off.
Extended reasoning...
Overview
This PR fixes a use-after-free / wild-write in node:zlib's native bindings (NativeZlib/NativeBrotli/NativeZstd). Previously, init() captured a raw [*]u32 into the _writeState Uint32Array's backing store and wrote avail_in/avail_out through it after every write. Since a typed array's vector can move (FastTypedArray→WastefulTypedArray) or be freed (detach via transfer()/postMessage), this was a stale-pointer write. The fix drops the write_result: ?[*]u32 field, adds writeState to the codegen values[] list (GC-visited JSValue cache), and re-resolves the backing store via asArrayBuffer().asU32() at write-completion time, length-checked so a detached buffer becomes a safe no-op. It also replaces an unconditional .? unwrap with proper validation.
Files touched: zlib.classes.ts (codegen +1 entry), node_zlib_binding.zig (new updateWriteResult helper, 2 call sites), three Native*.zig files (drop field, validate + cache JSValue in init()), and a new 4-fixture regression test.
Security risks
This is itself a memory-safety hardening — it removes a wild 8-byte write reachable via _handle misuse. The replacement is strictly safer: re-resolution happens only on the JS thread (both runFromJSThread and writeSync), asU32() returns an empty slice for detached buffers, and the len < 2 guard prevents OOB. No new attack surface; the new throwInvalidArgumentTypeValue paths are only reachable via direct _handle.init() misuse. I see no injection, auth, or data-exposure concerns.
Level of scrutiny
Moderate-to-high. The diff is small and mechanical, and the new caching pattern exactly mirrors the existing writeCallback/errorCallback/dictionary handling in the same file, so the risk of regression is low. However, this is native Zig at the JSC boundary, on the hot path of every compression-stream write, and the correctness argument depends on JSC typed-array internals (vector stability, detach semantics). That domain knowledge is exactly where a maintainer's eye adds value over automated review.
Other factors
- All prior review feedback (mine and CodeRabbit's) has been addressed:
test.concurrent, zstd coverage, and pre-detach sanity checks are in. The two open inline threads are explicitly pre-existing/non-blocking follow-ups (dictionary stale-pointer, remaining.?unwraps) that the author has acknowledged for a separate PR. - Test coverage is strong: four subprocess fixtures (zlib sync/async, brotli sync, zstd sync) that demonstrably fail on the unfixed binary (
CORRUPTED 0 400) and pass with the fix; existing zlib suites reportedly pass. - CI: author reports full-matrix green except known unrelated flakes; latest build #51522 in flight at last check.
- No CODEOWNERS conflict apparent for these paths.
…ing raw pointer NativeZlib/NativeBrotli/NativeZstd captured a raw *mut u32 into the _writeState Uint32Array's backing store at init() time and wrote avail_in/avail_out through it after every write()/writeSync(). A typed array's vector is not stable: accessing .buffer migrates a FastTypedArray to a WastefulTypedArray (new allocation), and ArrayBuffer.prototype.transfer() / structuredClone transfer / postMessage detach it entirely. After either, the stored pointer is stale and flush_write_result writes into memory the handle no longer owns — corrupting whoever does, or a use-after-free once that owner is collected. Cache the writeState JSValue on the wrapper (codegen values[]) and re-resolve its backing store in flush_write_result; a detached or undersized buffer becomes a safe no-op.
1266bb9 to
aec7f30
Compare
There was a problem hiding this comment.
No new issues from my side — earlier feedback is all addressed/resolved — but this rewrites how the native write-completion path touches JS-heap-backed memory across all three compression backends, so I'd like a human to sign off on the JSC/typed-array lifetime reasoning.
Extended reasoning...
Overview
This PR fixes a stale-pointer bug in NativeZlib/NativeBrotli/NativeZstd: instead of capturing a raw *mut u32 into the _writeState Uint32Array's backing store at init() time, it caches the JSValue on the wrapper (via the codegen values[] list) and re-resolves it to a slice on every write completion. The write_result field is removed from all three structs, flush_write_result now takes (global, this_value) and length-checks before writing, and the __impl_compression_stream! macro grows a write_state_get_cached accessor. A new test file with four subprocess fixtures (zlib sync/async, brotli sync, zstd sync) verifies that detaching the writeState backing store via .buffer.transfer() no longer results in the native handle scribbling over the transferred memory.
Security risks
The bug being fixed is itself a memory-safety hazard (8-byte wild write through a stale pointer into JS-heap-managed storage). The fix strictly reduces attack surface — the new path re-resolves the typed array on each call, checks byte_len >= 8, and turns a detached buffer into a no-op. I don't see new injection, auth, or data-exposure surface introduced; the only new behavior is that init() now throws ERR_INVALID_ARG_TYPE/ERR_INVALID_ARG_VALUE instead of panicking on a bad writeResult, which is a hardening improvement.
Level of scrutiny
This warrants careful human review: it's native Rust on the hot path of every node:zlib write, and correctness depends on JSC typed-array lifetime semantics (FastTypedArray→WastefulTypedArray vector migration, detach behavior of as_array_buffer/as_u32 returning empty for detached views). The reasoning in the PR description and code comments is thorough and matches my understanding, and the fail-before/pass-after tests are convincing, but I'd rather a human with JSC-heap expertise confirm the re-resolve approach is sound for both call sites (runFromJSThread and writeSync, both JS-thread-only).
Other factors
All prior review threads are resolved: my test.concurrent nit was applied, the zstd fixture and brotli/zstd pre-detach sanity checks were added per CodeRabbit, and my two pre-existing observations (the analogous dictionary stale-slice hazard and the remaining .? unwraps) were explicitly acknowledged as out-of-scope follow-ups (the dictionary one needs a different fix shape since it's dereferenced off-thread). The bug-hunting system found nothing on this revision, and the author reports green CI across the zlib/brotli/zstd suites plus the Node-compat test-zlib*.js set.
|
Superseded by 684f2af (#31606), which landed the same fix on the Rust runtime: the raw |
Problem
NativeZlib/NativeBrotli/NativeZstdcapture a raw*mut u32into the_writeStateUint32Array's backing store atinit()time (write_result: Cell<Option<*mut u32>>) and writeavail_in/avail_outthrough it after everywrite()/writeSync()(flush_write_result).A typed array's vector is not a stable address:
.bufferon a smallUint32Arraymigrates it fromFastTypedArray(inline storage) toWastefulTypedArray(new heap allocation) — the vector moves.ArrayBuffer.prototype.transfer()/structuredClone(..., { transfer })/postMessagetransfer detach the buffer entirely — the storage is now owned by someone else (or freed once they're collected).After either, the stored pointer is stale and
flush_write_resultwrites 8 bytes into memory the handle no longer owns.Repro
Fix
writeStateto the codegenvalues[]list inzlib.classes.tsso theUint32ArrayJSValue is cached on the wrapper (GC-visited), and to thecodegen_cached_accessors!list in the__impl_compression_stream!macro.write_result: Cell<Option<*mut u32>>field from all three structs;init()now stores the JSValue viawrite_state_set_cached(keeping the existing Uint32Array/length validation).flush_write_result(called fromrun_from_js_threadandwrite_sync, always on the JS thread) re-resolves the cached JSValue →as_array_buffer→as_u32()and only writes when the buffer still has ≥ 2 elements, so a detached/migrated_writeStateis a safe no-op instead of a wild write. Abyte_lenguard avoids reinterpreting a detached view's poisoned vector pointer.(Originally implemented against the Zig bindings; rebased onto the Rust runtime after #30412 — the Rust port carried the same raw-pointer pattern, and the old Zig sources are no longer compiled, so the fix now targets
node_zlib_binding.rs/NativeZlib.rs/NativeBrotli.rs/NativeZstd.rsonly.)Verification
New test
test/js/node/zlib/zlib-writestate-detached.test.tscovers zlib sync, zlib async, brotli sync, and zstd sync. Each transfers the writeState backing store to a sentinel-filled buffer and asserts the native handle does not scribble over it.Before fix: all 4 fail (
CORRUPTED 0 400/3fe— the handle writes avail_out/avail_in into the transferred buffer). After fix: all 4 pass. The rest oftest/js/node/zlib/has an identical pass/fail set with and without this change (the only differences are the 4 new tests), andcargo check --workspaceis clean.Note: #28250 and the
pendingInput/pendingOutputrooting added in #31221 address the in/out buffers passed towrite(); this PR addresses thewriteStatebuffer passed toinit()— a separate stale-pointer path.