diff --git a/src/CLAUDE.md b/src/CLAUDE.md index 770763ea4f..17910ec277 100644 --- a/src/CLAUDE.md +++ b/src/CLAUDE.md @@ -10,3 +10,295 @@ Conventions: - Prefer `@import` at the **bottom** of the file, but the auto formatter will move them so you don't need to worry about it. - **Never** use `@import()` inline inside of functions. **Always** put them at the bottom of the file or containing struct. Imports in Zig are free of side-effects, so there's no such thing as a "dynamic" import. - You must be patient with the build. + +## Prefer Bun APIs over `std` + +**Always use `bun.*` APIs instead of `std.*`.** The `bun` namespace (`@import("bun")`) provides cross-platform wrappers that preserve OS error info and never use `unreachable`. Using `std.fs`, `std.posix`, or `std.os` directly is wrong in this codebase. + +| Instead of | Use | +| ------------------------------------------------------------ | ------------------------------------ | +| `std.fs.File` | `bun.sys.File` | +| `std.fs.cwd()` | `bun.FD.cwd()` | +| `std.posix.open/read/write/stat/mkdir/unlink/rename/symlink` | `bun.sys.*` equivalents | +| `std.fs.path.join/dirname/basename` | `bun.path.join/dirname/basename` | +| `std.mem.eql/indexOf/startsWith` (for strings) | `bun.strings.eql/indexOf/startsWith` | +| `std.posix.O` / `std.posix.mode_t` / `std.posix.fd_t` | `bun.O` / `bun.Mode` / `bun.FD` | +| `std.process.Child` | `bun.spawnSync` | +| `catch bun.outOfMemory()` | `bun.handleOom(...)` | + +## `bun.sys` — System Calls (`src/sys.zig`) + +All return `Maybe(T)` — a tagged union of `.result: T` or `.err: bun.sys.Error`: + +```zig +const fd = switch (bun.sys.open(path, bun.O.RDONLY, 0)) { + .result => |fd| fd, + .err => |err| return .{ .err = err }, +}; +// Or: const fd = try bun.sys.open(path, bun.O.RDONLY, 0).unwrap(); +``` + +Key functions (all take `bun.FileDescriptor`, not `std.posix.fd_t`): + +- `open`, `openat`, `openA` (non-sentinel) → `Maybe(bun.FileDescriptor)` +- `read`, `readAll`, `pread` → `Maybe(usize)` +- `write`, `pwrite`, `writev` → `Maybe(usize)` +- `stat`, `fstat`, `lstat` → `Maybe(bun.Stat)` +- `mkdir`, `unlink`, `rename`, `symlink`, `chmod`, `fchmod`, `fchown` → `Maybe(void)` +- `readlink`, `getFdPath`, `getcwd` → `Maybe` of path slice +- `getFileSize`, `dup`, `sendfile`, `mmap` + +Use `bun.O.RDONLY`, `bun.O.WRONLY | bun.O.CREAT | bun.O.TRUNC`, etc. for open flags. + +### `bun.sys.File` (`src/sys/File.zig`) + +Higher-level file handle wrapping `bun.FileDescriptor`: + +```zig +// One-shot read: open + read + close +const bytes = switch (bun.sys.File.readFrom(bun.FD.cwd(), path, allocator)) { + .result => |b| b, + .err => |err| return .{ .err = err }, +}; + +// One-shot write: open + write + close +switch (bun.sys.File.writeFile(bun.FD.cwd(), path, data)) { + .result => {}, + .err => |err| return .{ .err = err }, +} +``` + +Key methods: + +- `File.open/openat/makeOpen` → `Maybe(File)` (`makeOpen` creates parent dirs) +- `file.read/readAll/write/writeAll` — single or looped I/O +- `file.readToEnd(allocator)` — read entire file into allocated buffer +- `File.readFrom(dir_fd, path, allocator)` — open + read + close +- `File.writeFile(dir_fd, path, data)` — open + write + close +- `file.stat()`, `file.close()`, `file.writer()`, `file.reader()` + +### `bun.FD` (`src/fd.zig`) + +Cross-platform file descriptor. Use `bun.FD.cwd()` for cwd, `bun.invalid_fd` for sentinel, `fd.close()` to close. + +### `bun.sys.Error` (`src/sys/Error.zig`) + +Preserves errno, syscall tag, and file path. Convert to JS: `err.toSystemError().toErrorInstance(globalObject)`. + +## `bun.strings` — String Utilities (`src/string/immutable.zig`) + +SIMD-accelerated string operations. Use instead of `std.mem` for strings. + +```zig +// Searching +strings.indexOf(haystack, needle) // ?usize +strings.contains(haystack, needle) // bool +strings.containsChar(haystack, char) // bool +strings.indexOfChar(haystack, char) // ?u32 +strings.indexOfAny(str, comptime chars) // ?OptionalUsize (SIMD-accelerated) + +// Comparison +strings.eql(a, b) // bool +strings.eqlComptime(str, comptime literal) // bool — optimized +strings.eqlCaseInsensitiveASCII(a, b, comptime true) // 3rd arg = check_len + +// Prefix/Suffix +strings.startsWith(str, prefix) // bool +strings.endsWith(str, suffix) // bool +strings.hasPrefixComptime(str, comptime prefix) // bool — optimized +strings.hasSuffixComptime(str, comptime suffix) // bool — optimized + +// Trimming +strings.trim(str, comptime chars) // strip from both ends +strings.trimSpaces(str) // strip whitespace + +// Encoding conversions +strings.toUTF8Alloc(allocator, utf16) // ![]u8 +strings.toUTF16Alloc(allocator, utf8) // !?[]u16 +strings.toUTF8FromLatin1(allocator, latin1) // !?Managed(u8) +strings.firstNonASCII(slice) // ?u32 +``` + +Bun handles UTF-8, Latin-1, and UTF-16/WTF-16 because JSC uses Latin-1 and UTF-16 internally. Latin-1 is NOT UTF-8 — bytes 128-255 are single chars in Latin-1 but invalid UTF-8. + +### `bun.String` (`src/string.zig`) + +Bridges Zig and JavaScriptCore. Prefer over `ZigString` in new code. + +```zig +const s = bun.String.cloneUTF8(utf8_slice); // copies into WTFStringImpl +const s = bun.String.borrowUTF8(utf8_slice); // no copy, caller keeps alive +const utf8 = s.toUTF8(allocator); // ZigString.Slice +defer utf8.deinit(); +const js_value = s.toJS(globalObject); + +// Create a JS string value directly from UTF-8 bytes: +const js_str = try bun.String.createUTF8ForJS(globalObject, utf8_slice); +``` + +## `bun.path` — Path Manipulation (`src/resolver/resolve_path.zig`) + +Use instead of `std.fs.path`. Platform param: `.auto` (current platform), `.posix`, `.windows`, `.loose` (both separators). + +```zig +// Join paths — uses threadlocal buffer, result must be copied if it needs to persist +bun.path.join(&.{ dir, filename }, .auto) +bun.path.joinZ(&.{ dir, filename }, .auto) // null-terminated + +// Join into a caller-provided buffer +bun.path.joinStringBuf(&buf, &.{ a, b }, .auto) +bun.path.joinStringBufZ(&buf, &.{ a, b }, .auto) // null-terminated + +// Resolve against an absolute base (like Node.js path.resolve) +bun.path.joinAbsString(cwd, &.{ relative_path }, .auto) +bun.path.joinAbsStringBufZ(cwd, &buf, &.{ relative_path }, .auto) + +// Path components +bun.path.dirname(path, .auto) +bun.path.basename(path) + +// Relative path between two absolute paths +bun.path.relative(from, to) +bun.path.relativeAlloc(allocator, from, to) + +// Normalize (resolve `.` and `..`) +bun.path.normalizeBuf(path, &buf, .auto) + +// Null-terminate a path into a buffer +bun.path.z(path, &buf) // returns [:0]const u8 +``` + +Use `bun.PathBuffer` for path buffers: `var buf: bun.PathBuffer = undefined;` + +For pooled path buffers (avoids 64KB stack allocations on Windows): + +```zig +const buf = bun.path_buffer_pool.get(); +defer bun.path_buffer_pool.put(buf); +``` + +## URL Parsing + +Prefer `bun.jsc.URL` (WHATWG-compliant, backed by WebKit C++) over `bun.URL.parse` (internal, doesn't properly handle errors or invalid URLs). + +```zig +// Parse a URL string (returns null if invalid) +const url = bun.jsc.URL.fromUTF8(href_string) orelse return error.InvalidURL; +defer url.deinit(); + +url.protocol() // bun.String +url.pathname() // bun.String +url.search() // bun.String +url.hash() // bun.String (includes leading '#') +url.port() // u32 (maxInt(u32) if not set, otherwise u16 range) + +// NOTE: host/hostname are SWAPPED vs JS: +url.host() // hostname WITHOUT port (opposite of JS!) +url.hostname() // hostname WITH port (opposite of JS!) + +// Normalize a URL string (percent-encode, punycode, etc.) +const normalized = bun.jsc.URL.hrefFromString(bun.String.borrowUTF8(input)); +if (normalized.tag == .Dead) return error.InvalidURL; +defer normalized.deref(); + +// Join base + relative URLs +const joined = bun.jsc.URL.join(base_str, relative_str); +defer joined.deref(); + +// Convert between file paths and file:// URLs +const file_url = bun.jsc.URL.fileURLFromString(path_str); // path → file:// +const file_path = bun.jsc.URL.pathFromFileURL(url_str); // file:// → path +``` + +## MIME Types (`src/http/MimeType.zig`) + +```zig +const MimeType = bun.http.MimeType; + +// Look up by file extension (without leading dot) +const mime = MimeType.byExtension("html"); // MimeType{ .value = "text/html", .category = .html } +const mime = MimeType.byExtensionNoDefault("xyz"); // ?MimeType (null if unknown) + +// Category checks +mime.category // .javascript, .css, .html, .json, .image, .text, .wasm, .font, .video, .audio, ... +mime.category.isCode() +``` + +Common constants: `MimeType.javascript`, `MimeType.json`, `MimeType.html`, `MimeType.css`, `MimeType.text`, `MimeType.wasm`, `MimeType.ico`, `MimeType.other`. + +## Memory & Allocators + +**Use `bun.default_allocator` for almost everything.** It's backed by mimalloc. + +`bun.handleOom(expr)` converts `error.OutOfMemory` into a crash without swallowing other errors: + +```zig +const buf = bun.handleOom(allocator.alloc(u8, size)); // correct +// NOT: allocator.alloc(u8, size) catch bun.outOfMemory() — could swallow non-OOM errors +``` + +## Environment Variables (`src/env_var.zig`) + +Type-safe, cached environment variable accessors via `bun.env_var`: + +```zig +bun.env_var.HOME.get() // ?[]const u8 +bun.env_var.CI.get() // ?bool +bun.env_var.BUN_CONFIG_DNS_TIME_TO_LIVE_SECONDS.get() // u64 (has default: 30) +``` + +## Logging (`src/output.zig`) + +```zig +const log = bun.Output.scoped(.MY_FEATURE, .visible); // .hidden = opt-in via BUN_DEBUG_MY_FEATURE=1 +log("processing {d} items", .{count}); + +// Color output (convenience wrappers auto-detect TTY): +bun.Output.pretty("success: {s}\n", .{msg}); +bun.Output.prettyErrorln("error: {s}", .{msg}); +``` + +## Spawning Subprocesses (`src/bun.js/api/bun/process.zig`) + +Use `bun.spawnSync` instead of `std.process.Child`: + +```zig +switch (bun.spawnSync(&.{ + .argv = argv, + .envp = null, // inherit parent env + .cwd = cwd, + .stdout = .buffer, // capture + .stderr = .inherit, // pass through + .stdin = .ignore, + + .windows = if (bun.Environment.isWindows) .{ + .loop = bun.jsc.EventLoopHandle.init(bun.jsc.MiniEventLoop.initGlobal(env, null)), + }, +}) catch return) { + .err => |err| { /* bun.sys.Error */ }, + .result => |result| { + defer result.deinit(); + const stdout = result.stdout.items; + if (result.status.isOK()) { ... } + }, +} +``` + +Options: `argv: []const []const u8`, `envp: ?[*:null]?[*:0]const u8` (null = inherit), `argv0: ?[*:0]const u8`. Stdio: `.inherit`, `.ignore`, `.buffer`. + +## Common Patterns + +```zig +// Read a file +const contents = switch (bun.sys.File.readFrom(bun.FD.cwd(), path, allocator)) { + .result => |bytes| bytes, + .err => |err| { globalObject.throwValue(err.toSystemError().toErrorInstance(globalObject)); return .zero; }, +}; + +// Create directories recursively +bun.makePath(dir.stdDir(), sub_path) catch |err| { ... }; + +// Hashing +bun.hash(bytes) // u64 — wyhash +bun.hash32(bytes) // u32 +``` diff --git a/src/bun.js/bindings/BunPlugin.cpp b/src/bun.js/bindings/BunPlugin.cpp index c14c8bf33c..d330fba8f4 100644 --- a/src/bun.js/bindings/BunPlugin.cpp +++ b/src/bun.js/bindings/BunPlugin.cpp @@ -954,6 +954,7 @@ BUN_DEFINE_HOST_FUNCTION(jsFunctionBunPluginClear, (JSC::JSGlobalObject * global global->onResolvePlugins.namespaces.clear(); delete global->onLoadPlugins.virtualModules; + global->onLoadPlugins.virtualModules = nullptr; return JSC::JSValue::encode(JSC::jsUndefined()); } diff --git a/src/bun.js/bindings/webcore/SerializedScriptValue.cpp b/src/bun.js/bindings/webcore/SerializedScriptValue.cpp index e1f9b581ea..0b6b3eb28c 100644 --- a/src/bun.js/bindings/webcore/SerializedScriptValue.cpp +++ b/src/bun.js/bindings/webcore/SerializedScriptValue.cpp @@ -5954,16 +5954,14 @@ ExceptionOr> SerializedScriptValue::create(JSGlobalOb auto* data = array->butterfly()->contiguous().data(); if (!containsHole(data, length)) { size_t byteSize = sizeof(JSValue) * length; - Vector buffer(byteSize, 0); - memcpy(buffer.mutableSpan().data(), data, byteSize); + Vector buffer(std::span { reinterpret_cast(data), byteSize }); return SerializedScriptValue::createInt32ArrayFastPath(WTF::move(buffer), length); } } else { auto* data = array->butterfly()->contiguousDouble().data(); if (!containsHole(data, length)) { size_t byteSize = sizeof(double) * length; - Vector buffer(byteSize, 0); - memcpy(buffer.mutableSpan().data(), data, byteSize); + Vector buffer(std::span { reinterpret_cast(data), byteSize }); return SerializedScriptValue::createDoubleArrayFastPath(WTF::move(buffer), length); } } diff --git a/src/cli/test_command.zig b/src/cli/test_command.zig index f00c167296..c2117e6a58 100644 --- a/src/cli/test_command.zig +++ b/src/cli/test_command.zig @@ -948,6 +948,7 @@ pub const CommandLineReporter = struct { this.printSummary(); Output.prettyError("\nBailed out after {d} failure{s}\n", .{ this.jest.bail, if (this.jest.bail == 1) "" else "s" }); Output.flush(); + this.writeJUnitReportIfNeeded(); Global.exit(1); } }, @@ -970,6 +971,20 @@ pub const CommandLineReporter = struct { Output.printStartEnd(bun.start_time, std.time.nanoTimestamp()); } + /// Writes the JUnit reporter output file if a JUnit reporter is active and + /// an outfile path was configured. This must be called before any early exit + /// (e.g. bail) so that the report is not lost. + pub fn writeJUnitReportIfNeeded(this: *CommandLineReporter) void { + if (this.reporters.junit) |junit| { + if (this.jest.test_options.reporter_outfile) |outfile| { + if (junit.current_file.len > 0) { + junit.endTestSuite() catch {}; + } + junit.writeToFile(outfile) catch {}; + } + } + } + pub fn generateCodeCoverage(this: *CommandLineReporter, vm: *jsc.VirtualMachine, opts: *TestCommand.CodeCoverageOptions, comptime reporters: TestCommand.Reporters, comptime enable_ansi_colors: bool) !void { if (comptime !reporters.text and !reporters.lcov) { return; @@ -1772,12 +1787,7 @@ pub const TestCommand = struct { Output.prettyError("\n", .{}); Output.flush(); - if (reporter.reporters.junit) |junit| { - if (junit.current_file.len > 0) { - junit.endTestSuite() catch {}; - } - junit.writeToFile(ctx.test_options.reporter_outfile.?) catch {}; - } + reporter.writeJUnitReportIfNeeded(); if (vm.hot_reload == .watch) { vm.runWithAPILock(jsc.VirtualMachine, vm, runEventLoopForWatch); @@ -1920,6 +1930,7 @@ pub const TestCommand = struct { if (reporter.jest.bail == reporter.summary().fail) { reporter.printSummary(); Output.prettyError("\nBailed out after {d} failure{s}\n", .{ reporter.jest.bail, if (reporter.jest.bail == 1) "" else "s" }); + reporter.writeJUnitReportIfNeeded(); vm.exit_handler.exit_code = 1; vm.is_shutting_down = true; diff --git a/src/http/websocket_client.zig b/src/http/websocket_client.zig index c78210efc4..8be16a5d42 100644 --- a/src/http/websocket_client.zig +++ b/src/http/websocket_client.zig @@ -27,6 +27,7 @@ pub fn NewWebSocketClient(comptime ssl: bool) type { ping_frame_bytes: [128 + 6]u8 = [_]u8{0} ** (128 + 6), ping_len: u8 = 0, ping_received: bool = false, + pong_received: bool = false, close_received: bool = false, close_frame_buffering: bool = false, @@ -120,6 +121,7 @@ pub fn NewWebSocketClient(comptime ssl: bool) type { this.clearReceiveBuffers(true); this.clearSendBuffers(true); this.ping_received = false; + this.pong_received = false; this.ping_len = 0; this.close_frame_buffering = false; this.receive_pending_chunk_len = 0; @@ -136,6 +138,10 @@ pub fn NewWebSocketClient(comptime ssl: bool) type { // Set to null FIRST to prevent re-entrancy (shutdown can trigger callbacks) if (this.proxy_tunnel) |tunnel| { this.proxy_tunnel = null; + // Detach the websocket from the tunnel before shutdown so the + // tunnel's onClose callback doesn't dispatch a spurious 1006 + // after we've already handled a clean close. + tunnel.clearConnectedWebSocket(); tunnel.shutdown(); tunnel.deref(); } @@ -650,14 +656,38 @@ pub fn NewWebSocketClient(comptime ssl: bool) type { if (data.len == 0) break; }, .pong => { - const pong_len = @min(data.len, @min(receive_body_remain, this.ping_frame_bytes.len)); + if (!this.pong_received) { + if (receive_body_remain > 125) { + this.terminate(ErrorCode.invalid_control_frame); + terminated = true; + break; + } + this.ping_len = @truncate(receive_body_remain); + receive_body_remain = 0; + this.pong_received = true; + } + const pong_len = this.ping_len; - this.dispatchData(data[0..pong_len], .Pong); + if (data.len > 0) { + const total_received = @min(pong_len, receive_body_remain + data.len); + const slice = this.ping_frame_bytes[6..][receive_body_remain..total_received]; + @memcpy(slice, data[0..slice.len]); + receive_body_remain = total_received; + data = data[slice.len..]; + } + const pending_body = pong_len - receive_body_remain; + if (pending_body > 0) { + // wait for more data - pong payload is fragmented across TCP segments + break; + } + + const pong_data = this.ping_frame_bytes[6..][0..pong_len]; + this.dispatchData(pong_data, .Pong); - data = data[pong_len..]; receive_state = .need_header; receive_body_remain = 0; receiving_type = last_receive_data_type; + this.pong_received = false; if (data.len == 0) break; }, @@ -884,7 +914,7 @@ pub fn NewWebSocketClient(comptime ssl: bool) type { } fn sendPong(this: *WebSocket, socket: Socket) bool { - if (socket.isClosed() or socket.isShutdown()) { + if (!this.hasTCP()) { this.dispatchAbruptClose(ErrorCode.ended); return false; } @@ -916,14 +946,17 @@ pub fn NewWebSocketClient(comptime ssl: bool) type { body_len: usize, ) void { log("Sending close with code {d}", .{code}); - if (socket.isClosed() or socket.isShutdown()) { + if (!this.hasTCP()) { this.dispatchAbruptClose(ErrorCode.ended); this.clearData(); return; } // we dont wanna shutdownRead when SSL, because SSL handshake can happen when writting + // For tunnel mode, shutdownRead on the detached socket is a no-op; skip it. if (comptime !ssl) { - socket.shutdownRead(); + if (this.proxy_tunnel == null) { + socket.shutdownRead(); + } } var final_body_bytes: [128 + 8]u8 = undefined; var header = @as(WebsocketHeader, @bitCast(@as(u16, 0))); diff --git a/src/http/websocket_client/WebSocketProxyTunnel.zig b/src/http/websocket_client/WebSocketProxyTunnel.zig index 18b6e8c919..be23ce0322 100644 --- a/src/http/websocket_client/WebSocketProxyTunnel.zig +++ b/src/http/websocket_client/WebSocketProxyTunnel.zig @@ -253,6 +253,13 @@ pub fn setConnectedWebSocket(this: *WebSocketProxyTunnel, ws: *WebSocketClient) this.#upgrade_client = .{ .none = {} }; } +/// Clear the connected WebSocket reference. Called before tunnel shutdown during +/// a clean close so the tunnel's onClose callback doesn't dispatch a spurious +/// abrupt close (1006) after the WebSocket has already sent a clean close frame. +pub fn clearConnectedWebSocket(this: *WebSocketProxyTunnel) void { + this.#connected_websocket = null; +} + /// SSLWrapper callback: Called with encrypted data to send to network fn writeEncrypted(this: *WebSocketProxyTunnel, encrypted_data: []const u8) void { log("writeEncrypted: {} bytes", .{encrypted_data.len}); diff --git a/src/ini.zig b/src/ini.zig index 23e8b57380..e15573b082 100644 --- a/src/ini.zig +++ b/src/ini.zig @@ -291,25 +291,32 @@ pub const Parser = struct { } }, else => { - try unesc.appendSlice(switch (bun.strings.utf8ByteSequenceLength(c)) { - 1 => brk: { - break :brk &[_]u8{ '\\', c }; + switch (bun.strings.utf8ByteSequenceLength(c)) { + 0, 1 => try unesc.appendSlice(&[_]u8{ '\\', c }), + 2 => if (val.len - i >= 2) { + try unesc.appendSlice(&[_]u8{ '\\', c, val[i + 1] }); + i += 1; + } else { + try unesc.appendSlice(&[_]u8{ '\\', c }); }, - 2 => brk: { - defer i += 1; - break :brk &[_]u8{ '\\', c, val[i + 1] }; + 3 => if (val.len - i >= 3) { + try unesc.appendSlice(&[_]u8{ '\\', c, val[i + 1], val[i + 2] }); + i += 2; + } else { + try unesc.append('\\'); + try unesc.appendSlice(val[i..val.len]); + i = val.len - 1; }, - 3 => brk: { - defer i += 2; - break :brk &[_]u8{ '\\', c, val[i + 1], val[i + 2] }; + 4 => if (val.len - i >= 4) { + try unesc.appendSlice(&[_]u8{ '\\', c, val[i + 1], val[i + 2], val[i + 3] }); + i += 3; + } else { + try unesc.append('\\'); + try unesc.appendSlice(val[i..val.len]); + i = val.len - 1; }, - 4 => brk: { - defer i += 3; - break :brk &[_]u8{ '\\', c, val[i + 1], val[i + 2], val[i + 3] }; - }, - // this means invalid utf8 else => unreachable, - }); + } }, } @@ -342,25 +349,30 @@ pub const Parser = struct { try unesc.append('.'); } }, - else => try unesc.appendSlice(switch (bun.strings.utf8ByteSequenceLength(c)) { - 1 => brk: { - break :brk &[_]u8{c}; + else => switch (bun.strings.utf8ByteSequenceLength(c)) { + 0, 1 => try unesc.append(c), + 2 => if (val.len - i >= 2) { + try unesc.appendSlice(&[_]u8{ c, val[i + 1] }); + i += 1; + } else { + try unesc.append(c); }, - 2 => brk: { - defer i += 1; - break :brk &[_]u8{ c, val[i + 1] }; + 3 => if (val.len - i >= 3) { + try unesc.appendSlice(&[_]u8{ c, val[i + 1], val[i + 2] }); + i += 2; + } else { + try unesc.appendSlice(val[i..val.len]); + i = val.len - 1; }, - 3 => brk: { - defer i += 2; - break :brk &[_]u8{ c, val[i + 1], val[i + 2] }; + 4 => if (val.len - i >= 4) { + try unesc.appendSlice(&[_]u8{ c, val[i + 1], val[i + 2], val[i + 3] }); + i += 3; + } else { + try unesc.appendSlice(val[i..val.len]); + i = val.len - 1; }, - 4 => brk: { - defer i += 3; - break :brk &[_]u8{ c, val[i + 1], val[i + 2], val[i + 3] }; - }, - // this means invalid utf8 else => unreachable, - }), + }, } } diff --git a/src/libarchive/libarchive.zig b/src/libarchive/libarchive.zig index 72bc5d5b96..57bae0347a 100644 --- a/src/libarchive/libarchive.zig +++ b/src/libarchive/libarchive.zig @@ -460,13 +460,13 @@ pub const Archiver = struct { if (comptime Environment.isWindows) { try bun.MakePath.makePath(u16, dir, path); } else { - std.posix.mkdiratZ(dir_fd, pathname, @intCast(mode)) catch |err| { + std.posix.mkdiratZ(dir_fd, path, @intCast(mode)) catch |err| { // It's possible for some tarballs to return a directory twice, with and // without `./` in the beginning. So if it already exists, continue to the // next entry. if (err == error.PathAlreadyExists or err == error.NotDir) continue; bun.makePath(dir, std.fs.path.dirname(path_slice) orelse return err) catch {}; - std.posix.mkdiratZ(dir_fd, pathname, 0o777) catch {}; + std.posix.mkdiratZ(dir_fd, path, 0o777) catch {}; }; } }, diff --git a/src/linker.zig b/src/linker.zig index bae52a8f40..f9ce66257e 100644 --- a/src/linker.zig +++ b/src/linker.zig @@ -222,7 +222,7 @@ pub const Linker = struct { if (comptime is_bun) { // make these happen at runtime - if (import_record.kind == .require or import_record.kind == .require_resolve) { + if (import_record.kind == .require or import_record.kind == .require_resolve or import_record.kind == .dynamic) { return false; } } diff --git a/src/logger.zig b/src/logger.zig index 1214403e0a..ab4f4e053f 100644 --- a/src/logger.zig +++ b/src/logger.zig @@ -927,7 +927,9 @@ pub const Log = struct { err: anyerror, ) OOM!void { @branchHint(.cold); - return try addResolveErrorWithLevel(log, source, r, allocator, fmt, args, import_kind, false, .err, err); + // Always dupe the line_text from the source to ensure the Location data + // outlives the source's backing memory (which may be arena-allocated). + return try addResolveErrorWithLevel(log, source, r, allocator, fmt, args, import_kind, true, .err, err); } pub fn addResolveErrorWithTextDupe( diff --git a/src/s3/credentials.zig b/src/s3/credentials.zig index 10588c1334..d6a8632611 100644 --- a/src/s3/credentials.zig +++ b/src/s3/credentials.zig @@ -221,7 +221,11 @@ pub const S3Credentials = struct { defer str.deref(); if (str.tag != .Empty and str.tag != .Dead) { new_credentials._contentDispositionSlice = str.toUTF8(bun.default_allocator); - new_credentials.content_disposition = new_credentials._contentDispositionSlice.?.slice(); + const slice = new_credentials._contentDispositionSlice.?.slice(); + if (containsNewlineOrCR(slice)) { + return globalObject.throwInvalidArguments("contentDisposition must not contain newline characters (CR/LF)", .{}); + } + new_credentials.content_disposition = slice; } } else { return globalObject.throwInvalidArgumentTypeValue("contentDisposition", "string", js_value); @@ -236,7 +240,11 @@ pub const S3Credentials = struct { defer str.deref(); if (str.tag != .Empty and str.tag != .Dead) { new_credentials._contentTypeSlice = str.toUTF8(bun.default_allocator); - new_credentials.content_type = new_credentials._contentTypeSlice.?.slice(); + const slice = new_credentials._contentTypeSlice.?.slice(); + if (containsNewlineOrCR(slice)) { + return globalObject.throwInvalidArguments("type must not contain newline characters (CR/LF)", .{}); + } + new_credentials.content_type = slice; } } else { return globalObject.throwInvalidArgumentTypeValue("type", "string", js_value); @@ -251,7 +259,11 @@ pub const S3Credentials = struct { defer str.deref(); if (str.tag != .Empty and str.tag != .Dead) { new_credentials._contentEncodingSlice = str.toUTF8(bun.default_allocator); - new_credentials.content_encoding = new_credentials._contentEncodingSlice.?.slice(); + const slice = new_credentials._contentEncodingSlice.?.slice(); + if (containsNewlineOrCR(slice)) { + return globalObject.throwInvalidArguments("contentEncoding must not contain newline characters (CR/LF)", .{}); + } + new_credentials.content_encoding = slice; } } else { return globalObject.throwInvalidArgumentTypeValue("contentEncoding", "string", js_value); @@ -1150,6 +1162,12 @@ const CanonicalRequest = struct { } }; +/// Returns true if the given slice contains any CR (\r) or LF (\n) characters, +/// which would allow HTTP header injection if used in a header value. +fn containsNewlineOrCR(value: []const u8) bool { + return strings.indexOfAny(value, "\r\n") != null; +} + const std = @import("std"); const ACL = @import("./acl.zig").ACL; const MultiPartUploadOptions = @import("./multipart_options.zig").MultiPartUploadOptions; diff --git a/src/shell/builtin/cp.zig b/src/shell/builtin/cp.zig index 7a06556955..8c92d38b90 100644 --- a/src/shell/builtin/cp.zig +++ b/src/shell/builtin/cp.zig @@ -266,8 +266,8 @@ pub const ShellCpOutputTask = OutputTask(Cp, .{ const ShellCpOutputTaskVTable = struct { pub fn writeErr(this: *Cp, childptr: anytype, errbuf: []const u8) ?Yield { + this.state.exec.output_waiting += 1; if (this.bltn().stderr.needsIO()) |safeguard| { - this.state.exec.output_waiting += 1; return this.bltn().stderr.enqueue(childptr, errbuf, safeguard); } _ = this.bltn().writeNoIO(.stderr, errbuf); @@ -279,8 +279,8 @@ const ShellCpOutputTaskVTable = struct { } pub fn writeOut(this: *Cp, childptr: anytype, output: *OutputSrc) ?Yield { + this.state.exec.output_waiting += 1; if (this.bltn().stdout.needsIO()) |safeguard| { - this.state.exec.output_waiting += 1; return this.bltn().stdout.enqueue(childptr, output.slice(), safeguard); } _ = this.bltn().writeNoIO(.stdout, output.slice()); diff --git a/src/shell/builtin/ls.zig b/src/shell/builtin/ls.zig index b5b68e5678..6e411f89cc 100644 --- a/src/shell/builtin/ls.zig +++ b/src/shell/builtin/ls.zig @@ -175,8 +175,8 @@ pub const ShellLsOutputTask = OutputTask(Ls, .{ const ShellLsOutputTaskVTable = struct { pub fn writeErr(this: *Ls, childptr: anytype, errbuf: []const u8) ?Yield { log("ShellLsOutputTaskVTable.writeErr(0x{x}, {s})", .{ @intFromPtr(this), errbuf }); + this.state.exec.output_waiting += 1; if (this.bltn().stderr.needsIO()) |safeguard| { - this.state.exec.output_waiting += 1; return this.bltn().stderr.enqueue(childptr, errbuf, safeguard); } _ = this.bltn().writeNoIO(.stderr, errbuf); @@ -190,8 +190,8 @@ const ShellLsOutputTaskVTable = struct { pub fn writeOut(this: *Ls, childptr: anytype, output: *OutputSrc) ?Yield { log("ShellLsOutputTaskVTable.writeOut(0x{x}, {s})", .{ @intFromPtr(this), output.slice() }); + this.state.exec.output_waiting += 1; if (this.bltn().stdout.needsIO()) |safeguard| { - this.state.exec.output_waiting += 1; return this.bltn().stdout.enqueue(childptr, output.slice(), safeguard); } log("ShellLsOutputTaskVTable.writeOut(0x{x}, {s}) no IO", .{ @intFromPtr(this), output.slice() }); diff --git a/src/shell/builtin/mkdir.zig b/src/shell/builtin/mkdir.zig index 5def7b1379..ee00089f04 100644 --- a/src/shell/builtin/mkdir.zig +++ b/src/shell/builtin/mkdir.zig @@ -129,8 +129,8 @@ pub const ShellMkdirOutputTask = OutputTask(Mkdir, .{ const ShellMkdirOutputTaskVTable = struct { pub fn writeErr(this: *Mkdir, childptr: anytype, errbuf: []const u8) ?Yield { + this.state.exec.output_waiting += 1; if (this.bltn().stderr.needsIO()) |safeguard| { - this.state.exec.output_waiting += 1; return this.bltn().stderr.enqueue(childptr, errbuf, safeguard); } _ = this.bltn().writeNoIO(.stderr, errbuf); @@ -142,8 +142,8 @@ const ShellMkdirOutputTaskVTable = struct { } pub fn writeOut(this: *Mkdir, childptr: anytype, output: *OutputSrc) ?Yield { + this.state.exec.output_waiting += 1; if (this.bltn().stdout.needsIO()) |safeguard| { - this.state.exec.output_waiting += 1; const slice = output.slice(); log("THE SLICE: {d} {s}", .{ slice.len, slice }); return this.bltn().stdout.enqueue(childptr, slice, safeguard); diff --git a/src/shell/builtin/seq.zig b/src/shell/builtin/seq.zig index f5b0061fa7..ab29d821ee 100644 --- a/src/shell/builtin/seq.zig +++ b/src/shell/builtin/seq.zig @@ -46,12 +46,14 @@ pub fn start(this: *@This()) Yield { const maybe1 = iter.next().?; const int1 = std.fmt.parseFloat(f32, bun.sliceTo(maybe1, 0)) catch return this.fail("seq: invalid argument\n"); + if (!std.math.isFinite(int1)) return this.fail("seq: invalid argument\n"); this._end = int1; if (this._start > this._end) this.increment = -1; const maybe2 = iter.next(); if (maybe2 == null) return this.do(); const int2 = std.fmt.parseFloat(f32, bun.sliceTo(maybe2.?, 0)) catch return this.fail("seq: invalid argument\n"); + if (!std.math.isFinite(int2)) return this.fail("seq: invalid argument\n"); this._start = int1; this._end = int2; if (this._start < this._end) this.increment = 1; @@ -60,6 +62,7 @@ pub fn start(this: *@This()) Yield { const maybe3 = iter.next(); if (maybe3 == null) return this.do(); const int3 = std.fmt.parseFloat(f32, bun.sliceTo(maybe3.?, 0)) catch return this.fail("seq: invalid argument\n"); + if (!std.math.isFinite(int3)) return this.fail("seq: invalid argument\n"); this._start = int1; this.increment = int2; this._end = int3; diff --git a/src/shell/builtin/touch.zig b/src/shell/builtin/touch.zig index fb66b431b7..d2695943a5 100644 --- a/src/shell/builtin/touch.zig +++ b/src/shell/builtin/touch.zig @@ -132,8 +132,8 @@ pub const ShellTouchOutputTask = OutputTask(Touch, .{ const ShellTouchOutputTaskVTable = struct { pub fn writeErr(this: *Touch, childptr: anytype, errbuf: []const u8) ?Yield { + this.state.exec.output_waiting += 1; if (this.bltn().stderr.needsIO()) |safeguard| { - this.state.exec.output_waiting += 1; return this.bltn().stderr.enqueue(childptr, errbuf, safeguard); } _ = this.bltn().writeNoIO(.stderr, errbuf); @@ -145,8 +145,8 @@ const ShellTouchOutputTaskVTable = struct { } pub fn writeOut(this: *Touch, childptr: anytype, output: *OutputSrc) ?Yield { + this.state.exec.output_waiting += 1; if (this.bltn().stdout.needsIO()) |safeguard| { - this.state.exec.output_waiting += 1; const slice = output.slice(); log("THE SLICE: {d} {s}", .{ slice.len, slice }); return this.bltn().stdout.enqueue(childptr, slice, safeguard); diff --git a/src/shell/interpreter.zig b/src/shell/interpreter.zig index cc79cb8971..7ca6c2bf41 100644 --- a/src/shell/interpreter.zig +++ b/src/shell/interpreter.zig @@ -1154,7 +1154,7 @@ pub const Interpreter = struct { _ = callframe; // autofix if (this.setupIOBeforeRun().asErr()) |e| { - defer this.#deinitFromExec(); + defer this.#derefRootShellAndIOIfNeeded(true); const shellerr = bun.shell.ShellErr.newSys(e); return try throwShellErr(&shellerr, .{ .js = globalThis.bunVM().event_loop }); } diff --git a/src/shell/states/CondExpr.zig b/src/shell/states/CondExpr.zig index 75c1dd2f37..9bec738fdd 100644 --- a/src/shell/states/CondExpr.zig +++ b/src/shell/states/CondExpr.zig @@ -168,6 +168,8 @@ fn commandImplStart(this: *CondExpr) Yield { .@"-d", .@"-f", => { + // Empty string expansion produces no args; the path doesn't exist. + if (this.args.items.len == 0) return this.parent.childDone(this, 1); this.state = .waiting_stat; return this.doStat(); }, diff --git a/src/sql/mysql/js/JSMySQLConnection.zig b/src/sql/mysql/js/JSMySQLConnection.zig index a85bc7519f..474e9a54b7 100644 --- a/src/sql/mysql/js/JSMySQLConnection.zig +++ b/src/sql/mysql/js/JSMySQLConnection.zig @@ -422,6 +422,19 @@ pub fn createInstance(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFra break :brk b.allocatedSlice(); }; + // Reject null bytes in connection parameters to prevent protocol injection + // (null bytes act as field terminators in the MySQL wire protocol). + inline for (.{ .{ username, "username" }, .{ password, "password" }, .{ database, "database" }, .{ path, "path" } }) |entry| { + if (entry[0].len > 0 and std.mem.indexOfScalar(u8, entry[0], 0) != null) { + bun.default_allocator.free(options_buf); + tls_config.deinit(); + if (tls_ctx) |tls| { + tls.deinit(true); + } + return globalObject.throwInvalidArguments(entry[1] ++ " must not contain null bytes", .{}); + } + } + const on_connect = arguments[9]; const on_close = arguments[10]; const idle_timeout = arguments[11].toInt32(); diff --git a/src/sql/postgres/PostgresSQLConnection.zig b/src/sql/postgres/PostgresSQLConnection.zig index 0cdbf71600..9130e18568 100644 --- a/src/sql/postgres/PostgresSQLConnection.zig +++ b/src/sql/postgres/PostgresSQLConnection.zig @@ -680,6 +680,20 @@ pub fn call(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JS break :brk b.allocatedSlice(); }; + // Reject null bytes in connection parameters to prevent Postgres startup + // message parameter injection (null bytes act as field terminators in the + // wire protocol's key\0value\0 format). + inline for (.{ .{ username, "username" }, .{ password, "password" }, .{ database, "database" }, .{ path, "path" } }) |entry| { + if (entry[0].len > 0 and std.mem.indexOfScalar(u8, entry[0], 0) != null) { + bun.default_allocator.free(options_buf); + tls_config.deinit(); + if (tls_ctx) |tls| { + tls.deinit(true); + } + return globalObject.throwInvalidArguments(entry[1] ++ " must not contain null bytes", .{}); + } + } + const on_connect = arguments[9]; const on_close = arguments[10]; const idle_timeout = arguments[11].toInt32(); @@ -1626,7 +1640,10 @@ pub fn on(this: *PostgresSQLConnection, comptime MessageType: @Type(.enum_litera // This will usually start with "v=" const comparison_signature = final.data.slice(); - if (comparison_signature.len < 2 or !bun.strings.eqlLong(server_signature, comparison_signature[2..], true)) { + if (comparison_signature.len < 2 or + server_signature.len != comparison_signature.len - 2 or + BoringSSL.c.CRYPTO_memcmp(server_signature.ptr, comparison_signature[2..].ptr, server_signature.len) != 0) + { debug("SASLFinal - SASL Server signature mismatch\nExpected: {s}\nActual: {s}", .{ server_signature, comparison_signature[2..] }); this.fail("The server did not return the correct signature", error.SASL_SIGNATURE_MISMATCH); } else { diff --git a/test/bake/dev-and-prod.test.ts b/test/bake/dev-and-prod.test.ts index b0b7ea0658..8d8897f9d8 100644 --- a/test/bake/dev-and-prod.test.ts +++ b/test/bake/dev-and-prod.test.ts @@ -260,14 +260,35 @@ devTest("hmr handles rapid consecutive edits", { await Bun.sleep(1); } + // Wait event-driven for "render 10" to appear. Intermediate renders may + // be skipped (watcher coalescing) and the final render may fire multiple + // times (duplicate reloads), so we just listen for any occurrence. const finalRender = "render 10"; - while (true) { - const message = await client.getStringMessage(); - if (message === finalRender) break; - if (typeof message === "string" && message.includes("HMR_ERROR")) { - throw new Error("Unexpected HMR error message: " + message); - } - } + await new Promise((resolve, reject) => { + const check = () => { + for (const msg of client.messages) { + if (typeof msg === "string" && msg.includes("HMR_ERROR")) { + cleanup(); + reject(new Error("Unexpected HMR error message: " + msg)); + return; + } + if (msg === finalRender) { + cleanup(); + resolve(); + return; + } + } + }; + const cleanup = () => { + client.off("message", check); + }; + client.on("message", check); + // Check messages already buffered. + check(); + }); + // Drain all buffered messages — intermediate renders and possible + // duplicates of the final render are expected and harmless. + client.messages.length = 0; const hmrErrors = await client.js`return globalThis.__hmrErrors ? [...globalThis.__hmrErrors] : [];`; if (hmrErrors.length > 0) { diff --git a/test/js/bun/archive.test.ts b/test/js/bun/archive.test.ts index 50e751f8c1..78fdbf54f1 100644 --- a/test/js/bun/archive.test.ts +++ b/test/js/bun/archive.test.ts @@ -611,6 +611,82 @@ describe("Bun.Archive", () => { // Very deep paths might fail on some systems - that's acceptable } }); + + test("directory entries with path traversal components cannot escape extraction root", async () => { + // Manually craft a tar archive containing directory entries with "../" traversal + // components in their pathnames. This tests that the extraction code uses the + // normalized path (which strips "..") rather than the raw pathname from the tarball. + function createTarHeader( + name: string, + size: number, + type: "0" | "5", // 0=file, 5=directory + ): Uint8Array { + const header = new Uint8Array(512); + const enc = new TextEncoder(); + header.set(enc.encode(name).slice(0, 100), 0); + header.set(enc.encode(type === "5" ? "0000755 " : "0000644 "), 100); + header.set(enc.encode("0000000 "), 108); + header.set(enc.encode("0000000 "), 116); + header.set(enc.encode(size.toString(8).padStart(11, "0") + " "), 124); + const mtime = Math.floor(Date.now() / 1000) + .toString(8) + .padStart(11, "0"); + header.set(enc.encode(mtime + " "), 136); + header.set(enc.encode(" "), 148); // checksum placeholder + header[156] = type.charCodeAt(0); + header.set(enc.encode("ustar"), 257); + header[262] = 0; + header.set(enc.encode("00"), 263); + let checksum = 0; + for (let i = 0; i < 512; i++) checksum += header[i]; + header.set(enc.encode(checksum.toString(8).padStart(6, "0") + "\0 "), 148); + return header; + } + + const blocks: Uint8Array[] = []; + const enc = new TextEncoder(); + + // A legitimate directory + blocks.push(createTarHeader("safe_dir/", 0, "5")); + // A directory entry with traversal: "safe_dir/../../escaped_dir/" + // After normalization this becomes "escaped_dir" (safe), + // but the raw pathname resolves ".." via the kernel in mkdirat. + blocks.push(createTarHeader("safe_dir/../../escaped_dir/", 0, "5")); + // A normal file + const content = enc.encode("hello"); + blocks.push(createTarHeader("safe_dir/file.txt", content.length, "0")); + blocks.push(content); + const pad = 512 - (content.length % 512); + if (pad < 512) blocks.push(new Uint8Array(pad)); + // End-of-archive markers + blocks.push(new Uint8Array(1024)); + + const totalLen = blocks.reduce((s, b) => s + b.length, 0); + const tarball = new Uint8Array(totalLen); + let offset = 0; + for (const b of blocks) { + tarball.set(b, offset); + offset += b.length; + } + + // Create a parent directory so we can check if "escaped_dir" appears outside extractDir + using parentDir = tempDir("archive-traversal-parent", {}); + const extractPath = join(String(parentDir), "extract"); + const { mkdirSync, existsSync } = require("fs"); + mkdirSync(extractPath, { recursive: true }); + + const archive = new Bun.Archive(tarball); + await archive.extract(extractPath); + + // The "escaped_dir" should NOT exist in the parent directory (outside extraction root) + const escapedOutside = join(String(parentDir), "escaped_dir"); + expect(existsSync(escapedOutside)).toBe(false); + + // The "safe_dir" should exist inside the extraction directory + expect(existsSync(join(extractPath, "safe_dir"))).toBe(true); + // The normalized "escaped_dir" may or may not exist inside extractPath + // (depending on whether normalization keeps it), but it must NOT be outside + }); }); describe("Archive.write()", () => { diff --git a/test/js/bun/http/fixtures/cert.key b/test/js/bun/http/fixtures/cert.key index bf41b78835..dda72338ba 100644 --- a/test/js/bun/http/fixtures/cert.key +++ b/test/js/bun/http/fixtures/cert.key @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCIzOJskt6VkEJY -XKSJv/Gdil3XYkjk3NVc/+m+kzqnkTRbPtT9w+IGWgmJhuf9DJPLCwHFAEFarVwV -x16Q0PbU4ajXaLRHEYGhrH10oTMjQnJ24xVm26mxRXPQa5vaLpWJqNyIdNLIQLe+ -UXUOzSGGsFTRMAjvYrkzjBe4ZUnaZV+aFY/ug0jfzeA1dJjzKZs6+yTJRbsuWUEb -8MsDmT4v+kBZDKdaDn7AFDWRVqx/38BnqsRzkM0CxpnyT2kRzw5zQajIE13gdTJo -1EHvYSUkkxrY5m30Rl9BuBBZBjhMzOHq0fYVVooHO+sf4XHPgvFTTxJum85u7J1J -oEUjrLKtAgMBAAECggEACInVNhaiqu4infZGVMy0rXMV8VwSlapM7O2SLtFsr0nK -XUmaLK6dvGzBPKK9dxdiYCFzPlMKQTkhzsAvYFWSmm3tRmikG+11TFyCRhXLpc8/ -ark4vD9Io6ZkmKUmyKLwtXNjNGcqQtJ7RXc7Ga3nAkueN6JKZHqieZusXVeBGQ70 -YH1LKyVNBeJggbj+g9rqaksPyNJQ8EWiNTJkTRQPazZ0o1VX/fzDFyr/a5npFtHl -4BHfafv9o1Xyr70Kie8CYYRJNViOCN+ylFs7Gd3XRaAkSkgMT/7DzrHdEM2zrrHK -yNg2gyDVX9UeEJG2X5UtU0o9BVW7WBshz/2hqIUHoQKBgQC8zsRFvC7u/rGr5vRR -mhZZG+Wvg03/xBSuIgOrzm+Qie6mAzOdVmfSL/pNV9EFitXt1yd2ROo31AbS7Evy -Bm/QVKr2mBlmLgov3B7O/e6ABteooOL7769qV/v+yo8VdEg0biHmsfGIIXDe3Lwl -OT0XwF9r/SeZLbw1zfkSsUVG/QKBgQC5fANM3Dc9LEek+6PHv5+eC1cKkyioEjUl -/y1VUD00aABI1TUcdLF3BtFN2t/S6HW0hrP3KwbcUfqC25k+GDLh1nM6ZK/gI3Yn -IGtCHxtE3S6jKhE9QcK/H+PzGVKWge9SezeYRP0GHJYDrTVTA8Kt9HgoZPPeReJl -+Ss9c8ThcQKBgECX6HQHFnNzNSufXtSQB7dCoQizvjqTRZPxVRoxDOABIGExVTYt -umUhPtu5AGyJ+/hblEeU+iBRbGg6qRzK8PPwE3E7xey8MYYAI5YjL7YjISKysBUL -AhM6uJ6Jg/wOBSnSx8xZ8kzlS+0izUda1rjKeprCSArSp8IsjlrDxPStAoGAEcPr -+P+altRX5Fhpvmb/Hb8OTif8G+TqjEIdkG9H/W38oP0ywg/3M2RGxcMx7txu8aR5 -NjI7zPxZFxF7YvQkY3cLwEsGgVxEI8k6HLIoBXd90Qjlb82NnoqqZY1GWL4HMwo0 -L/Rjm6M/Rwje852Hluu0WoIYzXA6F/Q+jPs6nzECgYAxx4IbDiGXuenkwSF1SUyj -NwJXhx4HDh7U6EO/FiPZE5BHE3BoTrFu3o1lzverNk7G3m+j+m1IguEAalHlukYl -rip9iUISlKYqbYZdLBoLwHAfHhszdrjqn8/v6oqbB5yR3HXjPFUWJo0WJ2pqJp56 -ZshgmQQ/5Khoj6x0/dMPSg== +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCt7iqkEIco372h +v19q0zjaYbm6gzxEnR45UjpQYqgztq4QHicD80mqIkCBCYknFxhwxhNn+Y3g5RWQ +dReplpQbkneqRVp+qixMvu2FmOA4zRRoqObP7FyF1Yusvmroe0Y9SP2xTTmA9Zo7 +3paywPUIuZ9eKGwIiFTtj1yQ1FdghLhzZgxcf3LHEHRkGnxgxxNITFxh4nd6fGIj +NqM5fQAY8z35lMXdeWjrhtaqgFYB+Z20YY0X7LJx39vYao0wqW8sZjX88TqHI1zX +WLpUk6UK9RqaNza5xc80wV+9/zjhr3dc1FRjBxI1DS/ufo33dUfvilxv9/LtWwUn +KfKLns9LAgMBAAECggEAAacPHM2G7GBIm/9rCr6tvihNgD8M685zOOZAqGYn9CqY +cYHC4gtF/L2U6CBj2pNAoCwo3LXUkD+6r7MYKXAgqQg3HTCM4rwFbhD1rU8FVHfh +OL0QwwZ2ut95DVdjoxTAlEN9ZcdSFc//llMJ1cF8lxoVvKFc4cv3uCI2mcaJk858 +iABfJLl3yfdv1xtpAuOfXf66sXbAmn5NQfN0qTEg2iOdgb4BUee5Wb35MakDQb6+ +/s7/bWB+ublZzYt12ChIh1jkBBHaGyQ8mFnPj99ZAJdFjAzi6ydoJ0a2rCVY7Ugs +bkhnzDUtAaHKxo9JXaqIwbUaVFkX8dDhbg82dJrWUQKBgQDb7hNR0bJFW845N19M +74p2PM+0dIiVzwxAg4E2dXDVe39awO/tw8Vu1o1+NPFhWAzGcidP7pAHmPEgRTVO +7LA2P3CDXpkAEx5E0QW6QWZGqHfSa3+P1AvetvAV+OxtlDphcNeLApY16TUVOKZg +SZlxW2e0dZylbHewgLBTIV9wUQKBgQDKdML+JD18WfenPeowsw8HzKdaw01iGiV1 +fvTjEXu6YxPPynWFMuj5gjBQodXM2vv0EsQBAPKYfe0nzRFL2kNuYs7TLoaNxqkp +DNfJ2Ww5OSg7Mp76XgppeKKlsXLyUMYHHrDh6MRi5jvWtiHRpaNmV3cHMRs22c+B +cqKP5Zma2wKBgCPNnS2Lsrbh3C+qWQRgVq0q9zFMa1PgEgGKpwVjlwvaAACZOjX9 +0e1aVkx+d/E98U55FPdJQf9Koa58NdJ0a7dZGor4YnYFpr7TPFh2/xxvnpoN0AVt +IsWOCIW7MVohcGOeiChkMmnyXibnQwaX1LgEhlx1bRvtDYsZWBsgarYRAoGAARvo +oYnDSHYZtDHToZapg2pslEOzndD02ZLrdn73BYtbZWz/fc5MlmlPKHHqgOfGL40W +w8akjY9LCEfIS3kTm3wxE9kSZZ5r+MyYNgPZ4upcPQ7G7iortm4xveSd85PbsdhK +McKbqMsIEuIGh2Z34ayi+0galQ9WYqglGdKxJ7cCgYEAuSPBHa+en0xaraZNRvMk +OfV9Su/wrpR3TXSeo0E1mZHLwq1JwulpfO1SjxTH5uOJtG0tusl122wfm0KjrXUO +vG5/It+X4u1Nv9oWj+z1+EV4fQrQ/Coqcc1r+5w1yzfURkKlHh74jbK5Yy/KfXrE +eqbbJD40tKhY8ho15D3iCSo= -----END PRIVATE KEY----- diff --git a/test/js/bun/http/fixtures/cert.pem b/test/js/bun/http/fixtures/cert.pem index 8ae1c1ea43..1c3ac8eb4b 100644 --- a/test/js/bun/http/fixtures/cert.pem +++ b/test/js/bun/http/fixtures/cert.pem @@ -1,23 +1,24 @@ -----BEGIN CERTIFICATE----- -MIID5jCCAs6gAwIBAgIUN7coIsdMcLo9amZfkwogu0YkeLEwDQYJKoZIhvcNAQEL +MIIEDDCCAvSgAwIBAgIUbddWE2woW5e96uC4S2fd2M0AsFAwDQYJKoZIhvcNAQEL BQAwfjELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVN0YXRlMREwDwYDVQQHDAhMb2Nh dGlvbjEaMBgGA1UECgwRT3JnYW5pemF0aW9uIE5hbWUxHDAaBgNVBAsME09yZ2Fu -aXphdGlvbmFsIFVuaXQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yMzA5MjExNDE2 -MjNaFw0yNDA5MjAxNDE2MjNaMH4xCzAJBgNVBAYTAlNFMQ4wDAYDVQQIDAVTdGF0 +aXphdGlvbmFsIFVuaXQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yNjAyMTMyMzEx +MjlaFw0zNjAyMTEyMzExMjlaMH4xCzAJBgNVBAYTAlNFMQ4wDAYDVQQIDAVTdGF0 ZTERMA8GA1UEBwwITG9jYXRpb24xGjAYBgNVBAoMEU9yZ2FuaXphdGlvbiBOYW1l MRwwGgYDVQQLDBNPcmdhbml6YXRpb25hbCBVbml0MRIwEAYDVQQDDAlsb2NhbGhv -c3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCIzOJskt6VkEJYXKSJ -v/Gdil3XYkjk3NVc/+m+kzqnkTRbPtT9w+IGWgmJhuf9DJPLCwHFAEFarVwVx16Q -0PbU4ajXaLRHEYGhrH10oTMjQnJ24xVm26mxRXPQa5vaLpWJqNyIdNLIQLe+UXUO -zSGGsFTRMAjvYrkzjBe4ZUnaZV+aFY/ug0jfzeA1dJjzKZs6+yTJRbsuWUEb8MsD -mT4v+kBZDKdaDn7AFDWRVqx/38BnqsRzkM0CxpnyT2kRzw5zQajIE13gdTJo1EHv -YSUkkxrY5m30Rl9BuBBZBjhMzOHq0fYVVooHO+sf4XHPgvFTTxJum85u7J1JoEUj -rLKtAgMBAAGjXDBaMA4GA1UdDwEB/wQEAwIDiDATBgNVHSUEDDAKBggrBgEFBQcD -ATAUBgNVHREEDTALgglsb2NhbGhvc3QwHQYDVR0OBBYEFNzx4Rfs9m8XR5ML0WsI -sorKmB4PMA0GCSqGSIb3DQEBCwUAA4IBAQB87iQy8R0fiOky9WTcyzVeMaavS3MX -iTe1BRn1OCyDq+UiwwoNz7zdzZJFEmRtFBwPNFOe4HzLu6E+7yLFR552eYRHlqIi -/fiLb5JiZfPtokUHeqwELWBsoXtU8vKxViPiLZ09jkWOPZWo7b/xXd6QYykBfV91 -usUXLzyTD2orMagpqNksLDGS3p3ggHEJBZtRZA8R7kPEw98xZHznOQpr26iv8kYz -ZWdLFoFdwgFBSfxePKax5rfo+FbwdrcTX0MhbORyiu2XsBAghf8s2vKDkHg2UQE8 -haonxFYMFaASfaZ/5vWKYDTCJkJ67m/BtkpRafFEO+ad1i1S61OjfxH4 +c3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCt7iqkEIco372hv19q +0zjaYbm6gzxEnR45UjpQYqgztq4QHicD80mqIkCBCYknFxhwxhNn+Y3g5RWQdRep +lpQbkneqRVp+qixMvu2FmOA4zRRoqObP7FyF1Yusvmroe0Y9SP2xTTmA9Zo73pay +wPUIuZ9eKGwIiFTtj1yQ1FdghLhzZgxcf3LHEHRkGnxgxxNITFxh4nd6fGIjNqM5 +fQAY8z35lMXdeWjrhtaqgFYB+Z20YY0X7LJx39vYao0wqW8sZjX88TqHI1zXWLpU +k6UK9RqaNza5xc80wV+9/zjhr3dc1FRjBxI1DS/ufo33dUfvilxv9/LtWwUnKfKL +ns9LAgMBAAGjgYEwfzAdBgNVHQ4EFgQUQCpSY7ODhdyD6pdZHvfHoWRXWsIwHwYD +VR0jBBgwFoAUQCpSY7ODhdyD6pdZHvfHoWRXWsIwDwYDVR0TAQH/BAUwAwEB/zAs +BgNVHREEJTAjgglsb2NhbGhvc3SHBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAEwDQYJ +KoZIhvcNAQELBQADggEBAGKTIzGQsOqfD0+x15F2cu7FKjIo1ua0OiILAhPqGX65 +kGcetjC/dJip2bGnw1NjG9WxEJNZ4YcsGrwh9egfnXXmfHNL0wzx/LTo2oysbXsN +nEj+cmzw3Lwjn/ywJc+AC221/xrmDfm3m/hMzLqncnj23ZAHqkXTSp5UtSMs+UDQ +my0AJOvsDGPVKHQsAX3JDjKHaoVJn4YqpHcIGmpjrNcQSvwUocDHPcC0ywco6SgF +Ylzy2bwWWdPd9Cz9JkAMb95nWc7Rwf/nxAqCjJFzKEisvrx7VZ+QSVI0nqJzt8V1 +pbtWYH5gMFVstU3ghWdSLbAk4XufGYrIWAlA5mqjQ4o= -----END CERTIFICATE----- diff --git a/test/js/bun/ini/ini.test.ts b/test/js/bun/ini/ini.test.ts index 8212316b7f..00ce8b4395 100644 --- a/test/js/bun/ini/ini.test.ts +++ b/test/js/bun/ini/ini.test.ts @@ -489,6 +489,61 @@ brr = 3 "zr": ["deedee"], }); }); + + describe("truncated/invalid utf-8", () => { + test("bare continuation byte (0x80) should not crash", () => { + // 0x80 is a continuation byte without a leading byte + // utf8ByteSequenceLength returns 0, which must not hit unreachable + const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0x80])]).toString("latin1"); + // Should not crash - just parse gracefully + expect(() => parse(ini)).not.toThrow(); + }); + + test("truncated 2-byte sequence at end of value", () => { + // 0xC0 is a 2-byte lead byte, but there's no continuation byte following + const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0xc0])]).toString("latin1"); + expect(() => parse(ini)).not.toThrow(); + }); + + test("truncated 3-byte sequence at end of value", () => { + // 0xE0 is a 3-byte lead byte, but only 0 continuation bytes follow + const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0xe0])]).toString("latin1"); + expect(() => parse(ini)).not.toThrow(); + }); + + test("truncated 3-byte sequence with 1 continuation byte at end", () => { + // 0xE0 is a 3-byte lead byte, but only 1 continuation byte follows + const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0xe0, 0x80])]).toString("latin1"); + expect(() => parse(ini)).not.toThrow(); + }); + + test("truncated 4-byte sequence at end of value", () => { + // 0xF0 is a 4-byte lead byte, but only 0 continuation bytes follow + const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0xf0])]).toString("latin1"); + expect(() => parse(ini)).not.toThrow(); + }); + + test("truncated 4-byte sequence with 1 continuation byte at end", () => { + const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0xf0, 0x80])]).toString("latin1"); + expect(() => parse(ini)).not.toThrow(); + }); + + test("truncated 4-byte sequence with 2 continuation bytes at end", () => { + const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0xf0, 0x80, 0x80])]).toString("latin1"); + expect(() => parse(ini)).not.toThrow(); + }); + + test("truncated 2-byte sequence in escaped context", () => { + // Backslash followed by a 2-byte lead byte at end of value + const ini = Buffer.concat([Buffer.from("key = \\"), Buffer.from([0xc0])]).toString("latin1"); + expect(() => parse(ini)).not.toThrow(); + }); + + test("bare continuation byte in escaped context", () => { + const ini = Buffer.concat([Buffer.from("key = \\"), Buffer.from([0x80])]).toString("latin1"); + expect(() => parse(ini)).not.toThrow(); + }); + }); }); const wtf = { diff --git a/test/js/bun/shell/shell-cmdsub-crash.test.ts b/test/js/bun/shell/shell-cmdsub-crash.test.ts new file mode 100644 index 0000000000..2e07c3201c --- /dev/null +++ b/test/js/bun/shell/shell-cmdsub-crash.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +// Regression test for use-after-poison in builtin OutputTask callbacks +// inside command substitution $(). +// +// The bug: output_waiting was only incremented for async writes but +// output_done was always incremented, so when stdout is sync (.pipe +// in cmdsub) the counter check `output_done >= output_waiting` fires +// prematurely, calling done() and freeing the builtin while IOWriter +// callbacks are still pending. +// +// Repro requires many ls tasks with errors — `ls /tmp/*` on a system +// with many /tmp entries and some permission-denied dirs reliably +// triggers the ASAN use-after-poison. + +describe("builtins in command substitution with errors should not crash", () => { + test("ls /tmp/* in command substitution", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + import { $ } from "bun"; + $.throws(false); + await $\`echo $(ls /tmp/*)\`; + console.log("done"); + `, + ], + env: bunEnv, + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("done"); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/shell/shell-seq-condexpr.test.ts b/test/js/bun/shell/shell-seq-condexpr.test.ts new file mode 100644 index 0000000000..adb1756d92 --- /dev/null +++ b/test/js/bun/shell/shell-seq-condexpr.test.ts @@ -0,0 +1,85 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +test("seq inf does not hang", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `import { $ } from "bun"; $.throws(false); const r = await $\`seq inf\`; process.exit(r.exitCode)`, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("invalid argument"); + expect(exitCode).toBe(1); +}, 10_000); + +test("seq nan does not hang", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `import { $ } from "bun"; $.throws(false); const r = await $\`seq nan\`; process.exit(r.exitCode)`, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("invalid argument"); + expect(exitCode).toBe(1); +}, 10_000); + +test("seq -inf does not hang", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `import { $ } from "bun"; $.throws(false); const r = await $\`seq -- -inf\`; process.exit(r.exitCode)`, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("invalid argument"); + expect(exitCode).toBe(1); +}, 10_000); + +test('[[ -d "" ]] does not crash', async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `import { $ } from "bun"; $.throws(false); const r = await $\`[[ -d "" ]]\`; process.exit(r.exitCode)`, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(1); +}, 10_000); + +test('[[ -f "" ]] does not crash', async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `import { $ } from "bun"; $.throws(false); const r = await $\`[[ -f "" ]]\`; process.exit(r.exitCode)`, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(1); +}, 10_000); diff --git a/test/js/bun/test/parallel/test-https-should-work-when-sending-request-with-agent-false.ts b/test/js/bun/test/parallel/test-https-should-work-when-sending-request-with-agent-false.ts index c9cdb41653..2d124cfe90 100644 --- a/test/js/bun/test/parallel/test-https-should-work-when-sending-request-with-agent-false.ts +++ b/test/js/bun/test/parallel/test-https-should-work-when-sending-request-with-agent-false.ts @@ -1,7 +1,20 @@ +import { tls } from "harness"; import https from "node:https"; +using server = Bun.serve({ + port: 0, + tls, + fetch() { + return new Response("OK"); + }, +}); + const { promise, resolve, reject } = Promise.withResolvers(); -const client = https.request("https://example.com/", { agent: false }); +const client = https.request(`https://localhost:${server.port}/`, { + agent: false, + ca: tls.cert, + rejectUnauthorized: true, +}); client.on("error", reject); client.on("close", resolve); client.end(); diff --git a/test/js/web/websocket/websocket-pong-fragmented.test.ts b/test/js/web/websocket/websocket-pong-fragmented.test.ts new file mode 100644 index 0000000000..8e20fd29a5 --- /dev/null +++ b/test/js/web/websocket/websocket-pong-fragmented.test.ts @@ -0,0 +1,222 @@ +import { TCPSocketListener } from "bun"; +import { describe, expect, test } from "bun:test"; + +const hostname = "127.0.0.1"; +const MAX_HEADER_SIZE = 16 * 1024; + +function doHandshake( + socket: any, + handshakeBuffer: Uint8Array, + data: Uint8Array, +): { buffer: Uint8Array; done: boolean } { + const newBuffer = new Uint8Array(handshakeBuffer.length + data.length); + newBuffer.set(handshakeBuffer); + newBuffer.set(data, handshakeBuffer.length); + + if (newBuffer.length > MAX_HEADER_SIZE) { + socket.end(); + throw new Error("Handshake headers too large"); + } + + const dataStr = new TextDecoder("utf-8").decode(newBuffer); + const endOfHeaders = dataStr.indexOf("\r\n\r\n"); + if (endOfHeaders === -1) { + return { buffer: newBuffer, done: false }; + } + + if (!dataStr.startsWith("GET")) { + throw new Error("Invalid handshake"); + } + + const magic = /Sec-WebSocket-Key:\s*(.*)\r\n/i.exec(dataStr); + if (!magic) { + throw new Error("Missing Sec-WebSocket-Key"); + } + + const hasher = new Bun.CryptoHasher("sha1"); + hasher.update(magic[1].trim()); + hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); + const accept = hasher.digest("base64"); + + socket.write( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + `Sec-WebSocket-Accept: ${accept}\r\n` + + "\r\n", + ); + socket.flush(); + + return { buffer: newBuffer, done: true }; +} + +function makeTextFrame(text: string): Uint8Array { + const payload = new TextEncoder().encode(text); + const len = payload.length; + let header: Uint8Array; + if (len < 126) { + header = new Uint8Array([0x81, len]); + } else if (len < 65536) { + header = new Uint8Array([0x81, 126, (len >> 8) & 0xff, len & 0xff]); + } else { + throw new Error("Message too large for this test"); + } + const frame = new Uint8Array(header.length + len); + frame.set(header); + frame.set(payload, header.length); + return frame; +} + +describe("WebSocket", () => { + test("fragmented pong frame does not cause frame desync", async () => { + let server: TCPSocketListener | undefined; + let client: WebSocket | undefined; + let handshakeBuffer = new Uint8Array(0); + let handshakeComplete = false; + + try { + const { promise, resolve, reject } = Promise.withResolvers(); + + server = Bun.listen({ + socket: { + data(socket, data) { + if (handshakeComplete) { + // After handshake, we just receive client frames (like close) - ignore them + return; + } + + const result = doHandshake(socket, handshakeBuffer, new Uint8Array(data)); + handshakeBuffer = result.buffer; + if (!result.done) return; + + handshakeComplete = true; + + // Build a pong frame with a 50-byte payload, but deliver it in two parts. + // Pong opcode = 0x8A, FIN=1 + const pongPayload = new Uint8Array(50); + for (let i = 0; i < 50; i++) pongPayload[i] = 0x41 + (i % 26); // 'A'-'Z' repeated + const pongFrame = new Uint8Array(2 + 50); + pongFrame[0] = 0x8a; // FIN + Pong opcode + pongFrame[1] = 50; // payload length + pongFrame.set(pongPayload, 2); + + // Part 1 of pong: header (2 bytes) + first 2 bytes of payload = 4 bytes + // This leaves 48 bytes of pong payload undelivered. + const pongPart1 = pongFrame.slice(0, 4); + // Part 2: remaining 48 bytes of pong payload + const pongPart2 = pongFrame.slice(4); + + // A text message to send after the pong completes. + const textFrame = makeTextFrame("hello after pong"); + + // Send part 1 of pong + socket.write(pongPart1); + socket.flush(); + + // After a delay, send part 2 of pong + the follow-up text message + setTimeout(() => { + // Concatenate part2 + text frame to simulate them arriving together + const combined = new Uint8Array(pongPart2.length + textFrame.length); + combined.set(pongPart2); + combined.set(textFrame, pongPart2.length); + socket.write(combined); + socket.flush(); + }, 50); + }, + }, + hostname, + port: 0, + }); + + const messages: string[] = []; + + client = new WebSocket(`ws://${server.hostname}:${server.port}`); + client.addEventListener("error", event => { + reject(new Error("WebSocket error")); + }); + client.addEventListener("close", event => { + // If the connection closes unexpectedly due to frame desync, the test should fail + reject(new Error(`WebSocket closed unexpectedly: code=${event.code} reason=${event.reason}`)); + }); + client.addEventListener("message", event => { + messages.push(event.data as string); + if (messages.length === 1) { + // We got the text message after the fragmented pong + try { + expect(messages[0]).toBe("hello after pong"); + resolve(); + } catch (err) { + reject(err); + } + } + }); + + await promise; + } finally { + client?.close(); + server?.stop(true); + } + }); + + test("pong frame with payload > 125 bytes is rejected", async () => { + let server: TCPSocketListener | undefined; + let client: WebSocket | undefined; + let handshakeBuffer = new Uint8Array(0); + let handshakeComplete = false; + + try { + const { promise, resolve, reject } = Promise.withResolvers(); + + server = Bun.listen({ + socket: { + data(socket, data) { + if (handshakeComplete) return; + + const result = doHandshake(socket, handshakeBuffer, new Uint8Array(data)); + handshakeBuffer = result.buffer; + if (!result.done) return; + + handshakeComplete = true; + + // Send a pong frame with a 126-byte payload (invalid per RFC 6455 Section 5.5) + // Control frames MUST have a payload length of 125 bytes or less. + // Use 2-byte extended length encoding since 126 > 125. + // But actually, the 7-bit length field in byte[1] can encode 0-125 directly. + // For 126, the server must use the extended 16-bit length. + // However, control frames with >125 payload are invalid regardless of encoding. + const pongFrame = new Uint8Array(4 + 126); + pongFrame[0] = 0x8a; // FIN + Pong + pongFrame[1] = 126; // Signals 16-bit extended length follows + pongFrame[2] = 0; // High byte of length + pongFrame[3] = 126; // Low byte of length = 126 + // Fill payload with arbitrary data + for (let i = 0; i < 126; i++) pongFrame[4 + i] = 0x42; + + socket.write(pongFrame); + socket.flush(); + }, + }, + hostname, + port: 0, + }); + + client = new WebSocket(`ws://${server.hostname}:${server.port}`); + client.addEventListener("error", () => { + // Expected - the connection should error due to invalid control frame + resolve(); + }); + client.addEventListener("close", () => { + // Also acceptable - connection closes due to protocol error + resolve(); + }); + client.addEventListener("message", () => { + reject(new Error("Should not receive a message from an invalid pong frame")); + }); + + await promise; + } finally { + client?.close(); + server?.stop(true); + } + }); +}); diff --git a/test/js/web/websocket/websocket-proxy.test.ts b/test/js/web/websocket/websocket-proxy.test.ts index c2b5a6e1c3..ae1f5b759d 100644 --- a/test/js/web/websocket/websocket-proxy.test.ts +++ b/test/js/web/websocket/websocket-proxy.test.ts @@ -398,6 +398,71 @@ describe("WebSocket wss:// through HTTP proxy (TLS tunnel)", () => { expect(messages).toContain("hello via tls tunnel"); gc(); }); + + test("server-initiated ping survives through TLS tunnel proxy", async () => { + // Regression test: sendPong checked socket.isClosed() on the detached tcp + // field instead of using hasTCP(). For wss:// through HTTP proxy, the + // WebSocket uses initWithTunnel which sets tcp = detached (all I/O goes + // through proxy_tunnel). Detached sockets return true for isClosed(), so + // sendPong would immediately dispatch a 1006 close instead of sending the + // pong through the tunnel. + using pingServer = Bun.serve({ + port: 0, + tls: { + key: tlsCerts.key, + cert: tlsCerts.cert, + }, + fetch(req, server) { + if (server.upgrade(req)) return; + return new Response("Expected WebSocket", { status: 400 }); + }, + websocket: { + message(ws, message) { + if (String(message) === "ready") { + // Send a ping after the client confirms it's connected. + // On the buggy code path, this triggers sendPong on the detached + // socket → dispatchAbruptClose → 1006. + ws.ping(); + // Follow up with a text message. If the client receives this, + // the connection survived the ping/pong exchange. + ws.send("after-ping"); + } + }, + }, + }); + + const { promise, resolve, reject } = Promise.withResolvers(); + + const ws = new WebSocket(`wss://127.0.0.1:${pingServer.port}`, { + proxy: `http://127.0.0.1:${proxyPort}`, + tls: { rejectUnauthorized: false }, + }); + + ws.onopen = () => { + ws.send("ready"); + }; + + ws.onmessage = event => { + if (String(event.data) === "after-ping") { + ws.close(1000); + } + }; + + ws.onclose = event => { + if (event.code === 1000) { + resolve(); + } else { + reject(new Error(`Unexpected close code: ${event.code}`)); + } + }; + + ws.onerror = event => { + reject(event); + }; + + await promise; + gc(); + }); }); describe("WebSocket through HTTPS proxy (TLS proxy)", () => { diff --git a/test/js/workerd/html-rewriter.test.js b/test/js/workerd/html-rewriter.test.js index 456796b0f5..ca48663772 100644 --- a/test/js/workerd/html-rewriter.test.js +++ b/test/js/workerd/html-rewriter.test.js @@ -109,13 +109,22 @@ describe("HTMLRewriter", () => { await gcTick(); let content; { + using contentServer = Bun.serve({ + port: 0, + fetch(req) { + return new Response("

Hello from content server

", { + headers: { "Content-Type": "text/html" }, + }); + }, + }); + using server = Bun.serve({ port: 0, fetch(req) { return new HTMLRewriter() .on("div", { async element(element) { - content = await fetch("https://www.example.com/").then(res => res.text()); + content = await fetch(`http://localhost:${contentServer.port}/`).then(res => res.text()); element.setInnerContent(content, { html: true }); }, }) diff --git a/test/regression/fixtures/cert.key b/test/regression/fixtures/cert.key index bf41b78835..dda72338ba 100644 --- a/test/regression/fixtures/cert.key +++ b/test/regression/fixtures/cert.key @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCIzOJskt6VkEJY -XKSJv/Gdil3XYkjk3NVc/+m+kzqnkTRbPtT9w+IGWgmJhuf9DJPLCwHFAEFarVwV -x16Q0PbU4ajXaLRHEYGhrH10oTMjQnJ24xVm26mxRXPQa5vaLpWJqNyIdNLIQLe+ -UXUOzSGGsFTRMAjvYrkzjBe4ZUnaZV+aFY/ug0jfzeA1dJjzKZs6+yTJRbsuWUEb -8MsDmT4v+kBZDKdaDn7AFDWRVqx/38BnqsRzkM0CxpnyT2kRzw5zQajIE13gdTJo -1EHvYSUkkxrY5m30Rl9BuBBZBjhMzOHq0fYVVooHO+sf4XHPgvFTTxJum85u7J1J -oEUjrLKtAgMBAAECggEACInVNhaiqu4infZGVMy0rXMV8VwSlapM7O2SLtFsr0nK -XUmaLK6dvGzBPKK9dxdiYCFzPlMKQTkhzsAvYFWSmm3tRmikG+11TFyCRhXLpc8/ -ark4vD9Io6ZkmKUmyKLwtXNjNGcqQtJ7RXc7Ga3nAkueN6JKZHqieZusXVeBGQ70 -YH1LKyVNBeJggbj+g9rqaksPyNJQ8EWiNTJkTRQPazZ0o1VX/fzDFyr/a5npFtHl -4BHfafv9o1Xyr70Kie8CYYRJNViOCN+ylFs7Gd3XRaAkSkgMT/7DzrHdEM2zrrHK -yNg2gyDVX9UeEJG2X5UtU0o9BVW7WBshz/2hqIUHoQKBgQC8zsRFvC7u/rGr5vRR -mhZZG+Wvg03/xBSuIgOrzm+Qie6mAzOdVmfSL/pNV9EFitXt1yd2ROo31AbS7Evy -Bm/QVKr2mBlmLgov3B7O/e6ABteooOL7769qV/v+yo8VdEg0biHmsfGIIXDe3Lwl -OT0XwF9r/SeZLbw1zfkSsUVG/QKBgQC5fANM3Dc9LEek+6PHv5+eC1cKkyioEjUl -/y1VUD00aABI1TUcdLF3BtFN2t/S6HW0hrP3KwbcUfqC25k+GDLh1nM6ZK/gI3Yn -IGtCHxtE3S6jKhE9QcK/H+PzGVKWge9SezeYRP0GHJYDrTVTA8Kt9HgoZPPeReJl -+Ss9c8ThcQKBgECX6HQHFnNzNSufXtSQB7dCoQizvjqTRZPxVRoxDOABIGExVTYt -umUhPtu5AGyJ+/hblEeU+iBRbGg6qRzK8PPwE3E7xey8MYYAI5YjL7YjISKysBUL -AhM6uJ6Jg/wOBSnSx8xZ8kzlS+0izUda1rjKeprCSArSp8IsjlrDxPStAoGAEcPr -+P+altRX5Fhpvmb/Hb8OTif8G+TqjEIdkG9H/W38oP0ywg/3M2RGxcMx7txu8aR5 -NjI7zPxZFxF7YvQkY3cLwEsGgVxEI8k6HLIoBXd90Qjlb82NnoqqZY1GWL4HMwo0 -L/Rjm6M/Rwje852Hluu0WoIYzXA6F/Q+jPs6nzECgYAxx4IbDiGXuenkwSF1SUyj -NwJXhx4HDh7U6EO/FiPZE5BHE3BoTrFu3o1lzverNk7G3m+j+m1IguEAalHlukYl -rip9iUISlKYqbYZdLBoLwHAfHhszdrjqn8/v6oqbB5yR3HXjPFUWJo0WJ2pqJp56 -ZshgmQQ/5Khoj6x0/dMPSg== +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCt7iqkEIco372h +v19q0zjaYbm6gzxEnR45UjpQYqgztq4QHicD80mqIkCBCYknFxhwxhNn+Y3g5RWQ +dReplpQbkneqRVp+qixMvu2FmOA4zRRoqObP7FyF1Yusvmroe0Y9SP2xTTmA9Zo7 +3paywPUIuZ9eKGwIiFTtj1yQ1FdghLhzZgxcf3LHEHRkGnxgxxNITFxh4nd6fGIj +NqM5fQAY8z35lMXdeWjrhtaqgFYB+Z20YY0X7LJx39vYao0wqW8sZjX88TqHI1zX +WLpUk6UK9RqaNza5xc80wV+9/zjhr3dc1FRjBxI1DS/ufo33dUfvilxv9/LtWwUn +KfKLns9LAgMBAAECggEAAacPHM2G7GBIm/9rCr6tvihNgD8M685zOOZAqGYn9CqY +cYHC4gtF/L2U6CBj2pNAoCwo3LXUkD+6r7MYKXAgqQg3HTCM4rwFbhD1rU8FVHfh +OL0QwwZ2ut95DVdjoxTAlEN9ZcdSFc//llMJ1cF8lxoVvKFc4cv3uCI2mcaJk858 +iABfJLl3yfdv1xtpAuOfXf66sXbAmn5NQfN0qTEg2iOdgb4BUee5Wb35MakDQb6+ +/s7/bWB+ublZzYt12ChIh1jkBBHaGyQ8mFnPj99ZAJdFjAzi6ydoJ0a2rCVY7Ugs +bkhnzDUtAaHKxo9JXaqIwbUaVFkX8dDhbg82dJrWUQKBgQDb7hNR0bJFW845N19M +74p2PM+0dIiVzwxAg4E2dXDVe39awO/tw8Vu1o1+NPFhWAzGcidP7pAHmPEgRTVO +7LA2P3CDXpkAEx5E0QW6QWZGqHfSa3+P1AvetvAV+OxtlDphcNeLApY16TUVOKZg +SZlxW2e0dZylbHewgLBTIV9wUQKBgQDKdML+JD18WfenPeowsw8HzKdaw01iGiV1 +fvTjEXu6YxPPynWFMuj5gjBQodXM2vv0EsQBAPKYfe0nzRFL2kNuYs7TLoaNxqkp +DNfJ2Ww5OSg7Mp76XgppeKKlsXLyUMYHHrDh6MRi5jvWtiHRpaNmV3cHMRs22c+B +cqKP5Zma2wKBgCPNnS2Lsrbh3C+qWQRgVq0q9zFMa1PgEgGKpwVjlwvaAACZOjX9 +0e1aVkx+d/E98U55FPdJQf9Koa58NdJ0a7dZGor4YnYFpr7TPFh2/xxvnpoN0AVt +IsWOCIW7MVohcGOeiChkMmnyXibnQwaX1LgEhlx1bRvtDYsZWBsgarYRAoGAARvo +oYnDSHYZtDHToZapg2pslEOzndD02ZLrdn73BYtbZWz/fc5MlmlPKHHqgOfGL40W +w8akjY9LCEfIS3kTm3wxE9kSZZ5r+MyYNgPZ4upcPQ7G7iortm4xveSd85PbsdhK +McKbqMsIEuIGh2Z34ayi+0galQ9WYqglGdKxJ7cCgYEAuSPBHa+en0xaraZNRvMk +OfV9Su/wrpR3TXSeo0E1mZHLwq1JwulpfO1SjxTH5uOJtG0tusl122wfm0KjrXUO +vG5/It+X4u1Nv9oWj+z1+EV4fQrQ/Coqcc1r+5w1yzfURkKlHh74jbK5Yy/KfXrE +eqbbJD40tKhY8ho15D3iCSo= -----END PRIVATE KEY----- diff --git a/test/regression/fixtures/cert.pem b/test/regression/fixtures/cert.pem index 8ae1c1ea43..1c3ac8eb4b 100644 --- a/test/regression/fixtures/cert.pem +++ b/test/regression/fixtures/cert.pem @@ -1,23 +1,24 @@ -----BEGIN CERTIFICATE----- -MIID5jCCAs6gAwIBAgIUN7coIsdMcLo9amZfkwogu0YkeLEwDQYJKoZIhvcNAQEL +MIIEDDCCAvSgAwIBAgIUbddWE2woW5e96uC4S2fd2M0AsFAwDQYJKoZIhvcNAQEL BQAwfjELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVN0YXRlMREwDwYDVQQHDAhMb2Nh dGlvbjEaMBgGA1UECgwRT3JnYW5pemF0aW9uIE5hbWUxHDAaBgNVBAsME09yZ2Fu -aXphdGlvbmFsIFVuaXQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yMzA5MjExNDE2 -MjNaFw0yNDA5MjAxNDE2MjNaMH4xCzAJBgNVBAYTAlNFMQ4wDAYDVQQIDAVTdGF0 +aXphdGlvbmFsIFVuaXQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yNjAyMTMyMzEx +MjlaFw0zNjAyMTEyMzExMjlaMH4xCzAJBgNVBAYTAlNFMQ4wDAYDVQQIDAVTdGF0 ZTERMA8GA1UEBwwITG9jYXRpb24xGjAYBgNVBAoMEU9yZ2FuaXphdGlvbiBOYW1l MRwwGgYDVQQLDBNPcmdhbml6YXRpb25hbCBVbml0MRIwEAYDVQQDDAlsb2NhbGhv -c3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCIzOJskt6VkEJYXKSJ -v/Gdil3XYkjk3NVc/+m+kzqnkTRbPtT9w+IGWgmJhuf9DJPLCwHFAEFarVwVx16Q -0PbU4ajXaLRHEYGhrH10oTMjQnJ24xVm26mxRXPQa5vaLpWJqNyIdNLIQLe+UXUO -zSGGsFTRMAjvYrkzjBe4ZUnaZV+aFY/ug0jfzeA1dJjzKZs6+yTJRbsuWUEb8MsD -mT4v+kBZDKdaDn7AFDWRVqx/38BnqsRzkM0CxpnyT2kRzw5zQajIE13gdTJo1EHv -YSUkkxrY5m30Rl9BuBBZBjhMzOHq0fYVVooHO+sf4XHPgvFTTxJum85u7J1JoEUj -rLKtAgMBAAGjXDBaMA4GA1UdDwEB/wQEAwIDiDATBgNVHSUEDDAKBggrBgEFBQcD -ATAUBgNVHREEDTALgglsb2NhbGhvc3QwHQYDVR0OBBYEFNzx4Rfs9m8XR5ML0WsI -sorKmB4PMA0GCSqGSIb3DQEBCwUAA4IBAQB87iQy8R0fiOky9WTcyzVeMaavS3MX -iTe1BRn1OCyDq+UiwwoNz7zdzZJFEmRtFBwPNFOe4HzLu6E+7yLFR552eYRHlqIi -/fiLb5JiZfPtokUHeqwELWBsoXtU8vKxViPiLZ09jkWOPZWo7b/xXd6QYykBfV91 -usUXLzyTD2orMagpqNksLDGS3p3ggHEJBZtRZA8R7kPEw98xZHznOQpr26iv8kYz -ZWdLFoFdwgFBSfxePKax5rfo+FbwdrcTX0MhbORyiu2XsBAghf8s2vKDkHg2UQE8 -haonxFYMFaASfaZ/5vWKYDTCJkJ67m/BtkpRafFEO+ad1i1S61OjfxH4 +c3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCt7iqkEIco372hv19q +0zjaYbm6gzxEnR45UjpQYqgztq4QHicD80mqIkCBCYknFxhwxhNn+Y3g5RWQdRep +lpQbkneqRVp+qixMvu2FmOA4zRRoqObP7FyF1Yusvmroe0Y9SP2xTTmA9Zo73pay +wPUIuZ9eKGwIiFTtj1yQ1FdghLhzZgxcf3LHEHRkGnxgxxNITFxh4nd6fGIjNqM5 +fQAY8z35lMXdeWjrhtaqgFYB+Z20YY0X7LJx39vYao0wqW8sZjX88TqHI1zXWLpU +k6UK9RqaNza5xc80wV+9/zjhr3dc1FRjBxI1DS/ufo33dUfvilxv9/LtWwUnKfKL +ns9LAgMBAAGjgYEwfzAdBgNVHQ4EFgQUQCpSY7ODhdyD6pdZHvfHoWRXWsIwHwYD +VR0jBBgwFoAUQCpSY7ODhdyD6pdZHvfHoWRXWsIwDwYDVR0TAQH/BAUwAwEB/zAs +BgNVHREEJTAjgglsb2NhbGhvc3SHBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAEwDQYJ +KoZIhvcNAQELBQADggEBAGKTIzGQsOqfD0+x15F2cu7FKjIo1ua0OiILAhPqGX65 +kGcetjC/dJip2bGnw1NjG9WxEJNZ4YcsGrwh9egfnXXmfHNL0wzx/LTo2oysbXsN +nEj+cmzw3Lwjn/ywJc+AC221/xrmDfm3m/hMzLqncnj23ZAHqkXTSp5UtSMs+UDQ +my0AJOvsDGPVKHQsAX3JDjKHaoVJn4YqpHcIGmpjrNcQSvwUocDHPcC0ywco6SgF +Ylzy2bwWWdPd9Cz9JkAMb95nWc7Rwf/nxAqCjJFzKEisvrx7VZ+QSVI0nqJzt8V1 +pbtWYH5gMFVstU3ghWdSLbAk4XufGYrIWAlA5mqjQ4o= -----END CERTIFICATE----- diff --git a/test/regression/issue/25707.test.ts b/test/regression/issue/25707.test.ts new file mode 100644 index 0000000000..22d9b54444 --- /dev/null +++ b/test/regression/issue/25707.test.ts @@ -0,0 +1,113 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// https://github.com/oven-sh/bun/issues/25707 +// Dynamic import() of non-existent node: modules inside CJS files should not +// fail at transpile/require time. They should be deferred to runtime so that +// try/catch can handle the error gracefully. + +test("require() of CJS file containing dynamic import of non-existent node: module does not fail at load time", async () => { + using dir = tempDir("issue-25707", { + // Simulates turbopack-generated chunks: a CJS module with a factory function + // containing import("node:sqlite") inside a try/catch that is never called + // during require(). + "chunk.js": ` + module.exports = [ + function factory(exports) { + async function detect(e) { + if ("createSession" in e) { + let c; + try { + ({DatabaseSync: c} = await import("node:sqlite")) + } catch(a) { + if (null !== a && "object" == typeof a && "code" in a && "ERR_UNKNOWN_BUILTIN_MODULE" !== a.code) + throw a; + } + } + } + exports.detect = detect; + } + ]; + `, + "main.js": ` + // This require() should not fail even though chunk.js contains import("node:sqlite") + const factories = require("./chunk.js"); + console.log("loaded " + factories.length + " factories"); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("loaded 1 factories"); + expect(exitCode).toBe(0); +}); + +test("require() of CJS file with bare dynamic import of non-existent node: module does not fail at load time", async () => { + // The dynamic import is NOT inside a try/catch, but it's still a dynamic import + // that should only be resolved at runtime, not at transpile time + using dir = tempDir("issue-25707-bare", { + "lib.js": ` + module.exports = async function() { + const { DatabaseSync } = await import("node:sqlite"); + return DatabaseSync; + }; + `, + "main.js": ` + const fn = require("./lib.js"); + console.log("loaded"); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("loaded"); + expect(exitCode).toBe(0); +}); + +test("dynamic import of non-existent node: module in CJS rejects at runtime with correct error", async () => { + using dir = tempDir("issue-25707-runtime", { + "lib.js": ` + module.exports = async function() { + try { + const { DatabaseSync } = await import("node:sqlite"); + return "resolved"; + } catch (e) { + return "caught: " + e.code; + } + }; + `, + "main.js": ` + const fn = require("./lib.js"); + fn().then(result => console.log(result)); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("caught: ERR_UNKNOWN_BUILTIN_MODULE"); + expect(exitCode).toBe(0); +}); diff --git a/test/regression/issue/26851.test.ts b/test/regression/issue/26851.test.ts new file mode 100644 index 0000000000..c3a0790978 --- /dev/null +++ b/test/regression/issue/26851.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "path"; + +test("--bail writes JUnit reporter outfile", async () => { + using dir = tempDir("bail-junit", { + "fail.test.ts": ` + import { test, expect } from "bun:test"; + test("failing test", () => { expect(1).toBe(2); }); + `, + }); + + const outfile = join(String(dir), "results.xml"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--bail", "--reporter=junit", `--reporter-outfile=${outfile}`, "fail.test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + + // The test should fail and bail + expect(exitCode).not.toBe(0); + + // The JUnit report file should still be written despite bail + const file = Bun.file(outfile); + expect(await file.exists()).toBe(true); + + const xml = await file.text(); + expect(xml).toContain(""); + expect(xml).toContain("failing test"); +}); + +test("--bail writes JUnit reporter outfile with multiple files", async () => { + using dir = tempDir("bail-junit-multi", { + "a_pass.test.ts": ` + import { test, expect } from "bun:test"; + test("passing test", () => { expect(1).toBe(1); }); + `, + "b_fail.test.ts": ` + import { test, expect } from "bun:test"; + test("another failing test", () => { expect(1).toBe(2); }); + `, + }); + + const outfile = join(String(dir), "results.xml"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--bail", "--reporter=junit", `--reporter-outfile=${outfile}`], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + + // The test should fail and bail + expect(exitCode).not.toBe(0); + + // The JUnit report file should still be written despite bail + const file = Bun.file(outfile); + expect(await file.exists()).toBe(true); + + const xml = await file.text(); + expect(xml).toContain(""); + // Both the passing and failing tests should be recorded + expect(xml).toContain("passing test"); + expect(xml).toContain("another failing test"); +}); diff --git a/test/regression/issue/postgres-null-byte-injection.test.ts b/test/regression/issue/postgres-null-byte-injection.test.ts new file mode 100644 index 0000000000..d5b1534c7e --- /dev/null +++ b/test/regression/issue/postgres-null-byte-injection.test.ts @@ -0,0 +1,187 @@ +import { SQL } from "bun"; +import { expect, test } from "bun:test"; +import net from "net"; + +test("postgres connection rejects null bytes in username", async () => { + let serverReceivedData = false; + + const server = net.createServer(socket => { + serverReceivedData = true; + socket.destroy(); + }); + + await new Promise(r => server.listen(0, "127.0.0.1", () => r())); + const port = (server.address() as net.AddressInfo).port; + + try { + const sql = new SQL({ + hostname: "127.0.0.1", + port, + username: "alice\x00search_path\x00evil_schema,public", + database: "testdb", + max: 1, + idleTimeout: 1, + connectionTimeout: 2, + }); + + await sql`SELECT 1`; + expect.unreachable(); + } catch (e: any) { + expect(e.message).toContain("null bytes"); + } finally { + server.close(); + } + + // The server should never have received any data because the null byte + // should be rejected before the connection is established. + expect(serverReceivedData).toBe(false); +}); + +test("postgres connection rejects null bytes in database", async () => { + let serverReceivedData = false; + + const server = net.createServer(socket => { + serverReceivedData = true; + socket.destroy(); + }); + + await new Promise(r => server.listen(0, "127.0.0.1", () => r())); + const port = (server.address() as net.AddressInfo).port; + + try { + const sql = new SQL({ + hostname: "127.0.0.1", + port, + username: "alice", + database: "testdb\x00search_path\x00evil_schema,public", + max: 1, + idleTimeout: 1, + connectionTimeout: 2, + }); + + await sql`SELECT 1`; + expect.unreachable(); + } catch (e: any) { + expect(e.message).toContain("null bytes"); + } finally { + server.close(); + } + + expect(serverReceivedData).toBe(false); +}); + +test("postgres connection rejects null bytes in password", async () => { + let serverReceivedData = false; + + const server = net.createServer(socket => { + serverReceivedData = true; + socket.destroy(); + }); + + await new Promise(r => server.listen(0, "127.0.0.1", () => r())); + const port = (server.address() as net.AddressInfo).port; + + try { + const sql = new SQL({ + hostname: "127.0.0.1", + port, + username: "alice", + password: "pass\x00search_path\x00evil_schema", + database: "testdb", + max: 1, + idleTimeout: 1, + connectionTimeout: 2, + }); + + await sql`SELECT 1`; + expect.unreachable(); + } catch (e: any) { + expect(e.message).toContain("null bytes"); + } finally { + server.close(); + } + + expect(serverReceivedData).toBe(false); +}); + +test("postgres connection does not use truncated path with null bytes", async () => { + // The JS layer's fs.existsSync() rejects paths containing null bytes, + // so the path is dropped before reaching the native layer. Verify that a + // path with null bytes doesn't silently connect via a truncated path. + let serverReceivedData = false; + + const server = net.createServer(socket => { + serverReceivedData = true; + socket.destroy(); + }); + + await new Promise(r => server.listen(0, "127.0.0.1", () => r())); + const port = (server.address() as net.AddressInfo).port; + + try { + const sql = new SQL({ + hostname: "127.0.0.1", + port, + username: "alice", + database: "testdb", + path: "/tmp\x00injected", + max: 1, + idleTimeout: 1, + connectionTimeout: 2, + }); + + await sql`SELECT 1`; + } catch { + // Expected to fail + } finally { + server.close(); + } + + // The path had null bytes so it should have been dropped by the JS layer, + // falling back to TCP where it hits our mock server (not a truncated Unix socket). + expect(serverReceivedData).toBe(true); +}); + +test("postgres connection works with normal parameters (no null bytes)", async () => { + // Verify that normal connections without null bytes still work. + // Use a mock server that sends an auth error so we can verify the + // startup message is sent correctly. + let receivedData = false; + + const server = net.createServer(socket => { + socket.once("data", () => { + receivedData = true; + const errMsg = Buffer.from("SFATAL\0VFATAL\0C28000\0Mauthentication failed\0\0"); + const len = errMsg.length + 4; + const header = Buffer.alloc(5); + header.write("E", 0); + header.writeInt32BE(len, 1); + socket.write(Buffer.concat([header, errMsg])); + socket.destroy(); + }); + }); + + await new Promise(r => server.listen(0, "127.0.0.1", () => r())); + const port = (server.address() as net.AddressInfo).port; + + try { + const sql = new SQL({ + hostname: "127.0.0.1", + port, + username: "alice", + database: "testdb", + max: 1, + idleTimeout: 1, + connectionTimeout: 2, + }); + + await sql`SELECT 1`; + } catch { + // Expected - mock server sends auth error + } finally { + server.close(); + } + + // Normal parameters should connect fine - the server should receive data + expect(receivedData).toBe(true); +}); diff --git a/test/regression/issue/s3-header-injection.test.ts b/test/regression/issue/s3-header-injection.test.ts new file mode 100644 index 0000000000..4b5fa26beb --- /dev/null +++ b/test/regression/issue/s3-header-injection.test.ts @@ -0,0 +1,148 @@ +import { S3Client } from "bun"; +import { describe, expect, test } from "bun:test"; + +// Test that CRLF characters in S3 options are rejected to prevent header injection. +// See: HTTP Header Injection via S3 Content-Disposition Value + +describe("S3 header injection prevention", () => { + test("contentDisposition with CRLF should throw", () => { + using server = Bun.serve({ + port: 0, + fetch() { + return new Response("OK", { status: 200 }); + }, + }); + + const client = new S3Client({ + accessKeyId: "test-key", + secretAccessKey: "test-secret", + endpoint: server.url.href, + bucket: "test-bucket", + }); + + expect(() => + client.write("test-file.txt", "Hello", { + contentDisposition: 'attachment; filename="evil"\r\nX-Injected: value', + }), + ).toThrow(/CR\/LF/); + }); + + test("contentEncoding with CRLF should throw", () => { + using server = Bun.serve({ + port: 0, + fetch() { + return new Response("OK", { status: 200 }); + }, + }); + + const client = new S3Client({ + accessKeyId: "test-key", + secretAccessKey: "test-secret", + endpoint: server.url.href, + bucket: "test-bucket", + }); + + expect(() => + client.write("test-file.txt", "Hello", { + contentEncoding: "gzip\r\nX-Injected: value", + }), + ).toThrow(/CR\/LF/); + }); + + test("type (content-type) with CRLF should throw", () => { + using server = Bun.serve({ + port: 0, + fetch() { + return new Response("OK", { status: 200 }); + }, + }); + + const client = new S3Client({ + accessKeyId: "test-key", + secretAccessKey: "test-secret", + endpoint: server.url.href, + bucket: "test-bucket", + }); + + expect(() => + client.write("test-file.txt", "Hello", { + type: "text/plain\r\nX-Injected: value", + }), + ).toThrow(/CR\/LF/); + }); + + test("contentDisposition with only CR should throw", () => { + using server = Bun.serve({ + port: 0, + fetch() { + return new Response("OK", { status: 200 }); + }, + }); + + const client = new S3Client({ + accessKeyId: "test-key", + secretAccessKey: "test-secret", + endpoint: server.url.href, + bucket: "test-bucket", + }); + + expect(() => + client.write("test-file.txt", "Hello", { + contentDisposition: "attachment\rinjected", + }), + ).toThrow(/CR\/LF/); + }); + + test("contentDisposition with only LF should throw", () => { + using server = Bun.serve({ + port: 0, + fetch() { + return new Response("OK", { status: 200 }); + }, + }); + + const client = new S3Client({ + accessKeyId: "test-key", + secretAccessKey: "test-secret", + endpoint: server.url.href, + bucket: "test-bucket", + }); + + expect(() => + client.write("test-file.txt", "Hello", { + contentDisposition: "attachment\ninjected", + }), + ).toThrow(/CR\/LF/); + }); + + test("valid contentDisposition without CRLF should not throw", async () => { + const { promise: requestReceived, resolve: onRequestReceived } = Promise.withResolvers(); + + using server = Bun.serve({ + port: 0, + async fetch(req) { + onRequestReceived(req.headers); + return new Response("OK", { status: 200 }); + }, + }); + + const client = new S3Client({ + accessKeyId: "test-key", + secretAccessKey: "test-secret", + endpoint: server.url.href, + bucket: "test-bucket", + }); + + // Valid content-disposition values should not throw synchronously. + // The write may eventually fail because the mock server doesn't speak S3 protocol, + // but the option parsing should succeed and a request should be initiated. + expect(() => + client.write("test-file.txt", "Hello", { + contentDisposition: 'attachment; filename="report.pdf"', + }), + ).not.toThrow(); + + const receivedHeaders = await requestReceived; + expect(receivedHeaders.get("content-disposition")).toBe('attachment; filename="report.pdf"'); + }); +});