Skip to content
Draft
1 change: 0 additions & 1 deletion src/js/builtins.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,6 @@ declare function $ERR_INSPECTOR_COMMAND(message: string): Error;
declare function $ERR_WORKER_UNSUPPORTED_OPERATION(message: string): TypeError;
declare function $ERR_SERVER_NOT_RUNNING(): Error;
declare function $ERR_SOCKET_BAD_TYPE(): Error;
declare function $ERR_ZLIB_INITIALIZATION_FAILED(): Error;
declare function $ERR_IPC_ONE_PIPE(): Error;
declare function $ERR_SOCKET_ALREADY_BOUND(): Error;
declare function $ERR_SOCKET_BAD_BUFFER_SIZE(): Error;
Expand Down
4 changes: 1 addition & 3 deletions src/js/internal/streams/iter/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,7 @@ function createBrotliHandle(mode, options, processCallback, onError) {
}

handle.onerror = onError;
if (!handle.init(brotliInitParamsArray, writeState, processCallback, dictionary)) {
throw $ERR_ZLIB_INITIALIZATION_FAILED();
}
handle.init(brotliInitParamsArray, writeState, processCallback, dictionary);

return { __proto__: null, handle, writeState, chunkSize };
}
Expand Down
4 changes: 1 addition & 3 deletions src/js/node/zlib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -785,9 +785,7 @@ function Brotli(opts, mode) {
const handle = new NativeBrotli(mode);

this._writeState = new Uint32Array(2);
if (!handle.init(brotliInitParamsArray, this._writeState, processCallback, dictionary)) {
throw $ERR_ZLIB_INITIALIZATION_FAILED();
}
handle.init(brotliInitParamsArray, this._writeState, processCallback, dictionary);

ZlibBase.$apply(this, [opts, mode, handle, brotliDefaultOpts]);
}
Expand Down
2 changes: 0 additions & 2 deletions src/jsc/bindings/ErrorCode.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2307,8 +2307,6 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Bun::jsFunctionMakeErrorWithCode, __att
return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_IPC_CHANNEL_CLOSED, "Channel closed."_s));
case ErrorCode::ERR_SOCKET_BAD_TYPE:
return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_SOCKET_BAD_TYPE, "Bad socket type specified. Valid types are: udp4, udp6"_s));
case ErrorCode::ERR_ZLIB_INITIALIZATION_FAILED:
return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_ZLIB_INITIALIZATION_FAILED, "Initialization failed"_s));
case ErrorCode::ERR_IPC_ONE_PIPE:
return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_IPC_ONE_PIPE, "Child process can have only one IPC pipe"_s));
case ErrorCode::ERR_SOCKET_ALREADY_BOUND:
Expand Down
22 changes: 22 additions & 0 deletions src/runtime/node/node_zlib_binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,28 @@ impl<T: CompressionStreamImpl> CompressionStream<T> {
Self::close_internal(this);
}
}

/// Closes the handle before `onerror` runs, so user JS cannot drive a handle that has no context.
/// Throws `message`, or `err`'s own text when `None`.
Comment thread
robobun marked this conversation as resolved.
Resolved
pub(crate) fn init_failed(
this: &T,
global_this: &JSGlobalObject,
this_value: JSValue,
err: Error,
message: Option<&str>,
) -> jsc::JsError {
Self::close_internal(this);
Self::emit_error(this, global_this, this_value, err);
// SAFETY: every caller passes an `is_error()` result, whose `msg` is a NUL-terminated C string.
let own_message = unsafe { bun_core::ffi::cstr(err.msg) }.to_bytes();
let message = message.map_or(own_message, str::as_bytes);
global_this
.err(
ErrorCode::ZLIB_INITIALIZATION_FAILED,
format_args!("{}", bstr::BStr::new(message)),
)
.throw()
}
}

/// Expose the [`CompressionStream<T>`] mixin entry points as inherent
Expand Down
27 changes: 18 additions & 9 deletions src/runtime/node/zlib/NativeBrotli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,10 +276,18 @@ mod _impl {
write_callback.with_async_context_if_needed(global_this),
);

// node's `BrotliCompressionStream::Init` throws this text for every failure.
const MESSAGE: Option<&str> = Some("Initialization failed");

let mut err = self.stream.with_mut(|s| s.init(dictionary));
if err.is_error() {
CompressionStream::<Self>::emit_error(self, global_this, this_value, err);
return Ok(JSValue::FALSE);
return Err(CompressionStream::<Self>::init_failed(
self,
global_this,
this_value,
err,
MESSAGE,
));
}

let params_ = params_buf.as_u32();
Expand All @@ -293,15 +301,16 @@ mod _impl {
.stream
.with_mut(|s| s.set_params(u32::try_from(i).expect("int cast") as c_uint, d));
if err.is_error() {
// impl.emitError(this, globalThis, this_value, err); //XXX: onerror isn't set yet
self.stream.with_mut(|s| s.close());
// The Context is torn down (`mode` is `NONE`); reject any
// further operation the way `close()` does.
self.closed.set(true);
return Ok(JSValue::FALSE);
return Err(CompressionStream::<Self>::init_failed(
self,
global_this,
this_value,
err,
MESSAGE,
));
}
}
Ok(JSValue::TRUE)
Ok(JSValue::UNDEFINED)
}

#[bun_jsc::host_fn(method)]
Expand Down
22 changes: 7 additions & 15 deletions src/runtime/node/zlib/NativeZstd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,9 @@ mod _impl {
.stream
.with_mut(|s| s.init(pledged_src_size, dictionary));
if err.is_error() {
CompressionStream::<Self>::emit_error(self, global, this_value, err);
return Ok(JSValue::FALSE);
return Err(CompressionStream::<Self>::init_failed(
self, global, this_value, err, None,
));
}

let Some(mut params_) = init_params_array_value.as_array_buffer(global) else {
Expand All @@ -261,22 +262,13 @@ mod _impl {
.stream
.with_mut(|s| s.set_params(c_uint::try_from(i).expect("int cast"), x));
if err_.is_error() {
self.stream.with_mut(|s| s.close());
// The Context is torn down (`mode` is `NONE`); reject any
// further operation the way `close()` does.
self.closed.set(true);
// SAFETY: is_error() ⇔ msg is non-null; it points at a NUL-terminated C string.
let msg = unsafe { bun_core::ffi::cstr(err_.msg) }.to_bytes();
return Err(global
.err(
jsc::ErrorCode::ZLIB_INITIALIZATION_FAILED,
format_args!("{}", bstr::BStr::new(msg)),
)
.throw());
return Err(CompressionStream::<Self>::init_failed(
self, global, this_value, err_, None,
));
}
}

Ok(JSValue::TRUE)
Ok(JSValue::UNDEFINED)
}

#[bun_jsc::host_fn(method)]
Expand Down
8 changes: 4 additions & 4 deletions test/js/node/zlib/zlib-handle-bounds-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,10 +338,10 @@ describe.concurrent("zlib native handle driven outside the zlib.ts lifecycle", (
`const C = zlib.createBrotliCompress()._handle.constructor;
const h = new C(8);
const p = new Uint32Array(50).fill(0xffffffff); p[49] = 0;
const r = h.init(p, new Uint32Array(2), () => {});
try { h.writeSync(0, null, 0, 0, new Uint8Array(64), 0, 64); console.log("handled " + r); }
catch (e) { console.log("threw " + e.code + ": " + e.message + " " + r); }`,
"threw ERR_INVALID_STATE: zlib binding closed false",
try { h.init(p, new Uint32Array(2), () => {}); } catch {}
Comment thread
robobun marked this conversation as resolved.
Resolved
try { h.writeSync(0, null, 0, 0, new Uint8Array(64), 0, 64); console.log("handled"); }
catch (e) { console.log("threw " + e.code + ": " + e.message); }`,
CLOSED,
],
[
"zstd: a handle whose init() parameters were rejected is closed",
Expand Down
73 changes: 71 additions & 2 deletions 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 { describe, expect, it, jest } from "bun:test";
import { bunEnv, bunExe, tmpdirSync } from "harness";
import * as buffer from "node:buffer";
import { randomFillSync } from "node:crypto";
import * as fs from "node:fs";
Expand Down Expand Up @@ -662,6 +662,75 @@ describe("zlib.zstd", () => {
expect(zlib.zstdDecompressSync(Buffer.concat([f1, f2])).toString()).toBe("first\nsecond\n");
});

describe("dictionary that fails to load", () => {
// Starts with the zstd dictionary magic (0xEC30A437) but carries no valid
// entropy tables, so ZSTD_DCtx_loadDictionary rejects it. A buffer without
// the magic is treated as a raw content dictionary and always loads.
const malformedDictionary = Buffer.from([0x37, 0xa4, 0x30, 0xec, 1, 2, 3, 4, 5, 6, 7, 8]);
const expectedError = {
name: "Error",
code: "ERR_ZLIB_INITIALIZATION_FAILED",
message: "Failed to load zstd dictionary",
};

it("zstdDecompressSync throws instead of returning an empty buffer", () => {
expect(() => zlib.zstdDecompressSync(compressedBuffer, { dictionary: malformedDictionary })).toThrow(
expect.objectContaining(expectedError),
);
});

it("zstdDecompress throws synchronously like node", () => {
const callback = jest.fn();
expect(() => zlib.zstdDecompress(compressedBuffer, { dictionary: malformedDictionary }, callback)).toThrow(
expect.objectContaining(expectedError),
);
expect(callback).not.toHaveBeenCalled();
});

it("createZstdDecompress throws from the constructor", () => {
expect(() => zlib.createZstdDecompress({ dictionary: malformedDictionary })).toThrow(
expect.objectContaining(expectedError),
);
});

// node:zlib/iter builds its own NativeZstd handle and needs the flag, so it runs in a child.
it("node:zlib/iter decompressZstd and decompressZstdSync throw when the handle is created", async () => {
const script = `
const { from, fromSync, pull, pullSync, bytes, bytesSync } = require("node:stream/iter");
const { decompressZstd, decompressZstdSync } = require("node:zlib/iter");
const dictionary = Buffer.from(${JSON.stringify([...malformedDictionary])});
const compressed = Buffer.from(${JSON.stringify(compressedString)}, "base64");
const show = e => [e.name, e.code, e.message].join("|");
try {
const out = bytesSync(pullSync(fromSync(compressed), decompressZstdSync({ dictionary })));
console.log("sync resolved with", out.byteLength, "bytes");
} catch (e) {
console.log("sync", show(e));
}
bytes(pull(from(compressed), decompressZstd({ dictionary }))).then(
out => console.log("async resolved with", out.byteLength, "bytes"),
e => console.log("async", show(e)),
);
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "--experimental-stream-iter", "-e", script],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const expected = [expectedError.name, expectedError.code, expectedError.message].join("|");
expect(stdout).toBe(`sync ${expected}\nasync ${expected}\n`);
expect(exitCode).toBe(0);
});

it("a raw content dictionary still round-trips", () => {
const dictionary = Buffer.from(inputString.slice(0, 64));
const compressed = zlib.zstdCompressSync(inputString, { dictionary });
expect(zlib.zstdDecompressSync(compressed, { dictionary }).toString()).toBe(inputString);
});
});
Comment thread
robobun marked this conversation as resolved.
Resolved

it("can compress streaming", async () => {
const encoder = zlib.createZstdCompress();
for (const chunk of window(inputString, 55)) {
Expand Down
Loading