diff --git a/src/runtime/node/zlib/NativeBrotli.rs b/src/runtime/node/zlib/NativeBrotli.rs index 9aee4a90d5dd..648eea62faa0 100644 --- a/src/runtime/node/zlib/NativeBrotli.rs +++ b/src/runtime/node/zlib/NativeBrotli.rs @@ -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, }; } diff --git a/test/js/node/zlib/zlib.test.js b/test/js/node/zlib/zlib.test.js index 51f0a51a3ea7..3bb2d42d48d3 100644 --- a/test/js/node/zlib/zlib.test.js +++ b/test/js/node/zlib/zlib.test.js @@ -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"; @@ -323,6 +323,93 @@ describe("zlib.brotli", () => { 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.concurrent.each([ + ["Z_FINISH", zlib.constants.Z_FINISH], + ["Z_BLOCK", zlib.constants.Z_BLOCK], + ])("BrotliDecompress.flush(%s) completes instead of aborting", async (_, kind) => { + 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.concurrent("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.concurrent("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([