Skip to content

zlib: safe FFI bindings via z_stream type invariants - #35319

Closed
robobun wants to merge 10 commits into
mainfrom
farm/7e4a8b34/zlib-safe-bindings
Closed

robobun wants to merge 10 commits into
mainfrom
farm/7e4a8b34/zlib-safe-bindings

Conversation

@robobun

@robobun robobun commented Jul 23, 2026 •

Copy link
Copy Markdown
Collaborator

Requested by @dylan-conway as the proving ground for the *_sys safe-binding pattern.

This is a zero-behavior-change refactor by design. The property being changed, "callers don't write unsafe to reach zlib", is a compile-time type-system property with no runtime observable. test/js/node/zlib/zlib-safe-bindings.test.ts is a behavior-preservation suite: it passes identically on main and on this branch (11/11, 23 expect() calls), which is the proof that the refactor preserved every touched code path. A fail-before test is structurally impossible here; merge on the strength of the diff review and the preservation suite.

What this does

Makes bun_zlib a fully safe public API so no product code writes unsafe to call zlib. The pattern: encode zlib's actual preconditions as type invariants on z_stream (private fields + documented invariants), then the FFI calls that only depend on those invariants become honestly safe fn. This is the template for the other *_sys crates.

The invariant

z_stream now privately owns internal_state, next_in/avail_in, next_out/avail_out, alloc_func/free_func, err_msg, user_data. Three invariants, stated once on the struct:

  • (S1) internal_state is null or points to a live state allocated by zlib for this stream. Enforced by field privacy: only zlib ever writes it.
  • (S2) alloc_func/free_func are null or valid fn ptrs. Already enforced by Option<extern fn>.
  • (B) next_in is null or readable for avail_in bytes; next_out is null or writable for avail_out bytes. Established by unsafe fn set_input/set_output, the two places a caller vouches for a buffer.

Verified against vendor/zlib's inflateStateCheck/deflateStateCheck (which dereference state when non-null, so (S1) is load-bearing) and deflateParams (which calls deflate(Z_BLOCK) internally, so it needs (B) not just (S1)).

bun_zlib_sys

  • raw::{inflateEnd, deflateEnd, inflateReset, deflateReset, deflateBound} declared as safe fn(&mut z_stream, ...): (S1)+(S2) discharge every precondition, and &mut z_stream is ABI-identical to a non-null z_streamp.
  • raw::{inflate, deflate, deflateParams, inflateInit2_, deflateInit2_, inflateSetDictionary, deflateSetDictionary, crc32, compress2} stay unsafe fn; fronted by #[inline(always)] safe methods on z_stream (inflate, deflate, inflate_init2, deflate_init2, deflate_params, inflate_set_dictionary(&[u8]), input() -> &[u8], etc.) and free wrappers (crc32_bytes, compress2_into).
  • posix.rs/win32.rs deleted; shared.rs is the single extern block (the 76-fn win32.rs bindgen dump was dead; nothing imported it).

bun_zlib

  • 4 duplicate unsafe extern "C" blocks (16 raw fns) removed from the public surface.
  • ZlibReaderArrayList/ZlibCompressorArrayList/DeflateEncoder/InflateDecoder route every call through the safe z_stream methods. Control flow and allocator choices preserved exactly.
  • Unused ZlibReader<W> type and its bun_io dependency deleted. Dead Options.method field removed.
  • New compress2_into(&mut [u8], &[u8], level) -> Result<usize, ReturnCode>.

Consumers

file zlib unsafe blocks before after
src/zlib/lib.rs 28 12 (each establishes (B) against a local slice or owned Vec tail)
src/runtime/node/zlib/NativeZlib.rs 19 1
src/crash_handler/lib.rs 1 0
the other 14 consumers 0 0

The one remaining unsafe in NativeZlib::Context::set_buffers is the irreducible one: it vouches that the pinned JS ArrayBuffer backing stores stay valid across the worker-thread do_work. That's a JS-pinning contract, not a zlib call. The UNZIP header sniff and the GUNZIP trailing-member loop now read z_stream::input() instead of raw-dereffing next_in.

Verification

  • test/js/node/zlib/zlib-safe-bindings.test.ts (new): known-answer vectors for every touched path (Bun.gzipSync/gunzipSync/deflateSync/inflateSync, Bun.hash.crc32, node:zlib dictionary inflate/deflate, createUnzip auto-detect with header split across two writes, concatenated gzip members with zero padding, params() mid-stream, reset(), err_msg surfaces). Passes identically on the released bun and this branch.
  • test/js/node/zlib/zlib.test.js (383 tests), zlib-handle-bounds-check.test.ts, fetch-gzip.test.ts, websocket-permessage-deflate.test.ts, the 18413*/17793 fetch-decompress regressions, and the node test-zlib*.js parallel suite all pass on bun bd.
  • bun run rust:check-all clean on all ten targets.
  • cargo clippy clean on bun_zlib_sys/bun_zlib/bun_runtime/bun_crash_handler.
  • Net -533 LOC.

Zero behavior change; #[inline(always)] on every wrapper method keeps the hot paths at the same codegen as the previous direct externs.

Make bun_zlib a fully safe public API by encoding zlib's preconditions
as type invariants on z_stream instead of scattering unsafe blocks at
every call site.

bun_zlib_sys:
- Privatize z_stream.{internal_state, next_in, next_out, avail_in,
  avail_out, alloc_func, free_func, err_msg, user_data}. The single
  type invariant (internal_state is null or zlib-owned; buffer windows
  are null or valid) is documented once on the struct.
- Declare inflateInit2_/deflateInit2_/inflateEnd/deflateEnd/
  inflateReset/deflateReset/deflateBound as safe fn(&mut z_stream) now
  that the invariant discharges their precondition (verified against
  vendor/zlib's inflateStateCheck/deflateStateCheck).
- Keep inflate/deflate/deflateParams/crc32/compress2/setDictionary as
  unsafe in the raw module; front each with an #[inline(always)] safe
  method (z_stream::inflate/deflate/..., z_stream::input()).
- Drop the posix/win32 extern duplication (shared.rs is the single
  source of truth; the per-platform modules just re-export).

bun_zlib:
- Remove the four duplicate extern "C" blocks (now imported from
  bun_zlib_sys::raw) and the 16 raw externs from the public surface.
- Add compress2_into(&mut [u8], &[u8], level) as the safe compress2.
- ZlibReaderArrayList/ZlibCompressorArrayList/DeflateEncoder/
  InflateDecoder route every zlib call through the safe z_stream
  methods; 28 unsafe blocks down to 12, each establishing the buffer
  invariant locally against a borrowed slice or owned Vec tail.
- Delete the unused ZlibReader<W> type and its bun_io dependency.

Consumers:
- NativeZlib::Context: 19 zlib unsafe blocks down to 1 (set_buffers,
  which vouches for the pinned-ArrayBuffer contract across the worker
  thread; the inflate/deflate/init/end/reset/params/setDictionary
  calls themselves are now safe method calls). The UNZIP header sniff
  reads z_stream::input() instead of raw-dereffing next_in.
- crash_handler: compress2 call goes through compress2_into.
- All other consumers were already behind the safe wrapper types and
  are untouched.

Zero behavior change; #[inline(always)] on every wrapper keeps the hot
paths at the same codegen. bun run rust:check-all passes on all ten
targets.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6d38f271-120c-4199-8540-73a98c2b1a48

📥 Commits

Reviewing files that changed from the base of the PR and between eded4f2 and e9abb3f.

📒 Files selected for processing (2)
  • src/runtime/node/node_zlib_binding.rs
  • test/js/node/zlib/zlib-safe-bindings.test.ts

Walkthrough

Changes

The zlib bindings now encapsulate stream state behind Rust methods and accessors. Compression, decompression, native Node integration, buffer safety contracts, crash trace compression, and regression tests were updated to use the new API.

Safe zlib bindings

Layer / File(s) Summary
Encapsulated zlib stream API
src/zlib_sys/...
The zlib system crate replaces split platform modules with crate-root types, encapsulated stream operations, allocator support, accessors, and raw bindings.
Rust zlib streaming implementation
src/zlib/lib.rs, src/zlib/Cargo.toml, src/crash_handler/lib.rs
Readers, compressors, encoders, decoders, CRC handling, and panic-message compression use wrapper methods, slice-based buffers, and typed return codes.
Node native stream integration
src/runtime/node/zlib/NativeZlib.rs
Native initialization, buffering, dictionary handling, gzip parsing, work execution, reset, error reporting, and close operations use the stream API.
Compression buffer safety contract
src/runtime/node/node_zlib_binding.rs, src/runtime/node/zlib/NativeBrotli.rs, src/runtime/node/zlib/NativeZstd.rs
Compression context buffer assignment is now unsafe, documented, and invoked within explicit unsafe boundaries.
Safe-binding behavior validation
test/js/node/zlib/zlib-safe-bindings.test.ts
Tests cover round trips, dictionaries, gzip parsing, concatenated members, parameter changes, reset reuse, and error messages.

Possibly related PRs

  • oven-sh/bun#33708: Both modify zlib streaming behavior around gzip members and decoder state.
  • oven-sh/bun#34751: Both modify buffer pinning and the CompressionContext::set_buffers contract.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: making zlib FFI bindings safe via z_stream invariants.
Description check ✅ Passed The description covers what changed and how it was verified, including invariants, affected areas, and test results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 23, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:14 PM PT - Jul 23rd, 2026

❌ @robobun, your commit e9abb3f has 1 failures in Build #79174 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35319

That installs a local version of the PR into your bun-35319 executable, so you can run:

bun-35319 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Deduplicate zlib/brotli/zstd binding helpers #31996 - Both PRs delete the dead posix.rs/win32.rs split in bun_zlib_sys, remove the unused ZlibReader type and bun_io dependency from bun_zlib, and clean up src/zlib/lib.rs

🤖 Generated with Claude Code

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #31996, though they share some mechanical cleanup. #31996 consolidates helper functions in the node zlib binding layer (validate_mode, parse_write_args, etc.) plus two hardening fixes. This PR restructures the FFI layer itself so callers don't write unsafe to call zlib: it encodes zlib's preconditions as z_stream type invariants and fronts the externs with safe methods.

The overlap is incidental dead-code removal both PRs needed (the unused posix.rs/win32.rs extern blocks, the unused ZlibReader<W>, the bun_io dep). Whichever lands second takes a trivial merge on those hunks; the actual changes don't touch each other.

Comment thread test/js/node/zlib/zlib-safe-bindings.test.ts Outdated
Comment thread src/zlib_sys/shared.rs Outdated
Comment thread src/zlib/lib.rs
Comment thread src/zlib/lib.rs
…Long in compress2_into, drop dead Options.method, assert Z_NEED_DICT code
Comment thread src/zlib_sys/lib.rs Outdated
Comment thread src/zlib_sys/shared.rs Outdated
Comment thread test/js/node/zlib/zlib-safe-bindings.test.ts
…ases, drop unused zError, assert output in reset() test
@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 8bbc492: the zlib test suites (node/zlib, fetch-gzip, websocket-permessage-deflate, the new zlib-safe-bindings.test.ts) are green on every lane. The red is test/js/bun/spawn/spawn.test.ts on a couple of linux-x64 lanes plus a handful of retried-green flakes (webview-chrome, fs-promises-file-handle-readFile, fs-read-stream-pos), none of which this diff touches. spawn.test.ts has been failing on main independently and is in triage.

Ready for review.

Comment thread src/zlib_sys/shared.rs Outdated
Comment thread src/zlib_sys/shared.rs Outdated
Comment thread src/zlib_sys/shared.rs Outdated
- with_allocator: now unsafe; (S2) requires a matched alloc/free pair
  since zlib defaults zalloc and zfree independently, so half-null is a
  cross-allocator free in *_end().
- data_type: c_int not a 3-variant enum; inflate() writes a bitfield
  (unused bits + 64/128/256) on every return, so the old DataType field
  held an invalid discriminant after every safe inflate() call. Nothing
  read it, but it was the one pub field whose invariant zlib itself
  violates.
- input() -> input_at(i): a raw byte read instead of from_raw_parts, so
  (B)'s "readable" contract suffices and no &[u8] aliasing assertion is
  introduced. Both callers only ever peek bytes 0/1.
Comment thread src/zlib/lib.rs Outdated
Comment thread src/zlib_sys/shared.rs Outdated
Comment thread src/zlib_sys/shared.rs Outdated
Comment thread src/zlib_sys/shared.rs Outdated
Comment thread src/zlib/lib.rs
Comment thread src/runtime/node/zlib/NativeZlib.rs Outdated
robobun added 2 commits July 24, 2026 01:58
- Zlib*ArrayList.list_ptr: privatized; a caller reallocating the Vec
  through the pub field dangled the z_stream's next_out.
- CompressionContext::set_buffers: now unsafe fn (trait + 3 inherent
  impls). The safe-signature version stored raw pointers that outlive
  the slice borrows, so a safe caller could pass stack slices and then
  call safe do_work() on freed memory. The pinning contract is now
  documented on the trait and discharged at the two call sites in
  node_zlib_binding.rs where the ArrayBuffers are actually pinned.
Comment thread test/js/node/zlib/zlib-safe-bindings.test.ts
Comment thread src/runtime/node/node_zlib_binding.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No findings remain — every soundness nit from the earlier rounds (raw::*Init2_ version deref, zError, with_allocator contract, data_type enum validity, cross-kind StateCheck, list_ptr/set_buffers pub launderers, the write_sync SAFETY wording, and the test try/finally) is resolved as of e9abb3f. Deferring to a human because this is the template-setting *_sys safe-binding pattern dylan-conway requested; the invariant design (S1/S2/B) and the safe fn extern choices deserve a maintainer sign-off before other crates copy it.

What was reviewed:

  • The (S1)/(S2)/(B) invariant set against vendored zlib-ng's inflateStateCheck/deflateStateCheck and deflateParams — each safe fn in raw:: is now total given the struct invariants plus the documented cross-kind mode/status disjointness.
  • Every remaining unsafe block in bun_zlib/NativeZlib establishes (B) against a locally-owned buffer or forwards a documented caller contract; no laundering via pub fields remains.
  • The UNZIP header-sniff and GUNZIP trailing-member rewrites preserve the exact byte-index reads and avail_in == 1 early-return of the original raw-pointer code.
  • compress2_into handles the LLP64 uLong truncation case; crash_handler behavior is preserved.
Extended reasoning...

Overview

This PR restructures bun_zlib_sys and bun_zlib so product code no longer writes unsafe to call zlib. It privatizes the z_stream fields that carry FFI preconditions, documents three type invariants (S1: internal_state null-or-zlib-owned; S2: allocator behavioral contract; B: buffer validity), and fronts the extern block with #[inline(always)] safe methods. posix.rs/win32.rs/shared.rs collapse into a single lib.rs; the dead ZlibReader<W>, Options.method, and bun_io dependency are removed. Consumers (ZlibReaderArrayList, ZlibCompressorArrayList, DeflateEncoder/InflateDecoder, NativeZlib::Context, crash_handler) are rewired to the safe surface. CompressionContext::set_buffers becomes unsafe fn across all three backends (zlib/brotli/zstd) with the pinning/call-frame-rooting contract lifted into a # Safety doc. Net −533 LOC across 13 files.

Security risks

None user-facing. This is an internal FFI refactor with no new attack surface — the same zlib calls happen with the same arguments. The soundness-relevant changes (which raw:: externs are safe fn, which struct fields stay pub) were the focus of the prior review rounds; each hole raised (garbage version pointer, data_type enum out-of-range discriminant, allocator misbehavior via safe with_allocator, cross-kind inflate_end on a deflate state, list_ptr/set_buffers launderers) has been closed by making the entry point unsafe, privatizing the field, or documenting the vendored zlib-ng defense that discharges it.

Level of scrutiny

High. This is memory-safety-adjacent FFI code on a hot path (every node:zlib operation, Bun.gzipSync, fetch decompression, WebSocket permessage-deflate), and — per the PR description — the explicit template for the other *_sys crates. The invariant design and the safe fn boundary choices will be copied, so a maintainer should ratify the pattern before it propagates. The five-round back-and-forth on this PR already tightened several of those boundaries; the current state looks sound to me, but the architectural sign-off belongs to a human.

Other factors

CI on the latest push is green on all zlib-adjacent suites (zlib.test.js 383 tests, fetch-gzip, websocket-permessage-deflate, the new behavior-preservation suite); the only red is unrelated spawn.test.ts flake also failing on main. The behavior-preservation test file is well-constructed (known-answer vectors, split-header UNZIP, concatenated gzip members, params() mid-stream, reset(), dictionary Adler-32 mismatch) and the earlier bare-.toThrow() / cleanup-after-assert nits are fixed. Every prior inline finding is marked resolved with a corresponding commit.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-24, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants