Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
14 changes: 9 additions & 5 deletions src/runtime/node/zlib/NativeBrotli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -415,16 +415,20 @@ mod _impl {
}

pub fn set_flush(&mut self, flush: c_int) {
// Caller passes a valid BrotliEncoderOperation discriminant (Node
// zlib constants 0..=3). Exhaustive match — `Op` is `#[repr(u32)]`
// so the prior `c_int` bit-cast was a width hazard anyway. Out-of-
// range traps.
// The shared `write`/`writeSync` validation accepts the zlib
// flush range (0..=6), so `Z_FINISH` (4) / `Z_BLOCK` (5) /
// `Z_TREES` (6) can reach here. Node stores the raw int; the
// decoder never consumes it and `get_error_info` only compares
// against `Op::finish`, while the brotli encoder treats any
// unknown op as a non-flushing, non-terminal step. Map anything
// outside the brotli operation set to `process` so both paths
// match Node instead of panicking.
self.flush = match flush {
0 => Op::process,
1 => Op::flush,
2 => Op::finish,
3 => Op::emit_metadata,
n => unreachable!("invalid BrotliEncoderOperation {n}"),
_ => Op::process,
};
}

Expand Down
89 changes: 88 additions & 1 deletion test/js/node/zlib/zlib.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { deflateSync, gunzipSync, gzipSync, inflateSync } from "bun";
import { describe, expect, it } from "bun:test";
import { tmpdirSync } from "harness";
import { bunEnv, bunExe, tmpdirSync } from "harness";
import * as buffer from "node:buffer";
import * as fs from "node:fs";
import { resolve } from "node:path";
Expand Down Expand Up @@ -323,6 +323,93 @@
expect(compressed.toString()).toEqual(Buffer.from(compressedString3, "base64").toString());
}
});

// Node validates the native write()/writeSync() flush argument against the
// zlib range (0..=6), so passing a zlib flush constant like Z_FINISH (4) or
// Z_BLOCK (5) to a brotli stream reaches the native set_flush. The Rust
// port mapped only 0..=3 and panicked on the rest; these should instead
// complete the flush and let end() surface the usual Z_BUF_ERROR.
it.each([
["Z_FINISH", zlib.constants.Z_FINISH],
["Z_BLOCK", zlib.constants.Z_BLOCK],
])("BrotliDecompress.flush(%s) completes instead of aborting", async (_, kind) => {

Check warning on line 335 in test/js/node/zlib/zlib.test.js

View check run for this annotation

Claude / Claude Code Review

New subprocess tests not marked concurrent

nit: per test/CLAUDE.md ("Prefer concurrent tests… make them concurrent with `test.concurrent` or `describe.concurrent`") and root CLAUDE.md ("`test.concurrent` for independent subprocess suites"), these three new tests each spawn an independent Bun subprocess with no shared state or ordering dependency — consider `it.concurrent.each` here and `it.concurrent` at lines 359 and 384 so the four spawns run in parallel rather than serially. Sibling subprocess tests in this directory (e.g. `zlib-estim
Comment thread
robobun marked this conversation as resolved.
Outdated
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
const script = `
const z = require("zlib");
const d = z.createBrotliDecompress();
const events = [];
d.on("error", e => {
events.push("err:" + e.code);
console.log(JSON.stringify(events));
});
d.flush(${kind}, () => events.push("flushed"));
d.end();
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
void stderr;
expect(stdout.trim()).toBe(JSON.stringify(["flushed", "err:Z_BUF_ERROR"]));
expect(exitCode).toBe(0);
});

it("BrotliDecompress.flush(Z_FINISH) still decodes valid input", async () => {
const script = `
const z = require("zlib");
const d = z.createBrotliDecompress();
const buf = z.brotliCompressSync(Buffer.from("hello world"));
const out = [];
d.on("data", b => out.push(b));
d.on("error", e => { console.log("err:" + e.code); process.exit(1); });
d.on("end", () => console.log("data:" + Buffer.concat(out).toString()));
d.write(buf);
d.flush(z.constants.Z_FINISH, () => console.log("flushed"));
d.end();
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
void stderr;
expect(stdout.trim().split("\n")).toEqual(["flushed", "data:hello world"]);
expect(exitCode).toBe(0);
});

it("BrotliCompress.flush(Z_FINISH) still produces decodable output", async () => {
// set_flush is shared between encode and decode; on the encode path the
// stored flush op is passed directly to BrotliEncoderCompressStream, so
// the out-of-range mapping must not break the finish sequence.
const script = `
const z = require("zlib");
const c = z.createBrotliCompress();
const chunks = [];
c.on("data", b => chunks.push(b));
c.on("error", e => { console.log("err:" + e.code); process.exit(1); });
c.on("end", () => {
const out = z.brotliDecompressSync(Buffer.concat(chunks));
console.log("roundtrip:" + out.toString());
});
c.write(Buffer.from("hello world"));
c.flush(z.constants.Z_FINISH, () => console.log("flushed"));
c.end();
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
void stderr;
expect(stdout.trim().split("\n")).toEqual(["flushed", "roundtrip:hello world"]);
expect(exitCode).toBe(0);
});
});

it.each([
Expand Down
Loading