mirror of
https://github.com/oven-sh/bun
synced 2026-02-14 12:51:54 +00:00
Merge branch 'main' into dylan/native-windows-aarch64-build
This commit is contained in:
292
src/CLAUDE.md
292
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("<green>success<r>: {s}\n", .{msg});
|
||||
bun.Output.prettyErrorln("<red>error<r>: {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
|
||||
```
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -5954,16 +5954,14 @@ ExceptionOr<Ref<SerializedScriptValue>> SerializedScriptValue::create(JSGlobalOb
|
||||
auto* data = array->butterfly()->contiguous().data();
|
||||
if (!containsHole(data, length)) {
|
||||
size_t byteSize = sizeof(JSValue) * length;
|
||||
Vector<uint8_t> buffer(byteSize, 0);
|
||||
memcpy(buffer.mutableSpan().data(), data, byteSize);
|
||||
Vector<uint8_t> buffer(std::span<const uint8_t> { reinterpret_cast<const uint8_t*>(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<uint8_t> buffer(byteSize, 0);
|
||||
memcpy(buffer.mutableSpan().data(), data, byteSize);
|
||||
Vector<uint8_t> buffer(std::span<const uint8_t> { reinterpret_cast<const uint8_t*>(data), byteSize });
|
||||
return SerializedScriptValue::createDoubleArrayFastPath(WTF::move(buffer), length);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -948,6 +948,7 @@ pub const CommandLineReporter = struct {
|
||||
this.printSummary();
|
||||
Output.prettyError("\nBailed out after {d} failure{s}<r>\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}<r>\n", .{ reporter.jest.bail, if (reporter.jest.bail == 1) "" else "s" });
|
||||
reporter.writeJUnitReportIfNeeded();
|
||||
|
||||
vm.exit_handler.exit_code = 1;
|
||||
vm.is_shutting_down = true;
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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});
|
||||
|
||||
72
src/ini.zig
72
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,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {};
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<void>((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) {
|
||||
|
||||
@@ -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()", () => {
|
||||
|
||||
@@ -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-----
|
||||
|
||||
@@ -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-----
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
40
test/js/bun/shell/shell-cmdsub-crash.test.ts
Normal file
40
test/js/bun/shell/shell-cmdsub-crash.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
85
test/js/bun/shell/shell-seq-condexpr.test.ts
Normal file
85
test/js/bun/shell/shell-seq-condexpr.test.ts
Normal file
@@ -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);
|
||||
@@ -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();
|
||||
|
||||
222
test/js/web/websocket/websocket-pong-fragmented.test.ts
Normal file
222
test/js/web/websocket/websocket-pong-fragmented.test.ts
Normal file
@@ -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<void>();
|
||||
|
||||
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<void>();
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<void>();
|
||||
|
||||
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)", () => {
|
||||
|
||||
@@ -109,13 +109,22 @@ describe("HTMLRewriter", () => {
|
||||
await gcTick();
|
||||
let content;
|
||||
{
|
||||
using contentServer = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
return new Response("<h1>Hello from content server</h1>", {
|
||||
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 });
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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-----
|
||||
|
||||
@@ -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-----
|
||||
|
||||
113
test/regression/issue/25707.test.ts
Normal file
113
test/regression/issue/25707.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
77
test/regression/issue/26851.test.ts
Normal file
77
test/regression/issue/26851.test.ts
Normal file
@@ -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("<?xml");
|
||||
expect(xml).toContain("<testsuites");
|
||||
expect(xml).toContain("</testsuites>");
|
||||
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("<?xml");
|
||||
expect(xml).toContain("<testsuites");
|
||||
expect(xml).toContain("</testsuites>");
|
||||
// Both the passing and failing tests should be recorded
|
||||
expect(xml).toContain("passing test");
|
||||
expect(xml).toContain("another failing test");
|
||||
});
|
||||
187
test/regression/issue/postgres-null-byte-injection.test.ts
Normal file
187
test/regression/issue/postgres-null-byte-injection.test.ts
Normal file
@@ -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<void>(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<void>(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<void>(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<void>(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<void>(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);
|
||||
});
|
||||
148
test/regression/issue/s3-header-injection.test.ts
Normal file
148
test/regression/issue/s3-header-injection.test.ts
Normal file
@@ -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<Headers>();
|
||||
|
||||
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"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user