diff --git a/.github/workflows/update-vendor.yml b/.github/workflows/update-vendor.yml index 0142e72b2dcb..928a021fa0bc 100644 --- a/.github/workflows/update-vendor.yml +++ b/.github/workflows/update-vendor.yml @@ -55,9 +55,12 @@ jobs: - name: Update version if needed if: success() && steps.check-version.outputs.current != steps.check-version.outputs.latest + env: + PACKAGE: ${{ matrix.package }} + LATEST_TAG: ${{ steps.check-version.outputs.latest }} run: | set -euo pipefail - bun -e 'await Bun.write("test/vendor.json", JSON.stringify((await Bun.file("test/vendor.json").json()).map(v=>{if(v.package===process.argv[1])v.tag=process.argv[2];return v;}), null, 2) + "\n")' ${{ matrix.package }} ${{ steps.check-version.outputs.latest }} + bun -e 'await Bun.write("test/vendor.json", JSON.stringify((await Bun.file("test/vendor.json").json()).map(v=>{if(v.package===process.argv[1])v.tag=process.argv[2];return v;}), null, 2) + "\n")' "$PACKAGE" "$LATEST_TAG" - name: Create Pull Request if: success() && steps.check-version.outputs.current != steps.check-version.outputs.latest diff --git a/Cargo.lock b/Cargo.lock index 2550563638a3..cd07035fb068 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1988,7 +1988,6 @@ dependencies = [ "bun_sql", "bun_uws", "bun_uws_sys", - "bun_wyhash", "const_format", "enum-map", "enumset", diff --git a/completions/bun.bash b/completions/bun.bash index 2188be22e344..7040f0ca51b2 100644 --- a/completions/bun.bash +++ b/completions/bun.bash @@ -48,7 +48,7 @@ _read_scripts_in_package_json() { scripts="${scripts//@(\"|\')/}"; readarray -td, scripts <<<"${scripts}"; for completion in "${scripts[@]}"; do - package_json_compreply+=( "${completion%:*}" ); + [[ "${completion}" =~ ^[[:space:]]*([[:alnum:]@/:._-]+)[[:space:]]*: ]] && package_json_compreply+=( "${BASH_REMATCH[1]}" ); done COMPREPLY+=( $(compgen -W "${package_json_compreply[*]}" -- "${cur_word}") ); } @@ -59,9 +59,16 @@ _read_scripts_in_package_json() { ( "${COMPREPLY[*]}" =~ ${re_prev_script} && -n "${COMP_WORDS[2]}" ) || \ ( "${COMPREPLY[*]}" =~ ${re_comp_word_script} ) ]] && { - local re_script=$(echo ${package_json_compreply[@]} | sed 's/[^ ]*/(&)/g'); - local new_reply=$(echo "${COMPREPLY[@]}" | sed -E "s/$re_script//"); - COMPREPLY=( $(compgen -W "${new_reply}" -- "${cur_word}") ); + local filtered_reply=(); + local reply_word script_name keep; + for reply_word in "${COMPREPLY[@]}"; do + keep=1; + for script_name in "${package_json_compreply[@]}"; do + [[ "${reply_word}" == "${script_name}" ]] && { keep=""; break; }; + done + [[ -n "${keep}" ]] && filtered_reply+=( "${reply_word}" ); + done + COMPREPLY=( "${filtered_reply[@]}" ); replaced_script="${prev}"; } } @@ -175,8 +182,12 @@ _bun_completions() { # the previous word is not part of the allowed completion # the previous word is not an argument to the last two option [[ -z "${cur_word}" ]] && { - declare -A comp_reply_associative="( $(echo ${COMPREPLY[@]} | sed 's/[^ ]*/[&]=&/g') )"; - [[ -z "${comp_reply_associative[${prev}]}" ]] && { + local prev_in_reply=""; + local reply_word; + for reply_word in "${COMPREPLY[@]}"; do + [[ "${reply_word}" == "${prev}" ]] && { prev_in_reply=1; break; }; + done + [[ -z "${prev_in_reply}" ]] && { local re_prev_prev="(^| )${COMP_WORDS[(( COMP_CWORD - 2 ))]}($| )"; local global_option_with_extra_args="--bunfile --server-bunfile --config --port --cwd --public-dir --jsx-runtime --platform --loader"; [[ diff --git a/completions/bun.zsh b/completions/bun.zsh index 768ff6cfdb2e..9ab15deedf66 100644 --- a/completions/bun.zsh +++ b/completions/bun.zsh @@ -738,7 +738,8 @@ _bun() { cmd) local -a scripts_list IFS=$'\n' scripts_list=($(SHELL=zsh bun getcompletes i)) - scripts="scripts:scripts:((${scripts_list//:/\\\\:}))" + scripts="scripts:scripts:compadd -a scripts_list" + local -a files_list IFS=$'\n' files_list=($(SHELL=zsh bun getcompletes j)) main_commands=( @@ -761,7 +762,7 @@ _bun() { 'help\:"Show all supported flags and commands" ' ) main_commands=($main_commands) - _alternative "$scripts" "args:command:(($main_commands))" "files:files:(($files_list))" + _alternative "$scripts" "args:command:(($main_commands))" "files:files:compadd -a files_list" ;; args) @@ -942,12 +943,12 @@ _bun_list_bunfig_toml() { } _bun_run_param_script_completion() { - local -a scripts_list + local -a scripts_list bins IFS=$'\n' scripts_list=($(SHELL=zsh bun getcompletes s)) IFS=$'\n' bins=($(SHELL=zsh bun getcompletes b)) - _alternative "scripts:scripts:((${scripts_list//:/\\\\:}))" - _alternative "bin:bin:((${bins//:/\\\\:}))" + _alternative "scripts:scripts:compadd -a scripts_list" + _alternative "bin:bin:compadd -a bins" _alternative "files:file:_files -g '*.(js|ts|jsx|tsx|wasm)'" } @@ -958,8 +959,8 @@ _bun_link_param_package_completion() { global_node_modules=$install_dir/install/global/node_modules local -a packages_full_path=(${global_node_modules}/*(N)) - packages=$(echo $packages_full_path | tr ' ' '\n' | xargs basename) - _alternative "dirs:directory:(($packages))" + local -a packages=(${packages_full_path:t}) + _alternative "dirs:directory:compadd -a packages" } _bun_remove_param_package_completion() { @@ -969,10 +970,11 @@ _bun_remove_param_package_completion() { # TODO: move to "bun getcompletes" if [ -f "package.json" ]; then - local dependencies=$(jq -r '.dependencies | keys[]' package.json) - local dev_dependencies=$(jq -r '.devDependencies | keys[]' package.json) - _alternative "deps:dependency:(($dependencies))" - _alternative "deps:dependency:(($dev_dependencies))" + local -a dependencies dev_dependencies + IFS=$'\n' dependencies=($(jq -r '.dependencies | keys[]' package.json)) + IFS=$'\n' dev_dependencies=($(jq -r '.devDependencies | keys[]' package.json)) + _alternative "deps:dependency:compadd -a dependencies" + _alternative "deps:dependency:compadd -a dev_dependencies" fi } diff --git a/dockerhub/alpine/Dockerfile b/dockerhub/alpine/Dockerfile index 4d5a01876f91..d02d8e0d271d 100644 --- a/dockerhub/alpine/Dockerfile +++ b/dockerhub/alpine/Dockerfile @@ -35,7 +35,7 @@ RUN apk --no-cache add ca-certificates curl dirmngr gpg gpg-agent unzip \ -fsSLO \ --compressed \ --retry 5 \ - && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \ + && gpg --batch --verify --output SHASUMS256.txt SHASUMS256.txt.asc \ || (echo "error: failed to verify: $tag" && exit 1) \ && grep " bun-linux-$build.zip\$" SHASUMS256.txt | sha256sum -c - \ || (echo "error: failed to verify: $tag" && exit 1) \ diff --git a/dockerhub/debian-slim/Dockerfile b/dockerhub/debian-slim/Dockerfile index b806573832d5..f510bf8220a0 100644 --- a/dockerhub/debian-slim/Dockerfile +++ b/dockerhub/debian-slim/Dockerfile @@ -44,7 +44,7 @@ RUN apt-get update -qq \ -fsSLO \ --compressed \ --retry 5 \ - && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \ + && gpg --batch --verify --output SHASUMS256.txt SHASUMS256.txt.asc \ || (echo "error: failed to verify: $tag" && exit 1) \ && grep " bun-linux-$build.zip\$" SHASUMS256.txt | sha256sum -c - \ || (echo "error: failed to verify: $tag" && exit 1) \ diff --git a/dockerhub/debian/Dockerfile b/dockerhub/debian/Dockerfile index a1438c52a44c..eb5c4a9e93c5 100644 --- a/dockerhub/debian/Dockerfile +++ b/dockerhub/debian/Dockerfile @@ -47,7 +47,7 @@ RUN apt-get update -qq \ -fsSLO \ --compressed \ --retry 5 \ - && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \ + && gpg --batch --verify --output SHASUMS256.txt SHASUMS256.txt.asc \ || (echo "error: failed to verify: $tag" && exit 1) \ && grep " bun-linux-$build.zip\$" SHASUMS256.txt | sha256sum -c - \ || (echo "error: failed to verify: $tag" && exit 1) \ diff --git a/dockerhub/distroless/Dockerfile b/dockerhub/distroless/Dockerfile index 8d4e98d78777..fec474bf7b42 100644 --- a/dockerhub/distroless/Dockerfile +++ b/dockerhub/distroless/Dockerfile @@ -44,7 +44,7 @@ RUN apt-get update -qq \ -fsSLO \ --compressed \ --retry 5 \ - && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \ + && gpg --batch --verify --output SHASUMS256.txt SHASUMS256.txt.asc \ || (echo "error: failed to verify: $tag" && exit 1) \ && grep " bun-linux-$build.zip\$" SHASUMS256.txt | sha256sum -c - \ || (echo "error: failed to verify: $tag" && exit 1) \ diff --git a/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts b/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts index 3d233bfce69f..f15cdab3e6ea 100644 --- a/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts +++ b/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts @@ -1,4 +1,5 @@ import { ChildProcess, spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; import { EventEmitter } from "node:events"; import { AddressInfo, createServer, Socket } from "node:net"; import * as path from "node:path"; @@ -2813,7 +2814,7 @@ function nextId(): number { } export function getRandomId() { - return Math.random().toString(36).slice(2); + return randomBytes(16).toString("hex"); } export function normalizeWindowsPath(winPath: string): string { diff --git a/packages/bun-debug-adapter-protocol/src/debugger/sourcemap.test.ts b/packages/bun-debug-adapter-protocol/src/debugger/sourcemap.test.ts index e4f883b9cb93..bb8524dfe8c9 100644 --- a/packages/bun-debug-adapter-protocol/src/debugger/sourcemap.test.ts +++ b/packages/bun-debug-adapter-protocol/src/debugger/sourcemap.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { connect } from "node:net"; import { networkInterfaces } from "node:os"; -import { WebSocketDebugAdapter } from "./adapter.js"; +import { getRandomId, WebSocketDebugAdapter } from "./adapter.js"; import { TCPSocketSignal } from "./signal.js"; import { SourceMap } from "./sourcemap.js"; @@ -67,6 +67,16 @@ test("only forwards inspector events from known protocol domains to the adapter" expect(heapEvents).toEqual([{ collection: { type: "full", startTime: 0, endTime: 1 } }]); }); +test("getRandomId returns a distinct 32-character lowercase hex string on every call", () => { + const ids = new Set(); + for (let i = 0; i < 256; i++) { + const id = getRandomId(); + expect(id).toMatch(/^[0-9a-f]{32}$/); + ids.add(id); + } + expect(ids.size).toBe(256); +}); + test("TCPSocketSignal accepts connections only on the loopback interface", async () => { // Same construction the VS Code extension uses (diagnostics.ts createSignal). const signal = new TCPSocketSignal(0); diff --git a/packages/bun-release/src/npm/install.ts b/packages/bun-release/src/npm/install.ts index b8c603e5a611..ce7853babc2a 100644 --- a/packages/bun-release/src/npm/install.ts +++ b/packages/bun-release/src/npm/install.ts @@ -1,3 +1,4 @@ +import { isAbsolute, relative } from "path"; import { unzipSync } from "zlib"; import { debug, error } from "../console"; import { fetch } from "../fetch"; @@ -107,12 +108,16 @@ async function downloadBun(platform: Platform, dst: string): Promise { const size = parseInt(str(offset + 124, 12), 8); offset += 512; if (!isNaN(size)) { - write(join(dst, name), buffer.subarray(offset, offset + size)); - if (name === platform.exe) { - try { - chmod(join(dst, name), 0o755); - } catch (error) { - debug("chmod failed", error); + const entryPath = join(dst, name); + const entryName = relative(dst, entryPath); + if (entryName && !entryName.startsWith("..") && !isAbsolute(entryName)) { + write(entryPath, buffer.subarray(offset, offset + size)); + if (name === platform.exe) { + try { + chmod(entryPath, 0o755); + } catch (error) { + debug("chmod failed", error); + } } } offset += (size + 511) & ~511; diff --git a/packages/bun-usockets/src/context.c b/packages/bun-usockets/src/context.c index 11deb4a87aee..6bd57a6887cb 100644 --- a/packages/bun-usockets/src/context.c +++ b/packages/bun-usockets/src/context.c @@ -299,6 +299,9 @@ struct us_socket_t *us_socket_adopt(struct us_socket_t *s, struct us_socket_grou s->flags.adopted = 1; /* Tell the event loop what is the new socket so we can route subsequent events */ s->prev = new_s; + if (s->ssl) { + us_internal_ssl_socket_relocated(loop, s, new_s); + } } if (c) { c->connecting_head = new_s; diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index ecd2887621d8..0c60d38e56c6 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -632,6 +632,18 @@ static void ssl_release_spill(struct us_loop_t *loop, struct us_socket_t *s) { } } +void us_internal_ssl_socket_relocated(struct us_loop_t *loop, struct us_socket_t *old_s, + struct us_socket_t *new_s) { + struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)loop->data.ssl_data; + if (!loop_ssl_data) return; + if (loop_ssl_data->ssl_spill_owner == old_s) { + loop_ssl_data->ssl_spill_owner = new_s; + } + if (loop_ssl_data->ssl_last_fatal_error_owner == (void *)old_s) { + loop_ssl_data->ssl_last_fatal_error_owner = (void *)new_s; + } +} + static int BIO_s_custom_read(BIO *bio, char *dst, int length) { struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)BIO_get_data(bio); diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 257613a796c5..ea176e0432db 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -197,6 +197,7 @@ void us_internal_socket_after_open(us_socket_r s, int error); void us_internal_ssl_attach(us_socket_r s, struct ssl_ctx_st *ssl_ctx, int is_client, const char *sni, struct us_listen_socket_t *listener); /* SSL_free(s->ssl); s->ssl = NULL. Idempotent. */ void us_internal_ssl_detach(us_socket_r s); +void us_internal_ssl_socket_relocated(us_loop_r loop, us_socket_r old_s, us_socket_r new_s); /* TLS-layer event hooks. loop.c calls these instead of us_dispatch_* when * s->ssl != NULL; they decrypt/encrypt and re-dispatch the plaintext. */ diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index fea221668dc1..36b146825154 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -975,7 +975,15 @@ namespace uWS /* RFC 9112 6.3 * If a message is received with both a Transfer-Encoding and a Content-Length header field, * the Transfer-Encoding overrides the Content-Length. */ - if (transferEncoding.has) { + if (isConnectRequest) { + // This only serves to mark that the connect request read all headers + // and can start emitting data. Don't try to parse remaining data as HTTP - + // it's pipelined data that we've already captured in req->head. + remainingStreamingBytes = STATE_IS_CHUNKED; + // Mark remaining data as consumed and break - it's not HTTP + consumedTotal += length; + break; + } else if (transferEncoding.has) { /* We already validated that chunked is last if present, before calling the handler */ remainingStreamingBytes = STATE_IS_CHUNKED; /* If consume minimally, we do not want to consume anything but we want to mark this as being chunked */ @@ -1013,14 +1021,6 @@ namespace uWS return HttpParserResult::success(consumedTotal, returnedUser); } } - } else if(isConnectRequest) { - // This only serves to mark that the connect request read all headers - // and can start emitting data. Don't try to parse remaining data as HTTP - - // it's pipelined data that we've already captured in req->head. - remainingStreamingBytes = STATE_IS_CHUNKED; - // Mark remaining data as consumed and break - it's not HTTP - consumedTotal += length; - break; } else { /* If we came here without a body; emit an empty data chunk to signal no data */ void *returnedUser = dataHandler(user, {}, true); diff --git a/packages/bun-vscode/src/features/debug.ts b/packages/bun-vscode/src/features/debug.ts index a653fd676f4b..ea0a0d4d80da 100644 --- a/packages/bun-vscode/src/features/debug.ts +++ b/packages/bun-vscode/src/features/debug.ts @@ -347,7 +347,7 @@ class FileDebugSession extends DebugSession { } async initialize() { - const uniqueId = this.sessionId ?? Math.random().toString(36).slice(2); + const uniqueId = this.sessionId ?? getRandomId(); const url = process.platform === "win32" ? `ws://127.0.0.1:${await getAvailablePort()}/${getRandomId()}` diff --git a/packages/bun-vscode/src/features/lockfile/lockfile.style.ts b/packages/bun-vscode/src/features/lockfile/lockfile.style.ts index 7c465049757a..f306c9d6a815 100644 --- a/packages/bun-vscode/src/features/lockfile/lockfile.style.ts +++ b/packages/bun-vscode/src/features/lockfile/lockfile.style.ts @@ -13,23 +13,35 @@ function styleSection(section: string) { function styleLine(line: string) { if (line.startsWith("#")) { - return `${line}`; + return `${escapeHtml(line)}`; } const parts = line.trim().split(" "); if (line.startsWith(" ")) { - return `    ${parts[0]} ${parts[1]}`; + return `    ${escapeHtml(parts[0])} ${escapeHtml(parts[1])}`; } if (line.startsWith(" ")) { - const leftPart = `  ${parts[0]} `; + const leftPart = `  ${escapeHtml(parts[0])} `; if (parts.length === 1) return `${leftPart}`; if (parts[1].startsWith('"http://') || parts[1].startsWith('"https://')) - return `${leftPart}${parts[1]}`; - if (parts[1].startsWith('"')) return `${leftPart}${parts[1]}`; + return `${leftPart}${escapeHtml(parts[1])}`; + if (parts[1].startsWith('"')) return `${leftPart}${escapeHtml(parts[1])}`; - return `${leftPart}${parts[1]}`; + return `${leftPart}${escapeHtml(parts[1])}`; } - return `${line} `; + return `${escapeHtml(line)} `; +} + +const htmlEscapes: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +function escapeHtml(text: string | undefined): string { + return String(text).replace(/[&<>"']/g, character => htmlEscapes[character]); } diff --git a/src/ast/e.rs b/src/ast/e.rs index f9229ea4802f..c56df1da9f2a 100644 --- a/src/ast/e.rs +++ b/src/ast/e.rs @@ -1341,10 +1341,12 @@ impl Object { if let Some(q) = self.as_property(key) { self.properties.slice_mut()[q.i as usize].value = Some(expr); } else { + let key = Expr::init(EString::init(key), expr.loc); VecExt::append( &mut self.properties, G::Property { - key: Some(Expr::init(EString::init(key), expr.loc)), + flags: own_key_property_flags(&key), + key: Some(key), value: Some(expr), ..G::Property::default() }, @@ -1403,6 +1405,7 @@ impl Object { G::Property { key: Some(rope.head), value: Some(obj), + flags: own_key_property_flags(&rope.head), ..G::Property::default() }, ); @@ -1415,6 +1418,7 @@ impl Object { G::Property { key: Some(rope.head), value: Some(out), + flags: own_key_property_flags(&rope.head), ..G::Property::default() }, ); @@ -1422,6 +1426,18 @@ impl Object { } } +/// Data-file parsers (JSON/JSON5/TOML/YAML) define every key as an own +/// property, so an own `"__proto__"` string key must be marked computed: +/// a plain `"__proto__":` key in a printed object literal sets the prototype. +pub fn own_key_property_flags(key: &Expr) -> crate::flags::PropertySet { + match &key.data { + crate::expr::Data::EString(key_str) if key_str.eql_comptime(b"__proto__") => { + crate::flags::Property::IsComputed.into() + } + _ => crate::flags::PROPERTY_NONE, + } +} + // `toJS` alias deleted — lives in `js_parser_jsc` extension trait. impl Object { pub fn set(&mut self, key: Expr, _bump: &Bump, value: Expr) -> Result<(), SetError> { @@ -1436,6 +1452,7 @@ impl Object { VecExt::append( &mut self.properties, G::Property { + flags: own_key_property_flags(&key), key: Some(key), value: Some(value), ..G::Property::default() @@ -1501,6 +1518,7 @@ impl Object { G::Property { key: Some(rope.head), value: Some(value_), + flags: own_key_property_flags(&rope.head), ..G::Property::default() }, ); @@ -1556,6 +1574,7 @@ impl Object { G::Property { key: Some(rope.head), value: Some(obj), + flags: own_key_property_flags(&rope.head), ..G::Property::default() }, ); @@ -1568,6 +1587,7 @@ impl Object { G::Property { key: Some(rope.head), value: Some(out), + flags: own_key_property_flags(&rope.head), ..G::Property::default() }, ); diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 9c1186921866..1329ab2bbbdb 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -2135,12 +2135,11 @@ pub mod parse_worker { len: wrapper.result.source_len, } }; + // The plugin buffer has exactly one owner: + // `self.task.external_free_function` (set above), + // released via `BundleV2.finalizers`. return Ok(CacheEntry { contents, - external_free_function: ExternalFreeFunction { - ctx: wrapper.result.user_context, - function: free_fn, - }, fd: wrapper.original_source_fd, }); } diff --git a/src/bundler/ThreadPool.rs b/src/bundler/ThreadPool.rs index 4db4d57ad313..13ec1639add6 100644 --- a/src/bundler/ThreadPool.rs +++ b/src/bundler/ThreadPool.rs @@ -17,7 +17,7 @@ use bun_core::{self, env_var, output as Output}; use bun_sys::Fd; use bun_threading::{Mutex, thread_pool as ThreadPoolLib}; -use crate::cache::{Contents, Entry as CacheEntry, ExternalFreeFunction}; +use crate::cache::{Contents, Entry as CacheEntry}; use crate::linker_context_mod::StmtList; // `crate::options::Target` is the lower-tier `bun_options_types` // enum (re-exported for downstream crates); `BundleOptions.target` is the @@ -354,7 +354,6 @@ impl ThreadPool { } }, fd: Fd::INVALID, - external_free_function: ExternalFreeFunction::NONE, }); } diff --git a/src/bundler/barrel_imports.rs b/src/bundler/barrel_imports.rs index 3f7c2f5efe77..b7747ca50750 100644 --- a/src/bundler/barrel_imports.rs +++ b/src/bundler/barrel_imports.rs @@ -540,6 +540,7 @@ pub(crate) fn schedule_barrel_deferred_imports( // Build work queue from this file's named_imports, then propagate // through chains of barrels. Only runs real work when barrels exist // (targets with deferred records). + let mut seeded_partial_aliases: Vec> = Vec::new(); let mut queue: Vec = Vec::new(); // Read-only deref — valid through Phase 2 (see the raw-read note above). @@ -632,19 +633,18 @@ pub(crate) fn schedule_barrel_deferred_imports( }), RequestedExports::Partial(partial) => { for key in partial.keys() { - // SAFETY: arena-backed key slices live for the bundler - // arena lifetime; raw-ptr round-trip to detach from the - // `&this.requested_exports` borrow before BFS mutates it. - let alias: &[u8] = unsafe { bun_ptr::detach_lifetime_ref(&**key) }; - queue.push(BarrelWorkItem { - barrel_source_index: this_source_index, - alias, - is_star: false, - }); + seeded_partial_aliases.push(key.to_vec().into_boxed_slice()); } } } } + for alias in &seeded_partial_aliases { + queue.push(BarrelWorkItem { + barrel_source_index: this_source_index, + alias: &alias[..], + is_star: false, + }); + } if queue.is_empty() { return Ok(0); diff --git a/src/bundler/cache.rs b/src/bundler/cache.rs index aee00128dd51..07a0f69234d2 100644 --- a/src/bundler/cache.rs +++ b/src/bundler/cache.rs @@ -300,7 +300,6 @@ impl Fs { Ok(Entry { contents, fd: if publish_fd { fd } else { Fd::INVALID }, - external_free_function: ExternalFreeFunction::NONE, }) } @@ -453,7 +452,6 @@ impl Fs { Ok(Entry { contents, fd: if publish_fd { fd } else { Fd::INVALID }, - external_free_function: ExternalFreeFunction::NONE, }) } } diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index b7ab7cc2a2ed..2ae6a1d809f2 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -834,13 +834,6 @@ pub enum AlreadyBundled { } impl AlreadyBundled { - pub fn bytecode_slice(&self) -> &[u8] { - match self { - AlreadyBundled::Bytecode(slice) | AlreadyBundled::BytecodeCjs(slice) => slice, - _ => &[], - } - } - pub fn is_bytecode(&self) -> bool { matches!( self, diff --git a/src/glob/GlobWalker.rs b/src/glob/GlobWalker.rs index 5dc4b6df5e13..4a018934645a 100644 --- a/src/glob/GlobWalker.rs +++ b/src/glob/GlobWalker.rs @@ -78,6 +78,10 @@ pub trait AccessorHandle: Copy { pub trait Accessor { const COUNT_FDS: bool; + /// True when the dir iterator's `kind()` is resolved through symlinks (a + /// symlinked directory is reported as `Directory`, never `SymLink`), so the + /// Directory descent arm must run the followed-link ancestor check itself. + const ENTRY_KIND_FOLLOWS_SYMLINKS: bool = true; type Handle: AccessorHandle; type DirIter: AccessorDirIter; @@ -103,6 +107,15 @@ pub trait AccessorDirIter { pub trait AccessorDirEntry { fn name_slice(&self) -> &[u8]; fn kind(&self) -> bun_sys::FileKind; + /// For accessors with [`Accessor::ENTRY_KIND_FOLLOWS_SYMLINKS`]: the + /// already-resolved real path of the entry's target when the entry itself + /// is a symlink (its `kind()` reports the target's kind). `None` for + /// non-symlinks and for accessors that never resolve targets. Must come + /// from data the accessor already holds — the walker uses it for the + /// followed-link ancestor check without issuing any extra syscall. + fn symlink_target(&self) -> Option<&[u8]> { + None + } } // ───────────────────────────────────────────────────────────────────────────── @@ -156,6 +169,9 @@ impl AccessorDirIter for SyscallDirIter { impl Accessor for SyscallAccessor { const COUNT_FDS: bool = true; + // readdir / lstat report symlinks as `SymLink`, so every symlinked + // directory flows through the (already checked) Symlink work-item arm. + const ENTRY_KIND_FOLLOWS_SYMLINKS: bool = false; type Handle = SyscallHandle; type DirIter = SyscallDirIter; @@ -261,6 +277,8 @@ pub struct GlobWalker { // iteration state pub workbuf: Vec>, + followed_links: Vec, + is_ignored: IgnoreFilterFn, _accessor: core::marker::PhantomData, @@ -814,7 +832,15 @@ impl<'a, A: Accessor, const SENTINEL: bool> Iterator<'a, A, SENTINEL> { if self.walker.workbuf.is_empty() { return Ok(Ok(None)); } - let work_item = self.walker.workbuf.pop().unwrap(); + let mut work_item = self.walker.workbuf.pop().unwrap(); + // The workbuf is LIFO, so `followed_links_len` restores the + // exact followed-link ancestor chain of this work item. + self.walker + .followed_links + .truncate(work_item.followed_links_len); + if let Some(link) = work_item.followed_link.take() { + self.walker.followed_links.push(link); + } match work_item.kind { WorkItemKind::Directory => { if let Err(err) = @@ -939,13 +965,45 @@ impl<'a, A: Accessor, const SENTINEL: bool> Iterator<'a, A, SENTINEL> { let mut add_dir: bool = false; let child = self.walker.eval_dir(&active, entry_name, &mut add_dir); - if child.count() != 0 { - self.walker.workbuf.push(WorkItem::new_with_fd( - work_item.path, - child, - WorkItemKind::Directory, - dir_fd, - )); + let mut followed_link: Option = None; + let descend = child.count() != 0 + && if self.walker.followed_links.is_empty() { + // No followed ancestor exists, so this descent + // cannot be a cycle: defer identifying the target + // (record its logical path) so the walk performs + // no stat unless a nested followed link needs it. + followed_link = Some(FollowedLink::Pending(dupe_z( + symlink_full_path_z.as_bytes(), + ))); + true + } else { + match A::statat(dir_fd, ZStr::from_slice_with_nul(b".\0")) { + Ok(target) => { + self.walker.resolve_pending_followed_links(self.cwd_fd); + match self + .walker + .check_followed_link(FollowedLink::Target(target)) + { + Some(link) => { + followed_link = Some(link); + true + } + None => false, + } + } + Err(_) => true, + } + }; + if descend { + self.walker.push_work_item( + WorkItem::new_with_fd( + work_item.path, + child, + WorkItemKind::Directory, + dir_fd, + ), + followed_link, + ); } else { self.close_disallowing_cwd(dir_fd); } @@ -1022,13 +1080,22 @@ impl<'a, A: Accessor, const SENTINEL: bool> Iterator<'a, A, SENTINEL> { let mut add_dir: bool = false; let child = self.walker.eval_dir(&active, entry_name, &mut add_dir); if child.count() != 0 { - let subdir_parts: &[&[u8]] = &[dir_dir_path, entry_name]; - let subdir_entry_name = self.walker.join(subdir_parts)?; - self.walker.workbuf.push(WorkItem::new( - subdir_entry_name, - child, - WorkItemKind::Directory, - )); + let mut followed_link: Option = None; + if self.walker.should_descend_resolved_dir( + entry.symlink_target(), + &mut followed_link, + ) { + let subdir_parts: &[&[u8]] = &[dir_dir_path, entry_name]; + let subdir_entry_name = self.walker.join(subdir_parts)?; + self.walker.push_work_item( + WorkItem::new( + subdir_entry_name, + child, + WorkItemKind::Directory, + ), + followed_link, + ); + } } if add_dir && !self.walker.only_files { match self.walker.prepare_matched_path(entry_name, dir_dir_path)? { @@ -1107,17 +1174,22 @@ impl<'a, A: Accessor, const SENTINEL: bool> Iterator<'a, A, SENTINEL> { } } bun_sys::FileKind::Directory => { + // `lstatat` never reports a symlinked directory as + // `Directory`, so no followed-link check is needed here. let mut add_dir: bool = false; let child = self.walker.eval_dir(&active, entry_name, &mut add_dir); if child.count() != 0 { let subdir_parts: &[&[u8]] = &[dir_dir_path, entry_name]; let subdir_entry_name = self.walker.join(subdir_parts)?; - self.walker.workbuf.push(WorkItem::new( - subdir_entry_name, - child, - WorkItemKind::Directory, - )); + self.walker.push_work_item( + WorkItem::new( + subdir_entry_name, + child, + WorkItemKind::Directory, + ), + None, + ); } if add_dir && !self.walker.only_files { match self @@ -1200,6 +1272,34 @@ impl<'a, A: Accessor, const SENTINEL: bool> Drop for Iterator<'a, A, SENTINEL> { // WorkItem // ───────────────────────────────────────────────────────────────────────────── +/// Identity of a symlinked directory on the current work item's ancestor chain. +/// A walker only ever produces one variant (its accessor either stats followed +/// targets or caches their real paths), so the two never need to compare equal. +enum FollowedLink { + /// Followed target not yet identified: the NUL-terminated logical path the + /// walker opened. Stat'd (once, in place) only if a nested followed link + /// later needs the ancestor comparison, so a followed link with no + /// followed-link descendant costs no extra syscall. + Pending(Box<[u8]>), + /// `(st_dev, st_ino)` of the followed target (Symlink work-item arm). + Target(Stat), + /// Accessor-cached real path of the followed target + /// ([`AccessorDirEntry::symlink_target`]). + RealPath(Box<[u8]>), +} + +impl FollowedLink { + /// `Pending` entries are resolved to `Target` before any comparison; one + /// that cannot be resolved never matches (the descent proceeds). + fn same_target(&self, other: &FollowedLink) -> bool { + match (self, other) { + (Self::Target(a), Self::Target(b)) => a.st_dev == b.st_dev && a.st_ino == b.st_ino, + (Self::RealPath(a), Self::RealPath(b)) => a == b, + _ => false, + } + } +} + pub struct WorkItem { pub path: Box<[u8]>, /// Bitmask of active component indices. @@ -1207,6 +1307,12 @@ pub struct WorkItem { pub kind: WorkItemKind, pub entry_start: u32, pub fd: Option, + /// `followed_links.len()` when this item was pushed: the length of its + /// followed-link ancestor chain, restored by truncation when it is popped. + followed_links_len: usize, + /// The followed link this item descends into, pushed onto `followed_links` + /// (after the truncation above) when it is popped. + followed_link: Option, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -1223,6 +1329,8 @@ impl WorkItem { kind, entry_start: 0, fd: None, + followed_links_len: 0, + followed_link: None, } } @@ -1233,21 +1341,15 @@ impl WorkItem { fd: A::Handle, ) -> Self { Self { - path, - active, - kind, - entry_start: 0, fd: Some(fd), + ..Self::new(path, active, kind) } } fn new_symlink(path: Box<[u8]>, active: ComponentSet, entry_start: u32) -> Self { Self { - path, - active, - kind: WorkItemKind::Symlink, entry_start, - fd: None, + ..Self::new(path, active, WorkItemKind::Symlink) } } } @@ -1395,6 +1497,7 @@ impl GlobWalker { i: 0, path_buf: Box::new(PathBuffer::uninit()), workbuf: Vec::new(), + followed_links: Vec::new(), is_ignored: ignore_filter_fn.unwrap_or(dummy_filter_false), _accessor: core::marker::PhantomData, }; @@ -1920,14 +2023,77 @@ impl GlobWalker { let joined = work_item_logical_path(&subdir_entry_name); let entry_start: u32 = u32::try_from(joined.len() - strings::basename(joined).len()).unwrap(); - self.workbuf.push(WorkItem::new_symlink( - subdir_entry_name, - active, - entry_start, - )); + self.push_work_item( + WorkItem::new_symlink(subdir_entry_name, active, entry_start), + None, + ); Ok(()) } + /// Single push site: snapshots the followed-link ancestor chain (and the + /// link `item` itself descends into) so the pop site can restore it. + fn push_work_item(&mut self, mut item: WorkItem, followed_link: Option) { + item.followed_links_len = self.followed_links.len(); + item.followed_link = followed_link; + self.workbuf.push(item); + } + + /// Identify `Pending` ancestors in place (each is stat'd at most once) so + /// the chain can be compared by `(st_dev, st_ino)`. Only called when a + /// nested followed link actually needs the comparison. + fn resolve_pending_followed_links(&mut self, cwd_fd: A::Handle) { + for link in &mut self.followed_links { + if let FollowedLink::Pending(path) = link { + // SAFETY: `Pending` paths come from `dupe_z` (NUL-terminated). + let pathz = ZStr::from_slice_with_nul(path); + if let Ok(target) = A::statat(cwd_fd, pathz) { + *link = FollowedLink::Target(target); + } + } + } + } + + /// `followed_links` holds exactly the ancestor chain of the work item + /// being processed, so a match means descending re-enters it. + fn is_followed_link_cycle(&self, target: &FollowedLink) -> bool { + self.followed_links + .iter() + .any(|followed| followed.same_target(target)) + } + + /// Returns the record to attach to the descent's work item, or `None` when + /// `target` is already on the followed-link ancestor chain (a cycle). + fn check_followed_link(&self, target: FollowedLink) -> Option { + if self.is_followed_link_cycle(&target) { + return None; + } + Some(target) + } + + /// Accessors with [`Accessor::ENTRY_KIND_FOLLOWS_SYMLINKS`] report a + /// symlinked directory as `Directory`, so it never reaches the Symlink + /// work-item arm's ancestor check; run the same check here on the + /// accessor's already-resolved target (no extra syscall). + fn should_descend_resolved_dir( + &self, + entry_symlink_target: Option<&[u8]>, + followed_link: &mut Option, + ) -> bool { + if !A::ENTRY_KIND_FOLLOWS_SYMLINKS { + return true; + } + let Some(target) = entry_symlink_target else { + return true; + }; + match self.check_followed_link(FollowedLink::RealPath(Box::from(target))) { + Some(link) => { + *followed_link = Some(link); + true + } + None => false, + } + } + #[inline] fn starts_with_dot(filepath: &[u8]) -> bool { !filepath.is_empty() && filepath[0] == b'.' diff --git a/src/http/lib.rs b/src/http/lib.rs index 468b55c14d80..79c8623049c7 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -1124,7 +1124,15 @@ fn write_proxy_auth_and_headers(writer: &mut Vec, client: &HTTPClient) { } } +fn validate_request_target(target: &[u8]) -> Result<(), bun_core::Error> { + if target.iter().any(|&byte| byte <= 0x20 || byte == 0x7f) { + return Err(err!(InvalidURL)); + } + Ok(()) +} + fn write_proxy_connect(writer: &mut Vec, client: &HTTPClient) -> Result<(), bun_core::Error> { + validate_request_target(client.url.href)?; let port: &[u8] = if client.url.get_port().is_some() { client.url.port } else if client.url.is_https() { @@ -1156,6 +1164,7 @@ fn write_proxy_request( request: &picohttp::Request<'_>, client: &HTTPClient, ) -> Result<(), bun_core::Error> { + validate_request_target(client.url.href)?; writer.extend_from_slice(request.method); // will always be http:// here, https:// needs CONNECT tunnel writer.extend_from_slice(b" http://"); @@ -1189,6 +1198,7 @@ fn write_request( writer: &mut Vec, request: &picohttp::Request<'_>, ) -> Result<(), bun_core::Error> { + validate_request_target(request.path)?; writer.extend_from_slice(request.method); writer.extend_from_slice(b" "); writer.extend_from_slice(request.path); @@ -2015,6 +2025,11 @@ impl<'a> HTTPClient<'a> { if self.unix_socket_path.slice().len() > 0 { return false; } + // A peer accepted by a per-request JS `checkServerIdentity` callback must + // not enter or leave the shared pool (same exclusion as `can_offer_h2`). + if self.signals.get(signals::Field::CertErrors) { + return false; + } // check state if self.state.flags.allow_keepalive && !self.flags.disable_keepalive { return true; @@ -2793,6 +2808,7 @@ impl<'a> HTTPClient<'a> { } } else { bun_core::scoped_log!(fetch, "normal request"); + validate_request_target(self.url.host)?; write_request(writer, &request)?; } @@ -3262,8 +3278,8 @@ impl<'a> HTTPClient<'a> { let writer = &mut temporary_send_buffer; let request = self.build_request(self.body_len_for_send()); - if write_request(writer, &request).is_err() { - self.close_and_fail::(err!(OutOfMemory), socket); + if let Err(e) = write_request(writer, &request) { + self.close_and_fail::(e, socket); return; } @@ -3486,6 +3502,12 @@ impl<'a> HTTPClient<'a> { // if less than 16 it will always be a ShortRead if to_read!().len() < 16 { bun_core::scoped_log!(fetch, "handleShortRead"); + if !needs_move { + let remaining = to_read!().len(); + let buffer = &mut self.state.response_message_buffer.list; + buffer.drain_front(buffer.len().saturating_sub(remaining)); + to_read = bun_ptr::RawSlice::new(buffer.as_slice()); + } self.handle_short_read::(to_read!(), socket, needs_move); return; } @@ -3509,6 +3531,12 @@ impl<'a> HTTPClient<'a> { self.close_and_fail::(err!(ResponseHeadersTooLarge), socket); return; } + if !needs_move { + let remaining = to_read!().len(); + let buffer = &mut self.state.response_message_buffer.list; + buffer.drain_front(buffer.len().saturating_sub(remaining)); + to_read = bun_ptr::RawSlice::new(buffer.as_slice()); + } self.handle_short_read::(to_read!(), socket, needs_move); return; } @@ -3532,7 +3560,9 @@ impl<'a> HTTPClient<'a> { to_read = bun_ptr::RawSlice::new(&to_read.slice()[bytes_read..]); if response.status_code == 101 { - if self.flags.upgrade_state == HTTPUpgradeState::None { + if self.flags.upgrade_state == HTTPUpgradeState::None + || (self.flags.proxy_tunneling && self.proxy_tunnel.is_none()) + { // we cannot upgrade to websocket because the client did not request it! self.close_and_fail::(err!(UnrequestedUpgrade), socket); return; @@ -3551,14 +3581,11 @@ impl<'a> HTTPClient<'a> { bun_core::scoped_log!(fetch, "information headers"); self.state.pending_response = None; - if !needs_move { - let remaining = to_read!().len(); - let buffer = &mut self.state.response_message_buffer.list; - let consumed = buffer.len().saturating_sub(remaining); - buffer.drain_front(consumed); - to_read = bun_ptr::RawSlice::new(buffer.as_slice()); - } if to_read!().is_empty() { + if !needs_move { + let buffer = &mut self.state.response_message_buffer.list; + buffer.drain_front(buffer.len()); + } // we only received 1XX responses, we wanna wait for the next status code return; } @@ -5122,9 +5149,13 @@ impl<'a> HTTPClient<'a> { } let new_url = new_url_.to_owned_slice(); + let parsed_url = URL::parse(&new_url); + if !parsed_url.has_http_like_protocol() { + return Err(err!(UnsupportedRedirectProtocol)); + } // SAFETY: self-borrow — `new_url` is moved into `self.redirect` // below, which lives as long as `self` (≥ `'a`). - self.url = unsafe { URL::parse(&new_url).erase_lifetime() }; + self.url = unsafe { parsed_url.erase_lifetime() }; is_same_origin = strings::eql_case_insensitive_ascii( strings::without_trailing_slash(self.url.origin), strings::without_trailing_slash(original_url.origin), diff --git a/src/http/ssl_config.rs b/src/http/ssl_config.rs index 2e69e11ae3d9..002b8a1a293c 100644 --- a/src/http/ssl_config.rs +++ b/src/http/ssl_config.rs @@ -211,6 +211,9 @@ impl SSLConfig { ctx_opts.reject_unauthorized = self.reject_unauthorized; ctx_opts.ssl_min_version = self.ssl_min_version; ctx_opts.ssl_max_version = self.ssl_max_version; + ctx_opts.secure_options = self.secure_options; + ctx_opts.client_renegotiation_limit = self.client_renegotiation_limit; + ctx_opts.client_renegotiation_window = self.client_renegotiation_window; ctx_opts } diff --git a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs index dad27580b5d2..8cdbbf6bbf76 100644 --- a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs +++ b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs @@ -1534,6 +1534,13 @@ impl HTTPClient { return; } + // SAFETY: short-lived `&self` read. + if !protocol_header_seen && !unsafe { (*this).subprotocols.is_empty() } { + // SAFETY: no `&mut Self` is live across this call. + unsafe { Self::terminate(this, ErrorCode::MissingClientProtocol) }; + return; + } + if !strings::eql_case_insensitive_ascii(connection_header.value(), b"Upgrade", true) { // SAFETY: no `&mut Self` is live across this call. unsafe { Self::terminate(this, ErrorCode::InvalidConnectionHeader) }; diff --git a/src/install/PackageInstaller.rs b/src/install/PackageInstaller.rs index 4cd5826c9b9f..1581b0f96953 100644 --- a/src/install/PackageInstaller.rs +++ b/src/install/PackageInstaller.rs @@ -346,7 +346,7 @@ impl<'a> LazyPackageDestinationDir<'a> { /// anything that could escape `node_modules`: empty names, `.`/`..` /// components, absolute paths, drive letters, backslashes, NUL bytes, and any /// separator other than the single `/` in a scoped name (`@scope/name`). -fn alias_is_safe_install_target(alias: &[u8]) -> bool { +pub(crate) fn alias_is_safe_install_target(alias: &[u8]) -> bool { if alias.is_empty() || alias.len() >= MAX_PATH_BYTES || alias.contains(&b'\\') diff --git a/src/install/PackageManager/PackageManagerEnqueue.rs b/src/install/PackageManager/PackageManagerEnqueue.rs index b83bd8cc1b2d..6d9b0946b9c5 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.rs +++ b/src/install/PackageManager/PackageManagerEnqueue.rs @@ -1924,6 +1924,7 @@ fn enqueue_local_tarball( ) .expect("unreachable"), skip_verify: false, + in_trusted_dependencies: false, }, tarball_path: StringOrTinyString::init_append_if_needed( tarball_path, diff --git a/src/install/PackageManager/runTasks.rs b/src/install/PackageManager/runTasks.rs index cc6b53c3a3e4..aa74b85ee9ef 100644 --- a/src/install/PackageManager/runTasks.rs +++ b/src/install/PackageManager/runTasks.rs @@ -1844,6 +1844,7 @@ pub fn generate_network_task_for_tarball<'a>( temp_dir, dependency_id, skip_verify: false, + in_trusted_dependencies: this.lockfile.in_trusted_dependencies(pkg_name), integrity: package.meta.integrity, url: strings::StringOrTinyString::init_append_if_needed( url, diff --git a/src/install/bin.rs b/src/install/bin.rs index 075ddec75baf..82e83c8119ec 100644 --- a/src/install/bin.rs +++ b/src/install/bin.rs @@ -747,7 +747,7 @@ pub(crate) fn normalized_bin_name(name: &[u8]) -> &[u8] { // npm's `join('/', key).slice(1)` collapses `.`/`..` to empty; do the same // so the `.bin/` destination cannot resolve outside `.bin/`. - if name == b"." || name == b".." { + if !crate::dependency::is_safe_install_folder_name(name) { return b""; } @@ -793,10 +793,14 @@ pub(crate) fn bin_target_escapes_package_dir(target: &[u8]) -> bool { false } -fn bin_target_has_dot_components(target: &[u8]) -> bool { - target +fn bin_target_needs_resolved_containment_check(target: &[u8]) -> bool { + let mut components = target .split(|&b| b == b'/' || b == b'\\') - .any(|component| component == b"." || component == b"..") + .filter(|component| !component.is_empty()); + let Some(first) = components.next() else { + return false; + }; + first == b"." || first == b".." || components.next().is_some() } pub struct Linker<'a> { @@ -892,7 +896,7 @@ impl<'a> Linker<'a> { abs_target: &ZStr, abs_dest: &ZStr, global: bool, - target_has_dot_components: bool, + target_needs_resolved_containment_check: bool, ) { debug_assert!(path::is_absolute(abs_target.as_bytes())); debug_assert!(path::is_absolute(abs_dest.as_bytes())); @@ -914,7 +918,7 @@ impl<'a> Linker<'a> { return; } - if target_has_dot_components { + if target_needs_resolved_containment_check { #[cfg(not(windows))] if self.resolved_target_parent_escapes_package_dir(abs_target) { return; @@ -1581,7 +1585,8 @@ impl<'a> Linker<'a> { if target.is_empty() || bin_target_escapes_package_dir(target) { return; } - let target_has_dot_components = bin_target_has_dot_components(target); + let target_needs_resolved_containment_check = + bin_target_needs_resolved_containment_check(target); let unscoped_package_name = Dependency::unscoped_package_name(self.package_name.slice()); @@ -1621,7 +1626,7 @@ impl<'a> Linker<'a> { abs_target, abs_dest, global, - target_has_dot_components, + target_needs_resolved_containment_check, ); } Tag::NamedFile => { @@ -1635,7 +1640,8 @@ impl<'a> Linker<'a> { { return; } - let target_has_dot_components = bin_target_has_dot_components(target); + let target_needs_resolved_containment_check = + bin_target_needs_resolved_containment_check(target); if normalized_name.len() >= self.abs_dest_buf.len().saturating_sub(dest_off) { self.err = Some(bun_core::err!("NameTooLong")); return; @@ -1666,7 +1672,7 @@ impl<'a> Linker<'a> { abs_target, abs_dest, global, - target_has_dot_components, + target_needs_resolved_containment_check, ); } Tag::Map => { @@ -1688,7 +1694,8 @@ impl<'a> Linker<'a> { i += 2; continue; } - let target_has_dot_components = bin_target_has_dot_components(bin_target); + let target_needs_resolved_containment_check = + bin_target_needs_resolved_containment_check(bin_target); if normalized_bin_dest.len() >= self.abs_dest_buf.len().saturating_sub(abs_dest_dir_end) { @@ -1721,7 +1728,7 @@ impl<'a> Linker<'a> { abs_target, abs_dest, global, - target_has_dot_components, + target_needs_resolved_containment_check, ); i += 2; @@ -1733,8 +1740,6 @@ impl<'a> Linker<'a> { if target.is_empty() || bin_target_escapes_package_dir(target) { return; } - let target_has_dot_components = bin_target_has_dot_components(target); - // for normalizing `target` let abs_target_dir: &ZStr = { let package_dir = &self.abs_target_buf[0..package_dir_len]; @@ -1799,12 +1804,7 @@ impl<'a> Linker<'a> { // SAFETY: abs_dest_buf[abs_dest_len] == 0 written above; see note above. let abs_dest = ZStr::from_raw(abs_dest_buf_ptr, abs_dest_len); - self.link_bin_or_create_shim( - abs_target, - abs_dest, - global, - target_has_dot_components, - ); + self.link_bin_or_create_shim(abs_target, abs_dest, global, true); } _ => {} } diff --git a/src/install/extract_tarball.rs b/src/install/extract_tarball.rs index cea222af16bc..0e9bd7d5f3b1 100644 --- a/src/install/extract_tarball.rs +++ b/src/install/extract_tarball.rs @@ -8,7 +8,7 @@ use bun_core::{StringOrTinyString, ZStr}; use bun_paths::WPathBuffer; use bun_paths::strings; use bun_paths::{self as path, PathBuffer}; -use bun_semver::{self as Semver, Version}; +use bun_semver::Version; use bun_sys::{self as sys, Dir, Fd}; use bun_install::install::{self as Install, DependencyID, ExtractData}; @@ -35,7 +35,8 @@ pub struct ExtractTarball { /// story as `cache_dir`). pub temp_dir: Fd, pub dependency_id: DependencyID, - pub skip_verify: bool, // = false + pub skip_verify: bool, // = false + pub in_trusted_dependencies: bool, pub integrity: Integrity, // = Integrity::default() pub url: StringOrTinyString, /// BACKREF: PackageManager owns the task pool that owns this struct. @@ -508,12 +509,26 @@ impl ExtractTarball { ) .as_bytes() } - ResolutionTag::Github => directories::cached_github_folder_name_print( - &mut bufs.folder_name_buf, - resolved, - None, - ) - .as_bytes(), + ResolutionTag::Github => { + if !bun_install::repository::is_safe_resolved_tag(resolved) { + log.add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "Refusing to install \"{}\": tarball root directory \"{}\" is not a valid folder name", + bun_fmt::s(name), + bun_fmt::s(resolved), + ), + ); + return Err(bun_core::err!("InstallFailed")); + } + directories::cached_github_folder_name_print( + &mut bufs.folder_name_buf, + resolved, + None, + ) + .as_bytes() + } ResolutionTag::LocalTarball | ResolutionTag::RemoteTarball => { directories::cached_tarball_folder_name_print( &mut bufs.folder_name_buf, @@ -750,15 +765,7 @@ impl ExtractTarball { ResolutionTag::Github | ResolutionTag::LocalTarball | ResolutionTag::RemoteTarball => true, - _ => { - package_manager.lockfile.trusted_dependencies.is_some() - && package_manager - .lockfile - .trusted_dependencies - .as_ref() - .unwrap() - .contains(&(Semver::semver_string::Builder::string_hash(name) as u32)) - } + _ => self.in_trusted_dependencies, }; if needs_json { let read_result = sys::File::read_file_from( diff --git a/src/install/integrity.rs b/src/install/integrity.rs index 6e1b93fb58b4..8e6d522ffa1f 100644 --- a/src/install/integrity.rs +++ b/src/install/integrity.rs @@ -90,6 +90,17 @@ impl Integrity { } pub fn parse(buf: &[u8]) -> Integrity { + let mut strongest = Integrity::default(); + for entry in buf.split(|c: &u8| c.is_ascii_whitespace()) { + let parsed = Self::parse_entry(entry); + if parsed.tag.0 > strongest.tag.0 { + strongest = parsed; + } + } + strongest + } + + fn parse_entry(buf: &[u8]) -> Integrity { if buf.len() < b"sha256-".len() { return Integrity { tag: Tag::UNKNOWN, @@ -115,8 +126,11 @@ impl Integrity { } let input = { + let mut s = &buf[offset..]; + if let Some(i) = strings::index_of_char(s, b'?') { + s = &s[..i as usize]; + } // trim trailing '=' padding - let s = &buf[offset..]; let mut end = s.len(); while end > 0 && s[end - 1] == b'=' { end -= 1; diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index 40d22bd7e0b6..ae58d577ed3c 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -2105,14 +2105,15 @@ pub(crate) fn install_isolated_packages( { let mut unsafe_folder_name: Option<&[u8]> = None; let name = pkg_name.slice(string_buf); - if !name.is_empty() && !crate::dependency::is_safe_install_folder_name(name) { + if !name.is_empty() && !crate::package_installer::alias_is_safe_install_target(name) + { unsafe_folder_name = Some(name); } else { for dep in entry_dependencies[entry_id.get() as usize].slice() { let dep_name = lockfile_ro.buffers.dependencies[dep.dep_id as usize] .name .slice(string_buf); - if !crate::dependency::is_safe_install_folder_name(dep_name) { + if !crate::package_installer::alias_is_safe_install_target(dep_name) { unsafe_folder_name = Some(dep_name); break; } diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index 8c814f0fa435..e4d2438eea45 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -3330,6 +3330,13 @@ pub mod default_trusted_dependencies { } impl Lockfile { + pub fn in_trusted_dependencies(&self, name: &[u8]) -> bool { + let hash = SemverStringBuilder::string_hash(name) as u32; + self.trusted_dependencies + .as_ref() + .is_some_and(|trusted| trusted.contains(&hash)) + } + pub fn has_trusted_dependency( &self, alias: &[u8], diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index 937a0f459147..b2dae61611d8 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -3380,6 +3380,20 @@ pub mod serializer { } } } + if matches!(field, PackageField::Scripts) { + // `Scripts.filled` is a `bool`; validate the raw byte the + // same way before the copy. + let stride = mem::size_of::(); + let filled_at = mem::offset_of!(Scripts, filled); + debug_assert!(stride != 0 && src.len().is_multiple_of(stride)); + for raw in src.chunks_exact(stride) { + if !matches!(raw[filled_at], 0 | 1) { + return Err(bun_core::err!( + "Lockfile validation failed: invalid package scripts" + )); + } + } + } bytes.copy_from_slice(src); stream.pos = end_pos; if matches!(field, PackageField::Meta) { diff --git a/src/js/internal/debugger.ts b/src/js/internal/debugger.ts index cb4615db7517..a8eee42a3ef5 100644 --- a/src/js/internal/debugger.ts +++ b/src/js/internal/debugger.ts @@ -338,6 +338,19 @@ class Debugger { }); } + const isUnix = this.#url!.protocol.includes("unix"); + if (!isUnix && !isHostAllowed(headers.get("Host"), this.#url!.hostname)) { + return new Response(null, { + status: 400, // Bad Request + }); + } + + if (!isOriginAllowed(headers.get("Origin"))) { + return new Response(null, { + status: 403, // Forbidden + }); + } + switch (pathname) { case "/json/version": return Response.json(versionInfo()); @@ -346,18 +359,12 @@ class Debugger { // TODO? } - if (!this.#url!.protocol.includes("unix") && this.#url!.pathname !== pathname) { + if (!isUnix && this.#url!.pathname !== pathname) { return new Response(null, { status: 404, // Not Found }); } - if (!isOriginAllowed(headers.get("Origin"))) { - return new Response(null, { - status: 403, // Forbidden - }); - } - const data: Connection = { refEventLoop: headers.get("Ref-Event-Loop") === "0", }; @@ -629,6 +636,25 @@ function isOriginAllowed(origin: string | null): boolean { return hostname === "localhost" || hostname === "[::1]" || /^127(\.\d{1,3}){3}$/.test(hostname); } +function isHostAllowed(host: string | null, expectedHostname: string): boolean { + if (!host) { + return true; + } + let hostname: string; + try { + ({ hostname } = new URL(`ws://${host}`)); + } catch { + return false; + } + if (hostname === expectedHostname || hostname === "localhost" || hostname === "localhost6") { + return true; + } + if (hostname.startsWith("[") && hostname.endsWith("]")) { + return true; + } + return /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname); +} + function randomId() { return crypto.randomUUID(); } diff --git a/src/js/internal/sql/postgres.ts b/src/js/internal/sql/postgres.ts index d84c5325e247..897f6526c82b 100644 --- a/src/js/internal/sql/postgres.ts +++ b/src/js/internal/sql/postgres.ts @@ -410,6 +410,9 @@ class PostgresAdapter } escapeIdentifier(str: string) { + if (str.includes("\0")) { + throw $ERR_INVALID_ARG_VALUE("name", str, "must not contain null bytes"); + } return '"' + str.replaceAll('"', '""').replaceAll(".", '"."') + '"'; } diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index 8fe68add1b4e..21c781812aa6 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -1010,6 +1010,12 @@ abstract class BaseSQLAdapter 999) { + throw $ERR_HTTP_INVALID_STATUS_CODE(`${originalStatusCode}`); + } + if (typeof statusMessage === "string" && checkInvalidHeaderChar(statusMessage)) { + throw $ERR_INVALID_CHAR("statusMessage"); + } head = { statusCode, statusMessage, headers }; }, flushHeaders() { @@ -5436,7 +5467,10 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim const length = buf ? (buf.byteLength ?? buf.length) : 0; writeHeadToSocket(length); writeBody(buf); - if (chunked) socket.write("0\r\n\r\n"); + // Like Node's `_hasBody && chunkedEncoding` gate: a bodiless (HEAD) + // response never writes the terminating chunk, even when the user set + // Transfer-Encoding: chunked themselves. + if (chunked && !noBody) socket.write("0\r\n\r\n"); this.ended = true; this.finished = true; const onfinished = this.onfinished; @@ -5444,6 +5478,10 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim this.onfinished = null; onfinished(); } + // A close-delimited body ends at EOF, so the response ends the connection. + if (closeDelimited && !socket.destroyed) { + socket.end(); + } return length; }, abort() { diff --git a/src/js/node/https.ts b/src/js/node/https.ts index 1d1104717b88..aa9ba14241c0 100644 --- a/src/js/node/https.ts +++ b/src/js/node/https.ts @@ -8,6 +8,7 @@ const net = require("node:net"); const { urlToHttpOptions } = require("internal/url"); const { kEmptyObject, once } = require("internal/shared"); const { kProxyConfig, checkShouldUseProxy, kWaitForProxyTunnel } = require("internal/http"); +const { validateHeaderValue } = require("node:_http_common"); const ArrayPrototypeShift = Array.prototype.shift; const ObjectAssign = Object.assign; @@ -68,10 +69,9 @@ function getTunnelConfigForProxiedHttps(agent, reqOptions) { const endpoint = `${requestHost}:${requestPort}`; // The ClientRequest constructor should already have validated the host and the port. // When the request options come from a string invalid characters would be stripped away, - // when it's an object ERR_INVALID_CHAR would be thrown. Here we just assert in case + // when it's an object ERR_INVALID_CHAR would be thrown. Validate again in case // agent.createConnection() is called with invalid options. - $assert(endpoint.includes("\r") === false); - $assert(endpoint.includes("\n") === false); + validateHeaderValue("host", endpoint); let payload = `CONNECT ${endpoint} HTTP/1.1\r\n`; // The parseProxyConfigFromEnv() method should have already validated the authorization header diff --git a/src/js/node/url.ts b/src/js/node/url.ts index 29b0dba646de..f8809450309e 100644 --- a/src/js/node/url.ts +++ b/src/js/node/url.ts @@ -29,6 +29,7 @@ const { URL, URLSearchParams } = globalThis; const [domainToASCII, domainToUnicode] = $cpp("NodeURL.cpp", "Bun::createNodeURLBinding"); const { urlToHttpOptions } = require("internal/url"); const { validateString } = require("internal/validators"); +const ObjectSetPrototypeOf = Object.setPrototypeOf; function Url() { this.protocol = null; @@ -76,16 +77,19 @@ var protocolPattern = /^([a-z0-9.+-]+:)/i, hostnameMaxLen = 255, // protocols that can allow "unsafe" and "unwise" chars. unsafeProtocol = { + __proto__: null, javascript: true, "javascript:": true, }, // protocols that never have a hostname. hostlessProtocol = { + __proto__: null, javascript: true, "javascript:": true, }, // protocols that always contain a // bit. slashedProtocol = { + __proto__: null, http: true, https: true, ftp: true, @@ -200,7 +204,7 @@ Url.prototype.parse = function parse(url: string, parseQueryString?: boolean, sl if (simplePath[2]) { this.search = simplePath[2]; if (parseQueryString) { - this.query = new URLSearchParams(this.search.slice(1)).toJSON(); + this.query = ObjectSetPrototypeOf(new URLSearchParams(this.search.slice(1)).toJSON(), null); } else { this.query = this.search.slice(1); } @@ -229,13 +233,13 @@ Url.prototype.parse = function parse(url: string, parseQueryString?: boolean, sl let slashes; if (slashesDenoteHost || proto || rest.match(/^\/\/[^@/]+@[^@/]+/)) { slashes = rest.substring(0, 2) === "//"; - if (slashes && !(proto && hostlessProtocol[proto])) { + if (slashes && !(proto && hostlessProtocol[lowerProto])) { rest = rest.substring(2); this.slashes = true; } } - if (!hostlessProtocol[proto] && (slashes || (proto && !slashedProtocol[proto]))) { + if (!hostlessProtocol[lowerProto] && (slashes || (lowerProto && !slashedProtocol[lowerProto]))) { /* * there's a hostname. * the first instance of /, ?, ;, or # ends the host. @@ -399,13 +403,13 @@ Url.prototype.parse = function parse(url: string, parseQueryString?: boolean, sl this.query = rest.substring(qm + 1); if (parseQueryString) { const query = this.query; - this.query = new URLSearchParams(query).toJSON(); + this.query = ObjectSetPrototypeOf(new URLSearchParams(query).toJSON(), null); } rest = rest.slice(0, qm); } else if (parseQueryString) { // no query string, but parseQueryString still requested this.search = null; - this.query = {}; + this.query = Object.create(null); } if (rest) { this.pathname = rest; diff --git a/src/js/node/wasi.ts b/src/js/node/wasi.ts index b437fb52192e..f3c791d71144 100644 --- a/src/js/node/wasi.ts +++ b/src/js/node/wasi.ts @@ -1094,6 +1094,8 @@ var require_wasi = __commonJS({ }), fd_fdstat_set_rights: wrap((fd, fsRightsBase, fsRightsInheriting) => { const stats = CHECK_FD(fd, BigInt(0)); + fsRightsBase = BigInt.asUintN(64, fsRightsBase); + fsRightsInheriting = BigInt.asUintN(64, fsRightsInheriting); const nrb = stats.rights.base | fsRightsBase; if (nrb > stats.rights.base) { return constants_1.WASI_EPERM; @@ -1496,8 +1498,8 @@ var require_wasi = __commonJS({ (dirfd, _dirflags, pathPtr, pathLen, oflags, fsRightsBase, fsRightsInheriting, fsFlags, fdPtr) => { try { const stats = CHECK_FD(dirfd, constants_1.WASI_RIGHT_PATH_OPEN); - fsRightsBase = BigInt(fsRightsBase); - fsRightsInheriting = BigInt(fsRightsInheriting); + fsRightsBase = BigInt.asUintN(64, BigInt(fsRightsBase)); + fsRightsInheriting = BigInt.asUintN(64, BigInt(fsRightsInheriting)); const read = (fsRightsBase & (constants_1.WASI_RIGHT_FD_READ | constants_1.WASI_RIGHT_FD_READDIR)) !== BigInt(0); const write = @@ -1647,7 +1649,7 @@ var require_wasi = __commonJS({ if (e instanceof types_1.WASIError) { return e.errno; } - console.error(e); + throw e; } return constants_1.WASI_ESUCCESS; }, diff --git a/src/js_parser/lexer.rs b/src/js_parser/lexer.rs index de8d3510d1cc..54e4e4b83ae5 100644 --- a/src/js_parser/lexer.rs +++ b/src/js_parser/lexer.rs @@ -1087,6 +1087,7 @@ lexer_impl_header! { continue; } None => { + self.current += remainder.len(); self.step_with(contents); continue; } diff --git a/src/js_parser_jsc/Macro.rs b/src/js_parser_jsc/Macro.rs index 033befb39d5a..b7efd2426321 100644 --- a/src/js_parser_jsc/Macro.rs +++ b/src/js_parser_jsc/Macro.rs @@ -786,10 +786,12 @@ impl<'a> Run<'a> { // key into the `MacroContext` bump arena so it outlives the // temporary `to_owned_slice()` Vec and the returned `Expr`. let key_bytes: &[u8] = self.bump.alloc_slice_copy(&prop.to_owned_slice()); + let key = Expr::init(E::EString::init(key_bytes), self.caller.loc); VecExt::append( &mut properties, G::Property { - key: Some(Expr::init(E::EString::init(key_bytes), self.caller.loc)), + flags: E::own_key_property_flags(&key), + key: Some(key), value: Some(object_value), ..Default::default() }, diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs index 28057e87299b..d9924856527d 100644 --- a/src/jsc/RuntimeTranspilerStore.rs +++ b/src/jsc/RuntimeTranspilerStore.rs @@ -1020,17 +1020,25 @@ impl TranspilerJob { } if !matches!(parse_result.already_bundled, AlreadyBundled::None) { - let bytecode_slice = parse_result.already_bundled.bytecode_slice(); + let already_bundled = core::mem::take(&mut parse_result.already_bundled); + let is_commonjs_module = already_bundled.is_common_js(); + let (bytecode_cache, bytecode_cache_size) = match already_bundled { + AlreadyBundled::Bytecode(bytes) | AlreadyBundled::BytecodeCjs(bytes) => { + let len = bytes.len(); + if len == 0 { + (ptr::null_mut(), 0) + } else { + (bun_core::heap::into_raw(bytes).cast::(), len) + } + } + _ => (ptr::null_mut(), 0), + }; self.resolved_source = OwnedResolvedSource::from(ResolvedSource { source_code: String::clone_latin1(&parse_result.source.contents), already_bundled: true, - bytecode_cache: if !bytecode_slice.is_empty() { - bytecode_slice.as_ptr().cast_mut() - } else { - ptr::null_mut() - }, - bytecode_cache_size: bytecode_slice.len(), - is_commonjs_module: parse_result.already_bundled.is_common_js(), + bytecode_cache, + bytecode_cache_size, + is_commonjs_module, tag: this_tag, ..Default::default() }); diff --git a/src/jsc/bindings/BunPlugin.cpp b/src/jsc/bindings/BunPlugin.cpp index bd187f28b706..3352c3f6896a 100644 --- a/src/jsc/bindings/BunPlugin.cpp +++ b/src/jsc/bindings/BunPlugin.cpp @@ -816,6 +816,12 @@ EncodedJSValue BunPlugin::OnResolve::run(JSC::JSGlobalObject* globalObject, BunS auto scope = DECLARE_THROW_SCOPE(vm); WTF::String pathString = path->toWTFString(BunString::ZeroCopy); + JSC::MarkedArgumentBuffer matchedCallbacks; + matchedCallbacks.ensureCapacity(filters.size()); + if (matchedCallbacks.hasOverflowed()) [[unlikely]] { + JSC::throwOutOfMemoryError(globalObject, scope); + return {}; + } for (size_t i = 0; i < filters.size(); i++) { if (!filters[i].get()->match(globalObject, pathString, 0)) { continue; @@ -824,6 +830,15 @@ EncodedJSValue BunPlugin::OnResolve::run(JSC::JSGlobalObject* globalObject, BunS if (!function) [[unlikely]] { continue; } + matchedCallbacks.append(function); + } + if (matchedCallbacks.hasOverflowed()) [[unlikely]] { + JSC::throwOutOfMemoryError(globalObject, scope); + return {}; + } + + for (size_t i = 0; i < matchedCallbacks.size(); i++) { + auto* function = matchedCallbacks.at(i).getObject(); JSC::MarkedArgumentBuffer arguments; diff --git a/src/jsc/bindings/CookieMap.cpp b/src/jsc/bindings/CookieMap.cpp index 1e0a6e67df25..f2396ee543cd 100644 --- a/src/jsc/bindings/CookieMap.cpp +++ b/src/jsc/bindings/CookieMap.cpp @@ -194,9 +194,10 @@ ExceptionOr CookieMap::remove(const CookieStoreDeleteOptions& options) String name = options.name; String domain = options.domain; String path = options.path; + bool secure = name.startsWithIgnoringASCIICase("__Secure-"_s) || name.startsWithIgnoringASCIICase("__Host-"_s); // Add the new cookie - auto cookie_exception = Cookie::create(name, ""_s, domain, path, 1, false, CookieSameSite::Lax, false, std::numeric_limits::quiet_NaN(), false); + auto cookie_exception = Cookie::create(name, ""_s, domain, path, 1, secure, CookieSameSite::Lax, false, std::numeric_limits::quiet_NaN(), false); if (cookie_exception.hasException()) { return cookie_exception.releaseException(); } diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index 3312748ef965..ea84d276eda9 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -447,10 +447,8 @@ static void populateStackTrace(JSC::VM& vm, const WTF::Vector& } else if (flags == PopulateStackTraceFlags::OnlySourceLines) { for (uint8_t i = 0; i < trace.frames_len; i++) { ZigStackFrame& frame = trace.frames_ptr[i]; - // A call with flags set to OnlySourceLines always follows a call with flags set to OnlyPosition, - // so jsc_stack_frame_index is always a valid value here. - ASSERT(frame.jsc_stack_frame_index >= 0); - ASSERT(static_cast(frame.jsc_stack_frame_index) < frames.size()); + if (frame.jsc_stack_frame_index < 0 || static_cast(frame.jsc_stack_frame_index) >= frames.size()) + continue; populateStackFrame(vm, trace, frames[frame.jsc_stack_frame_index], frame, i == 0, &trace.referenced_source_provider, globalObject, flags, finalizerSafety); } } diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index adb5ed6ecdda..863526177c4e 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -2243,7 +2243,7 @@ void GlobalObject::finishCreation(VM& vm) m_nativeMicrotaskTrampoline.initLater( [](const Initializer& init) { - init.set(JSFunction::create(init.vm, init.owner, 2, ""_s, functionNativeMicrotaskTrampoline, ImplementationVisibility::Public)); + init.set(JSFunction::create(init.vm, init.owner, 2, ""_s, functionNativeMicrotaskTrampoline, ImplementationVisibility::Private)); }); m_navigatorObject.initLater( diff --git a/src/jsc/bindings/decodeURIComponentSIMD.cpp b/src/jsc/bindings/decodeURIComponentSIMD.cpp index e3a679b10ad4..4e45406715ad 100644 --- a/src/jsc/bindings/decodeURIComponentSIMD.cpp +++ b/src/jsc/bindings/decodeURIComponentSIMD.cpp @@ -1,6 +1,7 @@ #include "root.h" +#include "BunString.h" #include #include #include @@ -18,9 +19,21 @@ ALWAYS_INLINE static uint8_t hexToInt(uint8_t c) return 255; // Invalid } +ALWAYS_INLINE static void appendLiteralRun(StringBuilder& result, std::span bytes, bool inputIsASCII) +{ + if (bytes.empty()) + return; + const std::span chars = { reinterpret_cast(bytes.data()), bytes.size() }; + if (inputIsASCII) { + result.append(chars); + return; + } + result.append(WTF::String::fromUTF8ReplacingInvalidSequences(chars)); +} + WTF::String decodeURIComponentSIMD(std::span input) { - ASSERT_WITH_MESSAGE(simdutf::validate_ascii(reinterpret_cast(input.data()), input.size()), "Input is not ASCII"); + const bool inputIsASCII = simdutf::validate_ascii(reinterpret_cast(input.data()), input.size()); const std::span lchar = { reinterpret_cast(input.data()), input.size() }; @@ -48,12 +61,17 @@ WTF::String decodeURIComponentSIMD(std::span input) cursor++; } - return String(lchar); + if (inputIsASCII) + return String(lchar); + return String::fromUTF8ReplacingInvalidSequences(lchar); slow_path: + while (cursor < end && *cursor != '%') { + cursor++; + } StringBuilder result; result.reserveCapacity(input.size()); - result.append(std::span(reinterpret_cast(input.data()), cursor - input.data())); + appendLiteralRun(result, input.first(static_cast(cursor - input.data())), inputIsASCII); while (cursor < end) { if (*cursor == '%') { @@ -244,6 +262,7 @@ WTF::String decodeURIComponentSIMD(std::span input) continue; } else { // Look ahead for next % using SIMD + const uint8_t* runStart = cursor; const uint8_t* lookAhead = cursor; while (lookAhead + stride <= end) { auto chunk = SIMD::load(lookAhead); @@ -252,18 +271,13 @@ WTF::String decodeURIComponentSIMD(std::span input) } lookAhead += stride; } - - // Append everything up to lookAhead - result.append(std::span(reinterpret_cast(cursor), lookAhead - cursor)); cursor = lookAhead; // Handle remaining bytes until next % or end while (cursor < end && *cursor != '%') { cursor++; } - if (cursor > lookAhead) { - result.append(std::span(reinterpret_cast(lookAhead), cursor - lookAhead)); - } + appendLiteralRun(result, std::span(runStart, static_cast(cursor - runStart)), inputIsASCII); } } @@ -280,27 +294,9 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionDecodeURIComponentSIMD, (JSC::JSGlobalObject auto string = input.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); - if (!string.is8Bit()) { - const auto span = string.span16(); - size_t expected_length = simdutf::latin1_length_from_utf16(span.size()); - std::span ptr; - WTF::String convertedString = WTF::String::tryCreateUninitialized(expected_length, ptr); - if (convertedString.isNull()) [[unlikely]] { - throwVMError(globalObject, scope, createOutOfMemoryError(globalObject)); - return {}; - } - - auto result = simdutf::convert_utf16le_to_latin1_with_errors(span.data(), span.size(), reinterpret_cast(ptr.data())); - - if (result.error) { - scope.throwException(globalObject, createRangeError(globalObject, "Invalid character in input"_s)); - return {}; - } - string = convertedString; - } - - auto span = string.span8(); - auto&& output = decodeURIComponentSIMD(span); + // decodeURIComponentSIMD consumes UTF-8 bytes, like the ServerRouteList and CookieMap callers. + UTF8View utf8View(string); + auto&& output = decodeURIComponentSIMD(utf8View.bytes()); return JSC::JSValue::encode(JSC::jsString(vm, output)); } diff --git a/src/jsc/bindings/napi.cpp b/src/jsc/bindings/napi.cpp index 475ce94c5186..ad91c044ffa5 100644 --- a/src/jsc/bindings/napi.cpp +++ b/src/jsc/bindings/napi.cpp @@ -639,9 +639,7 @@ extern "C" napi_status napi_create_arraybuffer(napi_env env, Zig::GlobalObject* globalObject = toJS(env); auto& vm = JSC::getVM(globalObject); - // Node probably doesn't create uninitialized array buffers - // but the node-api docs don't specify whether memory is initialized or not. - RefPtr arrayBuffer = ArrayBuffer::tryCreateUninitialized(byte_length, 1); + RefPtr arrayBuffer = ArrayBuffer::tryCreate(byte_length, 1); if (!arrayBuffer) { return napi_set_last_error(env, napi_generic_failure); } diff --git a/src/jsc/bindings/ncrypto.cpp b/src/jsc/bindings/ncrypto.cpp index ddb53fdd45be..becafcceb04b 100644 --- a/src/jsc/bindings/ncrypto.cpp +++ b/src/jsc/bindings/ncrypto.cpp @@ -685,6 +685,8 @@ bool VerifySpkac(const char* input, size_t length) // case. length = std::string_view(input, length).find_last_not_of(" \n\r\t") + 1; #endif + if (length == 0) return false; + NetscapeSPKIPointer spki(NETSCAPE_SPKI_b64_decode(input, length)); if (!spki) return false; @@ -703,6 +705,8 @@ BIOPointer ExportPublicKey(const char* input, size_t length) // As such, we trim those characters here for compatibility. length = std::string_view(input, length).find_last_not_of(" \n\r\t") + 1; #endif + if (length == 0) return {}; + NetscapeSPKIPointer spki(NETSCAPE_SPKI_b64_decode(input, length)); if (!spki) return {}; @@ -722,6 +726,8 @@ Buffer ExportChallenge(const char* input, size_t length) // As such, we trim those characters here for compatibility. length = std::string_view(input, length).find_last_not_of(" \n\r\t") + 1; #endif + if (length == 0) return {}; + NetscapeSPKIPointer sp(NETSCAPE_SPKI_b64_decode(input, length)); if (!sp) return {}; diff --git a/src/jsc/bindings/node/crypto/CryptoHkdf.cpp b/src/jsc/bindings/node/crypto/CryptoHkdf.cpp index c7fc76e5ffca..ef87999cdd4f 100644 --- a/src/jsc/bindings/node/crypto/CryptoHkdf.cpp +++ b/src/jsc/bindings/node/crypto/CryptoHkdf.cpp @@ -134,8 +134,12 @@ void HkdfJob::createAndSchedule(JSGlobalObject* globalObject, HkdfJobCtx&& ctx, KeyObject prepareKey(JSGlobalObject* globalObject, ThrowScope& scope, JSValue key) { if (JSKeyObject* keyObject = dynamicDowncast(key)) { - // Node doesn't check for CryptoKeyType::Secret, so we don't either - return keyObject->handle(); + auto& handle = keyObject->handle(); + if (handle.type() != CryptoKeyType::Secret) { + ERR::CRYPTO_INVALID_KEY_OBJECT_TYPE(scope, globalObject, handle.type(), "secret"_s); + return {}; + } + return handle; } // Handle string or buffer diff --git a/src/jsc/bindings/node/crypto/CryptoPrimes.cpp b/src/jsc/bindings/node/crypto/CryptoPrimes.cpp index 44920a005607..2dda73ce7a30 100644 --- a/src/jsc/bindings/node/crypto/CryptoPrimes.cpp +++ b/src/jsc/bindings/node/crypto/CryptoPrimes.cpp @@ -86,6 +86,12 @@ JSC_DEFINE_HOST_FUNCTION(jsCheckPrimeSync, (JSC::JSGlobalObject * lexicalGlobalO auto candidateView = getArrayBufferOrView2(lexicalGlobalObject, scope, candidateValue, "candidate"_s, jsUndefined()); RETURN_IF_EXCEPTION(scope, {}); + ncrypto::BignumPointer candidate = ncrypto::BignumPointer(candidateView->data(), candidateView->size()); + if (!candidate) { + throwCryptoError(lexicalGlobalObject, scope, ERR_get_error(), "BignumPointer"_s); + return {}; + } + JSValue optionsValue = callFrame->argument(1); if (!optionsValue.isUndefined()) { V::validateObject(scope, lexicalGlobalObject, optionsValue, "options"_s); @@ -104,12 +110,6 @@ JSC_DEFINE_HOST_FUNCTION(jsCheckPrimeSync, (JSC::JSGlobalObject * lexicalGlobalO } } - ncrypto::BignumPointer candidate = ncrypto::BignumPointer(candidateView->data(), candidateView->size()); - if (!candidate) { - throwCryptoError(lexicalGlobalObject, scope, ERR_get_error(), "BignumPointer"_s); - return {}; - } - auto res = candidate.isPrime(checks, [](int32_t a, int32_t b) -> bool { // TODO(dylan-conway): ideally we check for !vm->isShuttingDown() here return true; @@ -132,6 +132,12 @@ JSC_DEFINE_HOST_FUNCTION(jsCheckPrime, (JSC::JSGlobalObject * lexicalGlobalObjec auto candidateView = getArrayBufferOrView2(lexicalGlobalObject, scope, candidateValue, "candidate"_s, jsUndefined()); RETURN_IF_EXCEPTION(scope, {}); + ncrypto::BignumPointer candidate = ncrypto::BignumPointer(candidateView->data(), candidateView->size()); + if (!candidate) { + throwCryptoError(lexicalGlobalObject, scope, ERR_get_error(), "BignumPointer"_s); + return {}; + } + JSValue optionsValue = callFrame->argument(1); JSValue callback = callFrame->argument(2); if (optionsValue.isCallable()) { @@ -159,12 +165,6 @@ JSC_DEFINE_HOST_FUNCTION(jsCheckPrime, (JSC::JSGlobalObject * lexicalGlobalObjec } } - ncrypto::BignumPointer candidate = ncrypto::BignumPointer(candidateView->data(), candidateView->size()); - if (!candidate) { - throwCryptoError(lexicalGlobalObject, scope, ERR_get_error(), "BignumPointer"_s); - return {}; - } - CheckPrimeJob::createAndSchedule(lexicalGlobalObject, WTF::move(candidate), checks, callback); return JSValue::encode(jsUndefined()); diff --git a/src/jsc/bindings/node/crypto/CryptoSignJob.cpp b/src/jsc/bindings/node/crypto/CryptoSignJob.cpp index 1b9d80177a86..972328ee33e5 100644 --- a/src/jsc/bindings/node/crypto/CryptoSignJob.cpp +++ b/src/jsc/bindings/node/crypto/CryptoSignJob.cpp @@ -314,10 +314,11 @@ std::optional SignJobCtx::fromJS(JSGlobalObject* globalObject, Throw auto dsaSigEnc = getDSASigEnc(globalObject, scope, keyValue); RETURN_IF_EXCEPTION(scope, {}); - GCOwnedDataScope> signatureView = { nullptr, {} }; + Vector signatureData; if (mode == Mode::Verify) { - signatureView = getArrayBufferOrView2(globalObject, scope, signatureValue, "signature"_s, jsUndefined(), true); + auto signatureView = getArrayBufferOrView2(globalObject, scope, signatureValue, "signature"_s, jsUndefined(), true); RETURN_IF_EXCEPTION(scope, {}); + signatureData.append(std::span { signatureView->data(), signatureView->size() }); } auto prepareResult = mode == Mode::Verify @@ -412,13 +413,13 @@ std::optional SignJobCtx::fromJS(JSGlobalObject* globalObject, Throw if (keyObject.asymmetricKey().isSigVariant() && dsaSigEnc == DSASigEnc::P1363) { convertP1363ToDER( ncrypto::Buffer { - .data = signatureView->data(), - .len = signatureView->size(), + .data = signatureData.span().data(), + .len = signatureData.size(), }, keyObject.asymmetricKey(), signature); } else { - signature.append(std::span { signatureView->data(), signatureView->size() }); + signature = WTF::move(signatureData); } return SignJobCtx( diff --git a/src/jsc/bindings/node/crypto/CryptoUtil.cpp b/src/jsc/bindings/node/crypto/CryptoUtil.cpp index c53ae57f7dd0..f10295bbc8ad 100644 --- a/src/jsc/bindings/node/crypto/CryptoUtil.cpp +++ b/src/jsc/bindings/node/crypto/CryptoUtil.cpp @@ -84,6 +84,7 @@ EncodedJSValue encode(JSGlobalObject* lexicalGlobalObject, ThrowScope& scope, st auto buffer = JSC::ArrayBuffer::tryCreateUninitialized(bytes.size(), 1); if (!buffer) { throwOutOfMemoryError(lexicalGlobalObject, scope); + return {}; } memcpy(buffer->data(), bytes.data(), bytes.size()); diff --git a/src/jsc/bindings/node/crypto/JSECDHConstructor.cpp b/src/jsc/bindings/node/crypto/JSECDHConstructor.cpp index f65e45710dee..71024bd0dd97 100644 --- a/src/jsc/bindings/node/crypto/JSECDHConstructor.cpp +++ b/src/jsc/bindings/node/crypto/JSECDHConstructor.cpp @@ -90,8 +90,6 @@ JSC_DEFINE_HOST_FUNCTION(jsECDHConvertKey, (JSC::JSGlobalObject * lexicalGlobalO auto* keyView = getArrayBufferOrView(lexicalGlobalObject, scope, keyValue, "key"_s, inEncValue); RETURN_IF_EXCEPTION(scope, {}); - auto buffer = keyView->span(); - JSValue formatValue = callFrame->argument(4); point_conversion_form_t form = JSECDH::getFormat(lexicalGlobalObject, scope, formatValue); RETURN_IF_EXCEPTION(scope, {}); @@ -99,6 +97,8 @@ JSC_DEFINE_HOST_FUNCTION(jsECDHConvertKey, (JSC::JSGlobalObject * lexicalGlobalO auto curveName = curveValue.toWTFString(lexicalGlobalObject); RETURN_IF_EXCEPTION(scope, {}); + auto buffer = keyView->span(); + int nid = OBJ_sn2nid(curveName.utf8().data()); if (nid == NID_undef) return Bun::ERR::CRYPTO_INVALID_CURVE(scope, lexicalGlobalObject); diff --git a/src/jsc/bindings/node/crypto/KeyObject.cpp b/src/jsc/bindings/node/crypto/KeyObject.cpp index b2a44dc72c6f..5939598cf527 100644 --- a/src/jsc/bindings/node/crypto/KeyObject.cpp +++ b/src/jsc/bindings/node/crypto/KeyObject.cpp @@ -1349,14 +1349,12 @@ KeyObject::PrepareAsymmetricKeyResult KeyObject::prepareAsymmetricKey(JSC::JSGlo } if (auto* view = dynamicDowncast(dataValue)) { - auto buffer = view->span(); - EVPKeyPointer::PrivateKeyEncodingConfig config; parseKeyEncoding(globalObject, scope, keyObj, jsUndefined(), isPublic, WTF::nullStringView(), config); RETURN_IF_EXCEPTION(scope, {}); return { - .keyDataView = { view, buffer }, + .keyDataView = { view, view->span() }, .formatType = config.format, .encodingType = config.type, .cipher = config.cipher, @@ -1365,15 +1363,13 @@ KeyObject::PrepareAsymmetricKeyResult KeyObject::prepareAsymmetricKey(JSC::JSGlo } if (auto* arrayBuffer = dynamicDowncast(dataValue)) { - auto* buffer = arrayBuffer->impl(); - auto data = buffer->span(); - EVPKeyPointer::PrivateKeyEncodingConfig config; parseKeyEncoding(globalObject, scope, keyObj, jsUndefined(), isPublic, WTF::nullStringView(), config); RETURN_IF_EXCEPTION(scope, {}); + auto* buffer = arrayBuffer->impl(); return { - .keyDataView = { arrayBuffer, data }, + .keyDataView = { arrayBuffer, buffer->span() }, .formatType = config.format, .encodingType = config.type, .cipher = config.cipher, diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index 1e90204a307d..2834e61c4498 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -1294,7 +1294,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementDeserialize, (JSC::JSGlobalObject * lexic } if (status != SQLITE_OK) { - auto message = status == SQLITE_ERROR ? "unable to deserialize database"_s : sqliteString(sqlite3_errstr(status)); + auto message = status == SQLITE_ERROR ? WTF::String("unable to deserialize database"_s) : WTF::String::fromUTF8(sqlite3_errstr(status)); sqlite3_close(db); throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, message)); return {}; @@ -1513,6 +1513,11 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteFunction, (JSC::JSGlobalObject * l JSC::JSValue reb = rebindStatement(lexicalGlobalObject, bindingsAliveScope.value(), scope, db, sql.stmt, bindings, safeIntegers, nullptr); RETURN_IF_EXCEPTION(scope, {}); + if (versionDB->db != db) [[unlikely]] { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Database has closed"_s)); + return {}; + } + if (!reb.isNumber()) [[unlikely]] { return JSValue::encode(reb); /* this means an error */ } diff --git a/src/jsc/bindings/v8/V8Number.cpp b/src/jsc/bindings/v8/V8Number.cpp index ab52f2ed9aa4..c620339dbb3e 100644 --- a/src/jsc/bindings/v8/V8Number.cpp +++ b/src/jsc/bindings/v8/V8Number.cpp @@ -8,7 +8,7 @@ namespace v8 { Local Number::New(Isolate* isolate, double value) { - return isolate->currentHandleScope()->createLocal(isolate->vm(), JSC::jsNumber(value)); + return isolate->currentHandleScope()->createLocal(isolate->vm(), JSC::jsNumber(JSC::purifyNaN(value))); } Local Number::NewFromInt32(Isolate* isolate, int32_t value) diff --git a/src/jsc/bindings/v8/V8String.cpp b/src/jsc/bindings/v8/V8String.cpp index 8f61fd57ffb2..e1a35a19b1a1 100644 --- a/src/jsc/bindings/v8/V8String.cpp +++ b/src/jsc/bindings/v8/V8String.cpp @@ -87,21 +87,8 @@ MaybeLocal String::NewFromOneByte(Isolate* isolate, const uint8_t* data, int String::Utf8Length(Isolate* isolate) const { - auto jsString = localToObjectPointer(); - if (jsString->length() == 0) { - return 0; - } - - auto str = jsString->view(isolate->globalObject()); - if (str->is8Bit()) { - const auto span = str->span8(); - size_t len = simdutf::utf8_length_from_latin1(reinterpret_cast(span.data()), span.size()); - return static_cast(std::min(len, static_cast(std::numeric_limits::max()))); - } else { - const auto span = str->span16(); - size_t len = simdutf::utf8_length_from_utf16(span.data(), span.size()); - return static_cast(std::min(len, static_cast(std::numeric_limits::max()))); - } + size_t len = Utf8LengthV2(isolate); + return static_cast(std::min(len, static_cast(std::numeric_limits::max()))); } bool String::IsOneByte() const @@ -302,23 +289,24 @@ size_t String::Utf8LengthV2(Isolate* isolate) const } const auto span = str->span16(); - size_t len = simdutf::utf8_length_from_utf16(span.data(), span.size()); - // simdutf counts every surrogate code unit as 2 bytes, so a valid pair - // totals 4 (matching its UTF-8 encoding) but an unpaired surrogate only - // counts 2. V8 replaces each unpaired surrogate with U+FFFD, which - // encodes as 3 bytes (the same size WriteUtf8V2's replacement behavior - // produces), so add one byte for each unpaired surrogate code unit. - // Valid UTF-16 (the overwhelmingly common case) needs no adjustment; - // check with SIMD before falling back to the scalar surrogate count. + // simdutf's answer is implementation-defined for invalid UTF-16, so only use it + // for valid input. Otherwise count exactly: V8 charges an unpaired surrogate 3 + // bytes, the size both writers produce for it (U+FFFD or its WTF-8 encoding). if (simdutf::validate_utf16(span.data(), span.size())) { - return len; + return simdutf::utf8_length_from_utf16(span.data(), span.size()); } + size_t len = 0; for (size_t i = 0; i < span.size(); i++) { const char16_t c = span[i]; - if (U16_IS_LEAD(c) && i + 1 < span.size() && U16_IS_TRAIL(span[i + 1])) { + if (c <= 0x7f) { + len += 1; + } else if (c <= 0x7ff) { + len += 2; + } else if (U16_IS_LEAD(c) && i + 1 < span.size() && U16_IS_TRAIL(span[i + 1])) { + len += 4; i++; - } else if (U16_IS_SURROGATE(c)) { - len++; + } else { + len += 3; } } return len; diff --git a/src/jsc/bindings/webcore/AbortSignal.cpp b/src/jsc/bindings/webcore/AbortSignal.cpp index 39e9603b3ecf..9a027b0a87cf 100644 --- a/src/jsc/bindings/webcore/AbortSignal.cpp +++ b/src/jsc/bindings/webcore/AbortSignal.cpp @@ -174,10 +174,14 @@ void AbortSignal::runAbortSteps() ASSERT(reason); auto callbacks = std::exchange(m_native_callbacks, {}); - for (auto callback : callbacks) { + m_nativeCallbacksBeingDispatched = &callbacks; + for (auto& callback : callbacks) { const auto [ctx, func] = callback; + if (!func) + continue; func(ctx, JSC::JSValue::encode(reason)); } + m_nativeCallbacksBeingDispatched = nullptr; // 1. For each algorithm of signal's abort algorithms: run algorithm. // 2. Empty signal's abort algorithms. (std::exchange empties) @@ -249,6 +253,13 @@ void AbortSignal::signalAbort(JSC::JSGlobalObject* globalObject, CommonAbortReas void AbortSignal::cleanNativeBindings(void* ref) { + if (m_nativeCallbacksBeingDispatched) { + for (auto& callback : *m_nativeCallbacksBeingDispatched) { + if (std::get<0>(callback) == ref) + std::get<1>(callback) = nullptr; + } + } + auto callbacks = std::exchange(m_native_callbacks, {}); callbacks.removeAllMatching([=](auto callback) { diff --git a/src/jsc/bindings/webcore/AbortSignal.h b/src/jsc/bindings/webcore/AbortSignal.h index 7eae1b2f3ba6..a41aeebac944 100644 --- a/src/jsc/bindings/webcore/AbortSignal.h +++ b/src/jsc/bindings/webcore/AbortSignal.h @@ -204,6 +204,7 @@ class AbortSignal final : public RefCounted, public EventTargetWith JSValueInWrappedObject m_reason; CommonAbortReason m_commonReason { CommonAbortReason::None }; Vector m_native_callbacks; + Vector* m_nativeCallbacksBeingDispatched { nullptr }; std::atomic pendingActivityCount { 0 }; uint32_t m_algorithmIdentifier { 0 }; AbortSignalTimeout m_timeout { nullptr }; diff --git a/src/jsc/bindings/webcore/JSDOMConvertRecord.h b/src/jsc/bindings/webcore/JSDOMConvertRecord.h index f9b9d7a88002..f002d1775442 100644 --- a/src/jsc/bindings/webcore/JSDOMConvertRecord.h +++ b/src/jsc/bindings/webcore/JSDOMConvertRecord.h @@ -122,22 +122,56 @@ template struct Converter> : DefaultConv } if (canUseFastPath) { + // Only the identifiers and offsets are snapshotted here: no user code runs inside + // forEachProperty, so the property table cannot be mutated out from under it. + Vector identifiers; + Vector offsets; structure->forEachProperty(vm, [&](const PropertyTableEntry& entry) -> bool { if (entry.attributes() & PropertyAttribute::DontEnum) { return true; } + identifiers.append(Identifier::fromUid(vm, entry.key())); + offsets.append(entry.offset()); + return true; + }); + + for (size_t i = 0; i < identifiers.size(); ++i) { + const auto& identifier = identifiers[i]; + + // Converter::convert below can run user code. The snapshotted offsets are only + // valid while the object keeps its original structure; any mutation transitions it. + bool structureIsUnchanged = object->structure() == structure; + JSC::PropertySlot slot(object, JSC::PropertySlot::InternalMethodType::GetOwnProperty); + if (!structureIsUnchanged) [[unlikely]] { + // 1. Let desc be ? O.[[GetOwnProperty]](key). + bool hasProperty = object->methodTable()->getOwnPropertySlot(object, &lexicalGlobalObject, identifier, slot); + RETURN_IF_EXCEPTION(scope, {}); + + // 2. If desc is not undefined and desc.[[Enumerable]] is true: + if (!hasProperty || (slot.attributes() & JSC::PropertyAttribute::DontEnum)) + continue; + } + // 1. Let typedKey be key converted to an IDL value of type K. - auto typedKey = Detail::IdentifierConverter::convert(lexicalGlobalObject, Identifier::fromUid(vm, entry.key())); - RETURN_IF_EXCEPTION(scope, false); + auto typedKey = Detail::IdentifierConverter::convert(lexicalGlobalObject, identifier); + RETURN_IF_EXCEPTION(scope, {}); // 2. Let value be ? Get(O, key). - JSC::JSValue value = object->getDirect(entry.offset()); - scope.assertNoException(); + JSC::JSValue value; + if (structureIsUnchanged) [[likely]] + value = object->getDirect(offsets[i]); + else { + if (!slot.isTaintedByOpaqueObject()) [[likely]] + value = slot.getValue(&lexicalGlobalObject, identifier); + else + value = object->get(&lexicalGlobalObject, identifier); + RETURN_IF_EXCEPTION(scope, {}); + } // 3. Let typedValue be value converted to an IDL value of type V. auto typedValue = Converter::convert(lexicalGlobalObject, value, args...); - RETURN_IF_EXCEPTION(scope, false); + RETURN_IF_EXCEPTION(scope, {}); // 4. Set result[typedKey] to typedValue. // Note: It's possible that typedKey is already in result if K is USVString and key contains unpaired surrogates. @@ -147,7 +181,7 @@ template struct Converter> : DefaultConv if (!addResult.isNewEntry) { ASSERT(result[addResult.iterator->value].key == typedKey); result[addResult.iterator->value].value = WTF::move(typedValue); - return true; + continue; } } } else @@ -155,10 +189,7 @@ template struct Converter> : DefaultConv // 5. Otherwise, append to result a mapping (typedKey, typedValue). result.append({ WTF::move(typedKey), WTF::move(typedValue) }); - return true; - }); - - RETURN_IF_EXCEPTION(scope, {}); + } return result; } diff --git a/src/jsc/bindings/webcore/SerializedScriptValue.cpp b/src/jsc/bindings/webcore/SerializedScriptValue.cpp index b1963c54c0a7..c124f7887d6b 100644 --- a/src/jsc/bindings/webcore/SerializedScriptValue.cpp +++ b/src/jsc/bindings/webcore/SerializedScriptValue.cpp @@ -4133,6 +4133,8 @@ class CloneDeserializer : public CloneBase { CryptoAlgorithmIdentifier algorithm; if (!read(algorithm)) return false; + if (!CryptoKeyRSA::isValidRSAAlgorithm(algorithm)) + return false; int32_t isRestrictedToHash; CryptoAlgorithmIdentifier hash = CryptoAlgorithmIdentifier::SHA_1; @@ -4253,6 +4255,16 @@ class CloneDeserializer : public CloneBase { CryptoKeyOKP::NamedCurve namedCurve; if (!read(namedCurve)) return false; + switch (namedCurve) { + case CryptoKeyOKP::NamedCurve::Ed25519: + if (algorithm != CryptoAlgorithmIdentifier::Ed25519) + return false; + break; + case CryptoKeyOKP::NamedCurve::X25519: + if (algorithm != CryptoAlgorithmIdentifier::X25519) + return false; + break; + } Vector keyData; if (!read(keyData)) return false; @@ -4266,6 +4278,8 @@ class CloneDeserializer : public CloneBase { CryptoAlgorithmIdentifier algorithm; if (!read(algorithm)) return false; + if (!CryptoKeyRaw::isValidRawAlgorithm(algorithm)) + return false; Vector keyData; if (!read(keyData)) return false; @@ -5304,7 +5318,7 @@ class CloneDeserializer : public CloneBase { #if ENABLE(WEB_CRYPTO) case CryptoKeyTag: { Vector serializedKey; - if (!read(serializedKey)) { + if (!read(serializedKey) || serializedKey.isEmpty()) { fail(); return JSValue(); } @@ -5463,6 +5477,8 @@ DeserializationResult CloneDeserializer::deserialize() switch (state) { arrayStartState: case ArrayStartState: { + if (outputObjectStack.size() > maximumFilterRecursion) + return std::make_pair(JSValue(), SerializationReturnCode::StackOverflowError); uint32_t length; if (!read(length)) { goto error; diff --git a/src/jsc/bindings/webcrypto/CryptoAlgorithmEd25519.cpp b/src/jsc/bindings/webcrypto/CryptoAlgorithmEd25519.cpp index c113cf7a8e05..2785dedc8352 100644 --- a/src/jsc/bindings/webcrypto/CryptoAlgorithmEd25519.cpp +++ b/src/jsc/bindings/webcrypto/CryptoAlgorithmEd25519.cpp @@ -39,6 +39,9 @@ namespace WebCore { static ExceptionOr> signEd25519(const Vector& sk, size_t len, const Vector& data) { + if (sk.size() != len || len != ED25519_PRIVATE_KEY_LEN) + return Exception { OperationError }; + uint8_t newSignature[64]; ED25519_sign(newSignature, data.begin(), data.size(), sk.begin()); @@ -52,6 +55,9 @@ ExceptionOr> CryptoAlgorithmEd25519::platformSign(const CryptoKe static ExceptionOr verifyEd25519(const Vector& key, size_t keyLengthInBytes, const Vector& signature, const Vector data) { + if (key.size() != ED25519_PUBLIC_KEY_LEN || keyLengthInBytes != ED25519_PUBLIC_KEY_LEN) + return false; + if (signature.size() != keyLengthInBytes * 2) return false; diff --git a/src/jsc/bindings/webcrypto/CryptoKeyOKP.cpp b/src/jsc/bindings/webcrypto/CryptoKeyOKP.cpp index 1f5b17a3dc20..dcfbc8e549bf 100644 --- a/src/jsc/bindings/webcrypto/CryptoKeyOKP.cpp +++ b/src/jsc/bindings/webcrypto/CryptoKeyOKP.cpp @@ -145,9 +145,16 @@ RefPtr CryptoKeyOKP::importJwkInternal(CryptoAlgorithmIdentifier i return nullptr; break; case NamedCurve::X25519: + if (keyData.kty != "OKP"_s) + return nullptr; if (keyData.crv != "X25519"_s) return nullptr; - // FIXME: Add further checks. + if (usages && !keyData.use.isEmpty() && keyData.use != "enc"_s) + return nullptr; + if (keyData.key_ops && ((keyData.usages & usages) != usages)) + return nullptr; + if (keyData.ext && !keyData.ext.value() && extractable) + return nullptr; break; } diff --git a/src/jsc/bindings/webcrypto/CryptoKeyRSA.cpp b/src/jsc/bindings/webcrypto/CryptoKeyRSA.cpp index 9f69d617e987..3d4d867f513d 100644 --- a/src/jsc/bindings/webcrypto/CryptoKeyRSA.cpp +++ b/src/jsc/bindings/webcrypto/CryptoKeyRSA.cpp @@ -174,6 +174,14 @@ JsonWebKey CryptoKeyRSA::exportJwk() const return result; } +bool CryptoKeyRSA::isValidRSAAlgorithm(CryptoAlgorithmIdentifier algorithm) +{ + return algorithm == CryptoAlgorithmIdentifier::RSAES_PKCS1_v1_5 + || algorithm == CryptoAlgorithmIdentifier::RSASSA_PKCS1_v1_5 + || algorithm == CryptoAlgorithmIdentifier::RSA_PSS + || algorithm == CryptoAlgorithmIdentifier::RSA_OAEP; +} + } // namespace WebCore #endif // ENABLE(WEB_CRYPTO) diff --git a/src/jsc/bindings/webcrypto/CryptoKeyRSA.h b/src/jsc/bindings/webcrypto/CryptoKeyRSA.h index 9c26536cd3d6..a7b3463f5194 100644 --- a/src/jsc/bindings/webcrypto/CryptoKeyRSA.h +++ b/src/jsc/bindings/webcrypto/CryptoKeyRSA.h @@ -94,6 +94,8 @@ class CryptoKeyRSA final : public CryptoKey { CryptoAlgorithmIdentifier hashAlgorithmIdentifier() const { return m_hash; } + static bool isValidRSAAlgorithm(CryptoAlgorithmIdentifier); + private: CryptoKeyRSA(CryptoAlgorithmIdentifier, CryptoAlgorithmIdentifier hash, bool hasHash, CryptoKeyType, PlatformRSAKeyContainer&&, bool extractable, CryptoKeyUsageBitmap); diff --git a/src/jsc/bindings/webcrypto/CryptoKeyRaw.cpp b/src/jsc/bindings/webcrypto/CryptoKeyRaw.cpp index 405b69942f44..99d5356f721f 100644 --- a/src/jsc/bindings/webcrypto/CryptoKeyRaw.cpp +++ b/src/jsc/bindings/webcrypto/CryptoKeyRaw.cpp @@ -45,6 +45,12 @@ auto CryptoKeyRaw::algorithm() const -> KeyAlgorithm return result; } +bool CryptoKeyRaw::isValidRawAlgorithm(CryptoAlgorithmIdentifier algorithm) +{ + return algorithm == CryptoAlgorithmIdentifier::HKDF + || algorithm == CryptoAlgorithmIdentifier::PBKDF2; +} + } // namespace WebCore #endif // ENABLE(WEB_CRYPTO) diff --git a/src/jsc/bindings/webcrypto/CryptoKeyRaw.h b/src/jsc/bindings/webcrypto/CryptoKeyRaw.h index bd9d90b4e5b8..6de165c6590d 100644 --- a/src/jsc/bindings/webcrypto/CryptoKeyRaw.h +++ b/src/jsc/bindings/webcrypto/CryptoKeyRaw.h @@ -40,6 +40,8 @@ class CryptoKeyRaw final : public CryptoKey { const Vector& key() const { return m_key; } + static bool isValidRawAlgorithm(CryptoAlgorithmIdentifier); + private: CryptoKeyRaw(CryptoAlgorithmIdentifier, Vector&& keyData, CryptoKeyUsageBitmap); diff --git a/src/jsc/bindings/wrapAnsi.cpp b/src/jsc/bindings/wrapAnsi.cpp index 2a30ec75d257..968705d87c1b 100644 --- a/src/jsc/bindings/wrapAnsi.cpp +++ b/src/jsc/bindings/wrapAnsi.cpp @@ -12,6 +12,7 @@ extern "C" size_t Bun__visibleWidthExcludeANSI_utf16(const uint16_t* ptr, size_t len, bool ambiguous_as_wide); extern "C" size_t Bun__visibleWidthExcludeANSI_latin1(const uint8_t* ptr, size_t len); extern "C" uint8_t Bun__codepointWidth(uint32_t cp, bool ambiguous_as_wide); +extern "C" bool Bun__graphemeBreak(uint32_t cp1, uint32_t cp2, uint8_t* state); namespace Bun { using namespace WTF; @@ -58,6 +59,51 @@ static size_t stringWidth(const Char* start, const Char* end, bool ambiguousIsNa } } +// A word may begin with ANSI escape sequences whose code units are all ASCII +// (ESC, '[', digits, 'm'), hiding the codepoint that actually lands on the seam. +// Skip them before classifying; a word not starting with ESC never enters the scan. +template +static inline const Char* skipLeadingAnsi(const Char* start, const Char* end) +{ + if (start < end && ANSI::isEscapeCharacter(*start)) + return ANSI::consumeANSI(start, end); + return start; +} + +// True when a grapheme cluster boundary always precedes the word's first codepoint +// (worst-case predecessor: the separator space). A word-initial cluster-fusing +// codepoint (combining mark, ZWJ, VS16, keycap) makes row widths non-additive. +template +static inline bool wordStartsNewCluster(const Char* wordStart, const Char* wordEnd) +{ + wordStart = skipLeadingAnsi(wordStart, wordEnd); + if (wordStart >= wordEnd) + return true; + char32_t cp; + if constexpr (sizeof(Char) == 1) { + cp = static_cast(static_cast(*wordStart)); + } else { + size_t cpLen; + cp = decodeUTF16(reinterpret_cast(wordStart), wordEnd - wordStart, cpLen); + } + if (cp < 0x80) + return true; + uint8_t state = 0; + return Bun__graphemeBreak(' ', cp, &state); +} + +// Without a separator space the row's trailing content is the word's real +// predecessor, and a trailing escape can hide a cluster-fusing codepoint +// (e.g. a Prepend): only an ASCII/ASCII seam keeps row widths additive. +template +static inline bool wordSeamIsAscii(Char rowTail, const Char* wordStart, const Char* wordEnd) +{ + wordStart = skipLeadingAnsi(wordStart, wordEnd); + if (wordStart >= wordEnd) + return true; + return static_cast(rowTail) < 0x80 && static_cast(*wordStart) < 0x80; +} + // ============================================================================ // Row Management (using WTF::Vector) // ============================================================================ @@ -90,61 +136,55 @@ class Row { return stringWidth(span.data(), span.data() + span.size(), ambiguousIsNarrow); } - void trimLeadingSpaces() + size_t trimLeadingSpaces() { - size_t removeCount = 0; - bool inEscape = false; + if (m_leadingTrimComplete) + return 0; + + const size_t size = m_data.size(); + size_t read = m_trimScanOffset; + size_t write = m_trimScanOffset; + bool inEscape = m_trimInEscape; + size_t removedWidth = 0; - // Count leading spaces (preserving ANSI) - for (size_t i = 0; i < m_data.size(); ++i) { - Char c = m_data[i]; + while (read < size) { + Char c = m_data[read]; if (c == 0x1b) { inEscape = true; - continue; - } - if (inEscape) { + } else if (inEscape) { if (c == 'm' || c == 0x07) inEscape = false; + } else if (c == ' ' || c == '\t') { + if (c == ' ') + removedWidth++; + read++; continue; - } - if (c == ' ' || c == '\t') - removeCount++; - else + } else { + m_leadingTrimComplete = true; break; + } + m_data[write] = c; + write++; + read++; } - if (removeCount == 0) - return; - - // Remove spaces while preserving ANSI codes - Vector newData; - newData.reserveCapacity(m_data.size() - removeCount); - - inEscape = false; - size_t removed = 0; - - for (size_t i = 0; i < m_data.size(); ++i) { - Char c = m_data[i]; - if (c == 0x1b) { - inEscape = true; - newData.append(c); - continue; + if (write != read) { + while (read < size) { + m_data[write] = m_data[read]; + write++; + read++; } - if (inEscape) { - if (c == 'm' || c == 0x07) - inEscape = false; - newData.append(c); - continue; - } - if ((c == ' ' || c == '\t') && removed < removeCount) { - removed++; - continue; - } - newData.append(c); + m_data.shrink(write); } - m_data = std::move(newData); + m_trimScanOffset = write; + m_trimInEscape = inEscape; + return removedWidth; } + + size_t m_trimScanOffset = 0; + bool m_trimInEscape = false; + bool m_leadingTrimComplete = false; }; // ============================================================================ @@ -533,6 +573,8 @@ static void processLine(const Char* lineStart, const Char* lineEnd, size_t colum // Process each word const Char* wordStart = lineStart; size_t wordIndex = 0; + size_t lastRowWidth = 0; + bool lastRowWidthDirty = false; for (const Char* it = lineStart; it <= lineEnd; ++it) { if (it < lineEnd && *it != ' ') @@ -540,10 +582,20 @@ static void processLine(const Char* lineStart, const Char* lineEnd, size_t colum const Char* wordEnd = it; - if (options.trim) - rows.last().trimLeadingSpaces(); + if (options.trim) { + size_t removedWidth = rows.last().trimLeadingSpaces(); + if (!lastRowWidthDirty) + lastRowWidth = removedWidth < lastRowWidth ? lastRowWidth - removedWidth : 0; + } + + if (lastRowWidthDirty) { + lastRowWidth = rows.last().width(options.ambiguousIsNarrow); + lastRowWidthDirty = false; + } - size_t rowLength = rows.last().width(options.ambiguousIsNarrow); + size_t rowLength = lastRowWidth; + bool spacePrecedesWord = true; + Char rowTail = static_cast(' '); if (wordIndex != 0) { if (rowLength >= columns && (!options.wordWrap || !options.trim)) { @@ -554,6 +606,9 @@ static void processLine(const Char* lineStart, const Char* lineEnd, size_t colum if (rowLength > 0 || !options.trim) { rows.last().append(static_cast(' ')); rowLength++; + } else if (!rows.last().m_data.isEmpty()) { + spacePrecedesWord = false; + rowTail = rows.last().m_data.last(); } } @@ -568,6 +623,7 @@ static void processLine(const Char* lineStart, const Char* lineEnd, size_t colum rows.append(Row()); wrapWord(rows, wordStart, wordEnd, columns, options); + lastRowWidthDirty = true; wordStart = it + 1; wordIndex++; continue; @@ -576,23 +632,29 @@ static void processLine(const Char* lineStart, const Char* lineEnd, size_t colum if (rowLength + wordLen > columns && rowLength > 0 && wordLen > 0) { if (!options.wordWrap && rowLength < columns) { wrapWord(rows, wordStart, wordEnd, columns, options); + lastRowWidthDirty = true; wordStart = it + 1; wordIndex++; continue; } rows.append(Row()); + rowLength = 0; } - rowLength = rows.last().width(options.ambiguousIsNarrow); if (rowLength + wordLen > columns && !options.wordWrap) { wrapWord(rows, wordStart, wordEnd, columns, options); + lastRowWidthDirty = true; wordStart = it + 1; wordIndex++; continue; } rows.last().append(wordStart, wordEnd); + if (spacePrecedesWord ? wordStartsNewCluster(wordStart, wordEnd) : wordSeamIsAscii(rowTail, wordStart, wordEnd)) + lastRowWidth = rowLength + wordLen; + else + lastRowWidthDirty = true; wordStart = it + 1; wordIndex++; } diff --git a/src/jsc/ipc.rs b/src/jsc/ipc.rs index e99ad0ecd37b..a9c59e7423e0 100644 --- a/src/jsc/ipc.rs +++ b/src/jsc/ipc.rs @@ -353,6 +353,9 @@ mod advanced { x if x == IPCMessageType::SerializedMessage as u8 || x == IPCMessageType::SerializedInternalMessage as u8 => { + if message_len > u32::MAX - HEADER_LENGTH_U32 { + return Err(IPCDecodeError::InvalidFormat); + } // `header_length + message_len` would be evaluated as u32; a peer-controlled // `message_len >= 0xFFFFFFFB` wraps the sum to a small value and defeats the // bounds check. Compare against the remaining bytes instead — `data.len >= @@ -1361,20 +1364,18 @@ impl SendQueue { ) -> SerializeAndSendResult { log!("SendQueue#serializeAndSend"); let indicate_backoff = self.waiting_for_ack.is_some() && !self.queue.is_empty(); - // Note: reshaped for borrowck — work on msg via local then drop borrow before continue_send. let mode = self.mode; - let msg = match self.start_message(global, callback, handle) { - Ok(m) => m, + let mut payload = StreamBuffer::default(); + let payload_length = match serialize(mode, &mut payload, global, value, is_internal) { + Ok(n) => n, Err(_) => return SerializeAndSendResult::Failure, }; - let start_offset = msg.data.list.len(); - - let payload_length = match serialize(mode, &mut msg.data, global, value, is_internal) { - Ok(n) => n, + debug_assert!(payload.list.len() == payload_length); + let msg = match self.start_message(global, callback, handle) { + Ok(m) => m, Err(_) => return SerializeAndSendResult::Failure, }; - debug_assert!(msg.data.list.len() == start_offset + payload_length); - + handle_oom(msg.data.write(&payload.list)); log!("IPC call continueSend() from serializeAndSend"); self.continue_send(global, ContinueSendReason::NewMessageAppended); @@ -1853,6 +1854,7 @@ fn handle_ipc_message( let res = ipc_parse(global_this, target, msg_data, fd_js); if let Err(e) = res { // ack written already, that's okay. + FdExt::close(fd); global_this.report_active_exception_as_unhandled(e); return; } diff --git a/src/md/links.rs b/src/md/links.rs index 4c657a19acbb..d418382948f9 100644 --- a/src/md/links.rs +++ b/src/md/links.rs @@ -545,6 +545,9 @@ impl Parser<'_> { { return Ok(None); } + if !self.charge_ref_def_output(dest.len(), title.len()) { + return Ok(None); + } let leave = self.enter_label_span(&dest, &title, is_image)?; return Ok(Some(LabelParse { label_start: start + 1, @@ -577,6 +580,9 @@ impl Parser<'_> { { return Ok(None); } + if !self.charge_ref_def_output(dest.len(), title.len()) { + return Ok(None); + } let leave = self.enter_label_span(&dest, &title, is_image)?; return Ok(Some(LabelParse { label_start: start + 1, diff --git a/src/md/parser.rs b/src/md/parser.rs index 40d651918e1b..b830cdc843e8 100644 --- a/src/md/parser.rs +++ b/src/md/parser.rs @@ -266,6 +266,20 @@ impl<'a> Parser<'a> { Ok(aligned) } + /// Charge one resolved reference link/image against the reference-definition + /// output budget (`max_ref_def_output`). On exhaustion the budget is zeroed, so + /// this and every later reference degrade to literal text (md4c, mity/md4c#238). + pub(crate) fn charge_ref_def_output(&mut self, dest_len: usize, title_len: usize) -> bool { + let n = dest_len as u64 + title_len as u64; + if n < self.max_ref_def_output { + self.max_ref_def_output -= n; + true + } else { + self.max_ref_def_output = 0; + false + } + } + fn init(text: &'a [u8], flags: Flags, rend: Renderer<'a>) -> Result, ParserError> { let size = input_size(text)?; let mut p = Parser { @@ -306,7 +320,7 @@ impl<'a> Parser<'a> { ref_def_labels: bun_collections::StringSet::new(), last_line_has_list_loosening_effect: false, last_list_item_starts_with_two_blank_lines: false, - max_ref_def_output: (16 * (size as u64)).min(1024 * 1024).min(u32::MAX as u64), + max_ref_def_output: 16 * (size as u64).min(1024 * 1024 / 16), stack_check: StackCheck::init(), }; p.build_mark_char_map(); diff --git a/src/node-fallbacks/url.js b/src/node-fallbacks/url.js index 3d45a9a19a02..555ab07c2407 100644 --- a/src/node-fallbacks/url.js +++ b/src/node-fallbacks/url.js @@ -77,16 +77,19 @@ var protocolPattern = /^([a-z0-9.+-]+:)/i, hostnamePartStart = /^([+a-z0-9A-Z_-]{0,63})(.*)$/, // protocols that can allow "unsafe" and "unwise" chars. unsafeProtocol = { + __proto__: null, javascript: true, "javascript:": true, }, // protocols that never have a hostname. hostlessProtocol = { + __proto__: null, javascript: true, "javascript:": true, }, // protocols that always contain a // bit. slashedProtocol = { + __proto__: null, http: true, https: true, ftp: true, @@ -195,13 +198,13 @@ Url.prototype.parse = function (url, parseQueryString, slashesDenoteHost) { // how the browser resolves relative URLs. if (slashesDenoteHost || proto || rest.match(/^\/\/[^@\/]+@[^@\/]+/)) { var slashes = rest.substr(0, 2) === "//"; - if (slashes && !(proto && hostlessProtocol[proto])) { + if (slashes && !(proto && hostlessProtocol[lowerProto])) { rest = rest.substr(2); this.slashes = true; } } - if (!hostlessProtocol[proto] && (slashes || (proto && !slashedProtocol[proto]))) { + if (!hostlessProtocol[lowerProto] && (slashes || (lowerProto && !slashedProtocol[lowerProto]))) { // there's a hostname. // the first instance of /, ?, ;, or # ends the host. // diff --git a/src/parsers/json.rs b/src/parsers/json.rs index 23ab53922448..aefd97269bee 100644 --- a/src/parsers/json.rs +++ b/src/parsers/json.rs @@ -983,6 +983,7 @@ impl Materializer<'_> { None => property_value_loc_or_key(self.contents, row.key_loc), }; properties.push(G::Property { + flags: E::own_key_property_flags(&key), key: Some(key), value: Some(self.json_value(&row.value, value_loc)), kind: G::PropertyKind::Normal, diff --git a/src/parsers/json5.rs b/src/parsers/json5.rs index 87800a95fab7..5713e170cfbe 100644 --- a/src/parsers/json5.rs +++ b/src/parsers/json5.rs @@ -573,6 +573,7 @@ impl<'a> JSON5Parser<'a> { let value = self.parse_value()?; properties.push(G::Property { + flags: E::own_key_property_flags(&key), key: Some(key), value: Some(value), ..Default::default() diff --git a/src/parsers/yaml.rs b/src/parsers/yaml.rs index df04839f116d..f4dbd8767e00 100644 --- a/src/parsers/yaml.rs +++ b/src/parsers/yaml.rs @@ -3280,7 +3280,10 @@ impl MappingProps { } } - pub fn append(&mut self, prop: G::Property) -> Result<(), AllocError> { + pub fn append(&mut self, mut prop: G::Property) -> Result<(), AllocError> { + if let Some(key) = &prop.key { + prop.flags |= E::own_key_property_flags(key); + } self.list.push(prop); Ok(()) } @@ -3345,12 +3348,11 @@ impl MappingProps { }; if !is_merge_key { - self.list.push(G::Property { + return self.append(G::Property { key: Some(key), value: Some(value), ..Default::default() }); - return Ok(()); } match &value.data { @@ -3365,14 +3367,11 @@ impl MappingProps { } Ok(()) } - _ => { - self.list.push(G::Property { - key: Some(key), - value: Some(value), - ..Default::default() - }); - Ok(()) - } + _ => self.append(G::Property { + key: Some(key), + value: Some(value), + ..Default::default() + }), } } diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index c533872e70c0..eb580783caba 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -73,10 +73,10 @@ pub fn is_parent_or_equal(parent_: &[u8], child: &[u8]) -> ParentEqual { } #[cfg(not(any(target_os = "linux", target_os = "android")))] - let contains = strings::contains_case_insensitive_ascii; + let starts_with = strings::starts_with_case_insensitive_ascii; #[cfg(any(target_os = "linux", target_os = "android"))] - let contains = strings::contains; - if !contains(child, parent) { + let starts_with = strings::starts_with; + if !starts_with(child, parent) { return ParentEqual::Unrelated; } diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 0359fc8c614d..b7614dca3651 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -1891,6 +1891,9 @@ pub mod dir_entry_accessor { pub struct DirEntryIterResult { pub name: DirEntryNameWrapper, pub kind: bun_sys::FileKind, + /// Resolver-cached real path of a symlink entry's target + /// (`Interned::EMPTY` for non-symlinks). + pub symlink_target: bun_ptr::Interned, } pub(crate) struct DirEntryNameWrapper { @@ -1922,6 +1925,10 @@ pub mod dir_entry_accessor { fn kind(&self) -> bun_sys::FileKind { self.kind } + fn symlink_target(&self) -> Option<&[u8]> { + let target = self.symlink_target.as_bytes(); + (!target.is_empty()).then_some(target) + } } impl AccessorDirIter for DirEntryDirIter { @@ -1948,6 +1955,9 @@ pub mod dir_entry_accessor { EntryKind::File => bun_sys::FileKind::File, EntryKind::Dir => bun_sys::FileKind::Directory, }; + // `Entry::kind` resolved through symlinks above; a non-empty + // cached realpath is what records the entry as a symlink. + let symlink_target = entry.cache().symlink; // BACKREF: wrap the HashMap key's bytes in a `RawSlice` // instead of fabricating `&'static [u8]` (PORTING.md §Forbidden). // The key is a `Box<[u8]>` owned by `DirEntry.data` and valid @@ -1958,6 +1968,7 @@ pub mod dir_entry_accessor { value: bun_ptr::RawSlice::new(&**key), }, kind: fskind, + symlink_target, })) } else { Ok(None) @@ -2142,8 +2153,8 @@ pub mod cache { } } - /// Optional external destructor (`function(ctx)`) invoked when an entry's - /// contents are released; `NONE` when the entry owns its bytes. + /// Optional external destructor (`function(ctx)`) for foreign-owned + /// source bytes; `NONE` when there is nothing external to free. #[repr(C)] pub struct ExternalFreeFunction { pub ctx: *mut c_void, @@ -2202,7 +2213,9 @@ pub mod cache { /// storage). Caller guarantees the pointee outlives all reads through /// this `Entry`. NOT freed on `deinit`. SharedBuffer { ptr: *const u8, len: usize }, - /// Native-plugin memory; freed via `Entry.external_free_function.call()`. + /// Externally-owned bytes the producer keeps alive past this `Entry` + /// (native-plugin buffers are freed by the bundler's + /// `BundleV2.finalizers`); NOT freed on `deinit`. External { ptr: *const u8, len: usize }, } @@ -2215,8 +2228,10 @@ pub mod cache { // SAFETY: FFI/ARENA — single encapsulation point for foreign- // owned bytes. `SharedBuffer` points into the caller-owned // per-thread `MutableString` (reset only after this `Entry` is - // dropped); `External` is native-plugin memory kept live until - // `external_free_function` runs in `deinit`. In both cases + // dropped); `External` is producer-owned memory the producer + // keeps alive past the `Entry` (native-plugin buffers are + // freed later by the bundler's `BundleV2.finalizers`, via + // `ParseTask.external_free_function`). In both cases // `ptr` is non-null, aligned, and `ptr[..len]` is initialized // and valid for shared reads for at least `'_`. Cannot be a // `bun_ptr::RawSlice` field without breaking `src/bundler/` @@ -2307,9 +2322,6 @@ pub mod cache { pub struct Entry { pub contents: Contents, pub fd: Fd, - /// When `contents` comes from a native plugin, this field is populated - /// with information on how to free it. - pub external_free_function: ExternalFreeFunction, } impl Default for Entry { @@ -2317,25 +2329,11 @@ pub mod cache { Entry { contents: Contents::Empty, fd: Fd::INVALID, - external_free_function: ExternalFreeFunction::NONE, } } } impl Entry { - /// Convenience: take ownership of a heap buffer. - pub fn new( - contents: Box<[u8]>, - fd: Fd, - external_free_function: ExternalFreeFunction, - ) -> Entry { - Entry { - contents: Contents::from(contents), - fd, - external_free_function, - } - } - #[inline] pub fn contents(&self) -> &[u8] { self.contents.as_slice() @@ -2345,10 +2343,6 @@ pub mod cache { /// explicitly (and frequently hand `contents` off to a `Source` that /// outlives the `Entry`). pub fn deinit(&mut self) { - if let Some(func) = self.external_free_function.function { - // SAFETY: ctx/function pair was supplied together by the native plugin. - unsafe { func(self.external_free_function.ctx) }; - } self.contents = Contents::Empty; } @@ -2453,7 +2447,6 @@ pub mod cache { Ok(Entry { contents, fd: if publish_fd { fd } else { Fd::INVALID }, - external_free_function: ExternalFreeFunction::NONE, }) } @@ -2614,7 +2607,6 @@ pub mod cache { Ok(Entry { contents, fd: if publish_fd { fd } else { Fd::INVALID }, - external_free_function: ExternalFreeFunction::NONE, }) } } diff --git a/src/resolver/package_json.rs b/src/resolver/package_json.rs index aaa918e724ed..ebf773804acf 100644 --- a/src/resolver/package_json.rs +++ b/src/resolver/package_json.rs @@ -3,7 +3,7 @@ use bun_collections::{ArrayHashMap, StringArrayHashMap}; use bun_core::Output; use bun_core::strings; use bun_js_parser::lexer as js_lexer; -use bun_paths::{self as resolve_path, PathBuffer, SEP_STR}; +use bun_paths::{self as resolve_path, MAX_PATH_BYTES, PathBuffer, SEP_STR}; use bun_semver as Semver; use bun_semver::String as SemverString; @@ -1899,7 +1899,7 @@ impl<'a> ESModule<'a> { } // A scopeguard cannot hold the &mut across the recursive // `&mut self` calls below; every return path in this arm invokes - // `dedent!()` manually instead (audited: all 10 returns in this arm dedent). + // `dedent!()` manually instead (audited: every return in this arm dedents). macro_rules! dedent { () => { if let Some(log) = self.debug_logs.as_deref_mut() { @@ -1907,6 +1907,39 @@ impl<'a> ESModule<'a> { } }; } + macro_rules! invalid_specifier_if_too_long { + ($len:expr) => { + if $len > MAX_PATH_BYTES { + if let Some(log) = self.debug_logs.as_deref_mut() { + log.add_note_fmt(format_args!( + "The path \"{}\" is invalid because it is too long", + bstr::BStr::new(subpath) + )); + } + dedent!(); + return Resolution { + path: Box::<[u8]>::from(subpath), + status: Status::InvalidModuleSpecifier, + debug: ResolutionDebug::default(), + }; + } + }; + } + + if package_url.len() + str.len() + subpath.len() + 8 > MAX_PATH_BYTES { + if let Some(log) = self.debug_logs.as_deref_mut() { + log.add_note_fmt(format_args!( + "The target \"{}\" is invalid because the resolved path would be too long", + bstr::BStr::new(str) + )); + } + dedent!(); + return Resolution { + path: Box::<[u8]>::from(str), + status: Status::InvalidPackageTarget, + debug: ResolutionDebug::default(), + }; + } // If pattern is false, subpath has non-zero length and target // does not end with "/", throw an Invalid Module Specifier error. @@ -1968,6 +2001,7 @@ impl<'a> ESModule<'a> { if PATTERN { // Return the URL resolution of resolvedTarget with every instance of "*" replaced with subpath. let len = replacement_size(str, b"*", subpath); + invalid_specifier_if_too_long!(len); let _ = replace(str, b"*", subpath, &mut resolve_target_buf2.0); let result = &resolve_target_buf2.0[0..len]; if let Some(log) = self.debug_logs.as_deref_mut() { @@ -2067,6 +2101,7 @@ impl<'a> ESModule<'a> { if PATTERN { // Return the URL resolution of resolvedTarget with every instance of "*" replaced with subpath. let len = replacement_size(resolved_target, b"*", subpath); + invalid_specifier_if_too_long!(len); let _ = replace(resolved_target, b"*", subpath, &mut resolve_target_buf2.0); let result = &resolve_target_buf2.0[0..len]; if let Some(log) = self.debug_logs.as_deref_mut() { diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index 204e34a74adf..1a64301a51ee 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -2302,19 +2302,29 @@ pub fn parse_compress_args( Ok((buffer_value, options_val)) } -/// [`parse_compress_args`] + sync `StringOrBuffer` coercion of `arguments[0]`. -/// Shared by `JSZlib::{gzip,gunzip,deflate,inflate}_sync` and -/// `JSZstd::{compress,decompress}_sync`. +/// Sync `StringOrBuffer` coercion of the buffer argument. Callers that read +/// option properties (which can run arbitrary JS) must do so *before* calling +/// this, so nothing runs between the coercion and the use of the slice. +#[inline] +pub fn coerce_compress_buffer( + global: &JSGlobalObject, + buffer_value: JSValue, +) -> JsResult { + if let Some(buffer) = node::StringOrBuffer::from_js(global, buffer_value)? { + return Ok(buffer); + } + Err(global.throw_invalid_arguments(format_args!("Expected buffer to be a string or buffer"))) +} + +/// [`parse_compress_args`] + [`coerce_compress_buffer`], for callers that read +/// no further option properties. #[inline] pub fn parse_compress_buffer_and_options( global: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult<(node::StringOrBuffer, Option)> { let (buffer_value, options_val) = parse_compress_args(global, callframe)?; - if let Some(buffer) = node::StringOrBuffer::from_js(global, buffer_value)? { - return Ok((buffer, options_val)); - } - Err(global.throw_invalid_arguments(format_args!("Expected buffer to be a string or buffer"))) + Ok((coerce_compress_buffer(global, buffer_value)?, options_val)) } #[allow(non_snake_case)] @@ -2363,13 +2373,45 @@ pub mod JSZlib { }; } + /// Move `list`'s allocation into a `Uint8Array` backing store without + /// copying. After `shrink_to_fit`, an empty `Vec` owns no allocation (its + /// pointer is dangling), so no deallocator is registered for it. + fn leak_list_into_uint8array( + global_this: &JSGlobalObject, + mut list: Vec, + ) -> JsResult { + list.shrink_to_fit(); + let is_empty = list.is_empty(); + let leaked: &'static mut [u8] = list.leak(); + let ptr = leaked.as_mut_ptr(); + let array_buffer = ArrayBuffer::from_bytes(leaked, jsc::JSType::Uint8Array); + // SAFETY: non-empty: `ptr` is the just-leaked `Vec` allocation, freed + // exactly once at GC by `global_deallocator` (`mi_free_ctx`) via the ctx + // pointer. Empty: no callback, and the dangling `ptr` is never read. + unsafe { + array_buffer.to_js_with_context( + global_this, + if is_empty { + core::ptr::null_mut() + } else { + ptr.cast::() + }, + if is_empty { + None + } else { + Some(global_deallocator) + }, + ) + } + } + #[bun_jsc::host_fn] pub(crate) fn gzip_sync( global_this: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { - let (buffer, options_val) = parse_compress_buffer_and_options(global_this, callframe)?; - gzip_or_deflate_sync(global_this, &buffer, options_val, true) + let (buffer_value, options_val) = parse_compress_args(global_this, callframe)?; + gzip_or_deflate_sync(global_this, buffer_value, options_val, true) } #[bun_jsc::host_fn] @@ -2377,8 +2419,8 @@ pub mod JSZlib { global_this: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { - let (buffer, options_val) = parse_compress_buffer_and_options(global_this, callframe)?; - gunzip_or_inflate_sync(global_this, &buffer, options_val, false) + let (buffer_value, options_val) = parse_compress_args(global_this, callframe)?; + gunzip_or_inflate_sync(global_this, buffer_value, options_val, false) } #[bun_jsc::host_fn] @@ -2386,8 +2428,8 @@ pub mod JSZlib { global_this: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { - let (buffer, options_val) = parse_compress_buffer_and_options(global_this, callframe)?; - gzip_or_deflate_sync(global_this, &buffer, options_val, false) + let (buffer_value, options_val) = parse_compress_args(global_this, callframe)?; + gzip_or_deflate_sync(global_this, buffer_value, options_val, false) } #[bun_jsc::host_fn] @@ -2395,13 +2437,13 @@ pub mod JSZlib { global_this: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { - let (buffer, options_val) = parse_compress_buffer_and_options(global_this, callframe)?; - gunzip_or_inflate_sync(global_this, &buffer, options_val, true) + let (buffer_value, options_val) = parse_compress_args(global_this, callframe)?; + gunzip_or_inflate_sync(global_this, buffer_value, options_val, true) } pub(crate) fn gunzip_or_inflate_sync( global_this: &JSGlobalObject, - buffer: &node::StringOrBuffer, + buffer_value: JSValue, options_val_: Option, is_gzip: bool, ) -> JsResult { @@ -2453,6 +2495,7 @@ pub mod JSZlib { return Ok(JSValue::ZERO); } + let buffer = coerce_compress_buffer(global_this, buffer_value)?; let compressed = buffer.slice(); let mut list: Vec = 'brk: { @@ -2513,22 +2556,7 @@ pub mod JSZlib { // `list` directly into the ArrayBuffer (freed by // `global_deallocator`). drop(reader); - list.shrink_to_fit(); - // Ownership of the allocation transfers to JSC; freed via - // `global_deallocator` once the ArrayBuffer is finalized. - let leaked: &'static mut [u8] = list.leak(); - let ptr = leaked.as_mut_ptr(); - let array_buffer = ArrayBuffer::from_bytes(leaked, jsc::JSType::Uint8Array); - // SAFETY: `ptr` is the just-leaked `Vec` allocation, live until - // `global_deallocator` (`mi_free_ctx`) frees it exactly once at - // GC via the ctx pointer (the data pointer itself). - unsafe { - array_buffer.to_js_with_context( - global_this, - ptr.cast::(), - Some(global_deallocator), - ) - } + leak_list_into_uint8array(global_this, list) } Library::Libdeflate => { let Some(mut decompressor) = bun_libdeflate::OwnedDecompressor::new() else { @@ -2582,7 +2610,7 @@ pub mod JSZlib { pub(crate) fn gzip_or_deflate_sync( global_this: &JSGlobalObject, - buffer: &node::StringOrBuffer, + buffer_value: JSValue, options_val_: Option, is_gzip: bool, ) -> JsResult { @@ -2624,6 +2652,7 @@ pub mod JSZlib { return Ok(JSValue::ZERO); } + let buffer = coerce_compress_buffer(global_this, buffer_value)?; let compressed = buffer.slice(); let _ = window_bits; // unused @@ -2666,22 +2695,7 @@ pub mod JSZlib { // NOTE: see gunzip path — reader borrows `list`, so drop // it before leaking `list` into the ArrayBuffer. drop(reader); - list.shrink_to_fit(); - // Ownership of the allocation transfers to JSC; freed via - // `global_deallocator` once the ArrayBuffer is finalized. - let leaked: &'static mut [u8] = list.leak(); - let ptr = leaked.as_mut_ptr(); - let array_buffer = ArrayBuffer::from_bytes(leaked, jsc::JSType::Uint8Array); - // SAFETY: `ptr` is the just-leaked `Vec` allocation, live until - // `global_deallocator` (`mi_free_ctx`) frees it exactly once at - // GC via the ctx pointer (the data pointer itself). - unsafe { - array_buffer.to_js_with_context( - global_this, - ptr.cast::(), - Some(global_deallocator), - ) - } + leak_list_into_uint8array(global_this, list) } Library::Libdeflate => { let Some(mut compressor) = bun_libdeflate::OwnedCompressor::new(level.unwrap_or(6)) @@ -2784,10 +2798,11 @@ pub mod JSZstd { global_this: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { - let (buffer, options_val) = parse_compress_buffer_and_options(global_this, callframe)?; + let (buffer_value, options_val) = parse_compress_args(global_this, callframe)?; let level = get_level(global_this, options_val)?; + let buffer = coerce_compress_buffer(global_this, buffer_value)?; let input = buffer.slice(); // Calculate max compressed size diff --git a/src/runtime/api/bun/h2/connection.rs b/src/runtime/api/bun/h2/connection.rs index 3fece751e11b..f2a55dd9bff2 100644 --- a/src/runtime/api/bun/h2/connection.rs +++ b/src/runtime/api/bun/h2/connection.rs @@ -18,6 +18,13 @@ pub struct Stream { pub state: State, pub send_window: SendWindow, pub recv_window: RecvWindow, + /// A non-informational inbound header block was delivered: the next inbound HEADERS on this + /// stream is a trailer section (RFC 9113 §8.1). + pub recv_final_headers: bool, + /// Declared `content-length` of the inbound message, if any (RFC 9113 §8.1.1). + pub content_length: Option, + /// DATA payload bytes (padding excluded) received so far. + pub recv_body_bytes: u64, } impl Stream { @@ -26,10 +33,29 @@ impl Stream { state: State::Idle, send_window: SendWindow::new(initial_send), recv_window: RecvWindow::new(initial_recv), + recv_final_headers: false, + content_length: None, + recv_body_bytes: 0, } } } +/// RFC 9110 §8.6: `content-length` is 1*DIGIT. Anything else, or a value that does not fit +/// in a u64, is rejected. +fn parse_content_length(value: &[u8]) -> Option { + if value.is_empty() { + return None; + } + let mut n: u64 = 0; + for &c in value { + if !c.is_ascii_digit() { + return None; + } + n = n.checked_mul(10)?.checked_add(u64::from(c - b'0'))?; + } + Some(n) +} + #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum WriteResult { Dropped = -1, @@ -80,6 +106,12 @@ pub trait Sink { /// A new stream was created by an inbound HEADERS (the embedder allocates its JS wrapper). fn on_stream_open(&self, _stream_id: u32) {} + /// Whether the embedder can afford the state for a new peer-initiated stream (the session + /// memory budget). `false` refuses the HEADERS with RST_STREAM (REFUSED_STREAM) before any + /// stream state is allocated; the header block is still decoded for HPACK-table sync. + fn can_open_stream(&self) -> bool { + true + } /// One decoded header field. `name`/`value` alias a shared buffer — copy before returning. fn on_header(&self, _stream_id: u32, _name: &[u8], _value: &[u8], _never_index: bool) {} /// The header block for `stream_id` is complete. `end_stream` = the HEADERS carried END_STREAM. @@ -168,6 +200,7 @@ pub struct Connection { /// connection-scoped HPACK table stays in sync (§4.3), then refused with RST_STREAM /// (STREAM_CLOSED) instead of being dispatched. header_stream_closed: bool, + header_stream_refused: bool, /// Scratch buffer for the outbound HPACK-encoded header block. enc_buf: Vec, @@ -206,6 +239,7 @@ impl Connection { header_target: 0, header_push_parent: 0, header_stream_closed: false, + header_stream_refused: false, enc_buf: Vec::new(), replenish_buf: Vec::new(), evict_buf: Vec::new(), @@ -724,44 +758,52 @@ impl Connection { ); return true; } - let cur_state = self - .streams - .entry(hdr.stream_id) - .or_insert_with(|| Stream::new(send_init, recv_init)) - .state; - let ev = if end_stream { - stream::Event::RecvHeadersEndStream - } else { - stream::Event::RecvHeaders - }; + let refused = is_new && self.is_server && !sink.can_open_stream(); let mut stream_closed = false; - match stream::transition(cur_state, ev) { - Ok(next) => { - if let Some(s) = self.streams.get_mut(&hdr.stream_id) { - s.state = next; + if !refused { + let cur_state = self + .streams + .entry(hdr.stream_id) + .or_insert_with(|| Stream::new(send_init, recv_init)) + .state; + let ev = if end_stream { + stream::Event::RecvHeadersEndStream + } else { + stream::Event::RecvHeaders + }; + match stream::transition(cur_state, ev) { + Ok(next) => { + if let Some(s) = self.streams.get_mut(&hdr.stream_id) { + s.state = next; + } + } + Err(stream::TransitionError::Protocol) => { + self.send_go_away( + sink, + ErrorCode::ProtocolError, + b"HEADERS in invalid stream state", + ); + return true; + } + Err(stream::TransitionError::StreamClosed) => { + // §4.3: the field block must still be decompressed even though the frames are + // discarded - skipping it would desync the connection-scoped HPACK table and + // corrupt the next valid stream's headers. Buffer/decode the block, then + // finish_header_block refuses it with RST_STREAM(STREAM_CLOSED). + stream_closed = true; } - } - Err(stream::TransitionError::Protocol) => { - self.send_go_away( - sink, - ErrorCode::ProtocolError, - b"HEADERS in invalid stream state", - ); - return true; - } - Err(stream::TransitionError::StreamClosed) => { - // §4.3: the field block must still be decompressed even though the frames are - // discarded - skipping it would desync the connection-scoped HPACK table and - // corrupt the next valid stream's headers. Buffer/decode the block, then - // finish_header_block refuses it with RST_STREAM(STREAM_CLOSED). - stream_closed = true; } } if is_new { + // Must advance even for refused streams: §5.1 treats anything at or below the + // high-water mark as having existed, so frames a client pipelined behind the + // refused HEADERS (RST_STREAM especially) are tolerated instead of GOAWAY'd. if hdr.stream_id > self.last_stream_id { self.last_stream_id = hdr.stream_id; } - sink.on_stream_open(hdr.stream_id); + if !refused { + sink.on_stream_open(hdr.stream_id); + } } self.header_block.clear(); @@ -771,6 +813,7 @@ impl Connection { self.header_target = hdr.stream_id; self.header_push_parent = 0; self.header_stream_closed = stream_closed; + self.header_stream_refused = refused; if !end_headers { self.continuation_stream = hdr.stream_id; return false; @@ -814,6 +857,7 @@ impl Connection { } let block = std::mem::take(&mut self.header_block); let stream_closed = std::mem::take(&mut self.header_stream_closed); + let stream_refused = std::mem::take(&mut self.header_stream_refused); let mut off = 0usize; let mut fatal = false; // RFC 9113 §10.5.1: enforce SETTINGS_MAX_HEADER_LIST_SIZE (uncompressed size: name + value @@ -823,10 +867,21 @@ impl Connection { let max_pairs = self.max_header_list_pairs as usize; let mut list_size: usize = 0; let mut field_count: usize = 0; + // RFC 9113 §8.1: a trailer section is the final header block on the stream — it must + // carry END_STREAM and must not contain pseudo-header fields. + let is_trailer = push_parent == 0 + && !stream_closed + && self + .streams + .get(&target) + .is_some_and(|s| s.recv_final_headers); let mut rejected = false; - let mut malformed = false; + let mut malformed = is_trailer && !self.header_end_stream; let mut seen_regular = false; let mut seen_pseudo: u8 = 0; + let mut informational = false; + let mut content_length: Option = None; + let mut connect = false; while off < block.len() { match self.hpack.decode(&block[off..]) { Ok(h) => { @@ -839,7 +894,7 @@ impl Connection { } // HEADERS on a closed stream: decode for HPACK-table sync only (§4.3); the // fields are never surfaced. - if stream_closed { + if stream_closed || stream_refused { continue; } // RFC 9113 §8.2.1/§8.2.2: connection-specific fields, a pseudo-header following a @@ -875,10 +930,17 @@ impl Connection { || bit == 64 || (seen_pseudo & bit) != 0 || wrong_direction + || is_trailer { malformed = true; } + if rest == b"status" && value_b.len() == 3 && value_b[0] == b'1' { + informational = true; + } seen_pseudo |= bit; + if rest == b"method" && value_b == b"CONNECT" { + connect = true; + } } else { seen_regular = true; match name_b { @@ -890,6 +952,12 @@ impl Connection { malformed = true; } } + b"content-length" => match parse_content_length(value_b) { + Some(n) if content_length.is_none() => { + content_length = Some(n); + } + _ => malformed = true, + }, _ => {} } } @@ -913,6 +981,11 @@ impl Connection { if fatal { return true; } + if stream_refused { + self.send_rst_stream(sink, target, ErrorCode::RefusedStream); + sink.on_stream_rejected(target); + return false; + } if stream_closed { // §5.1: HEADERS on a closed/half-closed-remote stream is a stream error of type // STREAM_CLOSED. The block was decoded above purely for HPACK-table sync. @@ -923,6 +996,19 @@ impl Connection { sink.on_stream_reset(target, ErrorCode::StreamClosed.as_u32()); return false; } + if push_parent == 0 && self.is_server && !malformed && !rejected { + if let Some(s) = self.streams.get_mut(&target) { + if !connect && s.content_length.is_none() { + s.content_length = content_length; + } + if self.header_end_stream + && s.content_length + .is_some_and(|declared| declared != s.recv_body_bytes) + { + malformed = true; + } + } + } if malformed && !rejected { // RFC 9113 §8.2: a malformed header block gets a stream error of type PROTOCOL_ERROR and // is not delivered to the application. @@ -946,6 +1032,12 @@ impl Connection { return false; } let end_stream = self.header_end_stream; + if push_parent == 0 + && !informational + && let Some(s) = self.streams.get_mut(&target) + { + s.recv_final_headers = true; + } sink.on_headers_complete(target, end_stream, self.header_flags); if end_stream { let state = self.streams.get(&target).map(|s| s.state as u8); @@ -1026,6 +1118,8 @@ impl Connection { } sink.on_stream_reset(hdr.stream_id, ErrorCode::FlowControlError.as_u32()); discard = true; + } else { + st.recv_body_bytes = st.recv_body_bytes.saturating_add(data_total as u64); } } } @@ -1053,6 +1147,9 @@ impl Connection { /// `handle_data` does for whole frames. fn finish_streamed_data(&mut self, sink: &impl Sink, inflight: &DataInFlight) { if inflight.end_stream && !inflight.discard { + if self.enforce_content_length(sink, inflight.stream_id) { + return; + } let state = match self.streams.get_mut(&inflight.stream_id) { Some(s) => { if let Ok(next) = stream::transition(s.state, stream::Event::RecvEndStream) { @@ -1138,6 +1235,7 @@ impl Connection { if s.recv_window.is_overflowed_with(recv_limit) { DataDecision::Rst(ErrorCode::FlowControlError) } else { + s.recv_body_bytes = s.recv_body_bytes.saturating_add((end - off) as u64); DataDecision::Deliver(0) } } @@ -1167,6 +1265,9 @@ impl Connection { // ignores flow control (sending a whole burst past the window in one batch) undetectable. if end_stream { + if self.enforce_content_length(sink, hdr.stream_id) { + return false; + } let state = match self.streams.get_mut(&hdr.stream_id) { Some(s) => { if let Ok(next) = stream::transition(s.state, stream::Event::RecvEndStream) { @@ -1183,6 +1284,28 @@ impl Connection { false } + /// RFC 9113 §8.1.1: once END_STREAM arrives, a request whose received DATA total contradicts + /// its declared `content-length` is malformed. Resets the stream with PROTOCOL_ERROR instead + /// of signalling end-of-stream and returns true if it did so. + fn enforce_content_length(&mut self, sink: &impl Sink, stream_id: u32) -> bool { + if !self.is_server { + return false; + } + let mismatch = self.streams.get(&stream_id).is_some_and(|s| { + s.content_length + .is_some_and(|declared| declared != s.recv_body_bytes) + }); + if !mismatch { + return false; + } + self.send_rst_stream(sink, stream_id, ErrorCode::ProtocolError); + if let Some(s) = self.streams.get_mut(&stream_id) { + s.state = State::Closed; + } + sink.on_stream_reset(stream_id, ErrorCode::ProtocolError.as_u32()); + true + } + /// RFC 9113 §6.4 RST_STREAM. fn handle_rst_stream(&mut self, sink: &impl Sink, hdr: &FrameHeader, payload: &[u8]) -> bool { let code_raw = u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]); @@ -1311,6 +1434,7 @@ impl Connection { self.header_target = promised; self.header_push_parent = hdr.stream_id; self.header_stream_closed = false; + self.header_stream_refused = false; if !end_headers { self.continuation_stream = hdr.stream_id; return false; diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 35ae5560c2fe..07dbf0510c53 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -1241,6 +1241,35 @@ enum BatchSegment { Ext { ptr: *const u8, len: u32 }, } +struct DispatchGuard<'a>(&'a Cell); + +impl Drop for DispatchGuard<'_> { + fn drop(&mut self) { + self.0.set(self.0.get() - 1); + } +} + +/// A `&mut Stream` that only exists inside an armed dispatch scope (`enter_stream_dispatch`): +/// while it is live, rewrite_read defers stream frees, so user JS that re-enters `read()` +/// (option getters, header-value `toString`) cannot free the stream out from under the borrow. +struct GuardedStream<'a> { + stream: &'a mut Stream, + _dispatch: DispatchGuard<'a>, +} + +impl core::ops::Deref for GuardedStream<'_> { + type Target = Stream; + fn deref(&self) -> &Stream { + self.stream + } +} + +impl core::ops::DerefMut for GuardedStream<'_> { + fn deref_mut(&mut self) -> &mut Stream { + self.stream + } +} + // R-2 (host-fn re-entrancy): every JS-exposed method takes `&self`; per-field // interior mutability via `Cell` (Copy) / `JsCell` (non-Copy). The codegen // shim still emits `this: &mut H2FrameParser` until Phase 1 lands — @@ -1297,6 +1326,7 @@ pub struct H2FrameParser { /// borrow (the normal request path: receive() -> JS handler -> respond -> END_STREAM). /// Drained into Connection::close_stream on the next rewrite_read batch. pending_engine_stream_closes: JsCell>, + dispatch_depth: Cell, max_rejected_streams: Cell, max_session_invalid_frames: Cell, max_outstanding_settings: Cell, @@ -2648,6 +2678,28 @@ impl H2FrameParser { let _ = self.write(&buffer); } + /// Armed across every JS dispatch wrapper AND every section that holds a `&mut Stream` + /// while user JS can run (property getters, iteration, string coercion), so + /// rewrite_read's deferred stream free (pending_engine_stream_closes) only runs at depth 0. + fn enter_dispatch(&self) -> DispatchGuard<'_> { + self.dispatch_depth.set(self.dispatch_depth.get() + 1); + DispatchGuard(&self.dispatch_depth) + } + + /// Reborrows a host fn's `*mut Stream` with the dispatch guard armed for the borrow's whole + /// lifetime: user JS the caller runs while holding it (option getters, `toString`) can + /// re-enter `read()` without freeing the stream. Use this instead of a raw `&mut *ptr`. + fn enter_stream_dispatch(&self, stream_ptr: *mut Stream) -> GuardedStream<'_> { + let _dispatch = self.enter_dispatch(); + GuardedStream { + // SAFETY: stream_ptr is the heap::alloc'd *mut Stream stored in self.streams; the + // map entry outlives the returned borrow because the armed dispatch depth defers + // the only free path (rewrite_read's pending close drain) while the guard is live. + stream: unsafe { &mut *stream_ptr }, + _dispatch, + } + } + pub(crate) fn dispatch(&self, event: JSH2FrameParser::Gc, value: JSValue) { value.ensure_still_alive(); let Some(this_value) = self.strong_this.get().try_get() else { @@ -2656,6 +2708,7 @@ impl H2FrameParser { let Some(ctx_value) = JSH2FrameParser::Gc::context.get(this_value) else { return; }; + let _dispatch = self.enter_dispatch(); let _ = self.handlers.get().call_event_handler( event, this_value, @@ -2672,12 +2725,14 @@ impl H2FrameParser { return JSValue::ZERO; }; value.ensure_still_alive(); + let _dispatch = self.enter_dispatch(); self.handlers .get() .call_event_handler_with_result(event, this_value, &[ctx_value, value]) } pub(crate) fn dispatch_write_callback(&self, callback: JSValue) { + let _dispatch = self.enter_dispatch(); let _ = self.handlers.get().call_write_callback(callback, &[]); } @@ -2695,6 +2750,7 @@ impl H2FrameParser { }; value.ensure_still_alive(); extra.ensure_still_alive(); + let _dispatch = self.enter_dispatch(); let _ = self.handlers.get().call_event_handler( event, this_value, @@ -2719,6 +2775,7 @@ impl H2FrameParser { value.ensure_still_alive(); extra.ensure_still_alive(); extra2.ensure_still_alive(); + let _dispatch = self.enter_dispatch(); let _ = self.handlers.get().call_event_handler( event, this_value, @@ -2745,6 +2802,7 @@ impl H2FrameParser { extra.ensure_still_alive(); extra2.ensure_still_alive(); extra3.ensure_still_alive(); + let _dispatch = self.enter_dispatch(); let _ = self.handlers.get().call_event_handler( event, this_value, @@ -5384,20 +5442,24 @@ impl H2FrameParser { // Streams whose legacy lifecycle finished since the last batch: evict the engine // entry and free the legacy slot. free_resources already ran for these (it is the // only producer of this queue); duplicate ids are fine — remove() yields None. - self.pending_engine_stream_closes.with_mut(|v| { - for id in v.drain(..) { - engine.close_stream(id); - if let Some(stream) = self.streams.with_mut(|m| m.remove(&id)) { - // SAFETY: stream is the heap::alloc'd *mut Stream owned by the map - // entry just removed; free_resources ran when it was queued, no - // borrows are live at the top of rewrite_read, ids never repeat - // within a session, so this frees exactly once. - unsafe { - drop(bun_core::heap::take(stream)); + if self.dispatch_depth.get() == 0 { + self.pending_engine_stream_closes.with_mut(|v| { + for id in v.drain(..) { + engine.close_stream(id); + if let Some(stream) = self.streams.with_mut(|m| m.remove(&id)) { + // SAFETY: stream is the heap::alloc'd *mut Stream owned by the + // map entry just removed; free_resources ran when it was queued, + // dispatch_depth == 0 means no caller below us on the stack holds + // a `&mut Stream` across anything that can run user JS (every + // such site arms enter_dispatch), ids never repeat within a + // session, so this frees exactly once. + unsafe { + drop(bun_core::heap::take(stream)); + } } } - } - }); + }); + } } if self.rewrite_tail.get().is_empty() { let feed = { @@ -5623,6 +5685,10 @@ impl crate::api::h2::connection::Sink for H2FrameParser { ); } + fn can_open_stream(&self) -> bool { + self.get_session_memory_usage() <= self.max_session_memory.get() as usize + } + fn is_local_stream(&self, stream_id: u32) -> bool { // The legacy outbound created an entry in the legacy streams map for every locally // initiated stream (request/respond), so membership there means "we sent HEADERS on it". @@ -5866,7 +5932,12 @@ impl crate::api::h2::connection::Sink for H2FrameParser { JSValue::js_number(code as f64), ); } - // The reset closes the stream; release its JS context root. + // The reset closes the stream; free the legacy slot (queueing the engine eviction) + // and release its JS context root, mirroring the on_stream_end full-close path. + if let Some(stream) = self.streams.get().get(&stream_id).copied() { + // SAFETY: stream is *mut Stream from self.streams; valid while the map entry exists + unsafe { (*stream).free_resources::(self) }; + } self.sctx.with_mut(|m| { m.remove(&stream_id); }); @@ -6618,8 +6689,8 @@ impl H2FrameParser { let Some(stream_ptr) = this.streams.get().get(&stream_id).copied() else { return Err(global_object.throw(format_args!("Invalid stream id"))); }; - // SAFETY: stream_ptr is a *mut Stream stored in self.streams (heap::alloc); valid for the lifetime of the entry, exclusive access reshaped for borrowck - let stream = unsafe { &mut *stream_ptr }; + // The `options` getters below can run user JS while `stream` is borrowed. + let mut stream = this.enter_stream_dispatch(stream_ptr); if !stream.can_send_data() && !stream.can_receive_data() { return Ok(JSValue::FALSE); @@ -7203,8 +7274,9 @@ impl H2FrameParser { let Some(stream_ptr) = this.streams.get().get(&stream_id).copied() else { return Err(global_object.throw(format_args!("Invalid stream id"))); }; - // SAFETY: stream_ptr is a *mut Stream stored in self.streams (heap::alloc); valid for the lifetime of the entry, exclusive access reshaped for borrowck - let stream = unsafe { &mut *stream_ptr }; + // The header/sensitive-object getters and value coercions below can run user JS + // while `stream` is borrowed. + let mut stream = this.enter_stream_dispatch(stream_ptr); let Some(headers_obj) = headers_arg.get_object() else { return Err(global_object.throw(format_args!("Expected headers to be an object"))); @@ -7319,7 +7391,7 @@ impl H2FrameParser { // session down gracefully — the encoder state is no longer trustworthy // (node/nghttp2 treat this as fatal and close with a NO_ERROR GOAWAY). let triggering_id = stream.id; - this.end_stream(stream, ErrorCode::FRAME_SIZE_ERROR); + this.end_stream(&mut stream, ErrorCode::FRAME_SIZE_ERROR); this.send_go_away( triggering_id, ErrorCode::NO_ERROR, @@ -7553,8 +7625,9 @@ impl H2FrameParser { let Some(stream_ptr) = this.streams.get().get(&stream_id).copied() else { return Err(global_object.throw(format_args!("Invalid stream id"))); }; - // SAFETY: stream_ptr is a *mut Stream stored in self.streams (heap::alloc); valid for the lifetime of the entry, exclusive access reshaped for borrowck - let stream = unsafe { &mut *stream_ptr }; + // Coercing `data_arg` (a String subclass's toString) can run user JS while `stream` + // is borrowed. + let mut stream = this.enter_stream_dispatch(stream_ptr); if !stream.can_send_data() { this.dispatch_write_callback(callback_arg); return Ok(JSValue::FALSE); @@ -7595,7 +7668,7 @@ impl H2FrameParser { } }; - let settled_state = this.send_data(stream, buffer.slice(), close, callback_arg, true); + let settled_state = this.send_data(&mut stream, buffer.slice(), close, callback_arg, true); // 5 = HALF_CLOSED_LOCAL: the JS caller runs markWritableDone itself instead of // the engine re-entering the VM with an onStreamEnd(5) dispatch. @@ -8030,6 +8103,14 @@ impl H2FrameParser { return Err(global_object.throw(format_args!("Expected error argument"))); } + // Like `goaway`: only numbers reach `to_u32` (it requires one), and the code is read + // once before any `&mut Stream` exists instead of once per stream inside the loop. + let error_arg = args_list.ptr[0]; + if !error_arg.is_number() { + return Err(global_object.throw(format_args!("Expected errorCode to be a number"))); + } + let rst_code = error_arg.to_u32(); + // R-2: StreamResumableIterator stores a `ParentRef`; `streams` is `JsCell`-backed, // so the loop body can keep using `this` (`&Self`) directly. let mut it = StreamResumableIterator::init(this); @@ -8039,15 +8120,11 @@ impl H2FrameParser { let stream = unsafe { &mut *stream_ptr }; if stream.state != StreamState::CLOSED { stream.state = StreamState::CLOSED; - stream.rst_code = args_list.ptr[0].to_u32(); + stream.rst_code = rst_code; let identifier = stream.get_identifier(); identifier.ensure_still_alive(); stream.free_resources::(this); - this.dispatch_with_extra( - JSH2FrameParser::Gc::onStreamError, - identifier, - args_list.ptr[0], - ); + this.dispatch_with_extra(JSH2FrameParser::Gc::onStreamError, identifier, error_arg); } } Ok(JSValue::UNDEFINED) @@ -8541,8 +8618,8 @@ impl H2FrameParser { let Some(stream_ptr) = this.handle_received_stream_id(stream_id) else { return Ok(JSValue::js_number(-1.0)); }; - // SAFETY: stream_ptr is a *mut Stream stored in self.streams (heap::alloc); valid for the lifetime of the entry, exclusive access reshaped for borrowck - let stream = unsafe { &mut *stream_ptr }; + // The `options` getters below can run user JS while `stream` is borrowed. + let mut stream = this.enter_stream_dispatch(stream_ptr); if !stream_ctx_arg.is_empty_or_undefined_or_null() && stream_ctx_arg.is_object() { stream.set_context(stream_ctx_arg, global_object); } @@ -8698,7 +8775,7 @@ impl H2FrameParser { if signal_.aborted() { stream.state = StreamState::IDLE; let wrapped = Bun__wrapAbortError(global_object, signal_.abort_reason()); - this.abort_stream(stream, wrapped); + this.abort_stream(&mut stream, wrapped); return Ok(JSValue::js_number(stream_id as f64)); } stream.attach_signal(this, signal_); @@ -9141,6 +9218,7 @@ impl H2FrameParser { pending_send_window_consumed: Cell::new(0), pending_stream_send_consumed: JsCell::new(Vec::new()), pending_engine_stream_closes: JsCell::new(Vec::new()), + dispatch_depth: Cell::new(0), pending_settings_window_submissions: JsCell::new(Vec::new()), max_rejected_streams: Cell::new(100), max_session_invalid_frames: Cell::new(1000), diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index 1825e72fa660..9a52eb4b0805 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -1448,6 +1448,16 @@ pub(super) enum DevHandlerId { /// non-loopback / non-IP / non-configured hostnames prevents the attacker's /// page from reading bundled source via same-origin fetch. pub(crate) fn is_allowed_dev_host(dev: &DevServer, req: &Request) -> bool { + is_allowed_host_header( + req, + dev.server.as_ref().map(|server| &server.config().address), + ) +} + +pub(crate) fn is_allowed_host_header( + req: &Request, + address: Option<&crate::server::server_config::Address>, +) -> bool { let Some(host) = req.header(b"host") else { return false; }; @@ -1473,13 +1483,11 @@ pub(crate) fn is_allowed_dev_host(dev: &DevServer, req: &Request) -> bool { if strings::is_ip_address(ip) { return true; } - if let Some(server) = dev.server.as_ref() { - if let crate::server::server_config::Address::Tcp { - hostname: Some(h), .. - } = &server.config().address - { - return strings::eql_case_insensitive_ascii(host, h.as_bytes(), true); - } + if let Some(crate::server::server_config::Address::Tcp { + hostname: Some(h), .. + }) = address + { + return strings::eql_case_insensitive_ascii(host, h.as_bytes(), true); } false } @@ -1582,6 +1590,11 @@ extern "C" fn dev_route_tramp( if !is_allowed_dev_host(dev, req) { return host_forbidden(resp); } + if matches!(ID, DevHandlerId::ReportError | DevHandlerId::UnrefSourceMap) + && !is_allowed_dev_origin(req) + { + return origin_forbidden(resp); + } match ID { DevHandlerId::JsRequest => on_js_request(dev, req, resp), DevHandlerId::AssetRequest => on_asset_request(dev, req, resp), @@ -6164,13 +6177,13 @@ impl DevServer { pub fn publish(&self, topic: HmrTopic, message: &[u8], opcode: Opcode) { if let Some(s) = &self.server { - let _ = s.publish(&[topic as u8], message, opcode, false); + let _ = s.publish(&topic.uws_topic(), message, opcode, false); } } pub fn num_subscribers(&self, topic: HmrTopic) -> u32 { if let Some(s) = &self.server { - s.num_subscribers(&[topic as u8]) + s.num_subscribers(&topic.uws_topic()) } else { 0 } diff --git a/src/runtime/bake/DevServer/ErrorReportRequest.rs b/src/runtime/bake/DevServer/ErrorReportRequest.rs index 9d35c13c0e80..790204d7b1ca 100644 --- a/src/runtime/bake/DevServer/ErrorReportRequest.rs +++ b/src/runtime/bake/DevServer/ErrorReportRequest.rs @@ -565,18 +565,22 @@ fn read_string32<'a>( /// (U+0080..=U+009F, i.e. `0xC2 0x80..=0x9F`) are also replaced: xterm-family /// terminals decode them back to C1, so `0xC2 0x9B` would otherwise act as CSI. pub(crate) fn sanitize_for_terminal<'a>(s: &'a [u8], arena: &'a Arena) -> &'a [u8] { - fn is_disallowed(prev: u8, b: u8) -> bool { - // Lone 0x80..=0x9F bytes are continuation bytes of legitimate - // multi-byte characters and must not be blanked; only the encoded C1 - // form (a 0xC2 lead byte followed by 0x80..=0x9F) reaches the - // terminal as a control. + let valid_utf8 = strings::is_valid_utf8(s); + fn is_disallowed(prev: u8, b: u8, valid_utf8: bool) -> bool { + // In well-formed UTF-8, lone 0x80..=0x9F bytes are continuation bytes + // of legitimate multi-byte characters and must not be blanked; only + // the encoded C1 form (a 0xC2 lead byte followed by 0x80..=0x9F) + // reaches the terminal as a control. The report body is raw bytes and + // is never validated elsewhere, so when it is not valid UTF-8 that + // assumption does not hold and every non-ASCII byte is blanked. (b < 0x20 && b != b'\t' && b != b'\n') || b == 0x7f || (prev == 0xc2 && (0x80..=0x9f).contains(&b)) + || (!valid_utf8 && b >= 0x80) } let mut prev = 0u8; if !s.iter().any(|&b| { - let bad = is_disallowed(prev, b); + let bad = is_disallowed(prev, b, valid_utf8); prev = b; bad }) { @@ -586,7 +590,7 @@ pub(crate) fn sanitize_for_terminal<'a>(s: &'a [u8], arena: &'a Arena) -> &'a [u let mut prev = 0u8; for i in 0..copy.len() { let cur = copy[i]; - if is_disallowed(prev, cur) { + if is_disallowed(prev, cur, valid_utf8) { copy[i] = b' '; // For an encoded C1 control, blank the 0xC2 lead byte too so the // output stays valid UTF-8 instead of leaving a dangling lead byte. diff --git a/src/runtime/bake/DevServer/HmrSocket.rs b/src/runtime/bake/DevServer/HmrSocket.rs index 626afd057cd1..b59690051f6c 100644 --- a/src/runtime/bake/DevServer/HmrSocket.rs +++ b/src/runtime/bake/DevServer/HmrSocket.rs @@ -124,7 +124,7 @@ impl HmrSocket { for &field in HmrTopic::ALL { let bit = field.as_bit(); if new_bits.contains(bit) && !self.subscriptions.contains(bit) { - let _ = ws.subscribe(&[field as u8]); + let _ = ws.subscribe(&field.uws_topic()); // on-subscribe hooks if feature_flags::BAKE_DEBUGGING_FEATURES { @@ -169,7 +169,7 @@ impl HmrSocket { // Note: this `else if` condition is identical to the `if` // above and is therefore unreachable; likely a bug // (intended: `!new && old` → unsubscribe). - let _ = ws.unsubscribe(&[field as u8]); + let _ = ws.unsubscribe(&field.uws_topic()); } } self.on_unsubscribe(!new_bits & self.subscriptions); diff --git a/src/runtime/bake/bun-framework-react/ssr.tsx b/src/runtime/bake/bun-framework-react/ssr.tsx index 811b44cd4936..b1ffc241a96e 100644 --- a/src/runtime/bake/bun-framework-react/ssr.tsx +++ b/src/runtime/bake/bun-framework-react/ssr.tsx @@ -339,19 +339,18 @@ function writeManyFlightScriptData( if (chunks.length === 1) return writeSingleFlightScriptData(chunks[0], decoder, controller); let i = 0; + let decoded = ""; try { // Combine all chunks into a single string if possible. for (; i < chunks.length; i++) { // `decode()` will throw on invalid UTF-8 sequences. - const str = toSingleQuote(decoder.decode(chunks[i], { stream: true })); - if (i === 0) controller.write("'"); - controller.write(str); + decoded += decoder.decode(chunks[i], { stream: true }); } - controller.write("')"); + controller.write("'" + toSingleQuote(decoded) + "')"); } catch { // The chunk cannot be embedded as a UTF-8 string in the script tag. // Since this is rare, just make the rest of the chunks base64. - if (i > 0) controller.write("');__bun_f.push("); + if (i > 0) controller.write("'" + toSingleQuote(decoded) + "');__bun_f.push("); controller.write('Uint8Array.from(atob("'); for (; i < chunks.length; i++) { const chunk = chunks[i]; diff --git a/src/runtime/bake/dev_server/mod.rs b/src/runtime/bake/dev_server/mod.rs index e3c0023190e7..e460537de24e 100644 --- a/src/runtime/bake/dev_server/mod.rs +++ b/src/runtime/bake/dev_server/mod.rs @@ -185,6 +185,14 @@ impl HmrTopic { } } + /// uWS topic name for this HMR channel. The leading `0xFF` byte cannot + /// occur in WTF-8, so no topic string passed to `ServerWebSocket` + /// `subscribe()`/`publish()` or `Server.publish()` can ever name it. + #[inline] + pub fn uws_topic(self) -> [u8; 2] { + [0xFF, self as u8] + } + /// Maps a topic to its packed `HmrTopicBits` flag. #[inline] pub fn as_bit(self) -> crate::bake::dev_server_body::HmrTopicBits { diff --git a/src/runtime/bake/mod.rs b/src/runtime/bake/mod.rs index 1bb71ef6cbf1..32932825d38e 100644 --- a/src/runtime/bake/mod.rs +++ b/src/runtime/bake/mod.rs @@ -21,6 +21,7 @@ pub(crate) mod bake_body; mod dev_server_body; pub(crate) use dev_server_body::get_deinit_count_for_testing; pub(crate) use dev_server_body::is_allowed_dev_host; +pub(crate) use dev_server_body::is_allowed_host_header; #[path = "FrameworkRouter.rs"] pub(crate) mod framework_router_body; diff --git a/src/runtime/cli/create_command.rs b/src/runtime/cli/create_command.rs index b78cacfd85e7..d32f2fc2f56f 100644 --- a/src/runtime/cli/create_command.rs +++ b/src/runtime/cli/create_command.rs @@ -775,8 +775,8 @@ impl CreateCommand { // SAFETY: single-threaded CLI dispatch; no other borrow of the // process-static `Cli::LOG_` is live across this scope. let log: &mut bun_ast::Log = unsafe { ctx.log_mut() }; - let bump = bun_alloc::Arena::new(); - let mut package_json_expr = match JSON::parse_utf8(&source, log, &bump) { + let bump: &'static bun_alloc::Arena = crate::cli::cli_arena(); + let mut package_json_expr = match JSON::parse_utf8(&source, log, bump) { Ok(e) => e, Err(_) => { if log.errors > 0 { diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index 87236bbf4369..8e5290cb119c 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -408,14 +408,16 @@ const ROOT_DEFAULT_IGNORE_PATTERNS: &[&[u8]] = &[ b"bun.lock", ]; -// (pattern, can_override) +// (pattern, can_override). `can_override == false` mirrors npm-packlist's +// strict rules (only `.git` and `.npmrc` here; lockfiles live in +// ROOT_DEFAULT_IGNORE_PATTERNS); everything else `"files"` can re-include. const DEFAULT_IGNORE_PATTERNS: &[(&[u8], bool)] = &[ (b".*.swp", true), (b"._*", true), (b".DS_Store", true), (b".git", false), (b".gitignore", true), - (b".hg", false), + (b".hg", true), (b".npmignore", true), (b".npmrc", false), (b".lock-wscript", true), @@ -515,9 +517,6 @@ fn iterate_included_project_tree( } } - let mut ignores: Vec = Vec::new(); - let _ = &mut ignores; // unused in this fn body (declared but not read) - let mut dirs: Vec = Vec::new(); dirs.push(DirInfo(Dir::from_fd(root_dir.fd), Box::from(&b""[..]), 1)); @@ -568,6 +567,24 @@ fn iterate_included_project_tree( } } + if let Some((pattern, kind)) = is_unconditionally_excluded(entry_name, dir_depth) { + if log_level.is_verbose() { + bun_core::prettyln!( + "ignore [{}:{}] {}{}", + <&str>::from(kind), + bstr::BStr::new(pattern), + bstr::BStr::new(entry_subpath.as_bytes()), + if entry.kind == bun_sys::FileKind::Directory { + "/" + } else { + "" + }, + ); + Output::flush(); + } + continue; + } + if !included { for include in includes { if include.flags.contains(PatternFlags::DIRS_ONLY) @@ -1602,6 +1619,42 @@ fn is_package_bin(bins: &[BinInfo], maybe_bin_path: &[u8]) -> bool { false } +/// Default ignores that nothing (including an explicit `"files"` entry) can +/// re-include: the root lockfiles and the `can_override == false` patterns. +fn is_unconditionally_excluded( + entry_name: &[u8], + dir_depth: usize, +) -> Option<(&'static [u8], IgnorePatternsKind)> { + if dir_depth == 1 { + // check default ignores that only apply to the root project directory + for &pattern in ROOT_DEFAULT_IGNORE_PATTERNS { + match glob::r#match(pattern, entry_name) { + GlobMatchResult::Match => { + // cannot be reversed + return Some((pattern, IgnorePatternsKind::Default)); + } + GlobMatchResult::NoMatch => {} + // default patterns don't use `!` + GlobMatchResult::NegateNoMatch | GlobMatchResult::NegateMatch => unreachable!(), + } + } + } + + for &(pattern, can_override) in DEFAULT_IGNORE_PATTERNS { + if can_override { + continue; + } + match glob::r#match(pattern, entry_name) { + GlobMatchResult::Match => return Some((pattern, IgnorePatternsKind::Default)), + GlobMatchResult::NoMatch => {} + // default patterns don't use `!` + GlobMatchResult::NegateNoMatch | GlobMatchResult::NegateMatch => unreachable!(), + } + } + + None +} + fn is_excluded<'a>( entry: &DirIterator::IteratorResult, entry_subpath: &'a ZStr, @@ -1618,19 +1671,10 @@ fn is_excluded<'a>( { return None; } + } - // check default ignores that only apply to the root project directory - for &pattern in ROOT_DEFAULT_IGNORE_PATTERNS { - match glob::r#match(pattern, entry_name) { - GlobMatchResult::Match => { - // cannot be reversed - return Some((pattern, IgnorePatternsKind::Default)); - } - GlobMatchResult::NoMatch => {} - // default patterns don't use `!` - GlobMatchResult::NegateNoMatch | GlobMatchResult::NegateMatch => unreachable!(), - } - } + if let Some(excluded) = is_unconditionally_excluded(entry_name, dir_depth) { + return Some(excluded); } let mut ignore_pattern: &[u8] = &[]; @@ -1641,19 +1685,18 @@ fn is_excluded<'a>( let mut ignored = false; for &(pattern, can_override) in DEFAULT_IGNORE_PATTERNS { + if !can_override { + continue; + } match glob::r#match(pattern, entry_name) { GlobMatchResult::Match => { - if can_override { - ignored = true; - ignore_pattern = pattern; - ignore_kind = IgnorePatternsKind::Default; - - // break. doesn't matter if more default patterns - // match this path - break; - } + ignored = true; + ignore_pattern = pattern; + ignore_kind = IgnorePatternsKind::Default; - return Some((pattern, IgnorePatternsKind::Default)); + // break. doesn't matter if more default patterns + // match this path + break; } GlobMatchResult::NoMatch => {} // default patterns don't use `!` diff --git a/src/runtime/cli/upgrade_command.rs b/src/runtime/cli/upgrade_command.rs index f501527b5114..81d47e4ec1b1 100644 --- a/src/runtime/cli/upgrade_command.rs +++ b/src/runtime/cli/upgrade_command.rs @@ -10,6 +10,7 @@ use bun_core::{self, Environment, Global, Output, Progress, fmt as bun_fmt}; use bun_core::{ZStr, strings}; use bun_dotenv as DotEnv; use bun_http::{self as HTTP, headers}; +use bun_install::integrity::{Integrity, Tag as IntegrityTag}; use bun_jsc::{self as jsc, CallFrame, JSGlobalObject, JSValue, JsResult}; use bun_parsers::json as JSON; use bun_paths::{self, PathBuffer, SEP_STR}; @@ -69,6 +70,7 @@ pub struct Version { pub tag: Box<[u8]>, pub buf: MutableString, pub size: u32, + pub digest: Integrity, } impl Version { @@ -148,6 +150,27 @@ impl Version { &*self.tag == Self::CURRENT_VERSION.as_bytes() } + pub fn parse_asset_digest(buf: &[u8]) -> Integrity { + const PREFIX: &[u8] = b"sha256:"; + const HEX_LEN: usize = 64; + if buf.len() != PREFIX.len() + HEX_LEN || !strings::starts_with(buf, PREFIX) { + return Integrity::default(); + } + + let mut digest = Integrity { + tag: IntegrityTag::SHA256, + ..Default::default() + }; + for (i, pair) in buf[PREFIX.len()..].chunks_exact(2).enumerate() { + match bun_fmt::hex_pair_value(pair[0], pair[1]) { + Some(byte) => digest.value[i] = byte, + None => return Integrity::default(), + } + } + + digest + } + pub fn export() { // force-reference — drop in Rust (linker keeps #[no_mangle]) } @@ -339,6 +362,7 @@ impl UpgradeCommand { tag: Box::default(), buf: MutableString::init_empty(), size: 0, + digest: Integrity::default(), }; if !expr.is_object() { @@ -439,6 +463,12 @@ impl UpgradeCommand { Output::flush(); } + if let Some(digest_) = asset.as_property(b"digest") { + if let Some(digest) = digest_.expr.as_utf8_string_literal() { + version.digest = Version::parse_asset_digest(digest); + } + } + if let Some(size_) = asset.as_property(b"size") { if let bun_ast::ExprData::ENumber(n) = &size_.expr.data { version.size = @@ -631,6 +661,7 @@ impl UpgradeCommand { .into(), size: 0, buf: MutableString::init_empty(), + digest: Integrity::default(), } }; @@ -704,6 +735,14 @@ impl UpgradeCommand { Global::exit(1); } + if version.digest.tag.is_supported() && !version.digest.verify(bytes) { + bun_core::pretty_errorln!( + "error: The file downloaded from {} did not match the checksum reported by the GitHub API for this release.\nnote: run bun upgrade again to retry the download", + bstr::BStr::new(&zip_url_bytes) + ); + Global::exit(1); + } + let version_name = version.name().unwrap(); if version_name.is_empty() diff --git a/src/runtime/napi/napi_body.rs b/src/runtime/napi/napi_body.rs index 4573d36c0e99..0f6d2508c95a 100644 --- a/src/runtime/napi/napi_body.rs +++ b/src/runtime/napi/napi_body.rs @@ -71,6 +71,11 @@ unsafe extern "C" { object: jsc::c_api::JSObjectRef, exception: jsc::c_api::ExceptionRef, ) -> jsc::c_api::JSObjectRef; + fn JSObjectGetTypedArrayByteOffset( + ctx: *mut JSGlobalObject, + object: jsc::c_api::JSObjectRef, + exception: jsc::c_api::ExceptionRef, + ) -> usize; fn JSObjectMakeDate( ctx: *mut JSGlobalObject, argument_count: usize, @@ -1422,7 +1427,7 @@ pub(super) extern "C" fn napi_get_typedarray_info( maybe_length: *mut usize, maybe_data: *mut *mut u8, maybe_arraybuffer: *mut napi_value, - maybe_byte_offset: *mut usize, // note: this is always 0 + maybe_byte_offset: *mut usize, ) -> napi_status { bun_output::scoped_log!(napi, "napi_get_typedarray_info"); let env = get_env!(env_); @@ -1466,9 +1471,17 @@ pub(super) extern "C" fn napi_get_typedarray_info( ); } - // `jsc::ArrayBuffer` used to have an `offset` field, but it was always 0 because `ptr` - // already had the offset applied. See . - write_out(maybe_byte_offset, 0); + // SAFETY: `maybe_byte_offset` is null or a valid exclusive out-param per N-API contract. + if let Some(byte_offset) = unsafe { maybe_byte_offset.as_mut() } { + // SAFETY: `typedarray` is a live typed-array object (kept by `_keep`); FFI reads its byte offset. + *byte_offset = unsafe { + JSObjectGetTypedArrayByteOffset( + env.to_js().as_ptr(), + typedarray.as_object_ref(), + ptr::null_mut(), + ) + }; + } env.ok() } @@ -1504,7 +1517,7 @@ pub(super) extern "C" fn napi_get_dataview_info( maybe_bytelength: *mut usize, maybe_data: *mut *mut u8, maybe_arraybuffer: *mut napi_value, - maybe_byte_offset: *mut usize, // note: this is always 0 + maybe_byte_offset: *mut usize, ) -> napi_status { bun_output::scoped_log!(napi, "napi_get_dataview_info"); let env = get_env!(env_); @@ -1529,9 +1542,17 @@ pub(super) extern "C" fn napi_get_dataview_info( }), ); } - // `jsc::ArrayBuffer` used to have an `offset` field, but it was always 0 because `ptr` - // already had the offset applied. See . - write_out(maybe_byte_offset, 0); + // SAFETY: `maybe_byte_offset` is null or a valid exclusive out-param per N-API contract. + if let Some(byte_offset) = unsafe { maybe_byte_offset.as_mut() } { + // SAFETY: `dataview` is a live DataView object (held in handle scope); FFI reads its byte offset. + *byte_offset = unsafe { + JSObjectGetTypedArrayByteOffset( + env.to_js().as_ptr(), + dataview.as_object_ref(), + ptr::null_mut(), + ) + }; + } env.ok() } diff --git a/src/runtime/node/net/BlockList.rs b/src/runtime/node/net/BlockList.rs index 34785be26bfa..e77c83dfb406 100644 --- a/src/runtime/node/net/BlockList.rs +++ b/src/runtime/node/net/BlockList.rs @@ -35,12 +35,13 @@ use bun_core::{String as BunString, ZStr}; use bun_jsc::{CallFrame, JSGlobalObject, JSValue, JsCell, JsResult, StringJsc as _}; use bun_threading::{Guarded, Mutex}; -/// Addresses of `BlockList` instances currently embedded in a live -/// `SerializedScriptValue` (one entry per serialize; removed by -/// `BlockList__onStructuredCloneDestroy`). Deserialize only honours pointers -/// present here so wire bytes from another process (IPC `advanced` mode, -/// `node:v8.deserialize`) cannot smuggle an arbitrary address through tag 251. -static SERIALIZED_REFS: Guarded> = Guarded::new(Vec::new()); +/// `(serialize_nonce, address)` of `BlockList` instances currently embedded in +/// a live `SerializedScriptValue` (one entry per serialize; removed by +/// `BlockList__onStructuredCloneDestroy`). Only the nonce is written to the +/// wire; deserialize resolves it to an address through this table, so wire +/// bytes from another process (IPC `advanced` mode, `node:v8.deserialize`) +/// cannot smuggle an arbitrary address through tag 251. +static SERIALIZED_REFS: Guarded> = Guarded::new(Vec::new()); use crate::node::util::validators; use crate::socket::socket_address::{SocketAddress, sockaddr}; @@ -74,11 +75,10 @@ pub struct BlockList { /// We cannot lock/unlock a mutex estimated_size: AtomicU32, - /// Per-instance random identity, written into the structured-clone wire - /// alongside the address. Deserialize re-reads it from the live instance - /// (after [`SERIALIZED_REFS`] confirms the address is safe to dereference) - /// so wire bytes captured before this instance existed cannot match even if - /// the allocator reused the same address. + /// Per-instance random identity; the only token written into the + /// structured-clone wire. Deserialize maps it back to a live instance via + /// [`SERIALIZED_REFS`], so the wire never carries a native address and + /// bytes captured before this instance existed cannot match. serialize_nonce: u64, } @@ -407,16 +407,15 @@ impl BlockList { let _guard = this.mutex.lock_guard(); this.ref_(); let addr = std::ptr::from_ref::(this) as usize; - SERIALIZED_REFS.lock().push(addr); + SERIALIZED_REFS.lock().push((this.serialize_nonce, addr)); let mut writer = StructuredCloneWriter { ctx, impl_: write_bytes, }; // The writer is infallible, so no `?` needed. - // Only the address is serialized; deserialize re-derives `*mut Self` - // via int→ptr cast and never forms `&mut Self` (only `ref_()` + + // Only the nonce is serialized; deserialize maps it back to `*mut Self` + // through `SERIALIZED_REFS` and never forms `&mut Self` (only `ref_()` + // `to_js_ptr`, both `&self`/raw-ptr), so `from_ref` provenance is fine. - _ = writer.write_int_le(addr); _ = writer.write_int_le(this.serialize_nonce); } @@ -440,9 +439,9 @@ impl BlockList { let mut r = bun_io::FixedBufferStream::new(unsafe { bun_core::ffi::slice(*ptr, total_length) }); - let (int, nonce) = match (r.read_int_le::(), r.read_int_le::()) { - (Ok(a), Ok(n)) => (a, n), - _ => { + let nonce = match r.read_int_le::() { + Ok(n) => n, + Err(_) => { return Err(global.throw(format_args!( "BlockList.onStructuredCloneDeserialize failed" ))); @@ -453,36 +452,31 @@ impl BlockList { // SAFETY: `r.pos <= total_length` (`read_exact` bounds-checks via `checked_add`). *ptr = unsafe { (*ptr).add(r.pos) }; - if !SERIALIZED_REFS.lock().contains(&int) { - return Err(global.throw(format_args!( - "BlockList.onStructuredCloneDeserialize failed" - ))); - } - - let this: *mut Self = int as *mut Self; - // SAFETY: presence in `SERIALIZED_REFS` (paired `ref_()`/`deref()`) - // guarantees `this` is a live `BlockList` allocation, so the field read - // is in-bounds. The nonce check then rejects wire bytes that name this - // address but were produced by a *different* instance that has since - // been freed and whose slot the allocator reused. - if unsafe { (*this).serialize_nonce } != nonce { - return Err(global.throw(format_args!( - "BlockList.onStructuredCloneDeserialize failed" - ))); - } // A single SerializedScriptValue can be deserialized multiple times // (e.g. BroadcastChannel fan-out), so each wrapper must own its own ref // instead of adopting the one taken in serialize. The serialize ref is - // what keeps the backing alive while the pointer sits in the byte buffer + // what keeps the backing alive while its entry sits in `SERIALIZED_REFS` // and is released by `~SerializedScriptValue` via the destroy hook below. - // SAFETY: `int` was produced by `on_structured_clone_serialize` from a - // live `*mut Self` whose ref was bumped at serialize time. Ownership of - // one ref transfers to the C++ wrapper (released via `finalize` → `deref`). - // `to_js_ptr` is the `#[bun_jsc::JsClass]`-generated `${T}__create` shim. - unsafe { - (*this).ref_(); - Ok(Self::to_js_ptr(this, global)) - } + let this: *mut Self = { + let refs = SERIALIZED_REFS.lock(); + let Some(addr) = refs.iter().find_map(|&(n, a)| (n == nonce).then_some(a)) else { + return Err(global.throw(format_args!( + "BlockList.onStructuredCloneDeserialize failed" + ))); + }; + let this = addr as *mut Self; + // SAFETY: the entry was pushed by `on_structured_clone_serialize` + // from a live `*mut Self` whose ref was bumped at serialize time + // (paired `ref_()`/`deref()`); that ref is only released by the + // destroy hook after it takes this lock and removes the entry, so + // `this` is live while the guard is held and we ref it first. + unsafe { (*this).ref_() }; + this + }; + // SAFETY: ownership of the ref taken above transfers to the C++ wrapper + // (released via `finalize` → `deref`). `to_js_ptr` is the + // `#[bun_jsc::JsClass]`-generated `${T}__create` shim. + Ok(unsafe { Self::to_js_ptr(this, global) }) } } @@ -495,7 +489,7 @@ bun_jsc::jsc_host_abi! { let addr = ptr as usize; { let mut refs = SERIALIZED_REFS.lock(); - if let Some(i) = refs.iter().position(|&a| a == addr) { + if let Some(i) = refs.iter().position(|&(_, a)| a == addr) { refs.swap_remove(i); } } diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index 2dd0060799a4..e8d5a52c7ff9 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -418,6 +418,12 @@ fn err_from_static(name: &'static str) -> bun_core::Error { const PREALLOCATE_SUPPORTED: bool = cfg!(any(target_os = "linux", target_os = "android")); const PREALLOCATE_LENGTH: usize = 2048 * 1024; +/// `CLONE_NOFOLLOW` from `` — not re-exported by `bun_sys::c` +/// (or the `libc` crate), so define it locally. `clonefile(2)` then clones a +/// symbolic-link `src` itself rather than the file it points to. +#[cfg(target_os = "macos")] +const CLONE_NOFOLLOW: u32 = 0x0001; + /// Path-length field width. type PathInt = u32; @@ -2004,8 +2010,10 @@ mod _async_tasks { #[cfg(target_os = "macos")] { + // CLONE_NOFOLLOW: `src` was classified as a directory via lstat, so + // mirror the O_NOFOLLOW directory open below instead of dereferencing. if let Some(err) = Maybe::::errno_sys_p( - bun_sys::c::clonefile_rc(src, dest, 0), + bun_sys::c::clonefile_rc(src, dest, CLONE_NOFOLLOW), sys::Tag::clonefile, src.as_bytes(), ) { @@ -2025,7 +2033,7 @@ mod _async_tasks { } } - let open_flags = sys::O::DIRECTORY | sys::O::RDONLY; + let open_flags = sys::O::DIRECTORY | sys::O::RDONLY | sys::O::NOFOLLOW; let fd = match openat_os_path(FD::cwd(), src, open_flags, 0) { Err(err) => { this_ref.finish_concurrently(Err( @@ -5116,7 +5124,7 @@ impl NodeFS { flags |= sys::O::EXCL; } - let dest_fd = match Syscall::open(dest, flags, DEFAULT_PERMISSION) { + let dest_fd = match Syscall::open(dest, flags, stat_.st_mode as Mode) { Ok(result) => result, Err(err) => return Err(err.with_path(args.dest.slice())), }; @@ -5188,7 +5196,7 @@ impl NodeFS { if args.mode.shouldnt_overwrite() { flags |= sys::O::EXCL; } - let dest_fd = match Syscall::open(dest, flags, DEFAULT_PERMISSION) { + let dest_fd = match Syscall::open(dest, flags, stat_.st_mode as Mode) { Ok(result) => result, Err(err) => return Err(err), }; @@ -5295,7 +5303,7 @@ impl NodeFS { flags |= sys::O::EXCL; } - let dest_fd = Syscall::open(dest, flags, DEFAULT_PERMISSION)?; + let dest_fd = Syscall::open(dest, flags, stat_.st_mode as Mode)?; let mut size: usize = stat_.st_size.max(0) as usize; @@ -8290,7 +8298,9 @@ impl NodeFS { ..Default::default() }); } - if attributes & sys::c::FILE_ATTRIBUTE_DIRECTORY == 0 { + if attributes & sys::c::FILE_ATTRIBUTE_DIRECTORY == 0 + || attributes & sys::c::FILE_ATTRIBUTE_REPARSE_POINT != 0 + { let r = self._copy_single_file_sync( src, dest, @@ -8351,8 +8361,10 @@ impl NodeFS { #[cfg(target_os = "macos")] 'try_with_clonefile: { + // CLONE_NOFOLLOW: `src` was classified as a directory via lstat, so + // mirror the O_NOFOLLOW directory open below instead of dereferencing. if let Some(err) = Maybe::::errno_sys_p( - bun_sys::c::clonefile_rc(src, dest, 0), + bun_sys::c::clonefile_rc(src, dest, CLONE_NOFOLLOW), sys::Tag::clonefile, src.as_bytes(), ) { @@ -8374,7 +8386,12 @@ impl NodeFS { } } - let fd = match openat_os_path(FD::cwd(), src, sys::O::DIRECTORY | sys::O::RDONLY, 0) { + let fd = match openat_os_path( + FD::cwd(), + src, + sys::O::DIRECTORY | sys::O::RDONLY | sys::O::NOFOLLOW, + 0, + ) { Err(err) => return Err(err.with_path(self.os_path_into_sync_error_buf(&src_buf[..sd]))), Ok(fd_) => fd_, }; @@ -8640,7 +8657,8 @@ impl NodeFS { flags |= sys::O::EXCL; } - let dest_fd = Self::_cp_open_dest_with_mkdir(self, dest, flags)?; + let dest_fd = + Self::_cp_open_dest_with_mkdir(self, dest, flags, stat_.st_mode as Mode)?; let _close_dest = scopeguard::guard((dest_fd, stat_.st_mode, &wrote), |(fd, m, wrote)| { let _ = Syscall::ftruncate(fd, (wrote.get() & ((1u64 << 63) - 1)) as i64); @@ -8733,7 +8751,7 @@ impl NodeFS { flags |= sys::O::EXCL; } - let dest_fd = Self::_cp_open_dest_with_mkdir(self, dest, flags)?; + let dest_fd = Self::_cp_open_dest_with_mkdir(self, dest, flags, stat_.st_mode as Mode)?; let mut size: usize = stat_.st_size.max(0) as usize; @@ -8904,10 +8922,11 @@ impl NodeFS { flags |= sys::O::EXCL; } - let dest_fd = match Self::_cp_open_dest_with_mkdir(self, dest, flags) { - Ok(fd) => fd, - Err(e) => return Err(e), - }; + let dest_fd = + match Self::_cp_open_dest_with_mkdir(self, dest, flags, stat_.st_mode as Mode) { + Ok(fd) => fd, + Err(e) => return Err(e), + }; // No O_TRUNC at open: if src and dest resolve to the same inode, // that would zero the file before the first read. @@ -9092,17 +9111,40 @@ impl NodeFS { return Maybe::::errno_sys_p(0, sys::Tag::copyfile, p) .unwrap_or(dst_enoent_maybe); } - let flags = if stat_ & windows::FILE_ATTRIBUTE_DIRECTORY != 0 { - windows::SYMBOLIC_LINK_FLAG_DIRECTORY + wbuf[len] = 0; + // `GetFinalPathNameByHandleW(VOLUME_NAME_DOS)` spells network + // targets as `\\?\UNC\server\share\…`; rewrite in place to the + // absolute `\\server\share\…` form (libuv `fs__realpath_handle`). + let is_unc = strings::has_prefix_comptime_utf16(&wbuf[..len], b"\\\\?\\UNC\\"); + let target = if is_unc { + let skip = b"\\\\?\\UN".len(); + wbuf[skip] = u16::from(b'\\'); + bun_core::WStr::from_buf(&wbuf[skip..], len - skip) } else { - 0 + bun_core::WStr::from_buf(&wbuf[..], len) }; - wbuf[len] = 0; - if unsafe { windows::CreateSymbolicLinkW(dest.as_ptr(), wbuf.as_ptr(), flags) } == 0 - { + let is_dir = stat_ & windows::FILE_ATTRIBUTE_DIRECTORY != 0; + // `symlink_w`/`symlink_or_junction` (not raw `CreateSymbolicLinkW`) + // so unprivileged creation is requested. UNC targets skip the junction + // fallback: libuv's `fs__create_junction` only accepts drive-letter targets. + let link_result = if is_dir && !is_unc { + let mut dest8 = paths::path_buffer_pool::get(); + let mut target8 = paths::path_buffer_pool::get(); + sys::symlink_or_junction( + strings::from_wpath(&mut dest8[..], dest.as_slice()), + strings::from_wpath(&mut target8[..], target.as_slice()), + None, + ) + } else { + sys::symlink_w( + dest, + target, + sys::WindowsSymlinkOptions { directory: is_dir }, + ) + }; + if let Err(err) = link_result { let p = self.os_path_into_sync_error_buf(dest.as_slice()); - return Maybe::::errno_sys_p(0, sys::Tag::copyfile, p) - .unwrap_or(dst_enoent_maybe); + return Err(err.with_path(p)); } return Ok(()); } @@ -9123,14 +9165,14 @@ impl NodeFS { /// Shared `dest_fd:` block from the mac/linux/freebsd branches of /// `_copy_single_file_sync`. - /// Tries `open(dest, flags, default_permission)`; on ENOENT creates the + /// Tries `open(dest, flags, mode)`; on ENOENT creates the /// parent directory and retries once. Any other error is annotated with /// `dest` copied into `sync_error_buf`. - fn _cp_open_dest_with_mkdir(&mut self, dest: &ZStr, flags: i32) -> Maybe { + fn _cp_open_dest_with_mkdir(&mut self, dest: &ZStr, flags: i32, mode: Mode) -> Maybe { // PORT: extracted from the mac/linux/freebsd arms of `_copySingleFileSync` // only — there `OSPathSliceZ == ZStr`. Taking `&ZStr` keeps the body // monomorphic (and lets it type-check on Windows where it's dead code). - match Syscall::open(dest, flags, DEFAULT_PERMISSION) { + match Syscall::open(dest, flags, mode) { Ok(result) => Ok(result), Err(err) => { if err.get_errno() == E::ENOENT { @@ -9149,7 +9191,7 @@ impl NodeFS { ..Default::default() }); mkdir_result?; - if let Ok(result) = Syscall::open(dest, flags, DEFAULT_PERMISSION) { + if let Ok(result) = Syscall::open(dest, flags, mode) { return Ok(result); } } diff --git a/src/runtime/node/types.rs b/src/runtime/node/types.rs index f8fc324b72bd..e34eac25803d 100644 --- a/src/runtime/node/types.rs +++ b/src/runtime/node/types.rs @@ -1235,12 +1235,8 @@ impl PathLikeExt for PathLike { use jsc::JSType; match arg.js_type() { JSType::Uint8Array | JSType::DataView => { - let mut buffer = if arguments.will_be_async { - Buffer::from_js_pinned(ctx, arg) - .unwrap_or_else(|| Buffer::from_typed_array(ctx, arg)) - } else { - Buffer::from_typed_array(ctx, arg) - }; + let mut buffer = Buffer::from_js_pinned(ctx, arg) + .unwrap_or_else(|| Buffer::from_typed_array(ctx, arg)); if let Err(err) = Valid::path_buffer(&buffer, ctx) .and_then(|_| Valid::path_null_bytes(buffer.slice(), ctx)) { @@ -1256,12 +1252,8 @@ impl PathLikeExt for PathLike { } JSType::ArrayBuffer => { - let mut buffer = if arguments.will_be_async { - Buffer::from_js_pinned(ctx, arg) - .unwrap_or_else(|| Buffer::from_array_buffer(ctx, arg)) - } else { - Buffer::from_array_buffer(ctx, arg) - }; + let mut buffer = Buffer::from_js_pinned(ctx, arg) + .unwrap_or_else(|| Buffer::from_array_buffer(ctx, arg)); if let Err(err) = Valid::path_buffer(&buffer, ctx) .and_then(|_| Valid::path_null_bytes(buffer.slice(), ctx)) { @@ -1646,7 +1638,7 @@ pub fn mode_from_js(ctx: &JSGlobalObject, value: JSValue) -> JsResult, jsobjref_buf: &mut [u8], marked_argument_buffer: &mut MarkedArgumentBuffer, + depth: u32, ) -> JsResult<()> { let mut builder = ShellSrcBuilder::init(global, out_script, jsstrings); if !template_value.is_empty() { @@ -845,6 +849,12 @@ pub fn handle_template_value( } if template_value.js_type().is_array() { + if depth >= MAX_TEMPLATE_ARRAY_DEPTH { + return Err(global.throw(format_args!( + "Shell script template arrays cannot be nested more than {} levels deep", + MAX_TEMPLATE_ARRAY_DEPTH + ))); + } let mut array = template_value.array_iterator(global)?; let last = array.len.saturating_sub(1); let mut i: u32 = 0; @@ -857,6 +867,7 @@ pub fn handle_template_value( jsstrings, jsobjref_buf, marked_argument_buffer, + depth + 1, )?; if i < last { let str = BunString::static_(b" "); @@ -985,7 +996,7 @@ impl<'a> ShellSrcBuilder<'a> { return Ok(true); } if ALLOW_ESCAPE { - if needs_escape_bunstr(bunstr) { + if needs_escape_bunstr(bunstr) || is_if_clause_keyword_bunstr(bunstr) { self.append_js_str_ref(bunstr)?; return Ok(true); } @@ -1012,7 +1023,7 @@ impl<'a> ShellSrcBuilder<'a> { return Ok(false); } if ALLOW_ESCAPE { - if needs_escape_utf8_ascii_latin1(utf8) { + if needs_escape_utf8_ascii_latin1(utf8) || IfClauseTok::from_text(utf8).is_some() { let bunstr = OwnedString::new(BunString::clone_utf8(utf8)); self.append_js_str_ref(bunstr.get())?; return Ok(true); diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 9ab73311d512..4639a24c899b 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -3323,9 +3323,11 @@ impl NewSocket { // Bytes already consumed from the wire before the upgrade (e.g. the // ClientHello sitting in the readable buffer of the socket being // wrapped); fed into the TLS engine once the upgrade is wired up. - let initial_data: StringOrBuffer = match opts.get_truthy(global, "initialData")? { - Some(v) => StringOrBuffer::from_js(global, v)?.unwrap_or(StringOrBuffer::EMPTY), - None => StringOrBuffer::EMPTY, + let initial_data: Vec = match opts.get_truthy(global, "initialData")? { + Some(v) => StringOrBuffer::from_js(global, v)? + .map(|data| data.slice().to_vec()) + .unwrap_or_default(), + None => Vec::new(), }; // Handlers lifecycle is always client-mode (heap-per-connection) here: a // standalone `new TLSSocket(socket, { isServer })` is NOT a SocketListener, @@ -3692,11 +3694,9 @@ impl NewSocket { // Feed bytes that arrived before the upgrade (already pulled off the fd // by the plain-TCP layer) into the TLS engine exactly as if they had // just been received — for a server-side wrap this is the ClientHello. - let initial_slice = initial_data.slice(); - if !initial_slice.is_empty() { - // SAFETY: `new_raw` is live; the slice borrows a JS-owned buffer kept - // alive by the options object for the duration of this call. - unsafe { (*new_raw.as_ptr()).tls_feed(initial_slice) }; + if !initial_data.is_empty() { + // SAFETY: `new_raw` is live; `initial_data` is an owned copy. + unsafe { (*new_raw.as_ptr()).tls_feed(initial_data.as_slice()) }; } let array = JSValue::create_empty_array(global, 2)?; diff --git a/src/runtime/valkey_jsc/valkey.rs b/src/runtime/valkey_jsc/valkey.rs index e68bf3213808..b909bff16982 100644 --- a/src/runtime/valkey_jsc/valkey.rs +++ b/src/runtime/valkey_jsc/valkey.rs @@ -536,6 +536,33 @@ impl ValkeyClient { Ok(()) } + fn reject_in_flight_commands(&mut self, message: &[u8], err: RedisError) -> JsTerminated<()> { + if self.in_flight.readable_length() == 0 { + return Ok(()); + } + + if self.flags.finalized { + let vm = self.vm; + let deferred_failure = Box::new(DeferredFailure { + message: Box::<[u8]>::from(message), + err, + global_this: GlobalRef::from(vm.global()), + in_flight: core::mem::replace( + &mut self.in_flight, + command::promise_pair::Queue::init(), + ), + queue: command::entry::Queue::init(), + }); + deferred_failure.enqueue(); + return Ok(()); + } + + let global_this = self.global_object(); + let jsvalue = valkey_error_to_js(&global_this, message, err); + let mut entries = command::entry::Queue::init(); + Self::reject_all_pending_commands(&mut self.in_flight, &mut entries, &global_this, jsvalue) + } + /// Flush pending data to the socket pub fn flush_data(&mut self) -> bool { let chunk = self.write_buffer.remaining(); @@ -678,6 +705,8 @@ impl ValkeyClient { self.flags.is_authenticated = false; self.flags.is_selecting_db_internal = false; + self.reject_in_flight_commands(b"Connection closed", RedisError::ConnectionClosed)?; + // Signal reconnect timer should be started self.on_valkey_reconnect(); Ok(()) @@ -788,7 +817,8 @@ impl ValkeyClient { return Ok(()); } - self.read_buffer.consume(bytes_consumed as u32); + self.read_buffer + .consume(u32::try_from(bytes_consumed).expect("int cast")); self.reply_scanner.reset(); let mut value_to_handle = value; // Use temp var for defer @@ -1070,21 +1100,24 @@ impl ValkeyClient { let mut pair_maybe: Option = None; // For subscription clients, check if this is a push message that doesn't need a promise pair - if self.parent().is_subscriber() { - if let RESPValue::Push(push) = value { - if let Some(msg_type) = protocol::SubscriptionPushMessage::from_bytes(&push.kind) { - match msg_type { - protocol::SubscriptionPushMessage::Message => { - // Message pushes never need promise pairs - should_consume_promise_pair = false; - } - protocol::SubscriptionPushMessage::Subscribe - | protocol::SubscriptionPushMessage::Unsubscribe => { - // Subscribe/unsubscribe pushes only need promise pairs if we have pending commands - if self.in_flight.readable_length() == 0 { - should_consume_promise_pair = false; - } - } + if let RESPValue::Push(push) = value { + match protocol::SubscriptionPushMessage::from_bytes(&push.kind) { + Some(protocol::SubscriptionPushMessage::Message) => { + // Message pushes never need promise pairs + should_consume_promise_pair = false; + } + Some( + protocol::SubscriptionPushMessage::Subscribe + | protocol::SubscriptionPushMessage::Unsubscribe, + ) => { + // Subscribe/unsubscribe pushes only need promise pairs if we have pending commands + if self.in_flight.readable_length() == 0 { + should_consume_promise_pair = false; + } + } + None => { + if !protocol::SubscriptionPushMessage::is_reply_kind(&push.kind) { + should_consume_promise_pair = false; } } } diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index bd5c2354ce9e..10c2ad3343f4 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -3188,6 +3188,16 @@ impl BlobExt for Blob { } } Lifetime::Transfer => { + if self.store().is_some_and(|s| !s.has_one_ref()) { + // SAFETY: same `buf` contract as the caller; the `Clone` arm only reads it. + let copied = unsafe { + self.to_array_buffer_view_with_bytes::<{ Lifetime::Clone }, TYPED_ARRAY_VIEW>( + global, buf, + ) + }; + self.detach(); + return copied; + } if buf_len > jsc::virtual_machine::synthetic_allocation_limit() && TYPED_ARRAY_VIEW != jsc::JSType::ArrayBuffer { diff --git a/src/runtime/webcore/Request.rs b/src/runtime/webcore/Request.rs index f179a2a1ac77..7e0fe4f2d53e 100644 --- a/src/runtime/webcore/Request.rs +++ b/src/runtime/webcore/Request.rs @@ -854,7 +854,10 @@ impl Request { let req = bun_opaque::opaque_deref(req); let req_url = Self::request_target_path(req.url()); if !req_url.is_empty() && req_url[0] == b'/' { - if let Some(host) = req.header(b"host") { + if let Some(host) = req + .header(b"host") + .filter(|host| Self::is_valid_host_header(host)) + { // With `port: None`, HostFormatter always emits exactly `host`, so the // formatted byte-count is just `host.len()`. Avoid the `core::fmt::write` // vtable dispatch that `bun_fmt::count(format_args!(...))` incurs — this @@ -902,6 +905,37 @@ impl Request { } } + /// RFC 3986 3.2.2 `uri-host [ ":" port ]` byte set. A Host value outside it, or an empty + /// one, cannot form a URL authority, so `request.url` synthesis falls back to the + /// configured host instead of pasting the client bytes into the URL. + fn is_valid_host_header(host: &[u8]) -> bool { + !host.is_empty() + && host.iter().all(|&c| { + c.is_ascii_alphanumeric() + || matches!( + c, + b'-' | b'.' + | b'_' + | b'~' + | b'%' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'[' + | b']' + ) + }) + } + pub fn ensure_url(&self) -> Result<(), AllocError> { if !self.url.get().is_empty() { return Ok(()); @@ -912,7 +946,10 @@ impl Request { let req = bun_opaque::opaque_deref(req); let req_url = Self::request_target_path(req.url()); if !req_url.is_empty() && req_url[0] == b'/' { - if let Some(host) = req.header(b"host") { + if let Some(host) = req + .header(b"host") + .filter(|host| Self::is_valid_host_header(host)) + { // With `port: None`, HostFormatter always emits exactly `host`. Compute the // length and assemble the URL with straight slice copies instead of going // through `core::fmt::write` (which is not monomorphized and shows up in diff --git a/src/runtime/webcore/TextDecoder.rs b/src/runtime/webcore/TextDecoder.rs index 727383225840..ce2388805bd8 100644 --- a/src/runtime/webcore/TextDecoder.rs +++ b/src/runtime/webcore/TextDecoder.rs @@ -236,6 +236,7 @@ impl TextDecoder { // Hoisted out of the labeled block — `ArrayBuffer::slice` borrows from // the by-value `ArrayBuffer`, so it must outlive the `'input_slice` block. let array_buffer; + let owned_input; let input_slice: &[u8] = 'input_slice: { if arguments.is_empty() || arguments[0].is_undefined() { break 'input_slice b""; @@ -243,6 +244,10 @@ impl TextDecoder { if let Some(ab) = arguments[0].as_array_buffer(global_this) { array_buffer = ab; + if array_buffer.shared || array_buffer.resizable { + owned_input = Box::<[u8]>::from(array_buffer.slice()); + break 'input_slice &owned_input; + } break 'input_slice array_buffer.slice(); } @@ -278,7 +283,16 @@ impl TextDecoder { if !self.do_not_flush.replace(false) { self.bom_seen.set(false); } - self.decode_slice::(global_this, uint8array.slice()) + let owned_input; + let input_slice: &[u8] = + match JSValue::from_cell::(uint8array).as_array_buffer(global_this) { + Some(array_buffer) if array_buffer.shared || array_buffer.resizable => { + owned_input = Box::<[u8]>::from(array_buffer.slice()); + &owned_input + } + _ => uint8array.slice(), + }; + self.decode_slice::(global_this, input_slice) } fn decode_slice( diff --git a/src/runtime/webview/ObjCRuntime.cpp b/src/runtime/webview/ObjCRuntime.cpp index 8f53fbcb5fa8..9c90a452f501 100644 --- a/src/runtime/webview/ObjCRuntime.cpp +++ b/src/runtime/webview/ObjCRuntime.cpp @@ -21,6 +21,7 @@ SEL Ref::s_init; SEL Ref::s_release; SEL Ref::s_retain; SEL Ref::s_description; +SEL Ref::s_isKindOfClass; Class NSString::cls; SEL NSString::s_stringWithUTF8String; @@ -43,6 +44,7 @@ SEL NSData::s_length; Class NSNumber::cls; SEL NSNumber::s_numberWithDouble; +Class NSArray::cls; SEL NSArray::s_count; SEL NSArray::s_objectAtIndex; @@ -209,9 +211,13 @@ static void delegateDidReceiveScriptMessage(id self, SEL, id /*controller*/, id ObjCRuntime::ARPool pool; auto* host = objc::NavigationDelegate(self).host(); if (!host) return; - objc::NSDictionary body(objc::WKScriptMessage(message).body()); + id rawBody = objc::WKScriptMessage(message).body(); + if (!objc::Ref(rawBody).isKindOf(objc::NSDictionary::cls)) return; + objc::NSDictionary body(rawBody); id type = body.objectForKey(objc::NSString::fromWTF("type"_s).m_id); id args = body.objectForKey(objc::NSString::fromWTF("args"_s).m_id); + if (!objc::Ref(type).isKindOf(objc::NSString::cls)) return; + if (args && !objc::Ref(args).isKindOf(objc::NSArray::cls)) return; host->onConsoleMessage(type, args); } @@ -342,6 +348,7 @@ bool ObjCRuntime::load() Ref::s_release = sel("release"); Ref::s_retain = sel("retain"); Ref::s_description = sel("description"); + Ref::s_isKindOfClass = sel("isKindOfClass:"); // --- populate wrapper classes ----------------------------------------- // A missing class at load time beats a nil-message (silent no-op) at @@ -376,6 +383,7 @@ bool ObjCRuntime::load() CLS(NSNumber::cls, "NSNumber"); NSNumber::s_numberWithDouble = sel("numberWithDouble:"); + CLS(NSArray::cls, "NSArray"); NSArray::s_count = sel("count"); NSArray::s_objectAtIndex = sel("objectAtIndex:"); diff --git a/src/runtime/webview/ObjCRuntime.h b/src/runtime/webview/ObjCRuntime.h index 888a27d6fa03..a7456602c714 100644 --- a/src/runtime/webview/ObjCRuntime.h +++ b/src/runtime/webview/ObjCRuntime.h @@ -47,12 +47,18 @@ struct Ref { if (m_id) msg(s_release); } + bool isKindOf(Class c) const + { + return m_id && c && msg(s_isKindOfClass, c) != 0; + } + static void *s_msgSend; static SEL s_alloc; static SEL s_init; static SEL s_release; static SEL s_retain; static SEL s_description; + static SEL s_isKindOfClass; template R msg(SEL op, A... a) const @@ -142,6 +148,7 @@ struct NSNumber : Ref { struct NSArray : Ref { using Ref::Ref; + static Class cls; static SEL s_count; static SEL s_objectAtIndex; diff --git a/src/runtime/webview/WebViewHost.cpp b/src/runtime/webview/WebViewHost.cpp index 975adbfabc0b..1c0d38de15a0 100644 --- a/src/runtime/webview/WebViewHost.cpp +++ b/src/runtime/webview/WebViewHost.cpp @@ -451,6 +451,7 @@ void WebViewHost::onSelectorComplete(id result, id error) return; } // click(selector): result is the NSString "cx,cy". Parse two doubles. + if (!objc::Ref(result).isKindOf(objc::NSString::cls)) result = nullptr; WTF::String s = objc::NSString(result).toWTF(); auto comma = s.find(','); if (comma == WTF::notFound) { @@ -726,7 +727,9 @@ void WebViewHost::onConsoleMessage(id type, id args) memcpy(p + 4 + typeLen, &argCount, 4); for (uint32_t i = 0; i < argCount; ++i) { - WTF::CString argC = objc::NSString(arr.objectAtIndex(i)).toWTF().utf8(); + id arg = arr.objectAtIndex(i); + if (!objc::Ref(arg).isKindOf(objc::NSString::cls)) arg = nullptr; + WTF::CString argC = objc::NSString(arg).toWTF().utf8(); uint32_t argLen = static_cast(argC.length()); size_t was = out.size(); out.grow(was + 4 + argLen); @@ -755,7 +758,7 @@ void WebViewHost::onEvalComplete(id result, id error) // Body returns JSON.stringify(...) — result is NSString or nil. // Empty reply → parent resolves jsUndefined(); non-empty → JSONParse. hostWriter()->sendReplyStr(m_viewId, Reply::EvalDone, - result ? objc::NSString(result).toWTF() : WTF::String()); + objc::Ref(result).isKindOf(objc::NSString::cls) ? objc::NSString(result).toWTF() : WTF::String()); } void WebViewHost::onScreenshotComplete(id nsimage, id error) diff --git a/src/s3_signing/credentials.rs b/src/s3_signing/credentials.rs index 683e3988c1f3..ec6840fe8a8f 100644 --- a/src/s3_signing/credentials.rs +++ b/src/s3_signing/credentials.rs @@ -397,6 +397,9 @@ impl S3Credentials { // only the host part is needed here break 'brk_host Box::<[u8]>::from(host); } else { + if !is_valid_host_component(region) { + return Err(SignError::InvalidEndpoint); + } if self.virtual_hosted_style { // virtual hosted style requires a bucket name if an endpoint is not provided if bucket.is_empty() { @@ -1113,7 +1116,11 @@ pub fn guess_bucket(endpoint: &[u8]) -> Option<&[u8]> { let Some(start) = strings::index_of(endpoint, b"/") else { return Some(&endpoint[0..end]); }; - return Some(&endpoint[start + 1..end]); + return Some( + endpoint + .get(start + 1..end) + .unwrap_or_else(|| &endpoint[0..end]), + ); } } else if let Some(r2_start) = strings::index_of(endpoint, b".r2.cloudflarestorage.com") { // check if is ..r2.cloudflarestorage.com @@ -1126,7 +1133,11 @@ pub fn guess_bucket(endpoint: &[u8]) -> Option<&[u8]> { let Some(start) = strings::index_of(endpoint, b"/") else { return Some(&endpoint[0..end]); }; - return Some(&endpoint[start + 1..end]); + return Some( + endpoint + .get(start + 1..end) + .unwrap_or_else(|| &endpoint[0..end]), + ); } None } @@ -1138,7 +1149,7 @@ pub fn guess_region(endpoint: &[u8]) -> &[u8] { } if let Some(end) = strings::index_of(endpoint, b".amazonaws.com") { if let Some(start) = strings::index_of(endpoint, b"s3.") { - return &endpoint[start + 3..end]; + return endpoint.get(start + 3..end).unwrap_or(b"us-east-1"); } } // endpoint is informed but is not s3 so auto detect @@ -1438,3 +1449,10 @@ impl CanonicalRequest { fn contains_newline_or_cr(value: &[u8]) -> bool { strings::index_of_any(value, b"\r\n").is_some() } + +fn is_valid_host_component(value: &[u8]) -> bool { + !value.is_empty() + && value + .iter() + .all(|&c| c.is_ascii_alphanumeric() || c == b'-' || c == b'.' || c == b'_') +} diff --git a/src/semver/Version.rs b/src/semver/Version.rs index bb175d79af91..286705e86f84 100644 --- a/src/semver/Version.rs +++ b/src/semver/Version.rs @@ -481,7 +481,7 @@ impl VersionType { } let mut is_done = false; - let mut i: usize = 0; + let mut i: usize = input.len(); for c in 0..input.len() { match input[c] { @@ -505,6 +505,8 @@ impl VersionType { if i == input.len() { result.valid = false; + result.wildcard = Wildcard::Major; + result.len = u32::try_from(i).expect("int cast"); return result; } diff --git a/src/shell_parser/parse.rs b/src/shell_parser/parse.rs index 3444a54e351e..d91c6704a5c4 100644 --- a/src/shell_parser/parse.rs +++ b/src/shell_parser/parse.rs @@ -1181,8 +1181,7 @@ impl<'bump> Parser<'bump> { } fn is_if_clause_text_token_impl(&self, range: TextRange, if_clause_token: IfClauseTok) -> bool { - let tagname = Self::extract_if_clause_text_token(if_clause_token); - self.text(range) == tagname + self.if_clause_tok_at(range) == Some(if_clause_token) } fn skip_newlines(&mut self) { @@ -1913,6 +1912,13 @@ impl<'bump> Parser<'bump> { .any(|r| pos >= r.start && pos < r.end) } + fn if_clause_tok_at(&self, range: TextRange) -> Option { + if self.is_interpolated_position(range.start) { + return None; + } + IfClauseTok::from_text(self.text(range)) + } + fn advance(&mut self) -> Token { if !self.is_at_end() { self.current += 1; @@ -1983,9 +1989,7 @@ impl<'bump> Parser<'bump> { fn match_if_clausetok(&mut self, toktag: IfClauseTok) -> bool { if let Token::Text(range) = self.peek() { - if self.delimits(self.peek_n(1)) - && self.text(range) == <&'static str>::from(toktag).as_bytes() - { + if self.delimits(self.peek_n(1)) && self.if_clause_tok_at(range) == Some(toktag) { let _ = self.advance(); let _ = self.expect_delimit(); return true; @@ -2037,13 +2041,10 @@ impl<'bump> Parser<'bump> { if !self.delimits(self.peek_n(1)) { return false; } - let txt = self.text(range); - for &tag in toktags { - if txt == <&'static str>::from(tag).as_bytes() { - return true; - } - } - false + let Some(tok) = self.if_clause_tok_at(range) else { + return false; + }; + toktags.contains(&tok) } fn peek_any_comptime_ifclausetok(&self, toktags: &[IfClauseTok]) -> bool { @@ -2167,7 +2168,7 @@ impl IfClauseTok { /// `expect_if_clause_text_token`. pub fn from_tok(p: &Parser<'_>, tok: Token) -> Option { match tok { - Token::Text(range) if p.delimits(p.peek_n(1)) => Self::from_text(p.text(range)), + Token::Text(range) if p.delimits(p.peek_n(1)) => p.if_clause_tok_at(range), _ => None, } } @@ -4146,14 +4147,17 @@ fn is_all_ascii(s: &[u8]) -> bool { // ───────────────────────────── escaping ───────────────────────────── /// Characters that need to be escaped -pub const SPECIAL_CHARS: [u8; 34] = [ +pub const SPECIAL_CHARS: [u8; 37] = [ b'~', b'[', b']', b'#', b';', b'\n', + b'\t', + b'\r', b'*', + b'?', b'{', b',', b'}', @@ -4335,6 +4339,13 @@ pub fn needs_escape_utf8_ascii_latin1(str: &[u8]) -> bool { false } +pub fn is_if_clause_keyword_bunstr(bunstr: BunString) -> bool { + use IfClauseTok::{Elif, Else, Fi, If, Then}; + [If, Else, Elif, Then, Fi] + .iter() + .any(|&kw| bunstr.eql_comptime(<&'static str>::from(kw))) +} + // ───────────────────────────── SmolList ───────────────────────────── /// `Allocator` routing `SmolList::Heap` through the parser arena. Must not outlive the arena. diff --git a/src/sourcemap/Mapping.rs b/src/sourcemap/Mapping.rs index 2891ff65bfdb..1ba4676ae051 100644 --- a/src/sourcemap/Mapping.rs +++ b/src/sourcemap/Mapping.rs @@ -405,8 +405,8 @@ impl Lookup { // SAFETY: `standalone_module_graph_data` returns a pointer // owned by the standalone module graph trailer; lifetime is - // process-static (mmapped). `source_file_contents` mutates the - // decompression cache in-place. + // process-static (mmapped). `source_file_contents` fills the + // per-index decompression cache through a `OnceLock`. let code = unsafe { (*serialized).source_file_contents(index) }; return Some(ZigStringSlice::from_utf8_never_free(code?)); diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs index 34b349a74b3d..0c8b002a26ce 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -727,36 +727,31 @@ pub mod SerializedSourceMap { /// Only decompress source code once! Once a file is decompressed, /// it is stored here. Decompression failure is recorded as an empty /// `Vec`, which `source_file_contents` treats as "no contents". - pub decompressed_files: Box<[Option>]>, + pub decompressed_files: Box<[std::sync::OnceLock>]>, } impl Loaded { - pub(crate) fn source_file_contents(&mut self, index: usize) -> Option<&[u8]> { - // Populate first if - // empty, then take a single borrow at the end (borrowck-friendly). - if self.decompressed_files[index].is_none() { + pub(crate) fn source_file_contents(&self, index: usize) -> Option<&[u8]> { + let decompressed = self.decompressed_files[index].get_or_init(|| { let sp = self.map.compressed_source_file_at(index); let compressed_file = sp.slice(self.map.bytes); let size = bun_zstd::get_decompressed_size(compressed_file); let mut bytes = vec![0u8; size]; - self.decompressed_files[index] = - Some(match bun_zstd::decompress(&mut bytes, compressed_file) { - bun_zstd::Result::Err(err) => { - bun_core::warn!( - "Source map decompression error: {}", - ::bstr::BStr::new(err.as_bytes()), - ); - Vec::new() - } - bun_zstd::Result::Success(n) => { - bytes.truncate(n); - bytes - } - }); - } - - let decompressed = self.decompressed_files[index].as_deref().unwrap(); + match bun_zstd::decompress(&mut bytes, compressed_file) { + bun_zstd::Result::Err(err) => { + bun_core::warn!( + "Source map decompression error: {}", + ::bstr::BStr::new(err.as_bytes()), + ); + Vec::new() + } + bun_zstd::Result::Success(n) => { + bytes.truncate(n); + bytes + } + } + }); if decompressed.is_empty() { None } else { diff --git a/src/sql/mysql/protocol/AuthSwitchRequest.rs b/src/sql/mysql/protocol/AuthSwitchRequest.rs index 2b8b090f614a..e829e0a6c318 100644 --- a/src/sql/mysql/protocol/AuthSwitchRequest.rs +++ b/src/sql/mysql/protocol/AuthSwitchRequest.rs @@ -34,7 +34,10 @@ impl AuthSwitchRequest { return Err(bun_core::err!("InvalidAuthSwitchRequest")); } - let remaining = reader.read((self.packet_size - 1) as usize)?; + let Some(remaining_len) = self.packet_size.checked_sub(1) else { + return Err(bun_core::err!("InvalidAuthSwitchRequest")); + }; + let remaining = reader.read(remaining_len as usize)?; let remaining_slice = remaining.slice(); debug_assert!(matches!(remaining, Data::Temporary(_))); diff --git a/src/sql/mysql/protocol/LocalInfileRequest.rs b/src/sql/mysql/protocol/LocalInfileRequest.rs index a45c08f95d70..40f3bcc23567 100644 --- a/src/sql/mysql/protocol/LocalInfileRequest.rs +++ b/src/sql/mysql/protocol/LocalInfileRequest.rs @@ -29,7 +29,10 @@ impl LocalInfileRequest { return Err(AnyMySQLError::InvalidLocalInfileRequest); } - self.filename = reader.read((self.packet_size - 1) as usize)?; + let Some(filename_len) = self.packet_size.checked_sub(1) else { + return Err(AnyMySQLError::InvalidLocalInfileRequest); + }; + self.filename = reader.read(filename_len as usize)?; Ok(()) } diff --git a/src/sql/postgres/protocol/CopyData.rs b/src/sql/postgres/protocol/CopyData.rs index 2f798ed82eaa..1e59cb06ab92 100644 --- a/src/sql/postgres/protocol/CopyData.rs +++ b/src/sql/postgres/protocol/CopyData.rs @@ -15,7 +15,7 @@ impl CopyData { ) -> Result { let length = reader.length()?; - let data = reader.read(usize::try_from(length.saturating_sub(5)).expect("int cast"))?; + let data = reader.read(usize::try_from(length - 4).expect("int cast"))?; Ok(Self { data }) } diff --git a/src/sql/postgres/protocol/NewReader.rs b/src/sql/postgres/protocol/NewReader.rs index 3f9d496a458a..0167ea77a39b 100644 --- a/src/sql/postgres/protocol/NewReader.rs +++ b/src/sql/postgres/protocol/NewReader.rs @@ -167,6 +167,11 @@ impl NewReaderWrap { Ok(expected) } + pub fn skip_message(&mut self) -> Result<(), AnyPostgresError> { + let length = self.length()?; + self.skip(usize::try_from(length - 4).expect("int cast")) + } + #[inline] pub fn bytes(&mut self, count: usize) -> Result { self.read(count) diff --git a/src/sql_jsc/Cargo.toml b/src/sql_jsc/Cargo.toml index 5b2a50e837b5..3f7972400c2e 100644 --- a/src/sql_jsc/Cargo.toml +++ b/src/sql_jsc/Cargo.toml @@ -38,4 +38,3 @@ bun_sha_hmac.workspace = true bun_sql.workspace = true bun_uws.workspace = true bun_uws_sys.workspace = true -bun_wyhash.workspace = true diff --git a/src/sql_jsc/mysql/JSMySQLConnection.rs b/src/sql_jsc/mysql/JSMySQLConnection.rs index d349bee95f56..fd3510456792 100644 --- a/src/sql_jsc/mysql/JSMySQLConnection.rs +++ b/src/sql_jsc/mysql/JSMySQLConnection.rs @@ -931,12 +931,12 @@ impl JSMySQLConnection { } } - pub fn get_statement_from_signature_hash( + pub fn get_statement_from_signature_name( &self, - signature_hash: u64, + signature_name: &[u8], ) -> Result, bun_core::AllocError> { - self.connection_mut().statements.get_or_put(signature_hash) + self.connection_mut().statements.get_or_put(signature_name) } } diff --git a/src/sql_jsc/mysql/MySQLConnection.rs b/src/sql_jsc/mysql/MySQLConnection.rs index c343c96a05c1..d4c9e9686653 100644 --- a/src/sql_jsc/mysql/MySQLConnection.rs +++ b/src/sql_jsc/mysql/MySQLConnection.rs @@ -1,5 +1,5 @@ use crate::jsc::{JSValue, VirtualMachineSqlExt as _}; -use bun_collections::{HashMap, IdentityContext, OffsetByteList, VecExt}; +use bun_collections::{OffsetByteList, StringHashMap, VecExt}; use bun_uws::{self as uws, AnySocket as Socket, SslCtx}; use bun_sql::mysql::Capabilities; @@ -1723,10 +1723,9 @@ impl ReaderContext for Reader { // `JSMySQLConnection::on_query_result(MySQLQueryResult)` without conversion. pub use bun_sql::mysql::MySQLQueryResult as QueryResult; -// Keys are already wyhash values, so identity hash avoids re-hashing. -pub(crate) type PreparedStatementsMap = HashMap>; +pub(crate) type PreparedStatementsMap = StringHashMap<*mut MySQLStatement>; /// Result of `PreparedStatementsMap::get_or_put` — surfaced for -/// `JSMySQLConnection::get_statement_from_signature_hash`. +/// `JSMySQLConnection::get_statement_from_signature_name`. pub(crate) type PreparedStatementsMapGetOrPutResult<'a> = bun_collections::hash_map::GetOrPutResult<'a, *mut MySQLStatement>; diff --git a/src/sql_jsc/mysql/MySQLQuery.rs b/src/sql_jsc/mysql/MySQLQuery.rs index 8432a8a249c7..bdf673992985 100644 --- a/src/sql_jsc/mysql/MySQLQuery.rs +++ b/src/sql_jsc/mysql/MySQLQuery.rs @@ -326,9 +326,7 @@ impl MySQLQuery { query_str = Some(query); // errdefer signature.deinit() — `Signature: Drop` handles the error path; on the // found_existing success path below we explicitly drop it. - let entry = match connection - .get_statement_from_signature_hash(bun_wyhash::hash(&signature.name)) - { + let entry = match connection.get_statement_from_signature_name(&signature.name) { Ok(e) => e, Err(err) => { // `err` is `bun_core::AllocError`; `throw_error` takes diff --git a/src/sql_jsc/postgres/PostgresSQLConnection.rs b/src/sql_jsc/postgres/PostgresSQLConnection.rs index 53bd07095ca8..5ff3a0cbebf4 100644 --- a/src/sql_jsc/postgres/PostgresSQLConnection.rs +++ b/src/sql_jsc/postgres/PostgresSQLConnection.rs @@ -11,7 +11,7 @@ use crate::jsc::{ VirtualMachineSqlExt as _, }; use bun_boringssl as BoringSSL; -use bun_collections::{HashMap, IdentityContext, OffsetByteList, StringMap}; +use bun_collections::{OffsetByteList, StringHashMap, StringMap}; use bun_core::strings; use bun_core::{self}; use bun_io::KeepAlive; @@ -53,8 +53,7 @@ bun_core::define_scoped_log!(debug, Postgres, visible); const MAX_PIPELINE_SIZE: usize = u16::MAX as usize; // about 64KB per connection -// Keys are already wyhash values, so identity hash avoids re-hashing. -type PreparedStatementsMap = HashMap>; +type PreparedStatementsMap = StringHashMap<*mut PostgresSQLStatement>; pub mod js { pub use crate::jsc::codegen::JSPostgresSQLConnection::*; @@ -2942,7 +2941,7 @@ impl PostgresSQLConnection { ); if self .statements - .with_mut(|m| m.remove(&bun_wyhash::hash(&stmt.signature.name))) + .with_mut(|m| m.remove(&stmt.signature.name[..])) .is_some() { // SAFETY: `stmt` is a live `Box`-allocated statement; the @@ -2958,9 +2957,7 @@ impl PostgresSQLConnection { request.on_js_error(js_err, self.global()); } MessageType::PortalSuspended => { - // try reader.eatMessage(&protocol.PortalSuspended); - // var request = this.current() orelse return error.ExpectedRequest; - // _ = request; + reader.skip_message()?; debug!("TODO PortalSuspended"); } MessageType::CloseComplete => { @@ -2975,6 +2972,7 @@ impl PostgresSQLConnection { self.update_ref(); } MessageType::CopyInResponse => { + reader.skip_message()?; debug!("TODO CopyInResponse"); } MessageType::NoticeResponse => { @@ -2993,12 +2991,15 @@ impl PostgresSQLConnection { self.update_ref(); } MessageType::CopyOutResponse => { + reader.skip_message()?; debug!("TODO CopyOutResponse"); } MessageType::CopyDone => { + reader.skip_message()?; debug!("TODO CopyDone"); } MessageType::CopyBothResponse => { + reader.skip_message()?; debug!("TODO CopyBothResponse"); } // else => @compileError("Unknown message type") // const-generic enum match is exhaustive in Rust; no compile error needed. diff --git a/src/sql_jsc/postgres/PostgresSQLQuery.rs b/src/sql_jsc/postgres/PostgresSQLQuery.rs index 1564973a2cfc..78a478263d9e 100644 --- a/src/sql_jsc/postgres/PostgresSQLQuery.rs +++ b/src/sql_jsc/postgres/PostgresSQLQuery.rs @@ -8,7 +8,6 @@ use crate::shared::query_ctor_args::QueryCtorArgs; use bun_core::String as BunString; use bun_jsc::JsCell; use bun_ptr::AsCtxPtr; -use bun_wyhash::hash; use super::PostgresSQLConnection; use super::PostgresSQLStatement; @@ -607,40 +606,19 @@ impl PostgresSQLQuery { // holding a `&mut` across other &mut connection borrows below trips borrowck, so // store the raw `*mut *mut PostgresSQLStatement` and re-dereference at use sites. let mut connection_entry_value: Option<*mut *mut PostgresSQLStatement> = None; - let signature_hash: u64 = hash(&signature.name); if !connection .flags .get() .contains(ConnectionFlags::USE_UNNAMED_PREPARED_STATEMENTS) { - // `JsCell::with_mut` scopes the `&mut PreparedStatementsMap` to - // the `get_or_put` call (single-JS-thread; no re-entry into JS - // until after the raw value-slot ptr is captured). Extract the - // raw slot ptr + existing value while the borrow is live so the - // remainder of this block needs no further `&mut` to the map. - let (entry_value_ptr, existing_stmt) = match connection.statements.with_mut(|s| { - s.get_or_put(signature_hash).map(|e| { - let existing = if e.found_existing { - Some(*e.value_ptr) - } else { - None - }; - ( - std::ptr::from_mut::<*mut PostgresSQLStatement>(e.value_ptr), - existing, - ) - }) - }) { - Ok(v) => v, - Err(err) => { - drop(signature); - release_query_ref(); - return Err( - global_object.throw_error(err.into(), "failed to allocate statement") - ); - } - }; - connection_entry_value = Some(entry_value_ptr); + // Zero-allocation hit probe: `get_or_put` below boxes the key + // bytes even when the entry already exists, and a hit (an + // already-prepared named statement) is the steady state. + let existing_stmt = connection + .statements + .get() + .get(&signature.name[..]) + .copied(); if let Some(stmt_ptr) = existing_stmt { this.statement.set(Some(stmt_ptr)); // Route the `&mut` through the audited `statement_mut()` @@ -700,6 +678,25 @@ impl PostgresSQLQuery { break 'enqueue; } + // `JsCell::with_mut` scopes the `&mut PreparedStatementsMap` to + // the `get_or_put` call (single-JS-thread; no re-entry into JS + // until after the raw value-slot ptr is captured). Extract the + // raw slot ptr while the borrow is live so the remainder of + // this block needs no further `&mut` to the map. + let entry_value_ptr = match connection.statements.with_mut(|s| { + s.get_or_put(&signature.name) + .map(|e| std::ptr::from_mut::<*mut PostgresSQLStatement>(e.value_ptr)) + }) { + Ok(v) => v, + Err(err) => { + drop(signature); + release_query_ref(); + return Err( + global_object.throw_error(err.into(), "failed to allocate statement") + ); + } + }; + connection_entry_value = Some(entry_value_ptr); } let can_execute = !connection.has_query_running(); @@ -718,7 +715,7 @@ impl PostgresSQLQuery { if connection_entry_value.is_some() { let _ = connection .statements - .with_mut(|m| m.remove(&signature_hash)); + .with_mut(|m| m.remove(&signature.name[..])); } drop(signature); release_query_ref(); @@ -750,7 +747,7 @@ impl PostgresSQLQuery { if connection_entry_value.is_some() { let _ = connection .statements - .with_mut(|m| m.remove(&signature_hash)); + .with_mut(|m| m.remove(&signature.name[..])); } drop(signature); release_query_ref(); @@ -760,7 +757,7 @@ impl PostgresSQLQuery { if connection_entry_value.is_some() { let _ = connection .statements - .with_mut(|m| m.remove(&signature_hash)); + .with_mut(|m| m.remove(&signature.name[..])); } drop(signature); release_query_ref(); @@ -800,9 +797,9 @@ impl PostgresSQLQuery { this.statement.set(Some(stmt)); // SAFETY: `entry_value` points into `connection.statements` and the map has - // not been mutated since `get_or_put`. This arm is reached only when - // `!entry.found_existing`; the slot was default-initialised to null by - // `get_or_put`, so a plain store is fine. + // not been mutated since `get_or_put`. `get_or_put` runs only after the + // existing-entry probe missed, so the slot it hands back was + // default-initialised to null and a plain store is fine. unsafe { *entry_value = stmt }; } else { let stmt = { diff --git a/src/standalone_graph/StandaloneModuleGraph.rs b/src/standalone_graph/StandaloneModuleGraph.rs index 499659249100..6e36989c9398 100644 --- a/src/standalone_graph/StandaloneModuleGraph.rs +++ b/src/standalone_graph/StandaloneModuleGraph.rs @@ -462,8 +462,10 @@ impl LazySourceMap { // copy the section bytes. Could switch // the field to `Vec<&'static [u8]>` for the standalone path. let mut file_names: Vec> = Vec::with_capacity(source_files_count); - let decompressed_contents_slice: Vec>> = - vec![None; source_files_count]; + let decompressed_contents_slice: Vec>> = + std::iter::repeat_with(std::sync::OnceLock::new) + .take(source_files_count) + .collect(); for i in 0..source_files_count { // SAFETY: `serialized.bytes` is a 'static read-only sourcemap subrange // (disjoint from bytecode); StringPointer offsets were serialized by @@ -2205,7 +2207,7 @@ pub struct SerializedSourceMapLoaded { /// Only decompress source code once! Once a file is decompressed, /// it is stored here. Decompression failures are stored as an empty /// string, which will be treated as "no contents". - pub decompressed_files: Box<[Option>]>, + pub decompressed_files: Box<[std::sync::OnceLock>]>, } pub(crate) fn serialize_json_source_map_for_standalone( diff --git a/src/url/lib.rs b/src/url/lib.rs index 070048b56780..9e5cae91270c 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -1062,14 +1062,10 @@ impl QueryStringMap { let name_hash: u64 = wyhash(name_slice); - value.length = match PercentEncoding::decode( - &mut buf, - result.raw_value(scanner.pathname.pathname), - ) { - Ok(n) => n, - Err(_) => continue, - }; + let value_slice = result.raw_value(scanner.pathname.pathname); + value.length = u32::try_from(value_slice.len()).unwrap(); value.offset = buf_writer_pos; + buf.extend_from_slice(value_slice); buf_writer_pos += value.length; list.push(Param { @@ -1610,7 +1606,7 @@ impl<'a> PathnameScanner<'a> { name_needs_decoding: false, // TODO: fix this technical debt value: string_pointer_from_strings(self.pathname, param.value), - value_needs_decoding: strings::index_of_char(param.value, b'%').is_some(), + value_needs_decoding: false, }) } } diff --git a/src/valkey/valkey_protocol.rs b/src/valkey/valkey_protocol.rs index 07ff959123b7..b87e084878bc 100644 --- a/src/valkey/valkey_protocol.rs +++ b/src/valkey/valkey_protocol.rs @@ -814,4 +814,18 @@ impl SubscriptionPushMessage { pub fn from_bytes(bytes: &[u8]) -> Option { SUBSCRIPTION_PUSH_MESSAGES.get(bytes).copied() } + + /// Pattern (`p`-prefixed) and sharded (`s`-prefixed) variants of the + /// `Subscribe`/`Unsubscribe` push kinds; the unprefixed kinds are matched by + /// `from_bytes` before this is consulted. + #[inline] + pub fn is_reply_kind(kind: &[u8]) -> bool { + match kind.split_first() { + Some((b'p' | b's', base)) => matches!( + Self::from_bytes(base), + Some(Self::Subscribe | Self::Unsubscribe) + ), + _ => false, + } + } } diff --git a/test/bake/dev/bundle.test.ts b/test/bake/dev/bundle.test.ts index 1a4e904167e3..6c6d3657029c 100644 --- a/test/bake/dev/bundle.test.ts +++ b/test/bake/dev/bundle.test.ts @@ -814,3 +814,54 @@ devTest("barrel optimization: two import statements from the same barrel (#28886 await c.expectMessage("got: ALPHA BETA"); }, }); + +devTest("barrel optimization: namespace re-export cycle through a star-exported module", { + files: { + "index.html": emptyHtmlFile({ scripts: ["index.ts"] }), + "index.ts": ` + import { x, y, deepValue } from 'loop-lib'; + import { keep } from 'loop-lib/w.js'; + import { other } from 'loop-lib/g.js'; + console.log('result: ' + typeof x + ' ' + y + ' ' + keep + ' ' + deepValue + ' ' + other); + `, + "node_modules/loop-lib/package.json": JSON.stringify({ + name: "loop-lib", + version: "1.0.0", + main: "./index.js", + sideEffects: false, + }), + "node_modules/loop-lib/index.js": ` + export * from './t.js'; + `, + "node_modules/loop-lib/t.js": ` + export { x } from './w.js'; + export * from './r.js'; + export * from './g.js'; + `, + "node_modules/loop-lib/w.js": ` + import * as ns from './t.js'; + export { ns as x }; + export { keep } from './keep.js'; + `, + "node_modules/loop-lib/keep.js": ` + export const keep = "KEEP"; + `, + "node_modules/loop-lib/r.js": ` + export const y = "Y"; + `, + "node_modules/loop-lib/g.js": ` + export { deepValue } from './deep.js'; + export { other } from './other.js'; + `, + "node_modules/loop-lib/deep.js": ` + export const deepValue = "DEEP"; + `, + "node_modules/loop-lib/other.js": ` + export const other = "OTHER"; + `, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage("result: object Y KEEP DEEP OTHER"); + }, +}); diff --git a/test/bake/dev/hot.test.ts b/test/bake/dev/hot.test.ts index 7f8d5c39b601..9f62067503c2 100644 --- a/test/bake/dev/hot.test.ts +++ b/test/bake/dev/hot.test.ts @@ -527,3 +527,87 @@ devTest("hmr forwards every merged inotify sub-path from a directory batch", { } }, }); +devTest("hot update frames are not delivered to application websocket topics", { + files: { + "index.html": emptyHtmlFile({ + scripts: ["index.ts"], + }), + "index.ts": ` + console.log("initial"); + import.meta.hot.accept(); + `, + "bun.app.ts": ` + import html from "./index.html"; + export default { + static: { + "/": html, + }, + fetch(req, server) { + if (new URL(req.url).pathname === "/app-ws") { + if (server.upgrade(req)) return; + return new Response("upgrade failed", { status: 400 }); + } + return new Response("Not Found", { status: 404 }); + }, + websocket: { + open(ws) { + ws.subscribe("h"); + ws.subscribe("e"); + ws.subscribe("E"); + ws.send("subscribed"); + }, + message(ws, message) { + ws.send("echo:" + message); + }, + }, + }; + `, + }, + htmlFiles: [], + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage("initial"); + + const received: string[] = []; + const ws = new WebSocket(dev.baseUrl.replace("http", "ws") + "/app-ws"); + try { + const opened = Promise.withResolvers(); + const echoed = Promise.withResolvers(); + ws.onerror = () => { + opened.reject(new Error("application websocket errored")); + echoed.reject(new Error("application websocket errored")); + }; + ws.onclose = () => { + opened.reject(new Error("application websocket closed")); + echoed.reject(new Error("application websocket closed")); + }; + ws.onmessage = event => { + if (event.data === "subscribed") { + opened.resolve(); + return; + } + received.push(typeof event.data === "string" ? event.data : ""); + if (event.data === "echo:after-update") { + echoed.resolve(); + } + }; + await opened.promise; + + await dev.write( + "index.ts", + ` + console.log("updated"); + import.meta.hot.accept(); + `, + ); + await c.expectMessage("updated"); + + ws.send("after-update"); + await echoed.promise; + expect(received).toEqual(["echo:after-update"]); + } finally { + ws.onclose = null; + ws.close(); + } + }, +}); diff --git a/test/bake/dev/html.test.ts b/test/bake/dev/html.test.ts index e50e9e3a50e3..c79ed5d0222a 100644 --- a/test/bake/dev/html.test.ts +++ b/test/bake/dev/html.test.ts @@ -279,3 +279,94 @@ devTest("error report endpoint handles stack frames with very long absolute path await dev.fetch("/").expect.toInclude("

Error Report

"); }, }); + +devTest("error report endpoint rejects requests whose origin header does not match the dev server", { + files: { + "index.html": emptyHtmlFile({ + scripts: ["/script.ts"], + body: "

Origin Check

", + }), + "script.ts": ` + console.log("hello"); + `, + }, + async test(dev) { + function u32(n: number) { + const b = Buffer.alloc(4); + b.writeUInt32LE(n >>> 0, 0); + return b; + } + function str32(s: string) { + const bytes = Buffer.from(s, "utf8"); + return Buffer.concat([u32(bytes.length), bytes]); + } + const body = Buffer.concat([str32("Error"), str32("origin-check-message"), str32(dev.baseUrl + "/"), u32(0)]); + + const crossOrigin = await dev.fetch("/_bun/report_error", { + method: "POST", + headers: { Origin: "http://other-page.example" }, + body, + }); + expect(await crossOrigin.text()).toBe("Blocked: Origin header does not match the dev server"); + expect(crossOrigin.status).toBe(403); + + const sameOrigin = await dev.fetch("/_bun/report_error", { + method: "POST", + headers: { Origin: dev.baseUrl }, + body, + }); + expect(sameOrigin.status).toBe(200); + + await dev.fetch("/").expect.toInclude("

Origin Check

"); + }, +}); + +devTest("error report endpoint blanks stray non-text bytes in reported frames", { + files: { + "index.html": emptyHtmlFile({ + scripts: ["/script.ts"], + body: "

Frame Bytes

", + }), + "script.ts": ` + console.log("hello"); + `, + }, + async test(dev) { + function u32(n: number) { + const b = Buffer.alloc(4); + b.writeUInt32LE(n >>> 0, 0); + return b; + } + function i32(n: number) { + const b = Buffer.alloc(4); + b.writeInt32LE(n, 0); + return b; + } + function bytes32(bytes: Buffer) { + return Buffer.concat([u32(bytes.length), bytes]); + } + function str32(s: string) { + return bytes32(Buffer.from(s, "utf8")); + } + + const functionName = Buffer.concat([Buffer.from("fnstart"), Buffer.from([0x9b]), Buffer.from("fnend")]); + const body = Buffer.concat([ + str32("Error"), + str32("frame-bytes-message"), + str32(dev.baseUrl + "/"), + u32(1), + i32(1), + i32(1), + bytes32(functionName), + str32("foo.ts"), + ]); + + const res = await dev.fetch("/_bun/report_error", { method: "POST", body }); + const reply = Buffer.from(await res.arrayBuffer()); + expect(reply.includes(Buffer.from("fnstart fnend", "latin1"))).toBe(true); + expect(reply.includes(0x9b)).toBe(false); + expect(res.status).toBe(200); + + await dev.fetch("/").expect.toInclude("

Frame Bytes

"); + }, +}); diff --git a/test/bake/dev/production.test.ts b/test/bake/dev/production.test.ts index ed9b7d26897c..1335e3244c00 100644 --- a/test/bake/dev/production.test.ts +++ b/test/bake/dev/production.test.ts @@ -500,6 +500,60 @@ export default function Counter() { expect(foundCounterBundle).toBe(true); }); + test("inline flight data is escaped as a single unit across stream chunks", async () => { + const dir = await tempDirWithBakeDeps("bake-production-flight-escaping", { + "src/index.tsx": `export default { app: { framework: "react" } };`, + "components/Box.tsx": `"use client"; + +export default function Box({ children }) { + return {children}; +}`, + "pages/index.tsx": `import Box from "../components/Box"; + +const filler = Buffer.alloc(495, "").toString(); + +async function Item({ index }: { index: number }) { + return {index + ":" + filler}; +} + +export default function IndexPage() { + return ( +
+

Chunked

+ hydrated + {Array.from({ length: 120 }, (_, i) => ( + + ))} +
+ ); +}`, + "package.json": JSON.stringify({ + "name": "test-app", + "version": "1.0.0", + "devDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + }, + }), + }); + + const { exitCode } = await Bun.$`${bunExe()} build --app ./src/index.tsx --outdir ./dist` + .cwd(dir) + .env(bunEnv) + .throws(false); + expect(exitCode).toBe(0); + + const htmlContent = await Bun.file(path.join(dir, "dist", "index.html")).text(); + const opener = "(self.__bun_f||=[]).push('"; + const start = htmlContent.indexOf(opener); + expect(start).toBeGreaterThan(-1); + const end = htmlContent.indexOf("')", start); + expect(end).toBeGreaterThan(start); + const payload = htmlContent.slice(start + opener.length, end); + expect(payload).toContain(""); + expect(payload).not.toContain(" { const dir = await tempDirWithBakeDeps("bake-production-no-client-js", { "src/index.tsx": `export default { app: { framework: "react" } };`, diff --git a/test/bundler/bundler_barrel.test.ts b/test/bundler/bundler_barrel.test.ts index 9cfdf733b7a6..6bf9084779bb 100644 --- a/test/bundler/bundler_barrel.test.ts +++ b/test/bundler/bundler_barrel.test.ts @@ -506,6 +506,53 @@ describe("bundler", () => { run: { stdout: "bbb" }, }); + itBundled("barrel/NamespaceReExportCycleThroughStarTarget", { + files: { + "/entry.js": /* js */ ` + import { keep } from 'looplib/w.js'; + import { other } from 'looplib/g.js'; + import { x, y, deepValue } from 'looplib'; + console.log(typeof x + " " + y + " " + keep + " " + deepValue + " " + other); + `, + "/node_modules/looplib/package.json": JSON.stringify({ + name: "looplib", + main: "./index.js", + sideEffects: false, + }), + "/node_modules/looplib/index.js": /* js */ ` + export * from './t.js'; + `, + "/node_modules/looplib/t.js": /* js */ ` + export { x } from './w.js'; + export * from './r.js'; + export * from './g.js'; + `, + "/node_modules/looplib/w.js": /* js */ ` + import * as ns from './t.js'; + export { ns as x }; + export { keep } from './keep.js'; + `, + "/node_modules/looplib/keep.js": /* js */ ` + export const keep = "KEEP"; + `, + "/node_modules/looplib/r.js": /* js */ ` + export const y = "Y"; + `, + "/node_modules/looplib/g.js": /* js */ ` + export { deepValue } from './deep.js'; + export { other } from './other.js'; + `, + "/node_modules/looplib/deep.js": /* js */ ` + export const deepValue = "DEEP"; + `, + "/node_modules/looplib/other.js": /* js */ ` + export const other = "OTHER"; + `, + }, + outdir: "/out", + run: { stdout: "object Y KEEP DEEP OTHER" }, + }); + // --- Ported from Rolldown: self-re-export --- // barrel re-exports a symbol from itself diff --git a/test/bundler/bundler_browser.test.ts b/test/bundler/bundler_browser.test.ts index 36378d112a9d..0ecd7c681c13 100644 --- a/test/bundler/bundler_browser.test.ts +++ b/test/bundler/bundler_browser.test.ts @@ -118,6 +118,22 @@ describe("bundler", () => { api.expectFile("out.js").not.toInclude("import "); }, }); + itBundled("browser/NodeUrlProtocolTablesIgnorePrototype", { + files: { + "/entry.js": /* js */ ` + import { parse } from "node:url"; + const clean = parse("evil://h/p").slashes; + Object.prototype["evil:"] = true; + const polluted = parse("evil://h/p").slashes; + delete Object.prototype["evil:"]; + console.log(clean === true && polluted === true ? "PASS" : "FAIL " + clean + " " + polluted); + `, + }, + target: "browser", + run: { + stdout: "PASS", + }, + }); // TODO: use nodePolyfillList to generate the code in here. const NodePolyfills = itBundled("browser/NodePolyfills", { files: { diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index 53ed3d33510d..4b90c64c9a5f 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -2496,6 +2496,27 @@ describe("bundler", () => { stdout: "", }, }); + itBundled("edgecase/MacroProtoKeyIsOwnProperty", { + files: { + "/entry.ts": /* js */ ` + import { getData } from "./macro.ts" with { type: "macro" }; + const data = getData(); + console.write(JSON.stringify([ + Object.getPrototypeOf(data) === Object.prototype, + Object.hasOwn(data, "__proto__"), + data.x, + JSON.stringify(data), + ])); + `, + "/macro.ts": /* js */ ` + export function getData() { + return JSON.parse('{"__proto__": {"x": 1}, "a": 2}'); + } + `, + }, + target: "bun", + run: { stdout: '[true,true,null,"{\\"__proto__\\":{\\"x\\":1},\\"a\\":2}"]' }, + }); itBundled("edgecase/NodeBuiltinWithoutPrefix", { files: { "/entry.ts": ` diff --git a/test/bundler/bundler_loader.test.ts b/test/bundler/bundler_loader.test.ts index 6f9a7bb3a687..daeccfa1acfe 100644 --- a/test/bundler/bundler_loader.test.ts +++ b/test/bundler/bundler_loader.test.ts @@ -87,6 +87,170 @@ describe("bundler", async () => { }, }); + itBundled("bun/loader-json-proto-key-is-own-property", { + target: "bun", + files: { + "/entry.ts": /* js */ ` + import data from './data.json'; + const out = [ + Object.getPrototypeOf(data) === Object.prototype, + Object.hasOwn(data, "__proto__"), + data.x, + JSON.stringify(data), + ]; + console.write(JSON.stringify(out)); + `, + "/data.json": `{"__proto__": {"x": 1}, "a": 2}`, + }, + run: { stdout: '[true,true,null,"{\\"__proto__\\":{\\"x\\":1},\\"a\\":2}"]' }, + }); + + itBundled("bun/loader-toml-proto-key-is-own-property", { + target: "bun", + files: { + "/entry.ts": /* js */ ` + import data from './data.toml'; + const out = [ + Object.getPrototypeOf(data) === Object.prototype, + Object.hasOwn(data, "__proto__"), + data.x, + JSON.stringify(data), + ]; + console.write(JSON.stringify(out)); + `, + "/data.toml": `a = 2\n[__proto__]\nx = 1\n`, + }, + run: { stdout: '[true,true,null,"{\\"a\\":2,\\"__proto__\\":{\\"x\\":1}}"]' }, + }); + + itBundled("bun/loader-yaml-proto-key-is-own-property", { + target: "bun", + files: { + "/entry.ts": /* js */ ` + import data from './data.yaml'; + const out = [ + Object.getPrototypeOf(data) === Object.prototype, + Object.hasOwn(data, "__proto__"), + data.x, + JSON.stringify(data), + ]; + console.write(JSON.stringify(out)); + `, + "/data.yaml": `__proto__:\n x: 1\na: 2\n`, + }, + run: { stdout: '[true,true,null,"{\\"__proto__\\":{\\"x\\":1},\\"a\\":2}"]' }, + }); + + itBundled("bun/loader-jsonc-proto-key-is-own-property", { + target: "bun", + files: { + "/entry.ts": /* js */ ` + import data from './data.jsonc'; + const out = [ + Object.getPrototypeOf(data) === Object.prototype, + Object.hasOwn(data, "__proto__"), + data.x, + JSON.stringify(data), + ]; + console.write(JSON.stringify(out)); + `, + "/data.jsonc": `// jsonc\n{"__proto__": {"x": 1}, "a": 2,}`, + }, + run: { stdout: '[true,true,null,"{\\"__proto__\\":{\\"x\\":1},\\"a\\":2}"]' }, + }); + + itBundled("bun/loader-json5-proto-key-is-own-property", { + target: "bun", + files: { + "/entry.ts": /* js */ ` + import data from './data.json5'; + const out = [ + Object.getPrototypeOf(data) === Object.prototype, + Object.hasOwn(data, "__proto__"), + data.x, + JSON.stringify(data), + ]; + console.write(JSON.stringify(out)); + `, + "/data.json5": `{__proto__: {x: 1}, a: 2}`, + }, + run: { stdout: '[true,true,null,"{\\"__proto__\\":{\\"x\\":1},\\"a\\":2}"]' }, + }); + + itBundled("bun/loader-json-nested-proto-key-is-own-property", { + target: "bun", + files: { + "/entry.ts": /* js */ ` + import data from './data.json'; + const nested = data.nested; + const out = [ + Object.getPrototypeOf(nested) === Object.prototype, + Object.hasOwn(nested, "__proto__"), + nested.x, + JSON.stringify(data), + ]; + console.write(JSON.stringify(out)); + `, + "/data.json": `{"nested": {"__proto__": {"x": 1}, "a": 2}}`, + }, + run: { stdout: '[true,true,null,"{\\"nested\\":{\\"__proto__\\":{\\"x\\":1},\\"a\\":2}}"]' }, + }); + + itBundled("bun/loader-toml-inline-table-proto-key-is-own-property", { + target: "bun", + files: { + "/entry.ts": /* js */ ` + import data from './data.toml'; + const out = [ + Object.getPrototypeOf(data) === Object.prototype, + Object.hasOwn(data, "__proto__"), + data.x, + JSON.stringify(data), + ]; + console.write(JSON.stringify(out)); + `, + "/data.toml": `a = 2\n"__proto__" = { x = 1 }\n`, + }, + run: { stdout: '[true,true,null,"{\\"a\\":2,\\"__proto__\\":{\\"x\\":1}}"]' }, + }); + + itBundled("bun/loader-yaml-flow-proto-key-is-own-property", { + target: "bun", + files: { + "/entry.ts": /* js */ ` + import data from './data.yaml'; + const out = [ + Object.getPrototypeOf(data) === Object.prototype, + Object.hasOwn(data, "__proto__"), + data.x, + JSON.stringify(data), + ]; + console.write(JSON.stringify(out)); + `, + "/data.yaml": `{__proto__: {x: 1}, a: 2}\n`, + }, + run: { stdout: '[true,true,null,"{\\"__proto__\\":{\\"x\\":1},\\"a\\":2}"]' }, + }); + + // The CSS-modules lazy export builds its object through `E::Object::put`. + itBundled("bun/loader-css-module-proto-class-is-own-property", { + target: "bun", + outdir: "/out", + files: { + "/entry.ts": /* js */ ` + import styles from './styles.module.css'; + const out = [ + Object.getPrototypeOf(styles) === Object.prototype, + Object.hasOwn(styles, "__proto__"), + typeof styles.a === "string", + ]; + console.write(JSON.stringify(out)); + `, + "/styles.module.css": `.__proto__ { color: red; }\n.a { color: blue; }\n`, + }, + run: { stdout: "[true,true,true]" }, + }); + itBundled("bun/wasm-is-copied-to-outdir", { target: "bun", outdir: "/out", diff --git a/test/bundler/native-plugin.test.ts b/test/bundler/native-plugin.test.ts index 3941a57fae26..6ca832691911 100644 --- a/test/bundler/native-plugin.test.ts +++ b/test/bundler/native-plugin.test.ts @@ -617,6 +617,60 @@ console.log(JSON.stringify(json)) expect(compilationCtxFreedCount).toBe(2); }); + it("frees the plugin-provided source exactly once when the replaced contents fail to parse", async () => { + await Bun.write(path.join(tempdir, "needs_foo.json"), `{ "a": foo }`); + await Bun.write( + path.join(tempdir, "json_entry.ts"), + `import json from "./needs_foo.json";\nconsole.log(JSON.stringify(json));\n`, + ); + await Bun.write(path.join(tempdir, "after_json_entry.ts"), `export const ok = 1;\n`); + + const buildScript = ` + import * as path from "path"; + const tempdir = process.env.BUN_TEST_TEMP_DIR; + const napiModule = require(path.join(tempdir, "build/Release/xXx123_foo_counter_321xXx.node")); + const external = napiModule.createExternal(); + let failed = false; + try { + await Bun.build({ + outdir: path.join(tempdir, "dist-json-entry"), + entrypoints: [path.join(tempdir, "json_entry.ts")], + plugins: [ + { + name: "xXx123_foo_counter_321xXx", + setup(build) { + build.onBeforeParse({ filter: /\\.json$/ }, { napiModule, symbol: "plugin_impl", external }); + }, + }, + ], + }); + } catch (e) { + failed = true; + } + await Bun.build({ + outdir: path.join(tempdir, "dist-after-json-entry"), + entrypoints: [path.join(tempdir, "after_json_entry.ts")], + }); + console.log(JSON.stringify({ failed, freed: napiModule.getCompilationCtxFreedCount(external) })); + `; + await Bun.write(path.join(tempdir, "json_entry_build.ts"), buildScript); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", path.join(tempdir, "json_entry_build.ts")], + env: { ...bunEnv, BUN_TEST_TEMP_DIR: tempdir }, + cwd: tempdir, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.split("Freed compilation ctx!").length - 1).toBe(1); + const resultLine = stdout.split("\n").find(line => line.startsWith('{"failed"')); + expect(resultLine).toBe('{"failed":true,"freed":1}'); + expect(exitCode).toBe(0); + }); + type AdditionalFile = { name: string; contents: BunFile | string; diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index 333437913676..5c05e408c92a 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; test("use strict causes CommonJS", () => { const { stdout, exitCode } = Bun.spawnSync({ @@ -209,3 +209,46 @@ test("math.pow", () => { expect(foo2(20.4) + "").toEqual("0.22140372138502384"); expect(20.4 ** -0.5 + "").toEqual("0.22140372138502384"); }); + +describe("unterminated string literals in large files", () => { + test("reports an unterminated string literal at the end of a large JavaScript file", async () => { + using dir = tempDir("transpiler-long-unterminated-js", { + "index.js": `var s = "${Buffer.alloc(1 << 20, "a").toString()}`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + expect(stderr).toContain("Unterminated string literal"); + expect(exitCode).toBe(1); + }); + + test("reports an unterminated string literal at the end of a large JSON file", async () => { + using dir = tempDir("transpiler-long-unterminated-json", { + "tsconfig.big.json": `{"name": "${Buffer.alloc(1 << 20, "a").toString()}`, + "index.js": `require("./tsconfig.big.json");`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + expect(stderr).toContain("Unterminated string literal"); + expect(exitCode).toBe(1); + }); +}); diff --git a/test/cli/inspect/inspect.test.ts b/test/cli/inspect/inspect.test.ts index e4f7b7e30030..e0e6b8471fd3 100644 --- a/test/cli/inspect/inspect.test.ts +++ b/test/cli/inspect/inspect.test.ts @@ -300,6 +300,83 @@ describe("websocket", () => { }); }); +describe("http metadata endpoint", () => { + let metadataInspectee: Subprocess | undefined; + + async function spawnInspectee(): Promise { + metadataInspectee = spawn({ + cwd: import.meta.dir, + cmd: [bunExe(), "--inspect=127.0.0.1:0", "inspectee.js"], + env: bunEnv, + stdout: "ignore", + stderr: "pipe", + }); + + let url: URL | undefined; + let stderr = ""; + const decoder = new TextDecoder(); + for await (const chunk of metadataInspectee.stderr as ReadableStream) { + stderr += decoder.decode(chunk); + for (const line of stderr.split("\n")) { + try { + url = new URL(line); + } catch {} + if (url?.protocol.includes("ws")) { + break; + } + } + if (stderr.includes("Listening:")) { + break; + } + } + + if (!url) { + process.stderr.write(stderr); + throw new Error("Unable to find listening URL"); + } + return url; + } + + afterEach(() => { + metadataInspectee?.kill(); + }); + + test("serves /json/version only for a Host of the bound hostname, localhost, or an IP literal", async () => { + const { port } = await spawnInspectee(); + const endpoint = `http://127.0.0.1:${port}/json/version`; + + const allowed = await fetch(endpoint); + expect(allowed.status).toBe(200); + expect(await allowed.json()).toEqual({ + "Protocol-Version": "1.3", + "Browser": "Bun", + "User-Agent": expect.any(String), + "WebKit-Version": expect.any(String), + "Bun-Version": expect.any(String), + "Bun-Revision": expect.any(String), + }); + + const localhost = await fetch(endpoint, { headers: { "Host": `localhost:${port}` } }); + expect(localhost.status).toBe(200); + + const named = await fetch(endpoint, { headers: { "Host": `inspector.example:${port}` } }); + expect(await named.text()).toBe(""); + expect(named.status).toBe(400); + }); + + test("serves /json/version only to allowed web origins", async () => { + const { port } = await spawnInspectee(); + const endpoint = `http://127.0.0.1:${port}/json/version`; + + const loopback = await fetch(endpoint, { headers: { "Origin": "http://127.0.0.1:8080" } }); + expect(loopback.status).toBe(200); + + const web = await fetch(endpoint, { headers: { "Origin": "http://inspector.example" } }); + expect(await web.text()).toBe(""); + expect(web.status).toBe(403); + }); +}); + describe("unix domain socket without websocket", () => { let tempdir: string; let randomSocketPath: () => string; diff --git a/test/cli/install/bun-create.test.ts b/test/cli/install/bun-create.test.ts index 936d03bff590..d939581968f5 100644 --- a/test/cli/install/bun-create.test.ts +++ b/test/cli/install/bun-create.test.ts @@ -157,6 +157,39 @@ for (const repo of ["https://github.com/dylan-conway/create-test", "github.com/d }, 20_000); } +it("should keep bun-create task and start strings containing escape sequences intact", async () => { + const bunCreateDir = join(x_dir, "bun-create"); + const testTemplate = "escaped-config-template"; + + await Bun.write( + join(bunCreateDir, testTemplate, "package.json"), + `{ + "name": "escaped-config-template", + "version": "1.0.0", + "bun-create": { + "postinstall": "echo cr\\u00e9ate-step-done", + "start": "bun run d\\u00e9v --hot" + } +} +`, + ); + await Bun.write(join(bunCreateDir, testTemplate, "index.js"), "console.log('hi');\n"); + + await using proc = spawn({ + cmd: [bunExe(), "create", testTemplate, join(x_dir, "escaped-dest")], + cwd: x_dir, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env: { ...env, BUN_CREATE_DIR: bunCreateDir, MIMALLOC_PURGE_DELAY: "0" }, + }); + + const [out, _err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(out).toContain("\n$ echo créate-step-done\n"); + expect(out).toContain("\n cd escaped-dest\n bun run dév --hot\n"); + expect(exitCode).toBe(0); +}); + it("should not crash with --no-install and bun-create.postinstall starting with 'bun '", async () => { const bunCreateDir = join(x_dir, "bun-create"); const testTemplate = "postinstall-test"; diff --git a/test/cli/install/bun-install-tarball-integrity.test.ts b/test/cli/install/bun-install-tarball-integrity.test.ts index e6963ceec2b6..a5c354484c18 100644 --- a/test/cli/install/bun-install-tarball-integrity.test.ts +++ b/test/cli/install/bun-install-tarball-integrity.test.ts @@ -608,6 +608,146 @@ describe.concurrent.each(["hoisted", "isolated"] as const)("tarball integrity mi }); }); +describe.concurrent("tarball integrity metadata forms", () => { + function octal(n: number, width: number) { + return n.toString(8).padStart(width - 1, "0") + "\0"; + } + function tarHeader(name: string, size: number) { + const buf = Buffer.alloc(512, 0); + buf.write(name, 0, 100, "utf8"); + buf.write(octal(0o644, 8), 100); + buf.write(octal(0, 8), 108); + buf.write(octal(0, 8), 116); + buf.write(octal(size, 12), 124); + buf.write(octal(0, 12), 136); + buf.fill(" ", 148, 156); + buf.write("0", 156); + buf.write("ustar\0", 257); + buf.write("00", 263); + let sum = 0; + for (let i = 0; i < 512; i++) sum += buf[i]; + buf.write(octal(sum, 8), 148); + return buf; + } + function buildTarball(body: Buffer) { + const tar = Buffer.concat([ + tarHeader("package/package.json", body.length), + body, + Buffer.alloc((512 - (body.length % 512)) % 512, 0), + Buffer.alloc(1024, 0), + ]); + const tgz = gzipSync(tar); + return { + tgz, + sha512: "sha512-" + createHash("sha512").update(tgz).digest("base64"), + sha384: "sha384-" + createHash("sha384").update(tgz).digest("base64"), + }; + } + function serveManifest(integrity: string, tgz: Buffer) { + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + const url = new URL(req.url); + if (url.pathname.endsWith("/pkg")) { + return Response.json({ + name: "pkg", + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name: "pkg", + version: "1.0.0", + dist: { + integrity, + tarball: `http://127.0.0.1:${server.port}/pkg/-/pkg-1.0.0.tgz`, + }, + }, + }, + }); + } + if (url.pathname.endsWith("/pkg-1.0.0.tgz")) { + return new Response(tgz, { headers: { "content-length": String(tgz.length) } }); + } + return new Response("Not found", { status: 404 }); + }, + }); + return server; + } + function projectDir(name: string, port: number) { + return tempDir(name, { + "package.json": JSON.stringify({ + name: "app", + version: "1.0.0", + dependencies: { pkg: "1.0.0" }, + }), + "bunfig.toml": `[install]\nregistry = "http://127.0.0.1:${port}/"\n`, + }); + } + + it("verifies the tarball against the strongest entry of a multi-hash integrity string", async () => { + const real = buildTarball(Buffer.from('{"name":"pkg","version":"1.0.0"}\n')); + const other = buildTarball(Buffer.from('{"name":"other","version":"9.9.9"}\n')); + + await using server = serveManifest(`${other.sha512} ${real.sha384}`, real.tgz); + using dir = projectDir("integrity-multi-hash", server.port); + + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: { ...env, BUN_INSTALL_CACHE_DIR: join(String(dir), ".cache") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + expect(stderr + stdout).toContain("Integrity check failed"); + expect(stdout).not.toContain("1 package installed"); + expect(exitCode).not.toBe(0); + }); + + it("records the strongest entry of a multi-hash integrity string in the lockfile", async () => { + const real = buildTarball(Buffer.from('{"name":"pkg","version":"1.0.0"}\n')); + const other = buildTarball(Buffer.from('{"name":"other","version":"9.9.9"}\n')); + + await using server = serveManifest(`${real.sha512} ${other.sha384}`, real.tgz); + using dir = projectDir("integrity-multi-hash-lock", server.port); + + await using proc = spawn({ + cmd: [bunExe(), "install", "--save-text-lockfile"], + cwd: String(dir), + env: { ...env, BUN_INSTALL_CACHE_DIR: join(String(dir), ".cache") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + expect(stdout).toContain("1 package installed"); + const lockContent = await file(join(String(dir), "bun.lock")).text(); + const integrityMatch = lockContent.match(/"(sha\d+-[A-Za-z0-9+/]+=*)"/); + expect(integrityMatch).not.toBeNull(); + expect(integrityMatch![1]).toBe(real.sha512); + expect(exitCode).toBe(0); + }); + + it("verifies the tarball when the integrity entry carries an option suffix", async () => { + const real = buildTarball(Buffer.from('{"name":"pkg","version":"1.0.0"}\n')); + const other = buildTarball(Buffer.from('{"name":"other","version":"9.9.9"}\n')); + + await using server = serveManifest(`${other.sha512}?vcs=git`, real.tgz); + using dir = projectDir("integrity-option-suffix", server.port); + + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: { ...env, BUN_INSTALL_CACHE_DIR: join(String(dir), ".cache") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + expect(stderr + stdout).toContain("Integrity check failed"); + expect(stdout).not.toContain("1 package installed"); + expect(exitCode).not.toBe(0); + }); +}); + describe.concurrent.each(["hoisted", "isolated"] as const)("tarball download failure (%s)", linker => { it("should fail (not hang) when registry returns 404 for tarball", async () => { await withContext({ linker }, async ctx => { diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 65a6a3e14d28..51f91d596712 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -9857,3 +9857,165 @@ it.skipIf(isWindows)("file: deps with colliding abs-path hashes resolve to disti const beta = await file(join(victimDir, "node_modules", "betadep", "package.json")).json(); expect({ alpha: alpha.name, beta: beta.name }).toEqual({ alpha: "pkg-alpha", beta: "pkg-beta" }); }); + +it("reports an invalid URL for a manifest tarball URL containing a newline", async () => { + await withContext(defaultOpts, async ctx => { + const tarballRequests: string[] = []; + setContextHandler(ctx, async request => { + const url = new URL(request.url); + if (url.pathname.includes(".tgz")) { + tarballRequests.push(request.url); + return new Response("Not Found", { status: 404 }); + } + return new Response( + JSON.stringify({ + name: "baz", + versions: { + "0.0.2": { + name: "baz", + version: "0.0.2", + dist: { + tarball: `${ctx.registry_url}baz\n-0.0.2.tgz`, + }, + }, + }, + "dist-tags": { + latest: "0.0.2", + }, + }), + ); + }); + await writeFile( + join(ctx.package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { + baz: "0.0.2", + }, + }), + ); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: ctx.package_dir, + stdout: "pipe", + stderr: "pipe", + env, + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(err).toContain("InvalidURL downloading tarball"); + expect(tarballRequests).toEqual([]); + expect(out).not.toContain("1 package installed"); + expect(exitCode).not.toBe(0); + }); +}); + +it("reports an invalid URL for a manifest tarball URL containing a space", async () => { + await withContext(defaultOpts, async ctx => { + setContextHandler(ctx, async request => { + const url = new URL(request.url); + if (url.pathname.includes(".tgz")) { + return new Response("Not Found", { status: 404 }); + } + return new Response( + JSON.stringify({ + name: "baz", + versions: { + "0.0.2": { + name: "baz", + version: "0.0.2", + dist: { + tarball: `${ctx.registry_url}baz -0.0.2.tgz`, + }, + }, + }, + "dist-tags": { + latest: "0.0.2", + }, + }), + ); + }); + await writeFile( + join(ctx.package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { + baz: "0.0.2", + }, + }), + ); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: ctx.package_dir, + stdout: "pipe", + stderr: "pipe", + env, + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(err).toContain("InvalidURL downloading tarball"); + expect(out).not.toContain("1 package installed"); + expect(exitCode).not.toBe(0); + }); +}); + +it.each([ + ["tab", "\t"], + ["vertical tab", "\x0b"], +])("reports an invalid URL for a manifest tarball URL containing a %s", async (_name, char) => { + await withContext(defaultOpts, async ctx => { + const tarballRequests: string[] = []; + setContextHandler(ctx, async request => { + const url = new URL(request.url); + if (url.pathname.includes(".tgz")) { + tarballRequests.push(request.url); + return new Response("Not Found", { status: 404 }); + } + return new Response( + JSON.stringify({ + name: "baz", + versions: { + "0.0.2": { + name: "baz", + version: "0.0.2", + dist: { + tarball: `${ctx.registry_url}baz${char}-0.0.2.tgz`, + }, + }, + }, + "dist-tags": { + latest: "0.0.2", + }, + }), + ); + }); + await writeFile( + join(ctx.package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { + baz: "0.0.2", + }, + }), + ); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: ctx.package_dir, + stdout: "pipe", + stderr: "pipe", + env, + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(err).toContain("InvalidURL downloading tarball"); + expect(tarballRequests).toEqual([]); + expect(out).not.toContain("1 package installed"); + expect(exitCode).not.toBe(0); + }); +}); diff --git a/test/cli/install/bun-lockb.test.ts b/test/cli/install/bun-lockb.test.ts index 69dd9e6df1c2..8cb78e76ff55 100644 --- a/test/cli/install/bun-lockb.test.ts +++ b/test/cli/install/bun-lockb.test.ts @@ -258,6 +258,63 @@ index d156130662798530e852e1afaec5b1c03d429cdc..b4ddf35975a952fdaed99f2b14236519 expect(await exists(join(packageDir, "node_modules", "optional-peer-deps"))).toBe(true); }); +function packageScriptsFilledOffsets(lockb: Buffer): number[] { + const fmt = lockb.readUInt32LE(42); + const N = Number(lockb.readBigUInt64LE(86)); + const begin = Number(lockb.readBigUInt64LE(110)); + const resolutionSize = fmt === 2 ? 64 : 72; + const scriptsStart = begin + N * (8 + 8 + resolutionSize + 8 + 8 + 88 + 20); + const offsets: number[] = []; + for (let i = 0; i < N; i++) { + offsets.push(scriptsStart + i * 49 + 48); + } + return offsets; +} + +it("rejects a binary lockfile whose package scripts flag byte is out of range", async () => { + const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: false } }); + + await write( + packageJson, + JSON.stringify({ + name: "lockb-scripts-flag", + version: "1.0.0", + dependencies: { + "no-deps": "1.0.0", + }, + }), + ); + + await runBunInstall(env, packageDir); + const lockbPath = join(packageDir, "bun.lockb"); + expect(await exists(lockbPath)).toBe(true); + + const lockb = Buffer.from(await file(lockbPath).arrayBuffer()); + const offsets = packageScriptsFilledOffsets(lockb); + expect(offsets.length).toBe(2); + expect(lockb[offsets[0]]).toBe(1); + expect(lockb[offsets[1]]).toBe(0); + lockb[offsets[1]] = 0x42; + await write(lockbPath, lockb); + + await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); + + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "install", "--no-progress"], + cwd: packageDir, + stdout: "pipe", + stderr: "pipe", + env, + }); + const [out, rawErr, code] = await Promise.all([stdout.text(), stderr.text(), exited]); + const err = stderrForInstall(rawErr); + + expect(err).toContain("invalid package scripts"); + expect(err).toContain("Ignoring lockfile"); + expect(out).toContain("no-deps@1.0.0"); + expect(code).toBe(0); + expect(await exists(join(packageDir, "node_modules", "no-deps"))).toBe(true); +}); it("rejects a binary lockfile whose git resolved tag contains path separators", async () => { const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: false } }); diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index 5bfa20e2eef9..370d9c0e1b23 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -1103,6 +1103,74 @@ describe("files", () => { ]); }); + test("'files' overrides the overridable default ignores but never .git/.npmrc/lockfiles", async () => { + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "pack-files-default-ignores", + version: "1.1.1", + files: ["lib", ".git", ".npmrc", ".gitignore", "bunfig.toml", "package-lock.json", ".hg", ".svn", "CVS"], + }), + ), + write(join(packageDir, "lib", "index.js"), "console.log('hello ./lib/index.js')"), + write(join(packageDir, ".git", "config"), "[core]"), + write(join(packageDir, ".npmrc"), "registry=https://registry.npmjs.org/"), + write(join(packageDir, ".gitignore"), "node_modules"), + write(join(packageDir, "bunfig.toml"), "[install]"), + write(join(packageDir, "package-lock.json"), "{}"), + write(join(packageDir, ".hg", "store"), "hg"), + write(join(packageDir, ".svn", "entries"), "svn"), + write(join(packageDir, "CVS", "Root"), "cvs"), + ]); + + await pack(packageDir, bunEnv); + const tarball = readTarball(join(packageDir, "pack-files-default-ignores-1.1.1.tgz")); + expect(tarball.entries).toMatchObject([ + { "pathname": "package/package.json" }, + { "pathname": "package/.gitignore" }, + { "pathname": "package/.hg/store" }, + { "pathname": "package/.svn/entries" }, + { "pathname": "package/CVS/Root" }, + { "pathname": "package/bunfig.toml" }, + { "pathname": "package/lib/index.js" }, + ]); + }); + + test("non-overridable default ignores are not packed when 'files' matches everything", async () => { + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "pack-files-default-ignores-glob", + version: "1.1.1", + files: ["**"], + }), + ), + write(join(packageDir, "lib", "index.js"), "console.log('hello ./lib/index.js')"), + write(join(packageDir, ".git", "config"), "[core]"), + write(join(packageDir, ".npmrc"), "registry=https://registry.npmjs.org/"), + write(join(packageDir, ".gitignore"), "node_modules"), + write(join(packageDir, "bunfig.toml"), "[install]"), + write(join(packageDir, "package-lock.json"), "{}"), + write(join(packageDir, ".hg", "store"), "hg"), + write(join(packageDir, ".svn", "entries"), "svn"), + write(join(packageDir, "CVS", "Root"), "cvs"), + ]); + + await pack(packageDir, bunEnv); + const tarball = readTarball(join(packageDir, "pack-files-default-ignores-glob-1.1.1.tgz")); + expect(tarball.entries).toMatchObject([ + { "pathname": "package/package.json" }, + { "pathname": "package/.gitignore" }, + { "pathname": "package/.hg/store" }, + { "pathname": "package/.svn/entries" }, + { "pathname": "package/CVS/Root" }, + { "pathname": "package/bunfig.toml" }, + { "pathname": "package/lib/index.js" }, + ]); + }); + test(".npmignore cannot exclude CHANGELOG", async () => { await Promise.all([ write( diff --git a/test/cli/install/bun-upgrade.test.ts b/test/cli/install/bun-upgrade.test.ts index 0f48514021b9..35f38910effb 100644 --- a/test/cli/install/bun-upgrade.test.ts +++ b/test/cli/install/bun-upgrade.test.ts @@ -294,3 +294,75 @@ it("recreates the staging directory in the temp dir instead of reusing a pre-exi // The bogus archive must not be installed; the upgrade fails cleanly. expect(exitCode).toBe(1); }); + +it("verifies the downloaded release archive against the digest reported by the release asset", async () => { + const archiveBody = "this is not a real zip archive"; + const correctDigest = `sha256:${new Bun.CryptoHasher("sha256").update(archiveBody).digest("hex")}`; + const wrongDigest = `sha256:${Buffer.alloc(32, 0xab).toString("hex")}`; + + const assetNames: string[] = []; + for (const os of ["windows", "linux", "darwin"]) { + for (const arch of ["x64", "aarch64"]) { + for (const abi of ["", "-musl"]) { + for (const cpu of ["", "-baseline"]) { + assetNames.push(`bun-${os}-${arch}${abi}${cpu}.zip`); + } + } + } + } + + const runUpgrade = async (tagName: string, digest: string) => { + using server = Bun.serve({ + tls: tls, + port: 0, + async fetch(req) { + const { pathname } = new URL(req.url); + if (pathname.startsWith("/releases/")) { + return new Response(archiveBody); + } + return new Response( + JSON.stringify({ + "tag_name": tagName, + "assets": assetNames.map(name => ({ + "url": "foo", + "content_type": "application/zip", + "name": name, + "digest": digest, + "browser_download_url": `https://${server.hostname}:${server.port}/releases/${tagName}/${name}`, + })), + }), + ); + }, + }); + + const cwd = tmpdirSync(); + const execPath = join(cwd, basename(bunExe())); + await copyFile(bunExe(), execPath); + + await using proc = Bun.spawn({ + cmd: [execPath, "upgrade", "--stable"], + cwd, + stdout: null, + stdin: "pipe", + stderr: "pipe", + env: { + ...env, + NODE_TLS_REJECT_UNAUTHORIZED: "0", + GITHUB_API_DOMAIN: `${server.hostname}:${server.port}`, + ASAN_OPTIONS: [env.ASAN_OPTIONS, "detect_leaks=0"].filter(Boolean).join(":"), + }, + }); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + return { stderr, exitCode }; + }; + + const mismatched = await runUpgrade("bun-v9.9.7", wrongDigest); + expect(mismatched.stderr).toContain("did not match the checksum reported by the GitHub API for this release"); + expect(mismatched.exitCode).toBe(1); + + const matched = await runUpgrade("bun-v9.9.8", correctDigest); + expect(matched.stderr).toContain("9.9.8"); + expect(matched.stderr).not.toContain("did not match the checksum reported by the GitHub API for this release"); + expect(matched.exitCode).toBe(1); +}); diff --git a/test/cli/install/isolated-install.test.ts b/test/cli/install/isolated-install.test.ts index fb4928baeb03..ccde8345686e 100644 --- a/test/cli/install/isolated-install.test.ts +++ b/test/cli/install/isolated-install.test.ts @@ -2027,6 +2027,33 @@ test("rejects dependency aliases that traverse outside node_modules", async () = expect(exitCode).not.toBe(0); }); +test("rejects a dependency alias with more than one path component", async () => { + const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); + + await write( + packageJson, + JSON.stringify({ + name: "test-pkg-nested-alias", + dependencies: { + "somepkg/lib": "npm:no-deps@1.0.0", + }, + }), + ); + + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toContain(`"somepkg/lib" is not a valid install folder name`); + expect(() => lstatSync(join(packageDir, "node_modules", "somepkg", "lib"))).toThrow(); + expect(exitCode).not.toBe(0); +}); + test("invalid --linker value is echoed back in the error", async () => { using dir = tempDir("install-linker-err", { "package.json": JSON.stringify({ name: "t" }), diff --git a/test/cli/install/semver.test.ts b/test/cli/install/semver.test.ts index 63aa787a222b..266117ab69ca 100644 --- a/test/cli/install/semver.test.ts +++ b/test/cli/install/semver.test.ts @@ -785,6 +785,33 @@ test("a range with a dangling '-' after a skipped tag does not crash the parser" expect(exitCode).toBe(0); }); +test("a version range made of hundreds of thousands of 'v' or '= ' prefix characters evaluates promptly", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const n = 1000000; + const vRun = Buffer.alloc(n, "v").toString(); + const eqRun = Buffer.alloc(n, "= ").toString(); + process.stdout.write( + JSON.stringify([ + Bun.semver.satisfies("1.0.0", vRun), + Bun.semver.satisfies("1.0.0", eqRun), + ]), + ); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([true, true]); + expect(exitCode).toBe(0); +}, 30_000); + test("a version range with hundreds of thousands of '||' or AND-ed comparators evaluates without crashing", async () => { // Ranges are stored as linked lists: one node per "||" alternative and one // node per space-separated AND comparator. Walking a very long chain must be diff --git a/test/cli/install/symlink-path-traversal.test.ts b/test/cli/install/symlink-path-traversal.test.ts index bc65c0930969..0b459ab79883 100644 --- a/test/cli/install/symlink-path-traversal.test.ts +++ b/test/cli/install/symlink-path-traversal.test.ts @@ -1,6 +1,6 @@ import { spawn } from "bun"; import { describe, expect, it, setDefaultTimeout } from "bun:test"; -import { access, chmod, lstat, readdir, readlink, rm, stat, symlink, writeFile } from "fs/promises"; +import { access, chmod, lstat, mkdir, readdir, readlink, realpath, rm, stat, symlink, writeFile } from "fs/promises"; import { bunExe, bunEnv as env, tempDir } from "harness"; import { createHash } from "node:crypto"; import { createServer } from "node:http"; @@ -685,3 +685,146 @@ it.skipIf(isWindows)( }, 60000, ); + +it.skipIf(isWindows)( + "skips a package bin entry whose name contains a NUL byte and links the remaining entries", + async () => { + using dir = tempDir("bin-name-nul-test", { + "bunfig.toml": `[install]\nlinker = "hoisted"\n`, + "package.json": JSON.stringify({ + name: "bin-name-nul-app", + version: "1.0.0", + workspaces: ["packages/*"], + }), + "packages/dep/package.json": JSON.stringify({ + name: "dep-with-nul-bin", + version: "1.0.0", + bin: { ["extra" + String.fromCharCode(0) + "ignoredtail"]: "./cli.js", "good-bin": "./cli.js" }, + }), + "packages/dep/cli.js": `#!/usr/bin/env node\nconsole.log("ok");\n`, + }); + const installDir = String(dir); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: installDir, + stdout: "pipe", + stderr: "pipe", + env, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect((await readdir(join(installDir, "node_modules", ".bin"))).sort()).toEqual(["good-bin"]); + + if (exitCode !== 0) { + console.error("Install failed with exit code:", exitCode); + console.error("stdout:", stdout); + console.error("stderr:", stderr); + } + expect(exitCode).toBe(0); + }, + 60000, +); + +it.skipIf(isWindows)( + "does not link a bin target that resolves outside the package through a symlinked directory", + async () => { + using dir = tempDir("bin-target-symlinked-dir-test", { + "bunfig.toml": `[install]\nlinker = "hoisted"\n`, + "package.json": JSON.stringify({ + name: "bin-target-dir-app", + version: "1.0.0", + workspaces: ["packages/*"], + }), + "packages/dep/package.json": JSON.stringify({ + name: "dep-with-linked-dir-bin", + version: "1.0.0", + bin: { "linked-dir-tool": "lnk/tool.js" }, + }), + }); + const installDir = await realpath(String(dir)); + + const outsideDir = `${installDir}/abcdefghijkl${installDir}/packages/dep/y`; + await mkdir(outsideDir, { recursive: true }); + const toolPath = join(outsideDir, "tool.js"); + await writeFile(toolPath, `#!/usr/bin/env node\nconsole.log("ok");\n`); + await chmod(toolPath, 0o600); + await symlink(outsideDir, join(installDir, "packages", "dep", "lnk")); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: installDir, + stdout: "pipe", + stderr: "pipe", + env, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect((await stat(toolPath)).mode & 0o777).toBe(0o600); + expect(await readdir(join(installDir, "node_modules", ".bin")).catch(() => [])).toEqual([]); + + if (exitCode !== 0) { + console.error("Install failed with exit code:", exitCode); + console.error("stdout:", stdout); + console.error("stderr:", stderr); + } + expect(exitCode).toBe(0); + }, + 60000, +); + +it.skipIf(isWindows)( + "rejects a GitHub tarball whose root directory name contains a path separator", + async () => { + const tarball = createTarball([ + { name: "pkg.root/extra/", type: "dir" }, + { + name: "pkg.root/package.json", + type: "file", + content: JSON.stringify({ name: "test-package", version: "1.0.0" }), + }, + { name: "pkg.root/index.js", type: "file", content: "module.exports = 1;" }, + ]); + + using server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (url.pathname.includes("/tarball/") || url.pathname.endsWith(".tar.gz")) { + return new Response(tarball, { headers: { "Content-Type": "application/gzip" } }); + } + if (url.pathname.includes("/repos/")) { + return Response.json({ default_branch: "main" }); + } + return new Response("Not Found", { status: 404 }); + }, + }); + + using dir = tempDir("github-tarball-root-name-test", { + "package.json": JSON.stringify({ + name: "test-app", + version: "1.0.0", + dependencies: { "test-package": "github:user/repo#main" }, + }), + }); + const installDir = String(dir); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: installDir, + stdout: "pipe", + stderr: "pipe", + env: { + ...env, + GITHUB_API_URL: `http://localhost:${server.port}`, + BUN_INSTALL_CACHE_DIR: join(installDir, ".bun-cache"), + }, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toContain('tarball root directory "pkg.root/extra" is not a valid folder name'); + expect(stdout).not.toContain("1 package installed"); + expect(exitCode).not.toBe(0); + }, + 60000, +); diff --git a/test/cli/run/filter-workspace.test.ts b/test/cli/run/filter-workspace.test.ts index 34becb5417e6..00444ee553ae 100644 --- a/test/cli/run/filter-workspace.test.ts +++ b/test/cli/run/filter-workspace.test.ts @@ -1,6 +1,7 @@ import { spawnSync } from "bun"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDirWithFiles } from "harness"; +import { symlinkSync } from "node:fs"; import { join } from "path"; const cwd_root = tempDirWithFiles("testworkspace", { @@ -591,6 +592,46 @@ describe("bun", () => { expect(exitCode).toBe(0); }); + test("self-referential directory symlink in a workspace does not loop", () => { + const dir = tempDirWithFiles("filter-symlink-loop", { + packages: { + pkga: { + "package.json": JSON.stringify({ name: "pkga", scripts: { present: "echo scripta" } }), + }, + cyc: { + "package.json": JSON.stringify({ name: "cyc", scripts: { present: "echo scriptcyc" } }), + }, + }, + // `packages/**` makes workspace discovery recurse into every package. + "package.json": JSON.stringify({ + name: "ws", + scripts: { present: "echo rootscript" }, + workspaces: ["packages/**"], + }), + }); + // "junction" so the link is creatable on unprivileged Windows; the type is + // ignored on POSIX. + symlinkSync(join(dir, "packages", "cyc"), join(dir, "packages", "cyc", "loop"), "junction"); + + const { exitCode, stdout, stderr } = spawnSync({ + cwd: dir, + cmd: [bunExe(), "run", "--filter", "*", "present"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const stdoutval = stdout.toString(); + const count = (needle: string) => stdoutval.split(needle).length - 1; + // `pkga` is matched once. `cyc` is matched at `packages/cyc` and once more + // through its own `loop` alias, where the cycle is detected and descent + // stops instead of recursing until the path length limit. + expect({ scripta: count("scripta"), scriptcyc: count("scriptcyc"), exitCode }).toEqual({ + scripta: 1, + scriptcyc: 2, + exitCode: 0, + }); + }); + test("warning names which package.json failed to parse", async () => { const dir = tempDirWithFiles("filter-bad-pkgjson", { packages: { diff --git a/test/cli/run/run-quote.test.ts b/test/cli/run/run-quote.test.ts index a6dffbd6a71b..9e58ff5a5257 100644 --- a/test/cli/run/run-quote.test.ts +++ b/test/cli/run/run-quote.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "bun:test"; -import { bunRunAsScript, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, bunRunAsScript, tempDirWithFiles } from "harness"; it("should handle quote escapes", () => { const package_json = JSON.stringify({ @@ -13,3 +13,26 @@ it("should handle quote escapes", () => { const { stdout } = bunRunAsScript(dir, "test"); expect(stdout).toBe(`test\\${dir}`); }); + +it("keeps pass-through arguments containing tabs and question marks as single words", async () => { + const dir = tempDirWithFiles("run-quote-passthrough", { + "package.json": JSON.stringify({ + scripts: { + args: `${bunExe()} print-args.js`, + }, + }), + "print-args.js": "console.log(JSON.stringify(process.argv.slice(2)));", + "aXb": "", + }); + const passthrough = ["a\tb", "a?b", "c\rd"]; + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", "args", "--", ...passthrough], + cwd: dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe(JSON.stringify(passthrough) + "\n"); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/cookie/cookie-map.test.ts b/test/js/bun/cookie/cookie-map.test.ts index 71ebbd845953..db6bfebd4069 100644 --- a/test/js/bun/cookie/cookie-map.test.ts +++ b/test/js/bun/cookie/cookie-map.test.ts @@ -350,6 +350,47 @@ describe("iterator", () => { }); }); +describe("cookie header values with non-ASCII characters", () => { + test("preserves a non-ASCII cookie value when another value in the header is percent-encoded", () => { + const map = new Bun.CookieMap("a=%20; b=café"); + expect(map.get("b")).toBe("café"); + expect(map.get("a")).toBe(" "); + }); + + test("decodes a percent-encoded cookie value that also contains non-ASCII characters", () => { + const map = new Bun.CookieMap("b=café%20au%20lait"); + expect(map.get("b")).toBe("café au lait"); + }); +}); + +describe("delete with prefixed cookie names", () => { + test("deleting a cookie whose name starts with __Host- emits a Secure expiring cookie", () => { + const map = new Bun.CookieMap("__Host-id=1"); + map.delete("__Host-id"); + expect(map.toSetCookieHeaders()).toEqual([ + "__Host-id=; Path=/; Expires=Fri, 1 Jan 1970 00:00:00 -0000; Secure; SameSite=Lax", + ]); + }); + + test("deleting a cookie whose name starts with __Secure- emits a Secure expiring cookie", () => { + const map = new Bun.CookieMap("__Secure-id=1"); + map.delete("__Secure-id"); + expect(map.toSetCookieHeaders()).toEqual([ + "__Secure-id=; Path=/; Expires=Fri, 1 Jan 1970 00:00:00 -0000; Secure; SameSite=Lax", + ]); + }); + + test("deleting a cookie without a name prefix emits an expiring cookie without Secure", () => { + const map = new Bun.CookieMap("__Host-id=1; id=1"); + map.delete("__Host-id"); + map.delete("id"); + expect(map.toSetCookieHeaders()).toEqual([ + "__Host-id=; Path=/; Expires=Fri, 1 Jan 1970 00:00:00 -0000; Secure; SameSite=Lax", + "id=; Path=/; Expires=Fri, 1 Jan 1970 00:00:00 -0000; SameSite=Lax", + ]); + }); +}); + describe("invalid delete usage", () => { test("invalid usage does not crash", () => { expect(() => { diff --git a/test/js/bun/glob/path-length.test.ts b/test/js/bun/glob/path-length.test.ts index d5279b3f3b8d..cdfd42f106d4 100644 --- a/test/js/bun/glob/path-length.test.ts +++ b/test/js/bun/glob/path-length.test.ts @@ -136,7 +136,7 @@ describe.skipIf(isWindows)("Glob path length", () => { expect(scanCode).toBe(0); }); - test("self-referential symlink does not overflow path buffer", async () => { + test("self-referential symlink terminates without overflowing the path buffer", async () => { const root = tmpdirSync("bun-glob-overflow-symlink-"); const segName = "S".repeat(255); try { @@ -161,11 +161,11 @@ describe.skipIf(isWindows)("Glob path length", () => { expect(scanStderr).not.toContain("panic"); expect(scanStderr).not.toContain("Segmentation fault"); expect(scanCode).toBe(0); - // Each hop through the self-loop appends a 256-byte segment, so after a - // few iterations work_item.path exceeds MAX_PATH_BYTES. The walker must - // terminate the loop with ENAMETOOLONG instead of copying the oversized - // path into its fixed-size PathBuffer. - expect(scanStdout.trim()).toBe("ERR:ENAMETOOLONG"); + // The walker descends a directory symlink that resolves to a directory it + // is already inside exactly once, so the scan completes with the symlink + // entry and its single nested visit instead of growing work_item.path + // toward MAX_PATH_BYTES. + expect(scanStdout.trim()).toBe("OK:2"); }); for (const component of ["..", "."] as const) { diff --git a/test/js/bun/glob/scan.test.ts b/test/js/bun/glob/scan.test.ts index e03da80c1bf5..01a4b6ec6fa4 100644 --- a/test/js/bun/glob/scan.test.ts +++ b/test/js/bun/glob/scan.test.ts @@ -1075,6 +1075,48 @@ describe.skipIf(!canCreateDirSymlink)("literal path segment through a symlinked expect(result).toEqual(["top/file.txt"]); }); + test("** with followSymlinks does not descend into a symlink that resolves to one of its own ancestors", () => { + using dir = tempDir("glob-scan-symlink-self-cycle", { + "top/file.txt": "x", + }); + fs.symlinkSync(".", path.join(String(dir), "top", "loop"), "dir"); + const cwd = path.join(String(dir), "top"); + const result = norm(Array.from(new Glob("**/*.txt").scanSync({ cwd, followSymlinks: true }))); + expect(result).toEqual(["file.txt", "loop/file.txt"]); + + using shared = tempDir("glob-scan-symlink-shared-target", { + "realdir/file.txt": "x", + }); + fs.symlinkSync("realdir", path.join(String(shared), "linkA"), "dir"); + fs.symlinkSync("realdir", path.join(String(shared), "linkB"), "dir"); + const dag = norm(Array.from(new Glob("**/*.txt").scanSync({ cwd: String(shared), followSymlinks: true }))); + expect(dag).toEqual(["linkA/file.txt", "linkB/file.txt", "realdir/file.txt"]); + }); + + // Symlinks to the same target in *different* subtrees are not a cycle: a + // followed link recorded in one subtree must not suppress its cousin. + test("** with followSymlinks descends cousin symlinks that share a target", () => { + using dir = tempDir("glob-scan-symlink-cousins", { + "shared/file.txt": "x", + "a/keep.txt": "x", + "b/keep.txt": "x", + }); + fs.symlinkSync(path.join("..", "shared"), path.join(String(dir), "a", "link"), "dir"); + fs.symlinkSync(path.join("..", "shared"), path.join(String(dir), "b", "link"), "dir"); + const result = norm(Array.from(new Glob("**/*.txt").scanSync({ cwd: String(dir), followSymlinks: true }))); + expect(result).toEqual(["a/keep.txt", "a/link/file.txt", "b/keep.txt", "b/link/file.txt", "shared/file.txt"]); + }); + + test("async ** with followSymlinks does not descend into a symlink that resolves to one of its own ancestors", async () => { + using dir = tempDir("glob-scan-symlink-self-cycle-async", { + "top/file.txt": "x", + }); + fs.symlinkSync(".", path.join(String(dir), "top", "loop"), "dir"); + const cwd = path.join(String(dir), "top"); + const result = await Array.fromAsync(new Glob("**/*.txt").scan({ cwd, followSymlinks: true })); + expect(norm(result)).toEqual(["file.txt", "loop/file.txt"]); + }); + test("async scan resolves a literal path through a symlink", async () => { using dir = makeTree("glob-scan-symlink-literal-async"); const result = await Array.fromAsync( diff --git a/test/js/bun/http/bun-serve-routes.test.ts b/test/js/bun/http/bun-serve-routes.test.ts index 79e0185e7b8f..e901e306e90a 100644 --- a/test/js/bun/http/bun-serve-routes.test.ts +++ b/test/js/bun/http/bun-serve-routes.test.ts @@ -1,5 +1,6 @@ import type { BunRequest, ServeOptions, Server } from "bun"; import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; +import net from "node:net"; describe("path parameters", () => { let server: Server; @@ -69,6 +70,27 @@ describe("path parameters", () => { method: "GET", }); }); + + it.each([ + ["valid UTF-8 bytes", [0xc3, 0xa9], "é"], + ["an invalid UTF-8 byte", [0xe9], "�"], + ])("decodes raw %s in a parameter segment", async (_label, bytes, expected) => { + const request = Buffer.concat([ + Buffer.from("GET /users/"), + Buffer.from(bytes), + Buffer.from(" HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"), + ]); + const { promise, resolve, reject } = Promise.withResolvers(); + const socket = net.connect(server.port, "127.0.0.1"); + const chunks: Buffer[] = []; + socket.on("error", reject); + socket.on("data", chunk => chunks.push(chunk)); + socket.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + socket.on("connect", () => socket.write(request)); + const response = await promise; + expect(response).toContain("HTTP/1.1 200"); + expect(JSON.parse(response.slice(response.indexOf("\r\n\r\n") + 4))).toEqual({ id: expected, method: "GET" }); + }); }); describe("HTTP methods", () => { diff --git a/test/js/bun/http/decodeURIComponentSIMD.test.ts b/test/js/bun/http/decodeURIComponentSIMD.test.ts index fcd8657619f2..a90218a484ef 100644 --- a/test/js/bun/http/decodeURIComponentSIMD.test.ts +++ b/test/js/bun/http/decodeURIComponentSIMD.test.ts @@ -327,6 +327,36 @@ describe("decodeURIComponentSIMD - Additional Tests", () => { } }); +describe("decodeURIComponentSIMD with UTF-8 byte input", () => { + const encoder = new TextEncoder(); + + it("decodes multi-byte characters in input bytes that contain no escape sequences", () => { + expect(decodeURIComponentSIMD(encoder.encode("café"))).toBe("café"); + }); + + it("decodes multi-byte characters preceding an escape sequence", () => { + expect(decodeURIComponentSIMD(encoder.encode("café%41"))).toBe("caféA"); + }); + + it("decodes multi-byte characters following an escape sequence", () => { + expect(decodeURIComponentSIMD(encoder.encode("%41café"))).toBe("Acafé"); + }); + + it("decodes a multi-byte character spanning a 16-byte chunk boundary", () => { + const prefix = Buffer.alloc(15, "A").toString(); + const suffix = Buffer.alloc(12, "x").toString(); + const input = encoder.encode(prefix + "é%41" + suffix); + expect(input.length).toBe(32); + expect(decodeURIComponentSIMD(input)).toBe(prefix + "éA" + suffix); + }); + + it("replaces an invalid byte sequence in the input bytes with U+FFFD", () => { + expect(decodeURIComponentSIMD(new Uint8Array([0x61, 0xe9, 0x62, 0x25, 0x34, 0x31]))).toBe( + "a" + String.fromCodePoint(0xfffd) + "bA", + ); + }); +}); + describe("decodeURIComponentSIMD edge cases", () => { it("should handle cursor advancement correctly with invalid hex", () => { // This test would fail because of the cursor advancement bug diff --git a/test/js/bun/http/proxy-stress-errors.test.ts b/test/js/bun/http/proxy-stress-errors.test.ts index 51b811e54b89..0f6b1d6884fe 100644 --- a/test/js/bun/http/proxy-stress-errors.test.ts +++ b/test/js/bun/http/proxy-stress-errors.test.ts @@ -86,6 +86,30 @@ describe("CONNECT failure status", () => { expect(origin.requests.length).toBe(0); }); } + + for (const proxyTls of [false, true] as const) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy CONNECT → 101 fails even when the request asked to upgrade`, + async () => { + await using origin = await createAdversarialOrigin({ tls: true, body: "unreachable" }); + await using proxy = await createAdversarialProxy({ + tls: proxyTls, + connectStatus: 101, + connectStatusBody: "from-the-proxy", + }); + + await expect( + fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + headers: { Connection: "Upgrade", Upgrade: "websocket" }, + }), + ).rejects.toMatchObject({ code: "UnrequestedUpgrade" }); + expect(origin.requests.length).toBe(0); + }, + ); + } }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/test/js/bun/http/request-smuggling.test.ts b/test/js/bun/http/request-smuggling.test.ts index e6b50e7f15e3..f915b160a1b2 100644 --- a/test/js/bun/http/request-smuggling.test.ts +++ b/test/js/bun/http/request-smuggling.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import net from "net"; +import { createServer } from "node:http"; // CVE-2020-8287 style request smuggling tests // These tests ensure Bun's HTTP server properly validates Transfer-Encoding headers @@ -1390,3 +1391,171 @@ test("rejects Transfer-Encoding header with empty value", async () => { // The trailing bytes must never be interpreted as a second request. expect(seen).not.toContain("GET /admin"); }); + +describe("Host header field values in request.url", () => { + // Windows refuses connections under accept-backlog/TIME_WAIT churn even while the + // server is listening, so a refused connect (before anything was read) is retried. + const maxRefusedConnects = 20; + async function sendRawRequest(server: { port: number }, payload: string): Promise { + for (let attempt = 0; ; attempt++) { + const outcome = await new Promise<{ response: string } | { refused: true }>((resolve, reject) => { + const client = net.connect(server.port, "127.0.0.1"); + const chunks: Buffer[] = []; + client.on("error", error => { + if ( + chunks.length === 0 && + (error as NodeJS.ErrnoException).code === "ECONNREFUSED" && + attempt < maxRefusedConnects + ) { + resolve({ refused: true }); + } else { + reject(error); + } + }); + client.on("data", chunk => chunks.push(chunk)); + client.on("end", () => resolve({ response: Buffer.concat(chunks).toString() })); + // latin1 keeps bytes >= 0x80 as single bytes on the wire (a string write would UTF-8-encode them). + client.write(Buffer.from(payload, "latin1")); + }); + if ("response" in outcome) return outcome.response; + } + } + + test.each([ + ["example.com/other"], + ["example com"], + ["user@example.com"], + ["example.com#frag"], + ["example.com\\other:8080"], + ["[::1]:3000?q"], + ])("an HTTP/1.1 request whose Host header is %j is served, with the request-target as request.url", async host => { + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + return new Response(req.url); + }, + }); + + const response = await sendRawRequest(server, `GET /index HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`); + expect(response).toStartWith("HTTP/1.1 200"); + // The handler ran, and none of the Host field's bytes were copied into the synthesized URL. + expect(response.slice(response.indexOf("\r\n\r\n") + 4)).toBe("/index"); + }); + + test.each([ + ["example.com", "http://example.com/index"], + ["example.com:8080", "http://example.com:8080/index"], + ["[::1]:3000", "http://[::1]:3000/index"], + ])("request.url is synthesized from the valid Host header %j", async (host, url) => { + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + return new Response(req.url); + }, + }); + + const response = await sendRawRequest(server, `GET /index HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`); + expect(response).toStartWith("HTTP/1.1 200"); + expect(response.slice(response.indexOf("\r\n\r\n") + 4)).toBe(url); + }); + + test.each([ + [" \t foo\tcom\t", "foo\tcom"], + [" example com ", "example com"], + ])("a node:http server serves a request whose raw Host header value is %j", async (raw, received) => { + const server = createServer((req, res) => { + res.end(String(req.headers.host)); + }); + try { + await new Promise(resolve => server.listen(0, resolve)); + const { port } = server.address() as { port: number }; + const response = await new Promise((resolve, reject) => { + const client = net.connect(port, "127.0.0.1"); + const chunks: Buffer[] = []; + client.on("error", reject); + client.on("data", chunk => chunks.push(chunk)); + client.on("end", () => resolve(Buffer.concat(chunks).toString("latin1"))); + client.write(`GET / HTTP/1.1\r\nHost:${raw}\r\nConnection: close\r\n\r\n`); + }); + expect(response).toContain("HTTP/1.1 200"); + const body = response.slice(response.indexOf("\r\n\r\n") + 4); + expect(body).toBe(received); + } finally { + server.close(); + } + }); + + test("accepts an empty Host header field value on HTTP/1.1, serving a request URL with no host", async () => { + await using server = Bun.serve({ + port: 0, + fetch(req) { + return new Response(req.url); + }, + }); + + const response = await sendRawRequest(server, "GET /index HTTP/1.1\r\nHost:\r\nConnection: close\r\n\r\n"); + expect(response).toContain("HTTP/1.1 200"); + expect(response.slice(response.indexOf("\r\n\r\n") + 4)).toBe("/index"); + }); + + test.each([ + [0x21, 0x40], + [0x41, 0x60], + [0x61, 0x7e], + [0x7f, 0xff], + ])( + "HTTP/1.1 and HTTP/1.0 requests synthesize request.url from the same Host bytes (%i-%i)", + async (firstByte, lastByte) => { + await using server = Bun.serve({ + port: 0, + fetch(req) { + return new Response(req.url); + }, + }); + + // RFC 3986 `uri-host [ ":" port ]`: unreserved / sub-delims / "%" / ":" / "[" / "]". + // Every byte in [0x7f, 0xff] is outside that set, so neither URL uses any of them. + const isHostByte = (char: string) => /^[A-Za-z0-9._~%!$&'()*+,;=:\[\]-]$/.test(char); + + async function checkByte(byte: number) { + const char = String.fromCharCode(byte); + const host = `a${char}b`; + // Request::is_valid_host_header decides whether the Host header becomes the + // request URL's authority; the request itself is served either way. + // The two probes run sequentially so each batch keeps at most one socket per byte open. + const http11 = await sendRawRequest(server, `GET /p HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`); + const http10 = await sendRawRequest(server, `GET /p HTTP/1.0\r\nHost: ${host}\r\n\r\n`); + return { + char, + http11Accepted: http11.startsWith("HTTP/1.1 200"), + http11Url: http11.slice(http11.indexOf("\r\n\r\n") + 4), + http10Url: http10.slice(http10.indexOf("\r\n\r\n") + 4), + }; + } + + const bytes = Array.from({ length: lastByte - firstByte + 1 }, (_, i) => firstByte + i); + // Connect in small batches: opening every connection at once can overflow the + // listen backlog (Windows answers with ECONNREFUSED instead of queueing). + const results: Awaited>[] = []; + const batchSize = 8; + for (let i = 0; i < bytes.length; i += batchSize) { + results.push(...(await Promise.all(bytes.slice(i, i + batchSize).map(checkByte)))); + } + expect(results).toEqual( + bytes.map(byte => { + const char = String.fromCharCode(byte); + // `req.url` carries the lowercased host (URL host normalization). + const url = isHostByte(char) ? `http://a${char.toLowerCase()}b/p` : "/p"; + return { + char, + http11Accepted: true, + http11Url: url, + http10Url: url, + }; + }), + ); + }, + ); +}); diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 93fe6b856105..6c3c79797d26 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -524,6 +524,31 @@ it("request.url should be based on the Host header", async () => { ); }); +it.each([ + ["HTTP/1.0", "GET /helloooo HTTP/1.0\r\nHost: a/b\r\n\r\n"], + ["HTTP/1.1", "GET /helloooo HTTP/1.1\r\nHost: a b\r\nConnection: close\r\n\r\n"], +])("request.url is the request-target when the %s Host header is not a valid authority", async (_version, payload) => { + using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + return new Response(req.url); + }, + }); + + const socket = net.connect(server.port, "127.0.0.1"); + const response = await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + socket.on("error", reject); + socket.on("data", chunk => chunks.push(chunk)); + socket.on("close", () => resolve(Buffer.concat(chunks).toString())); + socket.write(payload); + }); + socket.destroy(); + expect(response).toStartWith("HTTP/1.1 200"); + expect(response.slice(response.indexOf("\r\n\r\n") + 4)).toBe("/helloooo"); +}); + describe("streaming", () => { describe("error handler", () => { it("throw on pull renders headers, does not call error handler", async () => { @@ -2659,6 +2684,31 @@ it.if(isPosix)("serves /bun:info over a unix socket in development mode", async expect(res.status).toBe(200); }); +it("only serves /bun:info to requests with a local Host header in development mode", async () => { + using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + development: true, + fetch() { + return new Response("handled by fetch"); + }, + }); + + const localHostRes = await fetch(`http://127.0.0.1:${server.port}/bun:info`, { + headers: { Host: "localhost" }, + }); + const localHostText = await localHostRes.text(); + expect(localHostText).toContain("bun_version"); + expect(localHostRes.status).toBe(200); + + const foreignHostRes = await fetch(`http://127.0.0.1:${server.port}/bun:info`, { + headers: { Host: "example.com" }, + }); + const foreignHostText = await foreignHostRes.text(); + expect(foreignHostText).toBe("handled by fetch"); + expect(foreignHostRes.status).toBe(200); +}); + // https://github.com/oven-sh/bun/issues/32469 it("applies backpressure to a Response(ReadableStream) body when the client stalls", async () => { const CHUNK = Buffer.alloc(64 * 1024, 65); // 64 KiB diff --git a/test/js/bun/jsc/bun-jsc.test.ts b/test/js/bun/jsc/bun-jsc.test.ts index 15fb3ee062ce..6e18bd7660dc 100644 --- a/test/js/bun/jsc/bun-jsc.test.ts +++ b/test/js/bun/jsc/bun-jsc.test.ts @@ -196,8 +196,11 @@ describe("bun:jsc", () => { // sampled regardless of how fast the optimized code runs. const sampleInterval = 50; + // fib(26) keeps each call long enough (~400k recursive calls) to collect + // samples at a 50us interval while staying within the per-test timeout on + // slow debug builds; fib(30) takes >4s per call there. // First profile call - const result1 = profile(() => fib(30), sampleInterval); + const result1 = profile(() => fib(26), sampleInterval); expect(result1).toBeDefined(); expect(result1.functions).toBeDefined(); expect(result1.stackTraces).toBeDefined(); @@ -205,14 +208,14 @@ describe("bun:jsc", () => { // Second profile call - should work after first one completed // This verifies that shutdown() -> pause() fix works - const result2 = profile(() => fib(30), sampleInterval); + const result2 = profile(() => fib(26), sampleInterval); expect(result2).toBeDefined(); expect(result2.functions).toBeDefined(); expect(result2.stackTraces).toBeDefined(); expect(result2.stackTraces.traces.length).toBeGreaterThan(0); // Third profile call - verify profiler can be reused multiple times - const result3 = profile(() => fib(30), sampleInterval); + const result3 = profile(() => fib(26), sampleInterval); expect(result3).toBeDefined(); expect(result3.functions).toBeDefined(); expect(result3.stackTraces).toBeDefined(); @@ -355,3 +358,149 @@ it("serialize rejects a CryptoKey created with extractable set to false", async expect(stdout).toBe("rejected\ntrue\n32\n"); expect(exitCode).toBe(0); }); + +it("deserialize rejects a CryptoKey whose named curve does not match its algorithm", async () => { + const script = ` + import { serialize, deserialize } from "bun:jsc"; + const { publicKey } = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]); + const bytes = new Uint8Array(serialize(publicKey)); + const pattern = [5, 22, 1, 32, 0, 0, 0]; + const offsets = []; + for (let i = 0; i + pattern.length <= bytes.length; i++) { + if (pattern.every((byte, j) => bytes[i + j] === byte)) offsets.push(i); + } + console.log(offsets.length); + const mutated = bytes.slice(); + mutated[offsets[0] + 2] = 0; + let outcome; + try { + outcome = deserialize(mutated) instanceof CryptoKey ? "accepted" : "rejected"; + } catch { + outcome = "rejected"; + } + console.log(outcome); + const roundTripped = deserialize(bytes); + console.log(roundTripped instanceof CryptoKey, roundTripped.algorithm.name); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, exitCode }).toEqual({ stdout: "1\nrejected\ntrue Ed25519\n", exitCode: 0 }); +}); + +it("deserialize rejects a CryptoKey whose algorithm does not belong to its key class", async () => { + const script = ` + import { serialize, deserialize } from "bun:jsc"; + const { publicKey } = await crypto.subtle.generateKey( + { name: "RSA-OAEP", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, + true, + ["encrypt", "decrypt"], + ); + const bytes = new Uint8Array(serialize(publicKey)); + const pattern = [2, 3, 1, 0, 0, 0, 16]; + const offsets = []; + for (let i = 0; i + pattern.length <= bytes.length; i++) { + if (pattern.every((byte, j) => bytes[i + j] === byte)) offsets.push(i); + } + console.log(offsets.length); + const mutated = bytes.slice(); + mutated[offsets[0] + 1] = 20; + let outcome; + try { + outcome = deserialize(mutated) instanceof CryptoKey ? "accepted" : "rejected"; + } catch { + outcome = "rejected"; + } + console.log(outcome); + const roundTripped = deserialize(bytes); + console.log(roundTripped instanceof CryptoKey, roundTripped.algorithm.name); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, exitCode }).toEqual({ stdout: "1\nrejected\ntrue RSA-OAEP\n", exitCode: 0 }); +}); + +it("deserialize rejects a CryptoKey record with no key bytes", async () => { + const script = ` + import { serialize, deserialize } from "bun:jsc"; + const prefix = new Uint8Array(serialize(undefined)); + const header = prefix.subarray(0, prefix.length - 1); + const payload = new Uint8Array([...header, 33, 0, 0, 0, 0]); + let outcome; + try { + outcome = deserialize(payload) instanceof CryptoKey ? "accepted" : "rejected"; + } catch { + outcome = "rejected"; + } + console.log(outcome); + const { publicKey } = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]); + const roundTripped = deserialize(serialize(publicKey)); + console.log(roundTripped instanceof CryptoKey, roundTripped.algorithm.name); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, exitCode }).toEqual({ stdout: "rejected\ntrue Ed25519\n", exitCode: 0 }); +}); + +it("deserialize applies the same nesting depth limit to arrays as to objects", async () => { + const script = ` + import { serialize, deserialize } from "bun:jsc"; + const prefix = new Uint8Array(serialize(undefined)); + const header = prefix.subarray(0, prefix.length - 1); + const undefinedTag = prefix[prefix.length - 1]; + const depth = 40005; + const open = new Uint8Array([1, 1, 0, 0, 0, 0, 0, 0, 0]); + const close = new Uint8Array([255, 255, 255, 255]); + const payload = new Uint8Array(header.length + open.length * depth + 1 + close.length * depth); + payload.set(header, 0); + let offset = header.length; + for (let i = 0; i < depth; i++) { + payload.set(open, offset); + offset += open.length; + } + payload[offset++] = undefinedTag; + for (let i = 0; i < depth; i++) { + payload.set(close, offset); + offset += close.length; + } + let outcome; + try { + outcome = Array.isArray(deserialize(payload)) ? "accepted" : "rejected"; + } catch { + outcome = "rejected"; + } + console.log(outcome); + const shallow = []; + let cursor = shallow; + for (let i = 0; i < 64; i++) { + const next = []; + cursor.push(next); + cursor = next; + } + let depthSeen = 0; + for (let value = deserialize(serialize(shallow)); Array.isArray(value); value = value[0]) depthSeen++; + console.log(depthSeen); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, exitCode }).toEqual({ stdout: "rejected\n65\n", exitCode: 0 }); +}); diff --git a/test/js/bun/md/md-edge-cases.test.ts b/test/js/bun/md/md-edge-cases.test.ts index 1361551514d3..4ea0c0ed28f2 100644 --- a/test/js/bun/md/md-edge-cases.test.ts +++ b/test/js/bun/md/md-edge-cases.test.ts @@ -1110,6 +1110,32 @@ describe("pathological reference definition inputs", () => { expect(resolved).toContain('
text'); expect(resolved).toContain("[missing]"); }, 90_000); + + test("caps the total destination and title bytes emitted by expanding reference links", () => { + const dest = "/" + Buffer.alloc(2000, "x").toString(); + const title = Buffer.alloc(500, "t").toString(); + const lines = [`[a]: ${dest} "${title}"`, ""]; + for (let i = 0; i < 1000; i++) { + lines.push("[a]", "", "[a][]", "", "[text][a]", ""); + } + // The budget is md4c's: min(16 * input size, 1 MiB). On exhaustion the parse + // still succeeds; remaining references degrade to literal bracket text. + const html = Markdown.html(lines.join("\n")); + const resolved = html.match(/a

`); + expect(html).toContain("

[a]

"); + expect(html).toContain("

[a][]

"); + expect(html).toContain("

[text][a]

"); + + const small = Markdown.html('[a]: /url "title"\n\n[a]\n\n[a][]\n\n[text][a]\n'); + expect(small).toBe( + '

a

\n' + + '

a

\n' + + '

text

\n', + ); + }); }); // ============================================================================ diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index e972e663ccd6..c160c42fd2df 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -755,6 +755,84 @@ describe.concurrent("socket", () => { expect(rawData.byteLength).toBeGreaterThanOrEqual(1980); } }); + it("upgradeTLS feeds the initialData bytes captured at call time", async () => { + const handshake = Promise.withResolvers(); + const echoed = Promise.withResolvers(); + const serverTls = { key: tls.key, cert: tls.cert }; + using listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open() {}, + data(raw, chunk) { + raw.upgradeTLS({ + isServer: true, + initialData: chunk, + get tls() { + chunk.fill(0); + return serverTls; + }, + socket: { + open() {}, + data(secure: Socket, payload: Buffer) { + secure.write(payload); + }, + error(_secure: Socket, err: Error) { + handshake.reject(err); + echoed.reject(err); + }, + close() { + handshake.reject(new Error("server socket closed before the echo completed")); + echoed.reject(new Error("server socket closed before the echo completed")); + }, + }, + } as any); + }, + error(_raw, err) { + handshake.reject(err); + echoed.reject(err); + }, + close() {}, + }, + }); + const client = await Bun.connect({ + hostname: "127.0.0.1", + port: listener.port, + tls: { rejectUnauthorized: false }, + socket: { + open() {}, + handshake(socket, success, authorizationError) { + if (!success) { + handshake.reject(authorizationError); + return; + } + handshake.resolve(); + socket.write("ping"); + }, + data(_socket, payload) { + echoed.resolve(payload.toString()); + }, + error(_socket, err) { + handshake.reject(err); + echoed.reject(err); + }, + connectError(_socket, err) { + handshake.reject(err); + echoed.reject(err); + }, + close() { + handshake.reject(new Error("client socket closed before the echo completed")); + echoed.reject(new Error("client socket closed before the echo completed")); + }, + }, + }); + try { + await handshake.promise; + expect(await echoed.promise).toBe("ping"); + } finally { + client.end(); + } + }); }); it.skipIf(isWindows)("should not crash when a socket from a file descriptor is closed after opening", async () => { diff --git a/test/js/bun/plugin/plugins.test.ts b/test/js/bun/plugin/plugins.test.ts index 66efe4c96513..2468704d7a5c 100644 --- a/test/js/bun/plugin/plugins.test.ts +++ b/test/js/bun/plugin/plugins.test.ts @@ -585,6 +585,34 @@ it("recursion throws stack overflow", () => { } }); +it("onResolve callbacks registered while a path is resolving only apply to later resolutions", () => { + Bun.plugin({ + name: "registers another onResolve while resolving", + setup(builder) { + builder.onResolve({ filter: /.*/, namespace: "regduring" }, () => { + Bun.plugin({ + name: "registered during resolution", + setup(inner) { + inner.onResolve({ filter: /.*/, namespace: "regduring" }, ({ path }) => ({ + path, + namespace: "regduring", + })); + }, + }); + return undefined; + }); + + builder.onLoad({ filter: /.*/, namespace: "regduring" }, ({ path }) => ({ + contents: `export default ${JSON.stringify(path)};`, + loader: "js", + })); + }, + }); + + expect(() => require("regduring:first")).toThrow(); + expect(require("regduring:second").default).toBe("second"); +}); + it("recursion throws stack overflow at entry point", () => { const result = Bun.spawnSync({ cmd: [bunExe(), "--preload=./plugin-recursive-fixture.ts", "plugin-recursive-fixture-run.ts"], diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index 6dcd935b07eb..9b9c1542b384 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -589,6 +589,113 @@ describe("wildcard exports with @ in matched subpath", () => { }); }); +describe("package.json exports targets longer than the maximum path length", () => { + it.concurrent("reports a resolution error for an oversized string exports target", async () => { + using dir = tempDir("resolver-exports-long-target", { + "package.json": JSON.stringify({ name: "host" }), + "node_modules/test-pkg/package.json": JSON.stringify({ + name: "test-pkg", + version: "1.0.0", + exports: "./" + Buffer.alloc(8192, "a").toString(), + }), + "index.js": `try {\n require.resolve("test-pkg");\n console.log("resolved");\n} catch {\n console.log("caught");\n}\n`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, exitCode }).toEqual({ stdout: "caught\n", exitCode: 0 }); + }); + + it.concurrent( + "reports a resolution error when a wildcard exports target expands past the maximum path length", + async () => { + using dir = tempDir("resolver-exports-long-wildcard-target", { + "package.json": JSON.stringify({ name: "host" }), + "node_modules/test-pkg/package.json": JSON.stringify({ + name: "test-pkg", + version: "1.0.0", + exports: { "./*": "./" + Buffer.alloc(8192, "a").toString() + "/*" }, + }), + "index.js": `try {\n require.resolve("test-pkg/sub");\n console.log("resolved");\n} catch {\n console.log("caught");\n}\n`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, exitCode }).toEqual({ stdout: "caught\n", exitCode: 0 }); + }, + ); + + // These two targets pass the coarse pre-expansion length check (the package URL, + // target and subpath together are far below the maximum path length) and only + // exceed it once every "*" is replaced with the matched subpath. + it.concurrent( + "reports a resolution error when repeated wildcard substitution expands an exports target past the maximum path length", + async () => { + using dir = tempDir("resolver-exports-multi-wildcard-target", { + "package.json": JSON.stringify({ name: "host" }), + "node_modules/test-pkg/package.json": JSON.stringify({ + name: "test-pkg", + version: "1.0.0", + exports: { "./*": "./" + "*/".repeat(100) + "x" }, + }), + "index.js": `const sub = Buffer.alloc(300, "s").toString();\ntry {\n require.resolve("test-pkg/" + sub);\n console.log("resolved");\n} catch (e) {\n console.log("caught", e.code);\n}\n`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, exitCode }).toEqual({ stdout: "caught MODULE_NOT_FOUND\n", exitCode: 0 }); + }, + ); + + it.concurrent( + "reports a resolution error when repeated wildcard substitution expands an imports target past the maximum path length", + async () => { + using dir = tempDir("resolver-imports-multi-wildcard-target", { + "package.json": JSON.stringify({ name: "host" }), + "node_modules/imports-pkg/package.json": JSON.stringify({ + name: "imports-pkg", + version: "1.0.0", + imports: { "#deep/*": "./" + "*/".repeat(100) + "x" }, + }), + "node_modules/imports-pkg/inner.js": `const sub = Buffer.alloc(300, "s").toString();\ntry {\n require.resolve("#deep/" + sub);\n console.log("resolved");\n} catch (e) {\n console.log("caught", e.code);\n}\n`, + "index.js": `require("imports-pkg/inner.js");\n`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, exitCode }).toEqual({ stdout: "caught MODULE_NOT_FOUND\n", exitCode: 0 }); + }, + ); +}); + // A package.json `imports` entry whose value is a bare package specifier // (e.g. `"#res": "@myproject/resolver"`) is handed back to package-resolve // for a second pass. Per the Node.js packages spec these are URL-like diff --git a/test/js/bun/s3/s3-list-encode-overflow.test.ts b/test/js/bun/s3/s3-list-encode-overflow.test.ts index d5eb6a769d4b..6fc726eec195 100644 --- a/test/js/bun/s3/s3-list-encode-overflow.test.ts +++ b/test/js/bun/s3/s3-list-encode-overflow.test.ts @@ -1,5 +1,6 @@ import { S3Client } from "bun"; import { describe, expect, it } from "bun:test"; +import { bunEnv, bunExe } from "harness"; describe("S3Client.list() option encoding", () => { it.each(["prefix", "delimiter", "continuationToken", "startAfter"])( @@ -47,3 +48,76 @@ describe("S3 object keys containing '?' or '#'", () => { } }); }); + +describe("S3Client region option", () => { + it.each(["us-east-1/other.example.com", "us-east-1?x", "us-east-1#x", "us east 1"])( + "rejects the region %s because it is not a valid host name component", + region => { + const client = new S3Client({ + accessKeyId: "test", + secretAccessKey: "test", + bucket: "bucket", + region, + }); + expect(() => client.presign("key.txt")).toThrow("Invalid S3 endpoint"); + }, + ); + + it("rejects a region that is not a valid host name component when using virtual hosted style", () => { + const client = new S3Client({ + accessKeyId: "test", + secretAccessKey: "test", + bucket: "bucket", + region: "us-east-1/other.example.com", + virtualHostedStyle: true, + }); + expect(() => client.presign("key.txt")).toThrow("Invalid S3 endpoint"); + }); + + it("uses a valid region to build the default host", () => { + const options = { + accessKeyId: "test", + secretAccessKey: "test", + bucket: "bucket", + }; + + const valid = new S3Client({ ...options, region: "eu-central-1" }); + const url = new URL(valid.presign("key.txt")); + expect(url.hostname).toBe("s3.eu-central-1.amazonaws.com"); + expect(url.pathname).toBe("/bucket/key.txt"); + + const invalid = new S3Client({ ...options, region: "eu-central-1@other.example.com" }); + expect(() => invalid.presign("key.txt")).toThrow("Invalid S3 endpoint"); + }); +}); + +describe("S3 endpoints without a region component", () => { + it("defaults the signing region to us-east-1", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `for (const endpoint of ["https://s3.amazonaws.com", "https://mybucket.s3.amazonaws.com"]) { + const client = new Bun.S3Client({ + accessKeyId: "test", + secretAccessKey: "test", + bucket: "mybucket", + endpoint, + }); + const url = new URL(client.presign("key.txt")); + console.log(url.hostname + " " + url.searchParams.get("X-Amz-Credential")); + }`, + ], + env: { ...bunEnv, AWS_REGION: undefined, AWS_DEFAULT_REGION: undefined, S3_REGION: undefined }, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const lines = stdout.trim().split("\n"); + expect(lines).toHaveLength(2); + expect(lines[0]).toMatch(/^s3\.amazonaws\.com test\/\d{8}\/us-east-1\/s3\/aws4_request$/); + expect(lines[1]).toMatch(/^mybucket\.s3\.amazonaws\.com test\/\d{8}\/us-east-1\/s3\/aws4_request$/); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 66697850d8bd..8082ef57b818 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -115,6 +115,24 @@ describe("bunshell", () => { runTest("Date", TestBuilder.command`echo hello ${new Date()}`.stdout(`hello ${new Date().toString()}\n`)); runTest("BigInt", TestBuilder.command`echo ${BigInt((2 ^ 52) - 1)}`.stdout(`${BigInt((2 ^ 52) - 1)}\n`)); runTest("Array", TestBuilder.command`echo ${[1, 2, 3]}`.stdout(`1 2 3\n`)); + + test("flattens nested template arrays up to the depth limit", async () => { + let nested: any = "x"; + for (let i = 0; i < 100; i++) nested = [nested]; + const { stdout } = await $`echo ${nested}`; + expect(stdout.toString()).toEqual("x\n"); + expect(() => $`echo ${[nested]}`).toThrow( + "Shell script template arrays cannot be nested more than 100 levels deep", + ); + }); + + test("rejects template arrays nested past the depth limit", () => { + let nested: any = "x"; + for (let i = 0; i < 101; i++) nested = [nested]; + expect(() => $`echo ${nested}`).toThrow( + "Shell script template arrays cannot be nested more than 100 levels deep", + ); + }); }); describe("escape", async () => { @@ -138,6 +156,14 @@ describe("bunshell", () => { escapeTest("元気かい、兄弟", "元気かい、兄弟"); escapeTest("d元気かい、兄弟", "d元気かい、兄弟"); + test("quotes values containing a tab, carriage return, or question mark", async () => { + expect($.escape("a\tb")).toEqual('"a\tb"'); + expect($.escape("a\rb")).toEqual('"a\rb"'); + expect($.escape("a?b")).toEqual('"a?b"'); + const { stdout } = await $`echo ${"a\tb"} ${"a?b"}`; + expect(stdout.toString()).toEqual("a\tb a?b\n"); + }); + test("escaped values containing interpolation marker bytes stay literal data", async () => { // Interpolated values that need escaping are stored out-of-band and // referenced from the script source via an internal `\x08__bunstr_N` @@ -2841,6 +2867,43 @@ describe("interpolated values in assignment position", () => { .runAsTest("interpolated equals in argument position passes through"); }); +describe("interpolated values in reserved-word position", () => { + TestBuilder.command`if true; then ${"if"} BUNISBAD; echo A; fi` + .stdout("A\n") + .stderr("bun: command not found: if\n") + .runAsTest("interpolated if stays a single command word"); + + TestBuilder.command`if echo A; ${"then"} echo B; then echo C; fi` + .stdout("A\n") + .stderr("bun: command not found: then\n") + .runAsTest("interpolated then stays a single command word"); + + TestBuilder.command`if BUNISBAD; then echo A; ${"elif"} true; then echo B; fi` + .stdout("") + .stderr("bun: command not found: BUNISBAD\n") + .runAsTest("interpolated elif stays a single command word"); + + TestBuilder.command`if BUNISBAD; then echo A; elif BUNISBAD2; then echo B; ${"else"} echo C; fi` + .stdout("") + .stderr("bun: command not found: BUNISBAD\nbun: command not found: BUNISBAD2\n") + .runAsTest("interpolated else stays a single command word"); + + TestBuilder.command`if BUNISBAD; then echo A; ${"fi"}; echo B; fi` + .stdout("") + .stderr("bun: command not found: BUNISBAD\n") + .runAsTest("interpolated fi stays a single command word"); + + TestBuilder.command`if BUNISBAD; then echo not true; ${"else"} echo unreachable; fi` + .stdout("") + .stderr("bun: command not found: BUNISBAD\n") + .runAsTest("interpolated else inside a then body stays a plain word"); + + TestBuilder.command`if BUNISBAD; then echo A; ${"fi"}; echo B; fi; echo ${"if"} ${"then"} ${"elif"} ${"else"} ${"fi"}` + .stdout("if then elif else fi\n") + .stderr("bun: command not found: BUNISBAD\n") + .runAsTest("interpolated reserved words in argument position pass through"); +}); + test("redirect target buffer stays attached while a builtin command is running", async () => { // A builtin with `> ${buf}` caches the buffer's raw pointer and length for // the whole (asynchronous) lifetime of the command. The backing store must diff --git a/test/js/bun/spawn/spawn.ipc.bun-node.test.ts b/test/js/bun/spawn/spawn.ipc.bun-node.test.ts index 668c351ba980..44138368a4a4 100644 --- a/test/js/bun/spawn/spawn.ipc.bun-node.test.ts +++ b/test/js/bun/spawn/spawn.ipc.bun-node.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { bunExe } from "harness"; +import { bunEnv, bunExe, isWindows, nodeExe, normalizeBunSnapshot } from "harness"; import path from "path"; test("ipc with json serialization still works when bun is parent and not the child", async () => { @@ -18,3 +18,55 @@ p I am your father ); expect(await new Response(child.stderr).text()).toEqual(""); }); + +test.skipIf(isWindows || !nodeExe())( + "releases the descriptor of a received handle whose type it does not accept", + async () => { + const parentSource = [ + `const net = require("node:net");`, + `let reported = false;`, + `const handleFailed = Promise.withResolvers();`, + `process.on("uncaughtException", () => {`, + ` if (!reported) {`, + ` reported = true;`, + ` console.log("handle-error");`, + ` handleFailed.resolve();`, + ` }`, + `});`, + `const socketClosed = Promise.withResolvers();`, + `const server = net.createServer(socket => {`, + ` socket.resume();`, + ` socket.on("close", () => socketClosed.resolve());`, + `});`, + `await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));`, + `const childSource = 'const net = require("net"); const socket = net.connect(Number(process.env.HANDLE_PORT), "127.0.0.1", () => { process.send("x", socket); });';`, + `const child = Bun.spawn({`, + ` cmd: [process.env.NODE_BIN, "-e", childSource],`, + ` stdio: ["ignore", "inherit", "inherit"],`, + ` serialization: "json",`, + ` ipc() {},`, + ` env: { ...process.env, HANDLE_PORT: String(server.address().port) },`, + `});`, + `await handleFailed.promise;`, + `child.kill();`, + `await child.exited;`, + `await socketClosed.promise;`, + `server.close();`, + `console.log("socket-closed");`, + ].join("\n"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", parentSource], + env: { ...bunEnv, NODE_BIN: nodeExe()! }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout: normalizeBunSnapshot(stdout), exitCode }).toEqual({ + stdout: "handle-error\nsocket-closed", + exitCode: 0, + }); + }, +); diff --git a/test/js/bun/spawn/spawn.ipc.test.ts b/test/js/bun/spawn/spawn.ipc.test.ts index c34321f30c10..ab186a0a87a7 100644 --- a/test/js/bun/spawn/spawn.ipc.test.ts +++ b/test/js/bun/spawn/spawn.ipc.test.ts @@ -1,6 +1,6 @@ import { spawn } from "bun"; import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, gcTick } from "harness"; +import { bunEnv, bunExe, gcTick, isWindows } from "harness"; import path from "path"; describe.each(["advanced", "json"])("ipc mode %s", mode => { @@ -49,6 +49,42 @@ describe.each(["advanced", "json"])("ipc mode %s", mode => { child.exited.then(code => reject(new Error(`exited ${code} before message`))); expect(await promise).toBe("hello"); }); + + it("delivers the outer message when a getter run during send enqueues more sends", async () => { + const childSource = [ + `const fill = Buffer.alloc(8192, "x").toString();`, + `const obj = {`, + ` get inner() {`, + ` for (let i = 0; i < 32; i++) process.send({ nested: i, fill });`, + ` return "outer";`, + ` },`, + `};`, + `process.send(obj);`, + `process.on("message", () => {});`, + ].join("\n"); + const { promise, resolve, reject } = Promise.withResolvers(); + const messages: any[] = []; + await using child = spawn([bunExe(), "-e", childSource], { + env: bunEnv, + stdio: ["ignore", "inherit", "inherit"], + serialization: mode, + ipc(message) { + messages.push(message); + if (messages.length === 33) resolve(messages); + }, + onExit(_subprocess, exitCode, signalCode) { + reject(new Error(`child exited (${exitCode}, ${signalCode}) after ${messages.length} messages`)); + }, + }); + const received = await promise; + expect(received.filter(message => "inner" in message)).toEqual([{ inner: "outer" }]); + expect( + received + .filter(message => "nested" in message) + .map(message => message.nested) + .sort((a, b) => a - b), + ).toEqual(Array.from({ length: 32 }, (_, i) => i)); + }); }); describe("ipc mode advanced", () => { @@ -90,6 +126,37 @@ describe("ipc mode advanced", () => { expect(stderr).not.toContain("UNEXPECTED_IPC_MESSAGE"); expect(exitCode).toBe(0); }); + + it.skipIf(isWindows)( + "closes the channel when a frame declares a length that cannot be framed with its header", + async () => { + const parent = ` + const child = Bun.spawn({ + cmd: [ + process.execPath, "-e", + 'process.on("disconnect", () => process.exit(42)); require("fs").writeSync(3, Buffer.from([0x02, 0xff, 0xff, 0xff, 0xff]));', + ], + stdio: ["ignore", "inherit", "inherit"], + serialization: "advanced", + ipc(msg) { console.error("UNEXPECTED_IPC_MESSAGE", msg); }, + }); + console.log("CHILD_EXIT", await child.exited); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", parent], + 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("CHILD_EXIT 42"); + expect(stderr).not.toContain("UNEXPECTED_IPC_MESSAGE"); + expect(exitCode).toBe(0); + }, + ); }); // getIPCInstance error path: on Windows, windowsConfigureClient can open the diff --git a/test/js/bun/sqlite/sqlite.test.js b/test/js/bun/sqlite/sqlite.test.js index a5e39a58c76f..ef9d27d26f5b 100644 --- a/test/js/bun/sqlite/sqlite.test.js +++ b/test/js/bun/sqlite/sqlite.test.js @@ -1749,6 +1749,57 @@ it("binds sparse array holes as NULL instead of reading past the backing store", expect(exitCode).toBe(0); }); +it("run() reports a closed database when a bound parameter's getter closes it", async () => { + const src = ` + const { Database } = require("bun:sqlite"); + const out = {}; + + const db = new Database(":memory:"); + db.run("CREATE TABLE t (a TEXT, b TEXT)"); + + let message = "did not throw"; + try { + db.run("INSERT INTO t (a, b) VALUES ($a, $b)", { + get $a() { + db.close(); + return "x"; + }, + get $b() { + return "y"; + }, + }); + } catch (e) { + message = e.message; + } + out.closeDuringBind = message; + + const db2 = new Database(":memory:"); + db2.run("CREATE TABLE t (a TEXT, b TEXT)"); + db2.run("INSERT INTO t (a, b) VALUES ($a, $b)", { $a: "x", $b: "y" }); + out.plain = db2.query("SELECT a, b FROM t").get(); + db2.close(); + + console.log(JSON.stringify(out)); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout: stdout.trim(), exitCode }).toEqual({ + stdout: JSON.stringify({ + closeDuringBind: "Database has closed", + plain: { a: "x", b: "y" }, + }), + exitCode: 0, + }); +}); + // Several SQLITE_FCNTL_* opcodes (VFSNAME, MMAP_SIZE, FILE_POINTER, ...) write // a full pointer or int64 through the result argument, so the result buffer // must be at least 8 bytes. A 1-byte Uint8Array used to be passed through diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index 0ca15bbd12f8..51248c37db71 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -621,6 +621,29 @@ it("decodes percent-encoded path segments and keeps params and pathname stable a expect(exitCode).toBe(0); }); +it(".params decodes percent escapes in a route segment exactly once", () => { + const { dir } = make(["index.tsx", "posts/[id].tsx"]); + + const router = new Bun.FileSystemRouter({ + dir, + style: "nextjs", + }); + + const spaced = router.match("/posts/a%20b")!; + expect(spaced.name).toBe("/posts/[id]"); + expect(spaced.pathname).toBe("/posts/a b"); + expect(spaced.params.id).toBe("a b"); + + const escaped = router.match("/posts/%252e%252e%252fetc")!; + expect(escaped.name).toBe("/posts/[id]"); + expect(escaped.pathname).toBe("/posts/%2e%2e%2fetc"); + expect(escaped.params.id).toBe("%2e%2e%2fetc"); + + const percent = router.match("/posts/100%2525")!; + expect(percent.pathname).toBe("/posts/100%25"); + expect(percent.params.id).toBe("100%25"); +}); + it("caps the number of parsed query string parameters instead of crashing", async () => { // A query string with more parameters than the iterator's fixed-size visited // bitset (2048 entries) must not be able to take down the process when @@ -659,16 +682,12 @@ it("caps the number of parsed query string parameters instead of crashing", asyn }); it("does not match a dynamic route whose static segment merely collides on length and 32-bit hash", () => { - // Route segment matching must compare bytes, not just (length, truncated - // 32-bit wyhash). Bun.hash.wyhash(s, 0) is the same hash the router stores - // for static route segments, so a birthday search over a few hundred - // thousand equal-length candidates finds a colliding pair with overwhelming - // probability (expected after ~80k candidates). + const low32 = (input: string) => Number(BigInt.asUintN(32, BigInt(Bun.hash.wyhash(input)))); const seen = new Map(); let pair: [string, string] | null = null; for (let i = 0; i < 600_000; i++) { const candidate = "s" + i.toString(36).padStart(9, "0"); - const h = Number(BigInt.asUintN(32, BigInt(Bun.hash.wyhash(candidate)))); + const h = low32(candidate); const prev = seen.get(h); if (prev !== undefined) { pair = [prev, candidate]; @@ -677,9 +696,9 @@ it("does not match a dynamic route whose static segment merely collides on lengt seen.set(h, candidate); } expect(pair).not.toBeNull(); - const [routeSegment, attackSegment] = pair!; - expect(attackSegment).not.toBe(routeSegment); - expect(attackSegment.length).toBe(routeSegment.length); + const [routeSegment, collidingSegment] = pair!; + expect(collidingSegment).not.toBe(routeSegment); + expect(collidingSegment.length).toBe(routeSegment.length); const { dir } = make([`${routeSegment}/[id].tsx`]); const router = new Bun.FileSystemRouter({ @@ -687,11 +706,9 @@ it("does not match a dynamic route whose static segment merely collides on lengt style: "nextjs", }); - // The genuine segment matches its dynamic route. expect(router.match(`/${routeSegment}/42`)?.name).toBe(`/${routeSegment}/[id]`); - // A different segment that only collides on (length, 32-bit hash) must not. - expect(router.match(`/${attackSegment}/42`)).toBeNull(); -}); + expect(router.match(`/${collidingSegment}/42`)).toBeNull(); +}, 60_000); it("match() does not panic on a leading '?' or a path that percent-decodes to empty", async () => { // URLPath::parse assumed the decoded pathname was non-empty and had a leading diff --git a/test/js/bun/util/wrapAnsi.test.ts b/test/js/bun/util/wrapAnsi.test.ts index becfe5f90afa..db2ab9c88fef 100644 --- a/test/js/bun/util/wrapAnsi.test.ts +++ b/test/js/bun/util/wrapAnsi.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; describe("Bun.wrapAnsi", () => { describe("basic wrapping", () => { @@ -128,6 +129,424 @@ describe("Bun.wrapAnsi", () => { }); }); + describe("word-initial cluster-fusing codepoints", () => { + // A word-initial codepoint that joins the preceding grapheme cluster (e.g. the + // combining enclosing keycap U+20E3 fusing with the separator space) makes the + // row's width less than the sum of its parts, so it must be recomputed. + const cases: [input: string, columns: number, hard: boolean, wordWrap: boolean, trim: boolean, expected: string][] = + [ + ["aa \u20E3bb cc", 7, false, false, false, "aa \u20E3bb \ncc"], + ["aa \u20E3bb cc", 7, false, false, true, "aa \u20E3bb\ncc"], + ["aa \u20E3bb cc", 7, false, true, false, "aa \u20E3bb \ncc"], + ["aa \u20E3bb cc", 7, false, true, true, "aa \u20E3bb\ncc"], + ["aa \u20E3bb cc", 7, true, false, false, "aa \u20E3bb \ncc"], + ["aa \u20E3bb cc", 7, true, false, true, "aa \u20E3bb\ncc"], + ["aa \u20E3bb cc", 7, true, true, false, "aa \u20E3bb \ncc"], + ["aa \u20E3bb cc", 7, true, true, true, "aa \u20E3bb\ncc"], + ["aa \u20E3bb cc", 8, false, false, false, "aa \u20E3bb c\nc"], + ["aa \u20E3bb cc", 8, false, false, true, "aa \u20E3bb c\nc"], + ["aa \u20E3bb cc", 8, false, true, false, "aa \u20E3bb \ncc"], + ["aa \u20E3bb cc", 8, false, true, true, "aa \u20E3bb\ncc"], + ["aa \u20E3bb cc", 8, true, false, false, "aa \u20E3bb c\nc"], + ["aa \u20E3bb cc", 8, true, false, true, "aa \u20E3bb c\nc"], + ["aa \u20E3bb cc", 8, true, true, false, "aa \u20E3bb \ncc"], + ["aa \u20E3bb cc", 8, true, true, true, "aa \u20E3bb\ncc"], + ["aa \u20E3bb cc", 9, false, false, false, "aa \u20E3bb cc"], + ["aa \u20E3bb cc", 9, false, false, true, "aa \u20E3bb cc"], + ["aa \u20E3bb cc", 9, false, true, false, "aa \u20E3bb cc"], + ["aa \u20E3bb cc", 9, false, true, true, "aa \u20E3bb cc"], + ["aa \u20E3bb cc", 9, true, false, false, "aa \u20E3bb cc"], + ["aa \u20E3bb cc", 9, true, false, true, "aa \u20E3bb cc"], + ["aa \u20E3bb cc", 9, true, true, false, "aa \u20E3bb cc"], + ["aa \u20E3bb cc", 9, true, true, true, "aa \u20E3bb cc"], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 7, + false, + false, + false, + "\u001B[31maa\u001B[39m \u20E3bb \n\u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 7, + false, + false, + true, + "\u001B[31maa\u001B[39m \u20E3bb\n\u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 7, + false, + true, + false, + "\u001B[31maa\u001B[39m \u20E3bb \n\u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 7, + false, + true, + true, + "\u001B[31maa\u001B[39m \u20E3bb\n\u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 7, + true, + false, + false, + "\u001B[31maa\u001B[39m \u20E3bb \n\u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 7, + true, + false, + true, + "\u001B[31maa\u001B[39m \u20E3bb\n\u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 7, + true, + true, + false, + "\u001B[31maa\u001B[39m \u20E3bb \n\u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 7, + true, + true, + true, + "\u001B[31maa\u001B[39m \u20E3bb\n\u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 8, + false, + false, + false, + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mc\u001B[39m\n\u001B[31mc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 8, + false, + false, + true, + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mc\u001B[39m\n\u001B[31mc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 8, + false, + true, + false, + "\u001B[31maa\u001B[39m \u20E3bb \n\u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 8, + false, + true, + true, + "\u001B[31maa\u001B[39m \u20E3bb\n\u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 8, + true, + false, + false, + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mc\u001B[39m\n\u001B[31mc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 8, + true, + false, + true, + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mc\u001B[39m\n\u001B[31mc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 8, + true, + true, + false, + "\u001B[31maa\u001B[39m \u20E3bb \n\u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 8, + true, + true, + true, + "\u001B[31maa\u001B[39m \u20E3bb\n\u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 9, + false, + false, + false, + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 9, + false, + false, + true, + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 9, + false, + true, + false, + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 9, + false, + true, + true, + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 9, + true, + false, + false, + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 9, + true, + false, + true, + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 9, + true, + true, + false, + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + ], + [ + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + 9, + true, + true, + true, + "\u001B[31maa\u001B[39m \u20E3bb \u001B[31mcc\u001B[39m", + ], + ["aa\tz \uFE0Fbb cc", 8, false, false, false, "aa\tz \uFE0Fbb c\nc"], + ["aa\tz \uFE0Fbb cc", 8, false, false, true, "aa\tz \uFE0Fbb c\nc"], + ["aa\tz \uFE0Fbb cc", 8, false, true, false, "aa\tz \uFE0Fbb \ncc"], + ["aa\tz \uFE0Fbb cc", 8, false, true, true, "aa\tz \uFE0Fbb\ncc"], + ["aa\tz \uFE0Fbb cc", 8, true, false, false, "aa\tz \uFE0Fbb c\nc"], + ["aa\tz \uFE0Fbb cc", 8, true, false, true, "aa\tz \uFE0Fbb c\nc"], + ["aa\tz \uFE0Fbb cc", 8, true, true, false, "aa\tz \uFE0Fbb \ncc"], + ["aa\tz \uFE0Fbb cc", 8, true, true, true, "aa\tz \uFE0Fbb\ncc"], + ["aa \u0301bb cc", 8, false, false, false, "aa \u0301bb cc"], + ["aa \u0301bb cc", 8, false, false, true, "aa \u0301bb cc"], + ["aa \u0301bb cc", 8, false, true, false, "aa \u0301bb cc"], + ["aa \u0301bb cc", 8, false, true, true, "aa \u0301bb cc"], + ["aa \u0301bb cc", 8, true, false, false, "aa \u0301bb cc"], + ["aa \u0301bb cc", 8, true, false, true, "aa \u0301bb cc"], + ["aa \u0301bb cc", 8, true, true, false, "aa \u0301bb cc"], + ["aa \u0301bb cc", 8, true, true, true, "aa \u0301bb cc"], + // U+0600 (Prepend) is a width-0 first word; with trim the next word is + // appended with no separator space and fuses with it across the rows. + ["\u0600 \u{1F44D}\u{1F3FF}ab cd", 7, false, false, false, "\u0600 \u{1F44D}\u{1F3FF}ab c\nd"], + ["\u0600 \u{1F44D}\u{1F3FF}ab cd", 7, false, false, true, "\u0600\u{1F44D}\u{1F3FF}ab\ncd"], + ["\u0600 \u{1F44D}\u{1F3FF}ab cd", 7, false, true, false, "\u0600 \u{1F44D}\u{1F3FF}ab \ncd"], + ["\u0600 \u{1F44D}\u{1F3FF}ab cd", 7, false, true, true, "\u0600\u{1F44D}\u{1F3FF}ab\ncd"], + ["\u0600 \u{1F44D}\u{1F3FF}ab cd", 7, true, false, false, "\u0600 \u{1F44D}\u{1F3FF}ab c\nd"], + ["\u0600 \u{1F44D}\u{1F3FF}ab cd", 7, true, false, true, "\u0600\u{1F44D}\u{1F3FF}ab\ncd"], + ["\u0600 \u{1F44D}\u{1F3FF}ab cd", 7, true, true, false, "\u0600 \u{1F44D}\u{1F3FF}ab \ncd"], + ["\u0600 \u{1F44D}\u{1F3FF}ab cd", 7, true, true, true, "\u0600\u{1F44D}\u{1F3FF}ab\ncd"], + ["\u0600 \u{1F44D}\u{1F3FF}ab cd", 8, false, false, false, "\u0600 \u{1F44D}\u{1F3FF}ab cd"], + ["\u0600 \u{1F44D}\u{1F3FF}ab cd", 8, false, false, true, "\u0600\u{1F44D}\u{1F3FF}ab c\nd"], + ["\u0600 \u{1F44D}\u{1F3FF}ab cd", 8, false, true, false, "\u0600 \u{1F44D}\u{1F3FF}ab cd"], + ["\u0600 \u{1F44D}\u{1F3FF}ab cd", 8, false, true, true, "\u0600\u{1F44D}\u{1F3FF}ab\ncd"], + ["\u0600 \u{1F44D}\u{1F3FF}ab cd", 8, true, false, false, "\u0600 \u{1F44D}\u{1F3FF}ab cd"], + ["\u0600 \u{1F44D}\u{1F3FF}ab cd", 8, true, false, true, "\u0600\u{1F44D}\u{1F3FF}ab c\nd"], + ["\u0600 \u{1F44D}\u{1F3FF}ab cd", 8, true, true, false, "\u0600 \u{1F44D}\u{1F3FF}ab cd"], + ["\u0600 \u{1F44D}\u{1F3FF}ab cd", 8, true, true, true, "\u0600\u{1F44D}\u{1F3FF}ab\ncd"], + // Same no-space seam with the Prepend hidden behind a trailing escape: + // the cluster still fuses across the escape sequence. + [ + "\u001B[31m\u0600\u001B[39m \u{1F44D}\u{1F3FF}ab cd", + 7, + false, + false, + false, + "\u001B[31m\u0600\u001B[39m \u{1F44D}\u{1F3FF}ab c\nd", + ], + [ + "\u001B[31m\u0600\u001B[39m \u{1F44D}\u{1F3FF}ab cd", + 7, + false, + false, + true, + "\u001B[31m\u0600\u001B[39m\u{1F44D}\u{1F3FF}ab\ncd", + ], + [ + "\u001B[31m\u0600\u001B[39m \u{1F44D}\u{1F3FF}ab cd", + 7, + false, + true, + false, + "\u001B[31m\u0600\u001B[39m \u{1F44D}\u{1F3FF}ab \ncd", + ], + [ + "\u001B[31m\u0600\u001B[39m \u{1F44D}\u{1F3FF}ab cd", + 7, + false, + true, + true, + "\u001B[31m\u0600\u001B[39m\u{1F44D}\u{1F3FF}ab\ncd", + ], + [ + "\u001B[31m\u0600\u001B[39m \u{1F44D}\u{1F3FF}ab cd", + 7, + true, + false, + false, + "\u001B[31m\u0600\u001B[39m \u{1F44D}\u{1F3FF}ab c\nd", + ], + [ + "\u001B[31m\u0600\u001B[39m \u{1F44D}\u{1F3FF}ab cd", + 7, + true, + false, + true, + "\u001B[31m\u0600\u001B[39m\u{1F44D}\u{1F3FF}ab\ncd", + ], + [ + "\u001B[31m\u0600\u001B[39m \u{1F44D}\u{1F3FF}ab cd", + 7, + true, + true, + false, + "\u001B[31m\u0600\u001B[39m \u{1F44D}\u{1F3FF}ab \ncd", + ], + [ + "\u001B[31m\u0600\u001B[39m \u{1F44D}\u{1F3FF}ab cd", + 7, + true, + true, + true, + "\u001B[31m\u0600\u001B[39m\u{1F44D}\u{1F3FF}ab\ncd", + ], + // ANSI-prefixed words: an SGR sequence (ESC is ASCII) at the start of a word + // must not hide the cluster-fusing codepoint that actually lands on the seam. + // Escape-wrapped emoji+modifier word after an escape-wrapped width-0 Prepend row. + [ + "\u001B[31m\u0600\u001B[39m \u001B[31m\u{1F44D}\u{1F3FF}\u001B[39mab cd", + 7, + false, + false, + false, + "\u001B[31m\u0600\u001B[39m \u001B[31m\u{1F44D}\u{1F3FF}\u001B[39mab c\nd", + ], + [ + "\u001B[31m\u0600\u001B[39m \u001B[31m\u{1F44D}\u{1F3FF}\u001B[39mab cd", + 7, + false, + false, + true, + "\u001B[31m\u0600\u001B[39m\u001B[31m\u{1F44D}\u{1F3FF}\u001B[39mab\ncd", + ], + [ + "\u001B[31m\u0600\u001B[39m \u001B[31m\u{1F44D}\u{1F3FF}\u001B[39mab cd", + 7, + false, + true, + false, + "\u001B[31m\u0600\u001B[39m \u001B[31m\u{1F44D}\u{1F3FF}\u001B[39mab \ncd", + ], + [ + "\u001B[31m\u0600\u001B[39m \u001B[31m\u{1F44D}\u{1F3FF}\u001B[39mab cd", + 7, + false, + true, + true, + "\u001B[31m\u0600\u001B[39m\u001B[31m\u{1F44D}\u{1F3FF}\u001B[39mab\ncd", + ], + [ + "\u001B[31m\u0600\u001B[39m \u001B[31m\u{1F44D}\u{1F3FF}\u001B[39mab cd", + 7, + true, + false, + false, + "\u001B[31m\u0600\u001B[39m \u001B[31m\u{1F44D}\u{1F3FF}\u001B[39mab c\nd", + ], + [ + "\u001B[31m\u0600\u001B[39m \u001B[31m\u{1F44D}\u{1F3FF}\u001B[39mab cd", + 7, + true, + false, + true, + "\u001B[31m\u0600\u001B[39m\u001B[31m\u{1F44D}\u{1F3FF}\u001B[39mab\ncd", + ], + [ + "\u001B[31m\u0600\u001B[39m \u001B[31m\u{1F44D}\u{1F3FF}\u001B[39mab cd", + 7, + true, + true, + false, + "\u001B[31m\u0600\u001B[39m \u001B[31m\u{1F44D}\u{1F3FF}\u001B[39mab \ncd", + ], + [ + "\u001B[31m\u0600\u001B[39m \u001B[31m\u{1F44D}\u{1F3FF}\u001B[39mab cd", + 7, + true, + true, + true, + "\u001B[31m\u0600\u001B[39m\u001B[31m\u{1F44D}\u{1F3FF}\u001B[39mab\ncd", + ], + // Escape-prefixed keycap word: SPACE + U+20E3 still fuses across the escape. + ["aa \u001B[31m\u20E3bb\u001B[39m cc", 7, false, false, false, "aa \u001B[31m\u20E3bb\u001B[39m \ncc"], + ["aa \u001B[31m\u20E3bb\u001B[39m cc", 7, false, false, true, "aa \u001B[31m\u20E3bb\u001B[39m\ncc"], + ["aa \u001B[31m\u20E3bb\u001B[39m cc", 7, false, true, false, "aa \u001B[31m\u20E3bb\u001B[39m \ncc"], + ["aa \u001B[31m\u20E3bb\u001B[39m cc", 7, false, true, true, "aa \u001B[31m\u20E3bb\u001B[39m\ncc"], + ["aa \u001B[31m\u20E3bb\u001B[39m cc", 7, true, false, false, "aa \u001B[31m\u20E3bb\u001B[39m \ncc"], + ["aa \u001B[31m\u20E3bb\u001B[39m cc", 7, true, false, true, "aa \u001B[31m\u20E3bb\u001B[39m\ncc"], + ["aa \u001B[31m\u20E3bb\u001B[39m cc", 7, true, true, false, "aa \u001B[31m\u20E3bb\u001B[39m \ncc"], + ["aa \u001B[31m\u20E3bb\u001B[39m cc", 7, true, true, true, "aa \u001B[31m\u20E3bb\u001B[39m\ncc"], + // At 9 columns the fused row fits exactly (real width 9); the stale additive + // width (10) would wrongly wrap it. + ["aa \u001B[31m\u20E3bb\u001B[39m cc", 9, false, false, false, "aa \u001B[31m\u20E3bb\u001B[39m cc"], + ["aa \u001B[31m\u20E3bb\u001B[39m cc", 9, false, false, true, "aa \u001B[31m\u20E3bb\u001B[39m cc"], + ["aa \u001B[31m\u20E3bb\u001B[39m cc", 9, false, true, false, "aa \u001B[31m\u20E3bb\u001B[39m cc"], + ["aa \u001B[31m\u20E3bb\u001B[39m cc", 9, false, true, true, "aa \u001B[31m\u20E3bb\u001B[39m cc"], + ["aa \u001B[31m\u20E3bb\u001B[39m cc", 9, true, false, false, "aa \u001B[31m\u20E3bb\u001B[39m cc"], + ["aa \u001B[31m\u20E3bb\u001B[39m cc", 9, true, false, true, "aa \u001B[31m\u20E3bb\u001B[39m cc"], + ["aa \u001B[31m\u20E3bb\u001B[39m cc", 9, true, true, false, "aa \u001B[31m\u20E3bb\u001B[39m cc"], + ["aa \u001B[31m\u20E3bb\u001B[39m cc", 9, true, true, true, "aa \u001B[31m\u20E3bb\u001B[39m cc"], + ]; + + test.each(cases)( + "wrapAnsi(%j, %i, { hard: %p, wordWrap: %p, trim: %p })", + (input, columns, hard, wordWrap, trim, expected) => { + expect(Bun.wrapAnsi(input, columns, { hard, wordWrap, trim })).toBe(expected); + }, + ); + }); + describe("existing newlines", () => { test("preserves existing newlines", () => { const input = "hello\nworld"; @@ -233,4 +652,52 @@ describe("Bun.wrapAnsi", () => { ); }); }); + + describe("long inputs", () => { + test("wraps a long run of color escape sequences on one line", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + [ + `const count = 100000;`, + `const input = Buffer.alloc(count * 6, "\\x1b[31m ").toString();`, + `const expected = Buffer.alloc(count * 5, "\\x1b[31m").toString();`, + `const result = Bun.wrapAnsi(input, 80);`, + `console.log(result === expected ? "match" : "mismatch:" + result.length);`, + ].join("\n"), + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("match\n"); + expect(exitCode).toBe(0); + }); + + test("keeps a long line of words on one row when columns is very large", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + [ + `const count = 400000;`, + `const input = Buffer.alloc(count * 5, "word ").toString();`, + `const expected = input.slice(0, -1);`, + `const result = Bun.wrapAnsi(input, 2 ** 30);`, + `console.log(result === expected ? "match" : "mismatch:" + result.length);`, + ].join("\n"), + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("match\n"); + expect(exitCode).toBe(0); + }); + }); }); diff --git a/test/js/bun/util/zstd.test.ts b/test/js/bun/util/zstd.test.ts index fe56f98d9674..2b755ccf0fda 100644 --- a/test/js/bun/util/zstd.test.ts +++ b/test/js/bun/util/zstd.test.ts @@ -1,4 +1,13 @@ -import { zstdCompress, zstdCompressSync, zstdDecompress, zstdDecompressSync } from "bun"; +import { + deflateSync, + gunzipSync, + gzipSync, + inflateSync, + zstdCompress, + zstdCompressSync, + zstdDecompress, + zstdDecompressSync, +} from "bun"; import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import path from "path"; @@ -242,6 +251,84 @@ describe("Zstandard compression", async () => { } }); +describe("sync compression argument handling", () => { + it("zstdCompressSync evaluates the options object before capturing the input", () => { + const input = new Uint8Array(64).fill(97); + const compressed = zstdCompressSync(input, { + get level() { + input.buffer.transfer(); + return 3; + }, + }); + expect(zstdDecompressSync(compressed).byteLength).toBe(0); + }); + + it("zstdCompressSync evaluates the options object before validating the input", () => { + expect(() => + zstdCompressSync(42 as any, { + get level() { + throw new Error("level option was read"); + }, + }), + ).toThrow("level option was read"); + }); + + it("gzipSync evaluates the options object before capturing the input", () => { + const input = new Uint8Array(64).fill(97); + const compressed = gzipSync(input, { + get level() { + input.buffer.transfer(); + return 6; + }, + }); + expect(gunzipSync(compressed).byteLength).toBe(0); + }); + + it("deflateSync evaluates the options object before capturing the input", () => { + const input = new Uint8Array(64).fill(97); + const compressed = deflateSync(input, { + get level() { + input.buffer.transfer(); + return 6; + }, + }); + expect(inflateSync(compressed).byteLength).toBe(0); + }); + + it("gunzipSync evaluates the options object before validating the input", () => { + expect(() => + gunzipSync(42 as any, { + get windowBits() { + throw new Error("windowBits option was read"); + }, + }), + ).toThrow("windowBits option was read"); + }); + + it("inflateSync evaluates the options object before validating the input", () => { + expect(() => + inflateSync(42 as any, { + get windowBits() { + throw new Error("windowBits option was read"); + }, + }), + ).toThrow("windowBits option was read"); + }); + + // An empty result must not register a GC-time deallocator: the backing Vec is + // empty, so its pointer is dangling and freeing it at collection is an invalid + // free (aborts under ASAN/debug allocators). + it("collecting empty decompression results does not free a dangling pointer", () => { + const empty = new Uint8Array(0); + for (let i = 0; i < 10; i++) { + expect(gunzipSync(gzipSync(empty)).byteLength).toBe(0); + expect(inflateSync(deflateSync(empty)).byteLength).toBe(0); + expect(zstdDecompressSync(zstdCompressSync(empty)).byteLength).toBe(0); + Bun.gc(true); + } + }); +}); + describe.concurrent("Zstandard HTTP compression", () => { // Sample data for HTTP tests const testData = { diff --git a/test/js/bun/wasm/wasi.test.js b/test/js/bun/wasm/wasi.test.js index 1da7efca6d25..6f3a8ac8486c 100644 --- a/test/js/bun/wasm/wasi.test.js +++ b/test/js/bun/wasm/wasi.test.js @@ -24,6 +24,67 @@ it("Should support printing 'hello world'", () => { }); }); +it("fd_fdstat_set_rights only narrows the rights of a descriptor", () => { + using dir = tempDir("wasi-set-rights", { + "inside.txt": "inside", + }); + const wasi = new WASI({ preopens: { "/": String(dir) } }); + wasi.setMemory(new WebAssembly.Memory({ initial: 1 })); + + const WASI_ESUCCESS = 0; + const WASI_EPERM = 63; + const WASI_RIGHT_FD_READ = BigInt(2); + const allRights = BigInt.asIntN(64, BigInt("0xffffffffffffffff")); + + const stdinRights = wasi.FD_MAP.get(0).rights; + const baseBefore = stdinRights.base; + const inheritingBefore = stdinRights.inheriting; + + expect(wasi.wasiImport.fd_fdstat_set_rights(0, allRights, allRights)).toBe(WASI_EPERM); + expect(wasi.FD_MAP.get(0).rights).toEqual({ base: baseBefore, inheriting: inheritingBefore }); + + expect(wasi.wasiImport.fd_fdstat_set_rights(0, WASI_RIGHT_FD_READ, BigInt(0))).toBe(WASI_ESUCCESS); + expect(wasi.FD_MAP.get(0).rights).toEqual({ base: WASI_RIGHT_FD_READ, inheriting: BigInt(0) }); +}); + +it("path_open reports the host errno to the guest when the open fails", () => { + using dir = tempDir("wasi-path-open-errno", { + "exists.txt": "x", + }); + const wasi = new WASI({ preopens: { "/": String(dir) } }); + wasi.setMemory(new WebAssembly.Memory({ initial: 1 })); + const memory = Buffer.from(wasi.memory.buffer); + const view = new DataView(wasi.memory.buffer); + + const WASI_EEXIST = 20; + const WASI_O_CREAT = 1 << 0; + const WASI_O_EXCL = 1 << 2; + const WASI_RIGHT_FD_READ = BigInt(2); + const preopenFd = 3; + const pathPtr = 1024; + const fdPtr = 16384; + const sentinel = 0x12345678; + + const len = memory.write("exists.txt", pathPtr); + view.setUint32(fdPtr, sentinel, true); + + expect( + wasi.wasiImport.path_open( + preopenFd, + 0, + pathPtr, + len, + WASI_O_CREAT | WASI_O_EXCL, + WASI_RIGHT_FD_READ, + BigInt(0), + 0, + fdPtr, + ), + ).toBe(WASI_EEXIST); + expect(new DataView(wasi.memory.buffer).getUint32(fdPtr, true)).toBe(sentinel); + expect(wasi.FD_MAP.has(4)).toBe(false); +}); + it("path_* syscalls cannot escape the preopened directory", () => { using dir = tempDir("wasi-sandbox", { "secret.txt": "outside", diff --git a/test/js/bun/webview/webview.test.ts b/test/js/bun/webview/webview.test.ts index a422d270303d..17dbecfee81a 100644 --- a/test/js/bun/webview/webview.test.ts +++ b/test/js/bun/webview/webview.test.ts @@ -317,6 +317,34 @@ it("console callback receives (type, ...args)", async () => { expect(calls[1]).toEqual(["warn", "w"]); }); +it("console callback ignores script messages with unexpected field shapes", async () => { + const calls: [string, ...unknown[]][] = []; + await using view = new Bun.WebView({ + width: 200, + height: 200, + console: (type: string, ...args: unknown[]) => calls.push([type, ...args]), + }); + await view.navigate(html("")); + await view.evaluate(`(() => { + const post = b => webkit.messageHandlers.bunConsole.postMessage(b); + post(5); + post("just a string"); + post(null); + post([1, 2, 3]); + post({}); + post({ type: 1, args: 1 }); + post({ type: "log", args: 42 }); + post({ type: ["log"], args: [] }); + post({ type: "log", args: [JSON.stringify("kept"), 7] }); + return 0; + })()`); + expect(await view.evaluate("console.log('still alive'), 1 + 1")).toBe(2); + expect(calls).toEqual([ + ["log", "kept", undefined], + ["log", "still alive"], + ]); +}); + it("onNavigationFailed callback fires", async () => { await using view = new Bun.WebView({ width: 200, height: 200 }); let failed = false; diff --git a/test/js/node/crypto/crypto-oneshot.test.ts b/test/js/node/crypto/crypto-oneshot.test.ts index 99529c3de37a..55dc9c4c7cdd 100644 --- a/test/js/node/crypto/crypto-oneshot.test.ts +++ b/test/js/node/crypto/crypto-oneshot.test.ts @@ -86,3 +86,31 @@ describe("crypto.hash", () => { }); }); }); + +describe("crypto.verify", () => { + test("uses the signature bytes provided at call time", () => { + const { privateKey, publicKey } = crypto.generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const data = Buffer.from("data to sign"); + const signature = crypto.sign("sha256", data, privateKey); + expect(crypto.verify("sha256", data, publicKey, signature)).toBe(true); + + const publicPem = publicKey.export({ type: "spki", format: "pem" }); + let passphraseReads = 0; + const verified = crypto.verify( + "sha256", + data, + { + key: publicPem, + format: "pem", + get passphrase() { + passphraseReads++; + signature.fill(0); + return undefined; + }, + }, + signature, + ); + expect(passphraseReads).toBe(1); + expect(verified).toBe(true); + }); +}); diff --git a/test/js/node/crypto/crypto-random.test.ts b/test/js/node/crypto/crypto-random.test.ts index 6c246d9f207d..d3479db8edb9 100644 --- a/test/js/node/crypto/crypto-random.test.ts +++ b/test/js/node/crypto/crypto-random.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { randomBytes, randomFill, randomFillSync, randomInt } from "crypto"; +import { checkPrime, checkPrimeSync, randomBytes, randomFill, randomFillSync, randomInt } from "crypto"; import { bunEnv, bunExe } from "harness"; describe("randomInt args validation", () => { @@ -188,3 +188,42 @@ describe("randomFill default size with multi-byte typed arrays", () => { expect(tailFilled).toBe(true); }); }); + +describe("checkPrime candidate handling", () => { + it("checkPrimeSync uses the candidate bytes provided at call time", () => { + expect(checkPrimeSync(Buffer.from([7]), { checks: 1 })).toBe(true); + expect(checkPrimeSync(Buffer.from([9]), { checks: 1 })).toBe(false); + + const candidate = Buffer.from([7]); + let checksReads = 0; + const result = checkPrimeSync(candidate, { + get checks() { + checksReads++; + candidate[0] = 9; + return 1; + }, + }); + expect(checksReads).toBe(1); + expect(result).toBe(true); + }); + + it("checkPrime uses the candidate bytes provided at call time", async () => { + const candidate = Buffer.from([7]); + let checksReads = 0; + const { promise, resolve, reject } = Promise.withResolvers(); + checkPrime( + candidate, + { + get checks() { + checksReads++; + candidate[0] = 9; + return 1; + }, + }, + (err, result) => (err ? reject(err) : resolve(result)), + ); + const result = await promise; + expect(checksReads).toBe(1); + expect(result).toBe(true); + }); +}); diff --git a/test/js/node/crypto/crypto.key-objects.test.ts b/test/js/node/crypto/crypto.key-objects.test.ts index f24aeaf9b09b..5f433d9cf161 100644 --- a/test/js/node/crypto/crypto.key-objects.test.ts +++ b/test/js/node/crypto/crypto.key-objects.test.ts @@ -343,6 +343,59 @@ describe("crypto.KeyObjects", () => { }).toThrow("error:06000066:public key routines:OPENSSL_internal:DECODE_ERROR"); }); + test("createPrivateKey resolves the encoding options before reading the key bytes", () => { + const der = createPrivateKey(privatePem).export({ format: "der", type: "pkcs8" }); + expect(createPrivateKey({ key: der, format: "der", type: "pkcs8" }).type).toBe("private"); + + const arrayBuffer = new ArrayBuffer(der.byteLength); + const view = new Uint8Array(arrayBuffer); + view.set(der); + let passphraseReads = 0; + let transferred; + expect(() => + createPrivateKey({ + key: view, + format: "der", + type: "pkcs8", + get passphrase() { + passphraseReads++; + transferred = arrayBuffer.transfer(); + return undefined; + }, + }), + ).toThrow(); + expect(passphraseReads).toBe(1); + expect(view.byteLength).toBe(0); + expect(transferred.byteLength).toBe(der.byteLength); + }); + + test("createPublicKey resolves the encoding options before reading the key bytes", () => { + const der = createPublicKey(publicPem).export({ format: "der", type: "spki" }); + const arrayBuffer = new ArrayBuffer(der.byteLength); + new Uint8Array(arrayBuffer).set(der); + expect(createPublicKey({ key: arrayBuffer, format: "der", type: "spki" }).type).toBe("public"); + + const detachable = new ArrayBuffer(der.byteLength); + new Uint8Array(detachable).set(der); + let passphraseReads = 0; + let transferred; + expect(() => + createPublicKey({ + key: detachable, + format: "der", + type: "spki", + get passphrase() { + passphraseReads++; + transferred = detachable.transfer(); + return undefined; + }, + }), + ).toThrow(); + expect(passphraseReads).toBe(1); + expect(detachable.byteLength).toBe(0); + expect(transferred.byteLength).toBe(der.byteLength); + }); + [ { private: readFile(path.join(import.meta.dir, "fixtures", "ed25519_private.pem"), "ascii"), diff --git a/test/js/node/crypto/hkdf-callback-null.test.ts b/test/js/node/crypto/hkdf-callback-null.test.ts index f7d234e3ff6e..9515611518e3 100644 --- a/test/js/node/crypto/hkdf-callback-null.test.ts +++ b/test/js/node/crypto/hkdf-callback-null.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from "bun:test"; +import { expect, jest, test } from "bun:test"; +import "harness"; import crypto from "node:crypto"; // Test that callback receives null (not undefined) for error on success @@ -21,3 +22,50 @@ test("crypto.hkdf callback should pass null (not undefined) on success", async ( await promise; }); + +test("crypto.hkdfSync only accepts a secret KeyObject as ikm", () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + + expect(() => crypto.hkdfSync("sha256", publicKey, "salt", "info", 16)).toThrowWithCode( + TypeError, + "ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE", + ); + expect(() => crypto.hkdfSync("sha256", publicKey, "salt", "info", 16)).toThrow( + "Invalid key object type public, expected secret.", + ); + + expect(() => crypto.hkdfSync("sha256", privateKey, "salt", "info", 16)).toThrowWithCode( + TypeError, + "ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE", + ); + expect(() => crypto.hkdfSync("sha256", privateKey, "salt", "info", 16)).toThrow( + "Invalid key object type private, expected secret.", + ); + + expect(crypto.hkdfSync("sha256", crypto.createSecretKey(Buffer.alloc(32, 7)), "salt", "info", 16)).toBeInstanceOf( + ArrayBuffer, + ); +}); + +test("crypto.hkdf only accepts a secret KeyObject as ikm", () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const callback = jest.fn(); + + expect(() => crypto.hkdf("sha256", publicKey, "salt", "info", 16, callback)).toThrowWithCode( + TypeError, + "ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE", + ); + expect(() => crypto.hkdf("sha256", publicKey, "salt", "info", 16, callback)).toThrow( + "Invalid key object type public, expected secret.", + ); + + expect(() => crypto.hkdf("sha256", privateKey, "salt", "info", 16, callback)).toThrowWithCode( + TypeError, + "ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE", + ); + expect(() => crypto.hkdf("sha256", privateKey, "salt", "info", 16, callback)).toThrow( + "Invalid key object type private, expected secret.", + ); + + expect(callback).toHaveBeenCalledTimes(0); +}); diff --git a/test/js/node/fs/cp.test.ts b/test/js/node/fs/cp.test.ts index d7fc554fc585..d4e11c89a286 100644 --- a/test/js/node/fs/cp.test.ts +++ b/test/js/node/fs/cp.test.ts @@ -1,8 +1,8 @@ import { describe, expect, jest, test } from "bun:test"; import fs from "fs"; -import { bunEnv, bunExe, isArm64, isPosix, isWindows, tempDir, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, isArm64, isLinux, isPosix, isWindows, tempDir, tempDirWithFiles } from "harness"; import { mkfifo } from "mkfifo"; -import { join } from "path"; +import { isAbsolute, join } from "path"; const impls = [ ["cpSync", fs.cpSync], @@ -471,6 +471,54 @@ test.skipIf(!isWindows || isArm64)("cpSync over symlinks does not leak Windows h expect(after - before).toBeLessThan(N / 2); }); +// Junctions are the one link type Windows lets unprivileged processes create, so +// node_modules trees from npm/pnpm/bun contain them. cpSync must copy them as +// links (Node's dereference:false default), and creating the copied link must not +// require symlink privilege (junction fallback). +test.skipIf(!isWindows)("cpSync recursive copies a junction as a link to the original target", () => { + const basename = tempDirWithFiles("cp-junction", { + "from/real/inner.txt": "inner", + }); + fs.symlinkSync(join(basename, "from", "real"), join(basename, "from", "junction"), "junction"); + + fs.cpSync(join(basename, "from"), join(basename, "result"), { recursive: true }); + + const copied = join(basename, "result", "junction"); + expect(fs.lstatSync(copied).isSymbolicLink()).toBe(true); + // Pin the stored link target, not just that creation succeeded: a relative or + // otherwise wrong target still produces a link that lstat reports as a symlink. + const copiedTarget = fs.readlinkSync(copied); + expect(isAbsolute(copiedTarget)).toBe(true); + expect(fs.realpathSync(copiedTarget)).toBe(fs.realpathSync(join(basename, "from", "real"))); + expect(fs.realpathSync(copied)).toBe(fs.realpathSync(join(basename, "from", "real"))); + expect(fs.readFileSync(join(copied, "inner.txt"), "utf8")).toBe("inner"); +}); + +// `GetFinalPathNameByHandleW(VOLUME_NAME_DOS)` spells targets on a network share as +// `\\?\UNC\server\share\...`. The copied link's target must come out as the absolute +// `\\server\share\...` form (libuv `fs__realpath_handle`), not a dangling relative path. +test.skipIf(!isWindows)("cpSync recursive copies a directory symlink to a UNC target as a working link", () => { + const basename = tempDirWithFiles("cp-unc-link", { + "from/keep.txt": "keep", + "real/inner.txt": "inner", + }); + // Administrative-share spelling of `real`, like the "windows path handling" + // suite in fs.test.ts relies on. + const real = fs.realpathSync(join(basename, "real")); + const uncReal = `\\\\localhost\\${real[0]}$\\${real.slice(3)}`; + expect(fs.readFileSync(join(uncReal, "inner.txt"), "utf8")).toBe("inner"); + fs.symlinkSync(uncReal, join(basename, "from", "link"), "dir"); + + fs.cpSync(join(basename, "from"), join(basename, "result"), { recursive: true }); + + const copied = join(basename, "result", "link"); + expect(fs.lstatSync(copied).isSymbolicLink()).toBe(true); + const copiedTarget = fs.readlinkSync(copied); + expect(isAbsolute(copiedTarget)).toBe(true); + expect(copiedTarget).toStartWith("\\\\"); + expect(fs.readFileSync(join(copied, "inner.txt"), "utf8")).toBe("inner"); +}); + // On Windows the OS path buffer is 32768 wide chars, which is impractical to exceed // with on-disk directories, so this test targets POSIX where MAX_PATH_BYTES is small // enough to reach via relative mkdir + chdir. @@ -606,3 +654,65 @@ test.skipIf(!isPosix)( expect(exitCode).toBe(0); }, ); + +test.skipIf(!isLinux)("fs.cp and fs.copyFile create the destination with the source file's mode", async () => { + using dir = tempDir("cp-dest-mode", {}); + const destNames = ["dest-copyFile.bin", "dest-cp.bin"]; + const src = join(String(dir), "src.bin"); + fs.writeFileSync(src, "", { mode: 0o600 }); + fs.truncateSync(src, 1 << 26); + fs.chmodSync(src, 0o600); + + const modeAtCreation = new Map(); + const { promise: allCreated, resolve: onAllCreated, reject: onWatchError } = Promise.withResolvers(); + const watcher = fs.watch(String(dir), (_event, filename) => { + if (typeof filename !== "string" || !filename.startsWith("dest-") || modeAtCreation.has(filename)) { + return; + } + try { + modeAtCreation.set(filename, (fs.statSync(join(String(dir), filename)).mode & 0o777).toString(8)); + } catch (err) { + onWatchError(err); + return; + } + if (modeAtCreation.size === destNames.length) { + onAllCreated(); + } + }); + using _watcher = { [Symbol.dispose]: () => watcher.close() }; + watcher.on("error", onWatchError); + + const script = ` + const fs = require("node:fs"); + process.umask(0o022); + fs.copyFileSync("src.bin", "dest-copyFile.bin"); + fs.cpSync("src.bin", "dest-cp.bin"); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: { + ...bunEnv, + BUN_CONFIG_DISABLE_ioctl_ficlonerange: "1", + BUN_CONFIG_DISABLE_COPY_FILE_RANGE: "1", + }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + allCreated, + ]); + const finalMode = (name: string) => (fs.statSync(join(String(dir), name)).mode & 0o777).toString(8); + expect({ + atCreation: Object.fromEntries(modeAtCreation), + final: Object.fromEntries(destNames.map(name => [name, finalMode(name)])), + }).toEqual({ + atCreation: { "dest-copyFile.bin": "600", "dest-cp.bin": "600" }, + final: { "dest-copyFile.bin": "600", "dest-cp.bin": "600" }, + }); + expect(stdout).toBe(""); + expect(exitCode).toBe(0); +}); diff --git a/test/js/node/fs/fs-mkdir.test.ts b/test/js/node/fs/fs-mkdir.test.ts index a80a8dc4ded9..762608f38a11 100644 --- a/test/js/node/fs/fs-mkdir.test.ts +++ b/test/js/node/fs/fs-mkdir.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { tmpdirSync } from "harness"; +import { isLinux, isWindows, tmpdirSync } from "harness"; import fs from "node:fs"; import path from "node:path"; @@ -112,6 +112,18 @@ describe("fs.mkdirSync", () => { expect(fs.existsSync(pathname)).toBe(true); }); + it.skipIf(isWindows)("creates a directory honoring mode bits above 0o777", () => { + const pathname = path.join(tmpdir, nextdir()); + + fs.mkdirSync(pathname, { mode: 0o1777 }); + const mode = fs.statSync(pathname).mode; + expect(mode & 0o777).toBe(0o777 & ~process.umask()); + // macOS mkdir(2) does not honor the sticky bit in the mode argument. + if (isLinux) { + expect(mode & 0o7000).toBe(0o1000); + } + }); + it("throws for invalid path types", () => { [false, 1, {}, [], null, undefined].forEach((invalidPath: any) => { expect(() => fs.mkdirSync(invalidPath)).toThrow(TypeError); diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index c47b6829952e..bcf0157af57d 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -5,6 +5,7 @@ import { gc, getMaxFD, isBroken, + isDebug, isIntelMacOS, isLinux, isPosix, @@ -92,6 +93,33 @@ function tmpdirTestMkdir(): string { return tempdir; } +it("fs.statSync keeps a Uint8Array path's ArrayBuffer attached while reading options", () => { + using dir = tempDir("fs-statsync-typed-array-path", { "target.txt": "bun" }); + const encoded = new TextEncoder().encode(join(String(dir), "target.txt")); + const pathBuffer = Buffer.from(encoded.buffer, encoded.byteOffset, encoded.byteLength); + const arrayBuffer = pathBuffer.buffer as ArrayBuffer; + const stats = statSync(pathBuffer, { + get throwIfNoEntry() { + arrayBuffer.transfer(); + return true; + }, + }); + expect(arrayBuffer.detached).toBe(false); + expect(stats!.isFile()).toBe(true); + arrayBuffer.transfer(); + expect(arrayBuffer.detached).toBe(true); +}); + +it.skipIf(isWindows)("fs.chmodSync applies mode bits above 0o777", () => { + using dir = tempDir("fs-chmod-special-bits", {}); + const dirPath = join(String(dir), "subdir"); + mkdirSync(dirPath); + fs.chmodSync(dirPath, 0o1777); + expect(statSync(dirPath).mode & 0o7777).toBe(0o1777); + fs.chmodSync(dirPath, "1755"); + expect(statSync(dirPath).mode & 0o7777).toBe(0o1755); +}); + it.concurrent("fs.writeFile(1, data) should work when its inherited", async () => { await using proc = Bun.spawn({ cmd: [bunExe(), join(import.meta.dir, "fs-writeFile-1-fixture.js"), "1"], @@ -3109,7 +3137,7 @@ describe("fs/promises", () => { ); for (let withFileTypes of [false, true] as const) { - const iterCount = 200; + const iterCount = isDebug ? 16 : 200; const full = resolve(import.meta.dir, "../"); const doIt = async () => { @@ -3189,7 +3217,7 @@ describe("fs/promises", () => { for (let withFileTypes of [false, true] as const) { const warmup = 1; - const iterCount = 200; + const iterCount = isDebug ? 4 : 200; const full = resolve(import.meta.dir, "../"); const doIt = async () => { diff --git a/test/js/node/http/node-http-connect.test.ts b/test/js/node/http/node-http-connect.test.ts index 17ca17f68878..eadb31365490 100644 --- a/test/js/node/http/node-http-connect.test.ts +++ b/test/js/node/http/node-http-connect.test.ts @@ -275,6 +275,208 @@ describe("HTTP server CONNECT", () => { expect(resumeCount).toBeGreaterThan(0); }); + test("should deliver bytes following a CONNECT request with Content-Length: 0 to the connect socket, not as a new request", async () => { + const requestUrls: string[] = []; + await using proxyServer = http.createServer((req, res) => { + requestUrls.push(req.url ?? ""); + res.end(); + }); + + const pipelined = "GET /pipelined HTTP/1.1\r\nHost: example.com\r\n\r\n"; + const afterEstablished = "GET /after-established HTTP/1.1\r\nHost: example.com\r\n\r\n"; + const expectedTunneled = pipelined + afterEstablished; + + const { promise: tunneled, resolve: resolveTunneled, reject: rejectTunneled } = Promise.withResolvers(); + proxyServer.on("connect", (req, socket, head) => { + const chunks: Buffer[] = [head]; + let receivedLength = head.length; + socket.on("data", chunk => { + chunks.push(chunk); + receivedLength += chunk.length; + if (receivedLength >= Buffer.byteLength(expectedTunneled)) { + socket.end(); + } + }); + socket.on("end", () => { + resolveTunneled(Buffer.concat(chunks).toString()); + }); + socket.on("error", rejectTunneled); + socket.write("HTTP/1.1 200 Connection established\r\n\r\n"); + }); + + await once(proxyServer.listen(0, "127.0.0.1"), "listening"); + const proxyAddress = proxyServer.address() as AddressInfo; + + const { promise: clientReceived, resolve: resolveClient, reject: rejectClient } = Promise.withResolvers(); + const received: string[] = []; + const client = net.connect(proxyAddress.port, proxyAddress.address, () => { + client.write(`CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\nContent-Length: 0\r\n\r\n${pipelined}`); + }); + client.on("data", data => { + received.push(data.toString()); + if (received.join("") === "HTTP/1.1 200 Connection established\r\n\r\n") { + client.write(afterEstablished); + } + }); + client.on("error", rejectClient); + client.on("end", () => { + client.end(); + resolveClient(received.join("")); + }); + + expect(await tunneled).toBe(expectedTunneled); + expect(await clientReceived).toBe("HTTP/1.1 200 Connection established\r\n\r\n"); + expect(requestUrls).toEqual([]); + }); + + // Node v26.3.0 tunnels "5\r\nhello\r\n0\r\n\r\nGET ..." verbatim — the chunked framing + // bytes reach the connect socket un-decoded and no 'request' event fires. + test("should deliver bytes following a CONNECT request with Transfer-Encoding: chunked raw, not chunk-decoded", async () => { + const requestUrls: string[] = []; + await using proxyServer = http.createServer((req, res) => { + requestUrls.push(req.url ?? ""); + res.end(); + }); + + const pipelined = "5\r\nhello\r\n0\r\n\r\nGET /smuggled HTTP/1.1\r\nHost: example.com\r\n\r\n"; + const afterEstablished = "GET /after-established HTTP/1.1\r\nHost: example.com\r\n\r\n"; + const expectedTunneled = pipelined + afterEstablished; + + const { promise: tunneled, resolve: resolveTunneled, reject: rejectTunneled } = Promise.withResolvers(); + proxyServer.on("connect", (req, socket, head) => { + const chunks: Buffer[] = [head]; + let receivedLength = head.length; + socket.on("data", chunk => { + chunks.push(chunk); + receivedLength += chunk.length; + if (receivedLength >= Buffer.byteLength(expectedTunneled)) { + socket.end(); + } + }); + socket.on("end", () => { + resolveTunneled(Buffer.concat(chunks).toString()); + }); + socket.on("error", rejectTunneled); + socket.write("HTTP/1.1 200 Connection established\r\n\r\n"); + }); + + await once(proxyServer.listen(0, "127.0.0.1"), "listening"); + const proxyAddress = proxyServer.address() as AddressInfo; + + const { promise: clientReceived, resolve: resolveClient, reject: rejectClient } = Promise.withResolvers(); + const received: string[] = []; + const client = net.connect(proxyAddress.port, proxyAddress.address, () => { + client.write( + `CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\nTransfer-Encoding: chunked\r\n\r\n${pipelined}`, + ); + }); + client.on("data", data => { + received.push(data.toString()); + if (received.join("") === "HTTP/1.1 200 Connection established\r\n\r\n") { + client.write(afterEstablished); + } + }); + client.on("error", rejectClient); + client.on("end", () => { + client.end(); + resolveClient(received.join("")); + }); + + expect(await tunneled).toBe(expectedTunneled); + expect(await clientReceived).toBe("HTTP/1.1 200 Connection established\r\n\r\n"); + expect(requestUrls).toEqual([]); + }); + + // Node v26.3.0 tunnels "helloGET /smuggled ..." verbatim — the declared body and + // everything after it reach the connect socket and no 'request' event fires. + test("should deliver the body and trailing bytes of a CONNECT request with a nonzero Content-Length to the connect socket, not as a new request", async () => { + const requestUrls: string[] = []; + await using proxyServer = http.createServer((req, res) => { + requestUrls.push(req.url ?? ""); + res.end(); + }); + + const pipelined = "helloGET /smuggled HTTP/1.1\r\nHost: example.com\r\n\r\n"; + const afterEstablished = "GET /after-established HTTP/1.1\r\nHost: example.com\r\n\r\n"; + const expectedTunneled = pipelined + afterEstablished; + + const { promise: tunneled, resolve: resolveTunneled, reject: rejectTunneled } = Promise.withResolvers(); + proxyServer.on("connect", (req, socket, head) => { + const chunks: Buffer[] = [head]; + let receivedLength = head.length; + socket.on("data", chunk => { + chunks.push(chunk); + receivedLength += chunk.length; + if (receivedLength >= Buffer.byteLength(expectedTunneled)) { + socket.end(); + } + }); + socket.on("end", () => { + resolveTunneled(Buffer.concat(chunks).toString()); + }); + socket.on("error", rejectTunneled); + socket.write("HTTP/1.1 200 Connection established\r\n\r\n"); + }); + + await once(proxyServer.listen(0, "127.0.0.1"), "listening"); + const proxyAddress = proxyServer.address() as AddressInfo; + + const { promise: clientReceived, resolve: resolveClient, reject: rejectClient } = Promise.withResolvers(); + const received: string[] = []; + const client = net.connect(proxyAddress.port, proxyAddress.address, () => { + client.write(`CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\nContent-Length: 5\r\n\r\n${pipelined}`); + }); + client.on("data", data => { + received.push(data.toString()); + if (received.join("") === "HTTP/1.1 200 Connection established\r\n\r\n") { + client.write(afterEstablished); + } + }); + client.on("error", rejectClient); + client.on("end", () => { + client.end(); + resolveClient(received.join("")); + }); + + expect(await tunneled).toBe(expectedTunneled); + expect(await clientReceived).toBe("HTTP/1.1 200 Connection established\r\n\r\n"); + expect(requestUrls).toEqual([]); + }); + + // Node v26.3.0: HPE_INVALID_CONTENT_LENGTH — Transfer-Encoding + Content-Length is + // rejected with a 400 before the 'connect' event is dispatched. + test("should reject a CONNECT request carrying both Transfer-Encoding and Content-Length with a 400", async () => { + const requestUrls: string[] = []; + await using proxyServer = http.createServer((req, res) => { + requestUrls.push(req.url ?? ""); + res.end(); + }); + let connectEvents = 0; + proxyServer.on("connect", (req, socket) => { + connectEvents++; + socket.end(); + }); + + await once(proxyServer.listen(0, "127.0.0.1"), "listening"); + const proxyAddress = proxyServer.address() as AddressInfo; + + const { promise, resolve, reject } = Promise.withResolvers(); + const received: string[] = []; + const client = net.connect(proxyAddress.port, proxyAddress.address, () => { + client.write( + "CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\nTransfer-Encoding: chunked\r\nContent-Length: 5\r\n\r\n", + ); + }); + client.on("data", data => received.push(data.toString())); + client.on("error", reject); + client.on("close", () => resolve(received.join(""))); + + const response = await promise; + expect(response).toContain("400 Bad Request"); + expect(connectEvents).toBe(0); + expect(requestUrls).toEqual([]); + }); + test("should handle malformed CONNECT requests", async () => { await using proxyServer = http.createServer(); diff --git a/test/js/node/http/node-http-proxy-url.test.ts b/test/js/node/http/node-http-proxy-url.test.ts index 40312a1b2c60..12603dfe8b72 100644 --- a/test/js/node/http/node-http-proxy-url.test.ts +++ b/test/js/node/http/node-http-proxy-url.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, nodeExe } from "harness"; +import { bunEnv, bunExe, nodeExe, normalizeBunSnapshot } from "harness"; import { join } from "node:path"; describe("HTTP server with proxy-style absolute URLs", () => { @@ -24,3 +24,46 @@ describe("HTTP server with proxy-style absolute URLs", () => { expect(await process.exited).toBe(0); }); }); + +describe("https request through a proxy agent", () => { + test("rejects a request host containing CR or LF with ERR_INVALID_CHAR", async () => { + const script = ` + const net = require("node:net"); + const https = require("node:https"); + const server = net.createServer(socket => socket.destroy()); + server.listen(0, "127.0.0.1", () => { + const proxyUrl = "http://127.0.0.1:" + server.address().port; + const agent = new https.Agent({ proxyEnv: { https_proxy: proxyUrl } }); + let req; + try { + req = https.request({ + host: "127.0.0.1\\r\\nx-extra: 1", + port: 443, + agent, + headers: { host: "127.0.0.1" }, + }); + console.log("no-error"); + } catch (err) { + console.log(err.code); + } + if (req) { + req.on("error", () => {}); + req.destroy(); + } + agent.destroy(); + server.close(); + }); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: normalizeBunSnapshot(stdout), exitCode }).toEqual({ + stdout: "ERR_INVALID_CHAR", + exitCode: 0, + }); + }); +}); diff --git a/test/js/node/http2/h2-conformance.test.ts b/test/js/node/http2/h2-conformance.test.ts index 0d4d4fa33da9..96cbd8ad1693 100644 --- a/test/js/node/http2/h2-conformance.test.ts +++ b/test/js/node/http2/h2-conformance.test.ts @@ -8,6 +8,7 @@ // WINDOW_UPDATE, frame-size and stream-id rules. HPACK/HEADERS cases live in a sibling file. import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, gcTick, normalizeBunSnapshot } from "harness"; import { once } from "node:events"; import http2 from "node:http2"; import net from "node:net"; @@ -587,3 +588,483 @@ describe("SETTINGS ack ordering (RFC 9113 §6.5.3)", () => { } }); }); + +function requestHeaderBlock(method: "GET" | "POST", extra: Buffer = Buffer.alloc(0)): Buffer { + return Buffer.concat([ + Buffer.from([method === "POST" ? 0x83 : 0x82, 0x86, 0x84, 0x01]), + hpackLiteral("localhost"), + extra, + ]); +} + +const CONTENT_LENGTH_5 = Buffer.concat([Buffer.from([0x0f, 0x0d]), hpackLiteral("5")]); + +describe("request header and body framing (RFC 9113 §8.1)", () => { + let deferredServer: http2.Http2Server; + let deferredPort: number; + + beforeAll(async () => { + deferredServer = http2.createServer(); + deferredServer.on("stream", (stream: any) => { + stream.on("error", () => {}); + stream.on("end", () => { + stream.respond({ ":status": 200 }); + stream.end("ok"); + }); + stream.resume(); + }); + deferredServer.listen(0); + await once(deferredServer, "listening"); + deferredPort = (deferredServer.address() as net.AddressInfo).port; + }); + + afterAll(() => { + deferredServer?.close(); + }); + + async function expectStreamRejected(send: (c: RawH2) => void) { + const c = await RawH2.connect(deferredPort); + try { + c.sendPreface(); + c.sendEmptySettings(); + send(c); + const rst = await c.waitFor(f => f.type === FrameType.RST_STREAM && f.streamId === 1); + expect(rst.payload.readUInt32BE(0)).toBe(ErrorCode.PROTOCOL_ERROR); + expect(c.frames.find(f => f.type === FrameType.HEADERS && f.streamId === 1)).toBeUndefined(); + } finally { + c.destroy(); + } + } + + test("a trailing header block carrying a pseudo-header is a stream PROTOCOL_ERROR", async () => { + await expectStreamRejected(c => { + c.sendFrame(FrameType.HEADERS, 0x4, 1, requestHeaderBlock("POST")); + c.sendFrame(FrameType.HEADERS, 0x5, 1, Buffer.concat([Buffer.from([0x01]), hpackLiteral("other.example")])); + }); + }); + + test("a trailing header block without END_STREAM is a stream PROTOCOL_ERROR", async () => { + await expectStreamRejected(c => { + c.sendFrame(FrameType.HEADERS, 0x4, 1, requestHeaderBlock("POST")); + c.sendFrame( + FrameType.HEADERS, + 0x4, + 1, + Buffer.concat([Buffer.from([0x00]), hpackLiteral("x-after"), hpackLiteral("1")]), + ); + }); + }); + + test("a request declaring a content-length with an empty body is a stream PROTOCOL_ERROR", async () => { + await expectStreamRejected(c => { + c.sendFrame(FrameType.HEADERS, 0x5, 1, requestHeaderBlock("POST", CONTENT_LENGTH_5)); + }); + }); + + test("a request body shorter than its declared content-length is a stream PROTOCOL_ERROR", async () => { + await expectStreamRejected(c => { + c.sendFrame(FrameType.HEADERS, 0x4, 1, requestHeaderBlock("POST", CONTENT_LENGTH_5)); + c.sendFrame(FrameType.DATA, 0x1, 1, Buffer.from("ab")); + }); + }); + + test("a request body longer than its declared content-length is a stream PROTOCOL_ERROR", async () => { + await expectStreamRejected(c => { + c.sendFrame(FrameType.HEADERS, 0x4, 1, requestHeaderBlock("POST", CONTENT_LENGTH_5)); + c.sendFrame(FrameType.DATA, 0x1, 1, Buffer.from("abcdefg")); + }); + }); + + test("a duplicate content-length field is a stream PROTOCOL_ERROR", async () => { + await expectStreamRejected(c => { + c.sendFrame( + FrameType.HEADERS, + 0x5, + 1, + requestHeaderBlock("POST", Buffer.concat([CONTENT_LENGTH_5, CONTENT_LENGTH_5])), + ); + }); + }); + + test("a request body matching its declared content-length is delivered while a longer one is reset", async () => { + const c = await RawH2.connect(deferredPort); + try { + c.sendPreface(); + c.sendEmptySettings(); + c.sendFrame(FrameType.HEADERS, 0x4, 1, requestHeaderBlock("POST", CONTENT_LENGTH_5)); + c.sendFrame(FrameType.DATA, 0x1, 1, Buffer.from("abcde")); + c.sendFrame(FrameType.HEADERS, 0x4, 3, requestHeaderBlock("POST", CONTENT_LENGTH_5)); + c.sendFrame(FrameType.DATA, 0x1, 3, Buffer.from("abcdefg")); + const headers = await c.waitFor(f => f.type === FrameType.HEADERS && f.streamId === 1); + expect(headers.streamId).toBe(1); + const rst = await c.waitFor(f => f.type === FrameType.RST_STREAM && f.streamId === 3); + expect(rst.payload.readUInt32BE(0)).toBe(ErrorCode.PROTOCOL_ERROR); + expect(c.frames.find(f => f.type === FrameType.RST_STREAM && f.streamId === 1)).toBeUndefined(); + expect(c.frames.find(f => f.type === FrameType.HEADERS && f.streamId === 3)).toBeUndefined(); + } finally { + c.destroy(); + } + }); +}); + +describe("inbound stream lifecycle", () => { + test("releases server stream objects once the peer resets their streams", async () => { + const total = 32; + const refs: WeakRef[] = []; + let closedCount = 0; + const allOpen = Promise.withResolvers(); + const allClosed = Promise.withResolvers(); + const server = http2.createServer(); + server.on("stream", (stream: any) => { + refs.push(new WeakRef(stream)); + stream.on("error", () => {}); + stream.on("close", () => { + if (++closedCount === total) allClosed.resolve(); + }); + stream.resume(); + if (refs.length === total) allOpen.resolve(); + }); + server.listen(0); + await once(server, "listening"); + const c = await RawH2.connect((server.address() as net.AddressInfo).port); + try { + c.sendPreface(); + c.sendEmptySettings(); + for (let i = 0; i < total; i++) { + c.sendFrame(FrameType.HEADERS, 0x4, 1 + 2 * i, requestHeaderBlock("POST")); + } + await allOpen.promise; + const cancel = Buffer.alloc(4); + cancel.writeUInt32BE(ErrorCode.CANCEL, 0); + for (let i = 0; i < total; i++) { + c.sendFrame(FrameType.RST_STREAM, 0, 1 + 2 * i, cancel); + } + await allClosed.promise; + for (let i = 0; i < 20 && refs.some(ref => ref.deref() !== undefined); i++) { + await gcTick(true); + } + expect(refs.filter(ref => ref.deref() !== undefined).length).toBe(0); + } finally { + c.destroy(); + server.close(); + } + }); + + // A header-value `toString` runs user JS while sendTrailers holds the native `&mut Stream`; + // feeding the stream's own RST_STREAM (then another read) back into the parser from that + // callback must not free the Stream out from under the caller (use-after-free under ASAN). + test("re-entrant read() from a trailer-value toString does not free the in-use stream", async () => { + const fixture = String.raw` + const http2 = require("node:http2"); + const { Duplex } = require("node:stream"); + function encodeFrame(type, flags, streamId, payload = Buffer.alloc(0)) { + const header = Buffer.alloc(9); + header.writeUIntBE(payload.length, 0, 3); + header.writeUInt8(type, 3); + header.writeUInt8(flags, 4); + header.writeUInt32BE(streamId & 0x7fffffff, 5); + return Buffer.concat([header, payload]); + } + // JS-fed duplex: bytes push()ed here reach the parser's read() synchronously. + class FakeSocket extends Duplex { + _read() {} + _write(chunk, _enc, cb) { + cb(); + } + } + const socket = new FakeSocket(); + const client = http2.connect("http://localhost:80", { createConnection: () => socket }); + client.on("error", e => console.log("session error", e.code)); + // peer SETTINGS + ACK of ours + socket.push(encodeFrame(0x4, 0, 0)); + socket.push(encodeFrame(0x4, 0x1, 0)); + client.on("connect", () => { + const req = client.request({ ":method": "POST", ":path": "/" }, { waitForTrailers: true }); + req.on("error", e => console.log("req error", e.code)); + req.on("close", () => console.log("req close")); + req.on("wantTrailers", () => { + console.log("wantTrailers id=" + req.id); + req.sendTrailers({ + "x-a": { + toString() { + console.log("toString:start"); + // RST_STREAM(NO_ERROR) for the stream sendTrailers is operating on: its + // legacy slot is queued for release inside this nested read(). + socket.push(encodeFrame(0x3, 0, req.id, Buffer.from([0, 0, 0, 0]))); + // A second read() (PING) runs the deferred-release drain while + // sendTrailers still holds the stream. + socket.push(encodeFrame(0x6, 0, 0, Buffer.alloc(8))); + console.log("toString:end"); + return "v"; + }, + }, + }); + console.log("sendTrailers:returned"); + client.destroy(); + }); + req.end(); + }); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` + "wantTrailers id=1 + toString:start + toString:end + sendTrailers:returned + req error ERR_HTTP2_STREAM_CANCEL + req close" + `); + expect(proc.signalCode).toBeNull(); + expect(exitCode).toBe(0); + }, 30_000); + + // emitErrorToAllStreams must reject a non-numeric error code up front (the native + // conversion requires a number) instead of reading it once per live stream. goaway() + // is stubbed because its own number check sits in front of this path in destroy(). + test("session teardown rejects a non-numeric error code instead of reading it per stream", async () => { + const fixture = String.raw` + const http2 = require("node:http2"); + const { Duplex } = require("node:stream"); + function encodeFrame(type, flags, streamId, payload = Buffer.alloc(0)) { + const header = Buffer.alloc(9); + header.writeUIntBE(payload.length, 0, 3); + header.writeUInt8(type, 3); + header.writeUInt8(flags, 4); + header.writeUInt32BE(streamId & 0x7fffffff, 5); + return Buffer.concat([header, payload]); + } + class FakeSocket extends Duplex { + _read() {} + _write(chunk, _enc, cb) { + cb(); + } + } + const socket = new FakeSocket(); + const client = http2.connect("http://localhost:80", { createConnection: () => socket }); + client.on("error", e => console.log("session error", e.message)); + // peer SETTINGS + ACK of ours + socket.push(encodeFrame(0x4, 0, 0)); + socket.push(encodeFrame(0x4, 0x1, 0)); + client.on("connect", () => { + const req = client.request({ ":method": "POST", ":path": "/" }); + req.on("error", e => console.log("req error", e.message)); + req.on("close", () => console.log("req close rst=" + req.rstCode)); + client.goaway = () => {}; + let calls = 0; + try { + client.destroy(new Error("boom"), { + valueOf() { + console.log("valueOf:" + ++calls); + return 8; + }, + }); + console.log("destroy:returned calls=" + calls); + } catch (e) { + console.log("destroy threw: " + e.message); + } + // A numeric code must still tear every open stream down. + client.destroy(undefined, 8); + console.log("destroy:done"); + }); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` + "destroy threw: Expected errorCode to be a number + destroy:done + req error boom + req close rst=8" + `); + expect(proc.signalCode).toBeNull(); + expect(exitCode).toBe(0); + }, 30_000); + + // node only marks trailers as sent after header validation succeeds, so a corrected + // retry after a validation error must reach the wire. + test("a sendTrailers validation error does not mark the trailers as sent", async () => { + const trailerError = Promise.withResolvers(); + const trailers = Promise.withResolvers(); + const server = http2.createServer(); + server.on("stream", (stream: any) => { + stream.on("error", (e: any) => trailers.reject(e)); + stream.respond({ ":status": 200 }, { waitForTrailers: true }); + stream.on("wantTrailers", () => { + try { + stream.sendTrailers({ ":status": "200" }); + } catch (e: any) { + trailerError.resolve(e); + stream.sendTrailers({ "x-ok": "1" }); + } + }); + stream.end("body"); + }); + server.listen(0); + await once(server, "listening"); + const client = http2.connect(`http://localhost:${(server.address() as net.AddressInfo).port}`); + client.on("error", e => trailers.reject(e)); + try { + const req = client.request({ ":path": "/" }); + req.on("error", e => trailers.reject(e)); + req.on("trailers", headers => trailers.resolve(headers)); + req.resume(); + req.end(); + expect((await trailerError.promise).code).toBe("ERR_HTTP2_INVALID_PSEUDOHEADER"); + expect((await trailers.promise)["x-ok"]).toBe("1"); + } finally { + client.close(); + server.close(); + } + }); + + test("refuses a new request stream once queued response data exhausts maxSessionMemory", async () => { + const server = http2.createServer({ maxSessionMemory: 1 }); + server.on("stream", (stream: any) => { + stream.on("error", () => {}); + stream.respond({ ":status": 200 }); + stream.write(Buffer.alloc(1 << 22, "a")); + }); + server.listen(0); + await once(server, "listening"); + const c = await RawH2.connect((server.address() as net.AddressInfo).port); + try { + c.sendPreface(); + c.sendEmptySettings(); + c.sendFrame(FrameType.HEADERS, 0x5, 1, requestHeaderBlock("GET")); + await c.waitFor(f => f.type === FrameType.DATA && f.streamId === 1); + c.sendFrame(FrameType.HEADERS, 0x5, 3, requestHeaderBlock("GET")); + const rst = await c.waitFor(f => f.type === FrameType.RST_STREAM && f.streamId === 3); + expect(rst.payload.readUInt32BE(0)).toBe(ErrorCode.REFUSED_STREAM); + expect(c.frames.find(f => f.type === FrameType.HEADERS && f.streamId === 3)).toBeUndefined(); + } finally { + c.destroy(); + server.close(); + } + }); + + /** A maxSessionMemory:1 server whose first stream queues enough response data that the + * next inbound HEADERS is refused. Streams that do reach JS are recorded in `seen`. */ + async function exhaustedSession() { + const seen: { path: string; sync?: string }[] = []; + let first = true; + const server = http2.createServer({ maxSessionMemory: 1 }); + server.on("stream", (stream: any, headers: any) => { + seen.push({ path: headers[":path"], sync: headers["x-bun-sync"] }); + stream.on("error", () => {}); + stream.respond({ ":status": 200 }); + if (first) { + first = false; + stream.end(Buffer.alloc(1 << 22, "a")); + } else { + stream.end("ok"); + } + }); + server.listen(0); + await once(server, "listening"); + const c = await RawH2.connect((server.address() as net.AddressInfo).port); + c.sendPreface(); + c.sendEmptySettings(); + c.sendFrame(FrameType.HEADERS, 0x5, 1, requestHeaderBlock("GET")); + await c.waitFor(f => f.type === FrameType.DATA && f.streamId === 1); + return { server, c, seen }; + } + + /** Open the connection and stream-1 windows so the queued response drains, bringing the + * session back under its memory limit; resolves once stream 1's END_STREAM arrives. */ + async function drainFirstStream(c: RawH2) { + const increment = Buffer.alloc(4); + increment.writeUInt32BE(1 << 24, 0); + c.sendFrame(FrameType.WINDOW_UPDATE, 0, 0, increment); + c.sendFrame(FrameType.WINDOW_UPDATE, 0, 1, increment); + // A GOAWAY here means a frame on the refused stream was escalated to a connection + // error - surface that immediately instead of timing out. + const frame = await c.waitFor( + f => f.type === FrameType.GOAWAY || (f.type === FrameType.DATA && f.streamId === 1 && (f.flags & 0x1) === 1), + 10_000, + ); + expect(frame.type).toBe(FrameType.DATA); + } + + // §5.1: a refused stream id has still existed, so frames a client pipelined behind the + // refused HEADERS (RST_STREAM especially) target a closed stream, never an idle one — + // none of them may escalate to a connection error. + test("tolerates DATA/WINDOW_UPDATE/PRIORITY/RST_STREAM pipelined behind a refused HEADERS", async () => { + const { server, c, seen } = await exhaustedSession(); + try { + const cancel = Buffer.alloc(4); + cancel.writeUInt32BE(ErrorCode.CANCEL, 0); + const priority = Buffer.alloc(5); + priority.writeUInt8(16, 4); + const windowUpdate = Buffer.alloc(4); + windowUpdate.writeUInt32BE(1000, 0); + // One write: the HEADERS that will be refused plus everything a client that has not + // yet seen the refusal would legitimately keep sending on that stream. + c.send( + Buffer.concat([ + encodeFrame(FrameType.HEADERS, 0x4, 3, requestHeaderBlock("POST")), + encodeFrame(FrameType.DATA, 0, 3, Buffer.from("hello")), + encodeFrame(FrameType.WINDOW_UPDATE, 0, 3, windowUpdate), + encodeFrame(FrameType.PRIORITY, 0, 3, priority), + encodeFrame(FrameType.RST_STREAM, 0, 3, cancel), + ]), + ); + const rst = await c.waitFor(f => f.type === FrameType.RST_STREAM && f.streamId === 3); + expect(rst.payload.readUInt32BE(0)).toBe(ErrorCode.REFUSED_STREAM); + + await drainFirstStream(c); + c.sendFrame(FrameType.HEADERS, 0x5, 5, requestHeaderBlock("GET")); + const resp = await c.waitFor( + f => (f.type === FrameType.HEADERS && f.streamId === 5) || f.type === FrameType.GOAWAY, + ); + expect(resp.type).toBe(FrameType.HEADERS); + expect(c.frames.find(f => f.type === FrameType.GOAWAY)).toBeUndefined(); + expect(seen).toEqual([{ path: "/" }, { path: "/" }]); + } finally { + c.destroy(); + server.close(); + } + }); + + // §4.3: a refused stream's header block must still be decoded — including the part carried + // by CONTINUATION — or the connection-scoped HPACK dynamic table desyncs. + test("keeps HPACK state in sync when a refused header block spans HEADERS and CONTINUATION", async () => { + const { server, c, seen } = await exhaustedSession(); + try { + // The refused request's block inserts `x-bun-sync: 1` into the dynamic table + // (literal with incremental indexing) from its CONTINUATION half. + const insert = Buffer.concat([Buffer.from([0x40]), hpackLiteral("x-bun-sync"), hpackLiteral("1")]); + c.send( + Buffer.concat([ + encodeFrame(FrameType.HEADERS, 0x1 /* END_STREAM, no END_HEADERS */, 3, requestHeaderBlock("GET")), + encodeFrame(FrameType.CONTINUATION, 0x4 /* END_HEADERS */, 3, insert), + ]), + ); + const rst = await c.waitFor(f => f.type === FrameType.RST_STREAM && f.streamId === 3); + expect(rst.payload.readUInt32BE(0)).toBe(ErrorCode.REFUSED_STREAM); + + await drainFirstStream(c); + // 0xbe: indexed field 62 = the entry the refused block inserted. If that block had + // not been decoded this is a COMPRESSION_ERROR and stream 5 never reaches JS. + c.sendFrame(FrameType.HEADERS, 0x5, 5, Buffer.concat([requestHeaderBlock("GET"), Buffer.from([0xbe])])); + const resp = await c.waitFor( + f => (f.type === FrameType.HEADERS && f.streamId === 5) || f.type === FrameType.GOAWAY, + ); + expect(resp.type).toBe(FrameType.HEADERS); + expect(c.frames.find(f => f.type === FrameType.GOAWAY)).toBeUndefined(); + expect(seen).toEqual([{ path: "/" }, { path: "/", sync: "1" }]); + } finally { + c.destroy(); + server.close(); + } + }); +}); diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 44ee6b0be956..8f658be0d0f6 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -2,6 +2,7 @@ import { bunEnv, bunExe, isASAN, isCI, isDebug, nodeExe } from "harness"; import { createTest } from "node-harness"; import fs from "node:fs"; import http2 from "node:http2"; +import https from "node:https"; import net from "node:net"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -2950,3 +2951,238 @@ it("http2 client.request() on a destroyed or closed session uses the right error server.close(); } }); + +function requestOverHttp1(port, headers) { + const { promise, resolve, reject } = Promise.withResolvers(); + const request = https.request( + { + host: "localhost", + port, + path: "/", + agent: false, + ca: TLS_CERT.cert, + headers: { connection: "close", ...headers }, + }, + async response => { + try { + let body = ""; + response.setEncoding("utf8"); + response.on("data", chunk => (body += chunk)); + await new Promise(done => response.on("end", done)); + resolve({ + statusCode: response.statusCode, + statusMessage: response.statusMessage, + headers: response.headers, + body, + }); + } catch (err) { + reject(err); + } + }, + ); + request.on("error", reject); + request.end(); + return promise; +} + +it("http2 allowHTTP1 fallback serializes every application response header as its own name/value line", async () => { + const server = http2.createSecureServer({ ...TLS_CERT, allowHTTP1: true }, (req, res) => { + res.writeHead(202, "Accepted", { + "content-type": "application/json; charset=utf-8", + "x-custom-token": "abcdef123456", + }); + res.end('{"ok":true}'); + }); + await new Promise(resolve => server.listen(0, resolve)); + try { + const response = await requestOverHttp1(server.address().port); + expect({ + statusCode: response.statusCode, + statusMessage: response.statusMessage, + contentType: response.headers["content-type"], + token: response.headers["x-custom-token"], + body: response.body, + }).toEqual({ + statusCode: 202, + statusMessage: "Accepted", + contentType: "application/json; charset=utf-8", + token: "abcdef123456", + body: '{"ok":true}', + }); + } finally { + server.close(); + } +}); + +it("http2 allowHTTP1 fallback rejects a statusMessage containing CR or LF", async () => { + const thrownCodes = []; + const server = http2.createSecureServer({ ...TLS_CERT, allowHTTP1: true }, (req, res) => { + res.statusMessage = "Split\r\nx-extra: injected"; + try { + res.end("nope"); + } catch (err) { + thrownCodes.push(err.code); + res.statusMessage = "All Good"; + res.end("body"); + } + }); + await new Promise(resolve => server.listen(0, resolve)); + try { + const response = await requestOverHttp1(server.address().port); + expect(response.headers["x-extra"]).toBeUndefined(); + expect(response.statusMessage).toBe("All Good"); + expect(response.body).toBe("body"); + expect(thrownCodes).toEqual(["ERR_INVALID_CHAR"]); + } finally { + server.close(); + } +}); + +it("http2 allowHTTP1 fallback rejects an out-of-range statusCode", async () => { + const thrownCodes = []; + const server = http2.createSecureServer({ ...TLS_CERT, allowHTTP1: true }, (req, res) => { + res.statusCode = 99; + try { + res.end("nope"); + } catch (err) { + thrownCodes.push(err.code); + res.statusCode = 200; + res.end("body"); + } + }); + await new Promise(resolve => server.listen(0, resolve)); + try { + const response = await requestOverHttp1(server.address().port); + expect(response.statusCode).toBe(200); + expect(response.body).toBe("body"); + expect(thrownCodes).toEqual(["ERR_HTTP_INVALID_STATUS_CODE"]); + } finally { + server.close(); + } +}); + +it("http2 allowHTTP1 fallback frames a HEAD response like plain HTTP/1 (no Content-Length, no Transfer-Encoding)", async () => { + const server = http2.createSecureServer({ ...TLS_CERT, allowHTTP1: true }, (req, res) => { + res.writeHead(200, { "x-method": req.method }); + res.end(); + }); + await new Promise(resolve => server.listen(0, resolve)); + try { + const { promise, resolve, reject } = Promise.withResolvers(); + const socket = tls.connect( + { host: "localhost", port: server.address().port, ca: TLS_CERT.cert, ALPNProtocols: ["http/1.1"] }, + () => socket.write("HEAD / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"), + ); + const chunks = []; + socket.on("error", reject); + socket.on("data", chunk => chunks.push(chunk)); + socket.on("end", () => resolve(Buffer.concat(chunks).toString())); + const raw = await promise; + expect(raw).toStartWith("HTTP/1.1 200 OK\r\n"); + expect(raw).toEndWith("\r\n\r\n"); + expect(raw.toLowerCase()).toContain("\r\nx-method: head\r\n"); + expect(raw.toLowerCase()).not.toContain("content-length"); + expect(raw.toLowerCase()).not.toContain("transfer-encoding"); + } finally { + server.close(); + } +}); + +it("http2 allowHTTP1 fallback writes a close-delimited body raw and ends the connection", async () => { + const server = http2.createSecureServer({ ...TLS_CERT, allowHTTP1: true }, (req, res) => { + res.removeHeader("content-length"); + res.removeHeader("transfer-encoding"); + res.write("part1"); + res.end("part2"); + }); + await new Promise(resolve => server.listen(0, resolve)); + try { + const { promise, resolve, reject } = Promise.withResolvers(); + const socket = tls.connect( + { host: "localhost", port: server.address().port, ca: TLS_CERT.cert, ALPNProtocols: ["http/1.1"] }, + () => socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"), + ); + const chunks = []; + socket.on("error", reject); + socket.on("data", chunk => chunks.push(chunk)); + socket.on("end", () => resolve(Buffer.concat(chunks).toString())); + const raw = await promise; + expect(raw).toStartWith("HTTP/1.1 200 OK\r\n"); + expect(raw.toLowerCase()).not.toContain("content-length"); + expect(raw.toLowerCase()).not.toContain("transfer-encoding"); + // The advertised connection state must match the close-delimited transport. + expect(raw.slice(0, raw.indexOf("\r\n\r\n") + 4).toLowerCase()).toContain("\r\nconnection: close\r\n"); + expect(raw.slice(raw.indexOf("\r\n\r\n") + 4)).toBe("part1part2"); + } finally { + server.close(); + } +}); + +it("http2 allowHTTP1 fallback writes no terminating chunk after a keep-alive HEAD with a user-set Transfer-Encoding: chunked", async () => { + const server = http2.createSecureServer({ ...TLS_CERT, allowHTTP1: true }, (req, res) => { + if (req.method === "HEAD") { + res.setHeader("Transfer-Encoding", "chunked"); + res.end(); + return; + } + res.end("body2"); + }); + await new Promise(resolve => server.listen(0, resolve)); + try { + const { promise, resolve, reject } = Promise.withResolvers(); + const socket = tls.connect( + { host: "localhost", port: server.address().port, ca: TLS_CERT.cert, ALPNProtocols: ["http/1.1"] }, + () => socket.write("HEAD / HTTP/1.1\r\nHost: localhost\r\n\r\n"), + ); + const chunks = []; + let sentSecond = false; + socket.on("error", reject); + socket.on("data", chunk => { + chunks.push(chunk); + if (!sentSecond && Buffer.concat(chunks).includes("\r\n\r\n")) { + // The HEAD head arrived; reuse the connection for a second request. + sentSecond = true; + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + } + }); + socket.on("end", () => resolve(Buffer.concat(chunks).toString())); + const raw = await promise; + const afterHead = raw.slice(raw.indexOf("\r\n\r\n") + 4); + // A HEAD response has no body and no terminating chunk: the next bytes on + // the connection must be the second response's status line. + expect(afterHead).toStartWith("HTTP/1.1 200 "); + expect(raw).not.toContain("0\r\n\r\n"); + expect(afterHead.slice(afterHead.indexOf("\r\n\r\n") + 4)).toBe("body2"); + } finally { + server.close(); + } +}); + +it("http2 allowHTTP1 fallback omits the Connection header on a close-delimited response when the user removed it", async () => { + const server = http2.createSecureServer({ ...TLS_CERT, allowHTTP1: true }, (req, res) => { + res.removeHeader("content-length"); + res.removeHeader("transfer-encoding"); + res.removeHeader("connection"); + res.end("body"); + }); + await new Promise(resolve => server.listen(0, resolve)); + try { + const { promise, resolve, reject } = Promise.withResolvers(); + const socket = tls.connect( + { host: "localhost", port: server.address().port, ca: TLS_CERT.cert, ALPNProtocols: ["http/1.1"] }, + () => socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"), + ); + const chunks = []; + socket.on("error", reject); + socket.on("data", chunk => chunks.push(chunk)); + socket.on("end", () => resolve(Buffer.concat(chunks).toString())); + const raw = await promise; + expect(raw).toStartWith("HTTP/1.1 200 OK\r\n"); + // Node writes no Connection (and no Keep-Alive) header here at all. + expect(raw.toLowerCase()).not.toContain("connection:"); + expect(raw.toLowerCase()).not.toContain("keep-alive"); + expect(raw.slice(raw.indexOf("\r\n\r\n") + 4)).toBe("body"); + } finally { + server.close(); + } +}); diff --git a/test/js/node/test/parallel/test-crypto-certificate.js b/test/js/node/test/parallel/test-crypto-certificate.js index da932c608d47..48cebfb693ab 100644 --- a/test/js/node/test/parallel/test-crypto-certificate.js +++ b/test/js/node/test/parallel/test-crypto-certificate.js @@ -106,6 +106,22 @@ function checkMethods(certificate) { checkMethods(Certificate); } +{ + const spkacText = spkacValid.toString('utf8').trimEnd(); + const padded = Buffer.alloc(Buffer.byteLength(spkacText) + 1); + padded.write(spkacText); + const zeroLengthView = padded.subarray(0, 0); + assert.strictEqual(Certificate.verifySpkac(zeroLengthView), false); + assert.strictEqual(Certificate.exportPublicKey(zeroLengthView), ''); + assert.strictEqual(Certificate.exportChallenge(zeroLengthView), ''); + + for (const input of [Buffer.alloc(0), Buffer.from(' \n\r\t'), '', ' \n\r\t']) { + assert.strictEqual(Certificate.verifySpkac(Buffer.from(input)), false); + assert.strictEqual(Certificate.exportPublicKey(input), ''); + assert.strictEqual(Certificate.exportChallenge(input), ''); + } +} + function stripLineEndings(obj) { return obj.replace(/\n/g, ''); } diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 6c705708a530..5c60031fc8dc 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1265,3 +1265,56 @@ describe("tls.Server socket destroySoon", () => { } }); }); + +it("tls.createServer honors secureOptions when negotiating the protocol version", async () => { + const server: Server = createServer({ ...COMMON_CERT, secureOptions: crypto.constants.SSL_OP_NO_TLSv1_3 }); + const accepted = Promise.withResolvers(); + server.on("secureConnection", socket => { + accepted.resolve(); + socket.end(); + }); + server.on("tlsClientError", accepted.reject); + server.listen(0); + await once(server, "listening"); + let client: TLSSocket | undefined; + try { + const port = (server.address() as AddressInfo).port; + client = connect({ port, host: "127.0.0.1", rejectUnauthorized: false }); + await once(client, "secureConnect"); + await accepted.promise; + expect(client.getProtocol()).toBe("TLSv1.2"); + } finally { + client?.destroy(); + server.close(); + } + await once(server, "close"); +}); + +it("tls.connect honors secureOptions when negotiating the protocol version", async () => { + const server: Server = createServer(COMMON_CERT); + server.on("secureConnection", socket => socket.end()); + server.listen(0); + await once(server, "listening"); + let baseline: TLSSocket | undefined; + let client: TLSSocket | undefined; + try { + const port = (server.address() as AddressInfo).port; + baseline = connect({ port, host: "127.0.0.1", rejectUnauthorized: false }); + await once(baseline, "secureConnect"); + expect(baseline.getProtocol()).toBe("TLSv1.3"); + + client = connect({ + port, + host: "127.0.0.1", + rejectUnauthorized: false, + secureOptions: crypto.constants.SSL_OP_NO_TLSv1_3, + }); + await once(client, "secureConnect"); + expect(client.getProtocol()).toBe("TLSv1.2"); + } finally { + baseline?.destroy(); + client?.destroy(); + server.close(); + } + await once(server, "close"); +}); diff --git a/test/js/node/url/url-parse-format.test.js b/test/js/node/url/url-parse-format.test.js index e613f8a783cb..bec764981954 100644 --- a/test/js/node/url/url-parse-format.test.js +++ b/test/js/node/url/url-parse-format.test.js @@ -1053,6 +1053,68 @@ describe("url.parse then url.format", () => { } }); + test("url.parse matches hostless protocols case-insensitively", () => { + const expected = Object.assign(new url.Url(), { + protocol: "javascript:", + slashes: null, + auth: null, + host: null, + port: null, + hostname: null, + hash: null, + search: null, + query: null, + pathname: "alert(1);a='@white-listed.com'", + path: "alert(1);a='@white-listed.com'", + href: "javascript:alert(1);a='@white-listed.com'", + }); + + assert.deepStrictEqual(url.parse("javAscript:alert(1);a=\x27@white-listed.com\x27"), expected); + assert.deepStrictEqual( + url.parse("javAscript:alert(1);a=\x27@white-listed.com\x27"), + url.parse("javascript:alert(1);a=\x27@white-listed.com\x27"), + ); + }); + + test("resolveObject treats 'constructor' as an unknown protocol", () => { + const base = url.parse("constructor://user@h0st:81/aa/bb?q#f"); + base.protocol = "constructor"; + const actual = base.resolveObject("../cc"); + const expected = Object.assign(new url.Url(), { + protocol: "constructor", + slashes: true, + auth: "user", + host: "h0st:81", + port: null, + hostname: "h0st:81", + hash: null, + search: null, + query: null, + pathname: "/cc", + path: "/cc", + href: "constructor://user@h0st:81/cc", + }); + assert.deepStrictEqual(actual, expected); + }); + + test("url.parse is unaffected by Object.prototype pollution", () => { + Object.prototype["evil:"] = true; + Object.prototype["evil"] = true; + Object.prototype["weird:"] = true; + try { + const slashed = url.parse("evil://host/p"); + assert.strictEqual(slashed.slashes, true); + assert.strictEqual(slashed.host, "host"); + assert.strictEqual(slashed.pathname, "/p"); + assert.strictEqual(slashed.href, "evil://host/p"); + assert.strictEqual(url.parse("weird:ja vasc'ript:").href, "weird:ja%20vasc%27ript:"); + } finally { + delete Object.prototype["evil:"]; + delete Object.prototype["evil"]; + delete Object.prototype["weird:"]; + } + }); + // TODO: Support parsing this. test.todo("xss", () => { const parsed = url.parse("http://nodejs.org/").resolveObject("jAvascript:alert(1);a=\x27@white-listed.com\x27"); diff --git a/test/js/node/url/url-parse-query.test.js b/test/js/node/url/url-parse-query.test.js index 80f156e731a1..ce6c3d5e7e65 100644 --- a/test/js/node/url/url-parse-query.test.js +++ b/test/js/node/url/url-parse-query.test.js @@ -4,6 +4,19 @@ import url from "node:url"; describe("url.parse", () => { // TODO: Support correct prototype and null values. + test("parseQueryString returns a null-prototype query object", () => { + const inputs = ["/foo/bar?baz=quux", "/foo/bar", "http://example.com/a?baz=quux", "http://example.com/a"]; + for (const input of inputs) { + const { query } = url.parse(input, true); + assert.strictEqual(Object.getPrototypeOf(query), null); + } + + const { query } = url.parse("/foo/bar?baz=quux", true); + assert.strictEqual(query.baz, "quux"); + assert.strictEqual(query.hasOwnProperty, undefined); + assert.strictEqual(query.toString, undefined); + }); + test.todo("with query string", () => { function createWithNoPrototype(properties = []) { const noProto = { __proto__: null }; diff --git a/test/js/node/v8/capture-stack-trace.test.js b/test/js/node/v8/capture-stack-trace.test.js index 26902dec71d4..20d405594b28 100644 --- a/test/js/node/v8/capture-stack-trace.test.js +++ b/test/js/node/v8/capture-stack-trace.test.js @@ -1,6 +1,7 @@ import { nativeFrameForTesting } from "bun:internal-for-testing"; import { noInline } from "bun:jsc"; import { afterEach, expect, mock, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; const origPrepareStackTrace = Error.prepareStackTrace; afterEach(() => { Error.prepareStackTrace = origPrepareStackTrace; @@ -504,6 +505,16 @@ test("err.stack should invoke prepareStackTrace", () => { var lineNumber = -1; var functionName = ""; var parentLineNumber = -1; + var referenceStack = ""; + // Line numbers of the first two frames of a default-formatted stack string. + function defaultStackLineNumbers(stack) { + return String(stack) + .split("\n") + .map(line => /:(\d+):\d+\)?\s*$/.exec(line)) + .filter(Boolean) + .slice(0, 2) + .map(match => Number(match[1])); + } function functionWithAName() { // This is V8's behavior. let prevPrepareStackTrace = Error.prepareStackTrace; @@ -515,16 +526,23 @@ test("err.stack should invoke prepareStackTrace", () => { expect(s[0].getFileName().includes("capture-stack-trace.test.js")).toBe(true); expect(s[1].getFileName().includes("capture-stack-trace.test.js")).toBe(true); }; - const e = new Error(); + // `reference` shares `e`'s line so the default formatter (consulted after the + // hook is removed) must report the same call-site lines prepareStackTrace saw. + const [reference, e] = [new Error(), new Error()]; e.stack; Error.prepareStackTrace = prevPrepareStackTrace; + referenceStack = reference.stack; } functionWithAName(); + const [expectedLineNumber, expectedParentLineNumber] = defaultStackLineNumbers(referenceStack); + expect(referenceStack).toContain("at functionWithAName"); + expect(expectedLineNumber).toBeGreaterThan(0); + expect(expectedParentLineNumber).toBeGreaterThan(expectedLineNumber); expect(functionName).toBe("functionWithAName"); - expect(lineNumber).toBe(518); - expect(parentLineNumber).toBe(523); + expect(lineNumber).toBe(expectedLineNumber); + expect(parentLineNumber).toBe(expectedParentLineNumber); }); test("Error.prepareStackTrace inside a node:vm works", () => { @@ -906,3 +924,82 @@ test("captureStackTrace does not crash when stackTraceLimit is non-numeric", () Error.stackTraceLimit = origLimit; } }); + +test("call sites inside a WebSocket message listener only contain script frames when the message arrives with the upgrade response", async () => { + // Runs in its own process: which dispatch path delivers the message (and therefore + // which frames are on the stack under the listener) depends on prior event-loop state. + const src = [ + `const { createHash } = require("node:crypto");`, + `const buffers = new Map();`, + `const server = Bun.listen({`, + ` hostname: "127.0.0.1",`, + ` port: 0,`, + ` socket: {`, + ` data(socket, chunk) {`, + ` const previous = buffers.get(socket) ?? Buffer.alloc(0);`, + ` const request = Buffer.concat([previous, chunk]);`, + ` buffers.set(socket, request);`, + ` const text = request.toString("latin1");`, + ` if (!text.includes("\\r\\n\\r\\n")) return;`, + ` const key = /^Sec-WebSocket-Key:\\s*(.+?)\\r\\n/im.exec(text)?.[1];`, + ` if (!key) { console.error("missing Sec-WebSocket-Key header"); process.exit(1); }`, + ` const accept = createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64");`, + ` const response = "HTTP/1.1 101 Switching Protocols\\r\\nUpgrade: websocket\\r\\nConnection: Upgrade\\r\\nSec-WebSocket-Accept: " + accept + "\\r\\n\\r\\n";`, + ` socket.write(Buffer.concat([Buffer.from(response, "latin1"), Buffer.from([0x81, 0x02, 0x68, 0x69])]));`, + ` },`, + ` error() { process.exit(1); },`, + ` },`, + `});`, + `const ws = new WebSocket("ws://127.0.0.1:" + server.port);`, + `ws.addEventListener("close", event => { console.error("closed " + event.code); process.exit(1); });`, + `ws.addEventListener("message", event => {`, + ` const previousPrepareStackTrace = Error.prepareStackTrace;`, + ` let callSites;`, + ` try {`, + ` Error.prepareStackTrace = (_error, stack) => stack;`, + ` const error = new Error();`, + ` Error.captureStackTrace(error);`, + ` callSites = error.stack;`, + ` } finally {`, + ` Error.prepareStackTrace = previousPrepareStackTrace;`, + ` }`, + ` console.log(event.data, callSites.filter(callSite => callSite.isNative()).length);`, + ` process.exit(0);`, + `});`, + ].join("\n"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe("hi 0"); + expect(exitCode).toBe(0); +}); + +test("printing an error whose message getter calls Error.captureStackTrace on itself prints normally", async () => { + const fixture = [ + `const vm = require("node:vm");`, + `let src = "function f0() {\\n";`, + `src += " const e = new Error('first');\\n";`, + `src += " Object.defineProperty(e, 'message', { get() { Error.captureStackTrace(e); return 'second'; } });\\n";`, + `src += " return e;\\n";`, + `src += "}\\n";`, + `for (let i = 1; i < 12; i++) src += "function f" + i + "() { return f" + (i - 1) + "(); }\\n";`, + `src += "f11();\\n";`, + `const err = vm.runInThisContext(src, { filename: "frame-index-fixture.js" });`, + `console.log(err);`, + `console.log("after");`, + ].join("\n"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ lastLine: stdout.trimEnd().split("\n").pop(), exitCode }).toEqual({ lastLine: "after", exitCode: 0 }); +}); diff --git a/test/js/sql/sql-helpers-validation.test.ts b/test/js/sql/sql-helpers-validation.test.ts index be7949c1c7d7..a8210853641c 100644 --- a/test/js/sql/sql-helpers-validation.test.ts +++ b/test/js/sql/sql-helpers-validation.test.ts @@ -77,6 +77,51 @@ describe.each(adapters)("%s helper validation", (_adapter, makeSql) => { }); }); +const distributedAdapters: [string, () => SQL][] = [ + ["postgres", () => new SQL("postgres://bun_sql_test@127.0.0.1:1/bun_sql_test", { max: 1 })], + ["mysql", () => new SQL("mysql://bun_sql_test@127.0.0.1:1/bun_sql_test", { max: 1 })], +]; + +describe.each(distributedAdapters)("%s distributed transaction name validation", (_adapter, makeSql) => { + const invalidNames = [["tx'name"], 42, null, undefined, { toString: () => "tx" }]; + + test("commitDistributed requires the transaction name to be a string", async () => { + await using sql = makeSql(); + for (const name of invalidNames) { + const err = await sql.commitDistributed(name as any).catch(e => e); + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe("Distributed transaction name must be a string."); + } + }); + + test("rollbackDistributed requires the transaction name to be a string", async () => { + await using sql = makeSql(); + for (const name of invalidNames) { + const err = await sql.rollbackDistributed(name as any).catch(e => e); + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe("Distributed transaction name must be a string."); + } + }); +}); + +describe("postgres dynamic identifier validation", () => { + test("identifiers containing a NUL byte are rejected", async () => { + await using sql = new SQL("postgres://bun_sql_test@127.0.0.1:1/bun_sql_test", { max: 1 }); + const err = await (sql("col\0umn") as unknown as Promise).catch(e => e); + expect(err).toBeInstanceOf(TypeError); + expect(err.code).toBe("ERR_INVALID_ARG_VALUE"); + expect(err.message).toStartWith("The argument 'name' must not contain null bytes. Received "); + }); + + test("insert helper column names containing a NUL byte are rejected", async () => { + await using sql = new SQL("postgres://bun_sql_test@127.0.0.1:1/bun_sql_test", { max: 1 }); + const err = await sql`INSERT INTO t ${sql([{ ["col\0umn"]: 1 }])}`.catch(e => e); + expect(err).toBeInstanceOf(TypeError); + expect(err.code).toBe("ERR_INVALID_ARG_VALUE"); + expect(err.message).toStartWith("The argument 'name' must not contain null bytes. Received "); + }); +}); + // Behaviors that must keep working; these execute real queries, so they run // against sqlite only. describe("sqlite helper behavior preserved", () => { diff --git a/test/js/sql/sql-mysql-auth-short-nonce.test.ts b/test/js/sql/sql-mysql-auth-short-nonce.test.ts index a6d273317e25..66d12e29a42f 100644 --- a/test/js/sql/sql-mysql-auth-short-nonce.test.ts +++ b/test/js/sql/sql-mysql-auth-short-nonce.test.ts @@ -13,7 +13,13 @@ import { SQL } from "bun"; import { expect, test } from "bun:test"; -import { listeningServer, mysqlAuthSwitchRequest, mysqlHandshakeV10 } from "./wire-frames"; +import { + listeningServer, + mysqlAuthSwitchRequest, + mysqlHandshakeV10, + mysqlRawPacket, + mysqlReadPackets, +} from "./wire-frames"; test("MySQL: AuthSwitchRequest with a short mysql_native_password nonce is rejected, not OOB-read", async () => { let sawAuthSwitchResponse = false; @@ -67,3 +73,34 @@ test("MySQL: AuthSwitchRequest with a short mysql_native_password nonce is rejec await new Promise(r => server.close(() => r())); } }); + +test("MySQL: an AuthSwitchRequest frame declaring a zero-length payload is rejected", async () => { + const greeting = mysqlHandshakeV10(); + + const { server, port } = await listeningServer(socket => { + let buffered = Buffer.alloc(0); + let replied = false; + socket.write(greeting); + socket.on("data", chunk => { + buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), seq => { + if (!replied) { + replied = true; + socket.end(mysqlRawPacket(seq + 1, Buffer.from([0xfe]), 0)); + } + }); + }); + socket.on("error", () => {}); + }); + + try { + await using sql = new SQL({ url: `mysql://root:pw@127.0.0.1:${port}/db`, max: 1 }); + const err = await sql`select 1`.then( + () => ({ code: "UNEXPECTED_SUCCESS" }), + e => ({ code: e?.code ?? String(e) }), + ); + + expect(err).toEqual({ code: "ERR_MYSQL_INVALID_AUTH_SWITCH_REQUEST" }); + } finally { + await new Promise(r => server.close(() => r())); + } +}); diff --git a/test/js/sql/wire-frames.test.ts b/test/js/sql/wire-frames.test.ts index 204c798b3b75..ac798f110ba2 100644 --- a/test/js/sql/wire-frames.test.ts +++ b/test/js/sql/wire-frames.test.ts @@ -12,9 +12,15 @@ import { mysqlOkPacket, mysqlReadPackets, pgAuthenticationOk, + pgCommandComplete, + pgCopyData, + pgCopyDone, + pgCopyOutResponse, + pgDataRow, pgErrorResponse, pgMinimalReadyServer, pgReadyForQuery, + pgRowDescription, } from "./wire-frames"; test("mysqlLenencInt encodes per page_protocol_basic_dt_integers.html", () => { @@ -50,6 +56,43 @@ test("postgres: pgAuthenticationOk + pgReadyForQuery are accepted by Bun's parse } }); +test("postgres: COPY OUT response frames are consumed and the following result set decodes", async () => { + const { port, server } = await listeningServer(socket => { + socket.on("error", () => {}); + let startup = true; + socket.on("data", data => { + if (startup) { + startup = false; + socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()])); + return; + } + if (data[0] !== 0x51) return; + socket.end( + Buffer.concat([ + pgCopyOutResponse([0]), + pgCopyData(Buffer.from("1\n")), + pgCopyData(Buffer.from("2\n")), + pgCopyDone(), + pgCommandComplete("COPY 2"), + pgRowDescription([{ name: "y", typeOid: 25 }]), + pgDataRow([Buffer.from("2")]), + pgCommandComplete("SELECT 1"), + pgReadyForQuery(), + ]), + ); + }); + }); + + const db = new SQL({ url: `postgres://u@127.0.0.1:${port}/db`, max: 1, idleTimeout: 5, connectionTimeout: 5 }); + try { + const result = await db`copy t to stdout; select 2 as y`.simple(); + expect(result).toEqual([[], [{ y: "2" }]]); + } finally { + await db.close().catch(() => {}); + await new Promise(r => server.close(() => r())); + } +}); + test("postgres: pgMinimalReadyServer satisfies connect()", async () => { const { port, server } = await pgMinimalReadyServer(); const db = new SQL({ url: `postgres://postgres@127.0.0.1:${port}/postgres`, max: 1 }); diff --git a/test/js/sql/wire-frames.ts b/test/js/sql/wire-frames.ts index 26184076b202..b4bfab875bcc 100644 --- a/test/js/sql/wire-frames.ts +++ b/test/js/sql/wire-frames.ts @@ -163,6 +163,25 @@ export function pgRowDescription(cols: PgRowDescriptionColumn[]): Buffer { return pgRaw("T", Buffer.concat(parts)); } +// PostgreSQL FE/BE protocol §55.7 CopyOutResponse: Byte1('H') Int32(len) Int8(overall format) Int16(ncols) Int16[ncols](per-column format) +export function pgCopyOutResponse(formats: (0 | 1)[], overallFormat: 0 | 1 = 0): Buffer { + const body = Buffer.alloc(3 + 2 * formats.length); + body[0] = overallFormat; + body.writeInt16BE(formats.length, 1); + for (let i = 0; i < formats.length; i++) body.writeInt16BE(formats[i], 3 + 2 * i); + return pgRaw("H", body); +} + +// PostgreSQL FE/BE protocol §55.7 CopyData: Byte1('d') Int32(len) Byte[n](data) +export function pgCopyData(data: Buffer): Buffer { + return pgRaw("d", data); +} + +// PostgreSQL FE/BE protocol §55.7 CopyDone: Byte1('c') Int32(4) +export function pgCopyDone(): Buffer { + return pgRaw("c", Buffer.alloc(0)); +} + // PostgreSQL FE/BE protocol §55.7 DataRow: Byte1('D') Int32(len) Int16(ncols) per col: Int32(byteLen | -1) Byte[len] export function pgDataRow(cols: (Buffer | null)[]): Buffer { const parts: Buffer[] = [Buffer.alloc(2)]; @@ -208,11 +227,13 @@ export const MYSQL_DEFAULT_CAPABILITIES = MYSQL_CLIENT_DEPRECATE_EOF; // MySQL packet framing — page_protocol_basic_packets.html: Int<3>(payload_length) Int<1>(sequence_id) payload -export function mysqlRawPacket(seq: number, payload: Buffer): Buffer { +// `declaredLength` is the low-level escape hatch for fault-injection tests that need a +// payload_length field that disagrees with the bytes that follow it (mirrors pgRaw). +export function mysqlRawPacket(seq: number, payload: Buffer, declaredLength: number = payload.length): Buffer { const header = Buffer.alloc(4); - header[0] = payload.length & 0xff; - header[1] = (payload.length >> 8) & 0xff; - header[2] = (payload.length >> 16) & 0xff; + header[0] = declaredLength & 0xff; + header[1] = (declaredLength >> 8) & 0xff; + header[2] = (declaredLength >> 16) & 0xff; header[3] = seq & 0xff; return Buffer.concat([header, payload]); } diff --git a/test/js/valkey/reliability/connection-failures.test.ts b/test/js/valkey/reliability/connection-failures.test.ts index 66ae89dc71c7..bcf8964ac348 100644 --- a/test/js/valkey/reliability/connection-failures.test.ts +++ b/test/js/valkey/reliability/connection-failures.test.ts @@ -1,5 +1,6 @@ import { RedisClient } from "bun"; import { describe, expect, mock, test } from "bun:test"; +import net from "net"; import { DEFAULT_REDIS_OPTIONS, DEFAULT_REDIS_URL, delay, isEnabled } from "../test-utils"; /** @@ -335,3 +336,108 @@ describe.skipIf(!isEnabled)("Valkey: Connection Failures", () => { }); }); }); + +describe("Valkey: Auto-Reconnect In-Flight Commands", () => { + function readCommands(state: { buffer: Buffer }): string[][] { + const commands: string[][] = []; + while (true) { + const text = state.buffer.toString("latin1"); + if (text[0] !== "*") break; + const headerEnd = text.indexOf("\r\n"); + if (headerEnd === -1) break; + const argCount = parseInt(text.slice(1, headerEnd), 10); + if (!Number.isInteger(argCount) || argCount < 0) break; + let pos = headerEnd + 2; + const args: string[] = []; + let complete = true; + for (let i = 0; i < argCount; i++) { + if (text[pos] !== "$") { + complete = false; + break; + } + const lenEnd = text.indexOf("\r\n", pos); + if (lenEnd === -1) { + complete = false; + break; + } + const len = parseInt(text.slice(pos + 1, lenEnd), 10); + if (!Number.isInteger(len) || len < 0) { + complete = false; + break; + } + const dataStart = lenEnd + 2; + const dataEnd = dataStart + len; + if (text.length < dataEnd + 2) { + complete = false; + break; + } + args.push(text.slice(dataStart, dataEnd)); + pos = dataEnd + 2; + } + if (!complete) break; + commands.push(args); + state.buffer = state.buffer.subarray(pos); + } + return commands; + } + + test("rejects commands that were in flight when the connection dropped instead of pairing them with replies from the next connection", async () => { + const sockets: net.Socket[] = []; + let connections = 0; + const secondHello = Promise.withResolvers(); + const serverError = Promise.withResolvers(); + const server = net.createServer(socket => { + connections += 1; + const connection = connections; + sockets.push(socket); + const state = { buffer: Buffer.alloc(0) }; + socket.on("data", chunk => { + state.buffer = Buffer.concat([state.buffer, chunk]); + for (const args of readCommands(state)) { + const name = (args[0] ?? "").toUpperCase(); + if (name === "HELLO") { + socket.write("+OK\r\n"); + if (connection === 2) { + secondHello.resolve(); + } + } else if (connection === 1) { + socket.destroy(); + } else { + socket.write("$5\r\nfresh\r\n"); + } + } + }); + socket.on("error", () => {}); + }); + server.on("error", serverError.reject); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as net.AddressInfo; + const client = new RedisClient(`redis://127.0.0.1:${port}`, { + autoReconnect: true, + enableOfflineQueue: true, + connectionTimeout: 5000, + maxRetries: 10, + }); + try { + const staleOutcome = client.get("stale-key").then( + value => ({ status: "fulfilled", value }), + error => ({ status: "rejected", code: error.code, message: error.message }), + ); + await Promise.race([secondHello.promise, serverError.promise]); + const fresh = client.get("fresh-key"); + expect(await Promise.race([staleOutcome, serverError.promise])).toEqual({ + status: "rejected", + code: "ERR_REDIS_CONNECTION_CLOSED", + message: "Connection closed", + }); + expect(await fresh).toBe("fresh"); + expect(connections).toBe(2); + } finally { + client.close(); + server.close(); + for (const socket of sockets) { + socket.destroy(); + } + } + }); +}); diff --git a/test/js/valkey/reliability/resp-nesting-depth.test.ts b/test/js/valkey/reliability/resp-nesting-depth.test.ts index bf351e62ac52..4c757f755240 100644 --- a/test/js/valkey/reliability/resp-nesting-depth.test.ts +++ b/test/js/valkey/reliability/resp-nesting-depth.test.ts @@ -2,6 +2,82 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; import net from "net"; +/** + * Count the number of complete RESP commands in a buffer. + * Each command starts with '*' (array) followed by the element count. + * We count top-level '*' markers that begin a new command frame. + */ +function countRespCommands(data: Buffer): number { + const str = data.toString(); + let count = 0; + let pos = 0; + while (pos < str.length) { + if (str[pos] === "*") { + count++; + // Skip past this command: find the array length line + const crlfIdx = str.indexOf("\r\n", pos); + if (crlfIdx === -1) break; + const arrayLen = parseInt(str.substring(pos + 1, crlfIdx), 10); + if (isNaN(arrayLen) || arrayLen < 0) break; + // Skip past arrayLen bulk-string elements (each is $\r\n\r\n) + let elemPos = crlfIdx + 2; + for (let i = 0; i < arrayLen; i++) { + if (elemPos >= str.length || str[elemPos] !== "$") break; + const lenEnd = str.indexOf("\r\n", elemPos); + if (lenEnd === -1) break; + const bulkLen = parseInt(str.substring(elemPos + 1, lenEnd), 10); + if (isNaN(bulkLen) || bulkLen < 0) break; + elemPos = lenEnd + 2 + bulkLen + 2; // skip $\r\n\r\n + } + pos = elemPos; + } else { + pos++; + } + } + return count; +} + +/** + * Creates a minimal mock Redis server that parses incoming RESP command + * frames. The first command (HELLO handshake) gets +OK; each subsequent + * command receives the next crafted payload (the last one is repeated when + * there are more commands than payloads). Handles the case where multiple + * commands arrive in a single TCP chunk. + */ +function createMockRedisServer(payload: Buffer | Buffer[]): Promise<{ server: net.Server; port: number }> { + const payloads = Array.isArray(payload) ? payload : [payload]; + return new Promise((resolve, reject) => { + const server = net.createServer(socket => { + let commandsSeen = 0; + + socket.on("data", (data: Buffer) => { + const numCmds = countRespCommands(data); + for (let i = 0; i < numCmds; i++) { + if (commandsSeen === 0) { + // Respond to HELLO handshake with a simple OK + socket.write("+OK\r\n"); + } else { + // Each subsequent command gets the next crafted payload + socket.write(payloads[Math.min(commandsSeen - 1, payloads.length - 1)]); + } + commandsSeen++; + } + }); + + socket.on("error", () => { + // Ignore socket errors from client disconnecting + }); + }); + + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as net.AddressInfo; + resolve({ server, port: addr.port }); + }); + + server.on("error", reject); + }); +} + /** * Test suite for RESP protocol nesting depth limits. * Ensures the parser handles deeply nested aggregate types gracefully. @@ -19,80 +95,6 @@ describe("Valkey: RESP Nesting Depth Handling", () => { return Buffer.from(prefix.repeat(depth) + leaf); } - /** - * Count the number of complete RESP commands in a buffer. - * Each command starts with '*' (array) followed by the element count. - * We count top-level '*' markers that begin a new command frame. - */ - function countRespCommands(data: Buffer): number { - const str = data.toString(); - let count = 0; - let pos = 0; - while (pos < str.length) { - if (str[pos] === "*") { - count++; - // Skip past this command: find the array length line - const crlfIdx = str.indexOf("\r\n", pos); - if (crlfIdx === -1) break; - const arrayLen = parseInt(str.substring(pos + 1, crlfIdx), 10); - if (isNaN(arrayLen) || arrayLen < 0) break; - // Skip past arrayLen bulk-string elements (each is $\r\n\r\n) - let elemPos = crlfIdx + 2; - for (let i = 0; i < arrayLen; i++) { - if (elemPos >= str.length || str[elemPos] !== "$") break; - const lenEnd = str.indexOf("\r\n", elemPos); - if (lenEnd === -1) break; - const bulkLen = parseInt(str.substring(elemPos + 1, lenEnd), 10); - if (isNaN(bulkLen) || bulkLen < 0) break; - elemPos = lenEnd + 2 + bulkLen + 2; // skip $\r\n\r\n - } - pos = elemPos; - } else { - pos++; - } - } - return count; - } - - /** - * Creates a minimal mock Redis server that parses incoming RESP command - * frames. The first command (HELLO handshake) gets +OK; all subsequent - * commands receive the crafted payload. Handles the case where multiple - * commands arrive in a single TCP chunk. - */ - function createMockRedisServer(payload: Buffer): Promise<{ server: net.Server; port: number }> { - return new Promise((resolve, reject) => { - const server = net.createServer(socket => { - let commandsSeen = 0; - - socket.on("data", (data: Buffer) => { - const numCmds = countRespCommands(data); - for (let i = 0; i < numCmds; i++) { - if (commandsSeen === 0) { - // Respond to HELLO handshake with a simple OK - socket.write("+OK\r\n"); - } else { - // All subsequent commands get the crafted payload - socket.write(payload); - } - commandsSeen++; - } - }); - - socket.on("error", () => { - // Ignore socket errors from client disconnecting - }); - }); - - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as net.AddressInfo; - resolve({ server, port: addr.port }); - }); - - server.on("error", reject); - }); - } - test("should reject responses that exceed the nesting depth limit", async () => { // 256 levels of nesting – well above the 128 limit const deepPayload = buildNestedArrayPayload(256); @@ -236,3 +238,53 @@ describe("Valkey: RESP Nesting Depth Handling", () => { expect(exitCode).toBe(0); }); }); + +describe("Valkey: RESP push frame routing", () => { + test("resolves a pending command with its own reply when an out-of-band push frame precedes it", async () => { + const payload = Buffer.from( + ">4\r\n$8\r\npmessage\r\n$7\r\npattern\r\n$7\r\nchannel\r\n$7\r\npayload\r\n" + "+PONG\r\n", + ); + + const { server, port } = await createMockRedisServer(payload); + try { + const client = new Bun.RedisClient(`redis://127.0.0.1:${port}`, { + autoReconnect: false, + connectionTimeout: 2000, + }); + + try { + const result = await client.send("PING", []); + expect(result).toBe("PONG"); + } finally { + client.close(); + } + } finally { + server.close(); + } + }); + + test("a psubscribe ack push consumes its own promise pair without desyncing pipelined replies", async () => { + const psubscribeAck = Buffer.from(">3\r\n$10\r\npsubscribe\r\n$6\r\nnews.*\r\n:1\r\n"); + const pong = Buffer.from("+PONG\r\n"); + + const { server, port } = await createMockRedisServer([psubscribeAck, pong]); + try { + const client = new Bun.RedisClient(`redis://127.0.0.1:${port}`, { + autoReconnect: false, + connectionTimeout: 2000, + }); + + try { + const psubscribed = client.psubscribe("news.*"); + const pinged = client.send("PING", []); + + expect(await psubscribed).toEqual({ type: "psubscribe", data: ["news.*", 1] }); + expect(await pinged).toBe("PONG"); + } finally { + client.close(); + } + } finally { + server.close(); + } + }); +}); diff --git a/test/js/web/crypto/web-crypto.test.ts b/test/js/web/crypto/web-crypto.test.ts index 3e3b20e646c9..1d735d47c3d3 100644 --- a/test/js/web/crypto/web-crypto.test.ts +++ b/test/js/web/crypto/web-crypto.test.ts @@ -544,3 +544,43 @@ describe("SubtleCrypto.deriveBits length", () => { expect(hex(await crypto.subtle.exportKey("raw", aesKey))).toBe(ecSecret); }); }); + +describe("X25519 JWK import", () => { + const x25519Public: JsonWebKey = { + kty: "OKP", + crv: "X25519", + x: "hSDwCYkwp1R0i33ctD73Wg2_Og0mOBr06uFD1q1y5Go", + }; + const x25519Private: JsonWebKey = { + ...x25519Public, + d: "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo", + }; + const outcome = (jwk: JsonWebKey, extractable: boolean, usages: KeyUsage[]) => + crypto.subtle.importKey("jwk", jwk, "X25519", extractable, usages).then( + key => (key instanceof CryptoKey ? "imported" : "other"), + e => e.name, + ); + + it("rejects a JWK whose kty is not OKP", async () => { + expect({ + wrongKty: await outcome({ ...x25519Public, kty: "EC" }, true, []), + okp: await outcome({ ...x25519Public, ext: true }, true, []), + }).toEqual({ wrongKty: "DataError", okp: "imported" }); + }); + + it("rejects a JWK whose key_ops does not include the requested usages", async () => { + expect({ + missingUsage: await outcome({ ...x25519Private, key_ops: ["deriveKey"] }, true, ["deriveBits"]), + supersetOfUsages: await outcome({ ...x25519Private, key_ops: ["deriveBits", "deriveKey"], ext: true }, true, [ + "deriveBits", + ]), + }).toEqual({ missingUsage: "DataError", supersetOfUsages: "imported" }); + }); + + it("rejects a JWK with ext set to false when extractable is requested", async () => { + expect({ + extFalse: await outcome({ ...x25519Public, ext: false }, true, []), + extTrue: await outcome({ ...x25519Public, ext: true }, true, []), + }).toEqual({ extFalse: "DataError", extTrue: "imported" }); + }); +}); diff --git a/test/js/web/encoding/text-decoder.test.js b/test/js/web/encoding/text-decoder.test.js index bd83adaa4de4..de39e2858a1d 100644 --- a/test/js/web/encoding/text-decoder.test.js +++ b/test/js/web/encoding/text-decoder.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { gc as gcTrace, isASAN, withoutAggressiveGC } from "harness"; +import { bunEnv, bunExe, gc as gcTrace, isASAN, normalizeBunSnapshot, tempDir, withoutAggressiveGC } from "harness"; const getByteLength = str => { // returns the byte length of an utf8 string @@ -772,6 +772,78 @@ it("sees writes made by the options.stream getter", () => { expect(result).toBe("BBBB"); }); +it("decodes a stable snapshot of a Uint8Array over a SharedArrayBuffer while another thread writes to it", async () => { + using dir = tempDir("text-decoder-shared", { + "index.js": ` + const N = 4096; + const dataSab = new SharedArrayBuffer(N); + const flagSab = new SharedArrayBuffer(4); + const data = new Uint8Array(dataSab); + const flag = new Int32Array(flagSab); + data.fill(0x61); + const worker = new Worker(new URL("./worker.js", import.meta.url).href); + const ready = new Promise((resolve, reject) => { + worker.onmessage = resolve; + worker.onerror = reject; + }); + worker.postMessage({ dataSab, flagSab }); + await ready; + const decoder = new TextDecoder(); + const allowed = new Set([0x61, 0x3042, 0xfffd]); + let bad = -1; + for (let i = 0; i < 10000 && bad < 0; i++) { + const out = decoder.decode(data); + const limit = Math.min(4, out.length); + for (let j = 0; j < limit; j++) { + const code = out.charCodeAt(j); + if (!allowed.has(code)) { + bad = code; + break; + } + } + } + Atomics.store(flag, 0, 1); + worker.terminate(); + console.log(bad < 0 ? "consistent" : "unexpected code unit 0x" + bad.toString(16)); + if (bad >= 0) process.exitCode = 1; + `, + "worker.js": ` + self.onmessage = function (event) { + const data = new Uint8Array(event.data.dataSab); + const flag = new Int32Array(event.data.flagSab); + postMessage("ready"); + let phase = 0; + while (Atomics.load(flag, 0) === 0) { + if (phase === 0) { + data[0] = 0xe3; + data[1] = 0x81; + data[2] = 0x82; + phase = 1; + } else { + data[0] = 0x61; + data[1] = 0x61; + data[2] = 0x61; + phase = 0; + } + } + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(normalizeBunSnapshot(stdout)).toBe("consistent"); + expect(exitCode).toBe(0); +}); + it.each(["utf-16le", "utf-16be"])( "TextDecoder(%s).decode() should not leak the output buffer", encoding => { diff --git a/test/js/web/fetch/blob-cow.test.ts b/test/js/web/fetch/blob-cow.test.ts index b074e2bf7a74..b48d559c3b08 100644 --- a/test/js/web/fetch/blob-cow.test.ts +++ b/test/js/web/fetch/blob-cow.test.ts @@ -35,3 +35,37 @@ test("Blob.arrayBuffer copy-on-write is not shared", async () => { expect(buf3[0]).toBe(2); expect(buf4[0]).toBe(3); }); + +test("Response.arrayBuffer from a Blob body is not shared with the Blob", async () => { + const blob = new Blob(["hello world"]); + const buf = new Uint8Array(await new Response(blob).arrayBuffer()); + expect(new TextDecoder().decode(buf)).toBe("hello world"); + buf.fill(120); + expect(await blob.text()).toBe("hello world"); +}); + +test("Response.bytes from a Blob body is not shared with the Blob", async () => { + const blob = new Blob(["hello world"]); + const buf = await new Response(blob).bytes(); + expect(new TextDecoder().decode(buf)).toBe("hello world"); + buf.fill(120); + expect(await blob.text()).toBe("hello world"); +}); + +test("Request.arrayBuffer from a Blob body is not shared with the Blob", async () => { + const blob = new Blob(["hello world"]); + const request = new Request("http://localhost/", { method: "POST", body: blob }); + const buf = new Uint8Array(await request.arrayBuffer()); + expect(new TextDecoder().decode(buf)).toBe("hello world"); + buf.fill(120); + expect(await blob.text()).toBe("hello world"); +}); + +test("Response.bytes from a Blob body is not shared with text previously read from the Blob", async () => { + const blob = new Blob(["hello world"]); + const text = await blob.text(); + const buf = await new Response(blob).bytes(); + buf.fill(120); + expect(text).toBe("hello world"); + expect(await blob.text()).toBe("hello world"); +}); diff --git a/test/js/web/fetch/fetch-redirect.test.ts b/test/js/web/fetch/fetch-redirect.test.ts index b10d2a056566..a964d0097ccb 100644 --- a/test/js/web/fetch/fetch-redirect.test.ts +++ b/test/js/web/fetch/fetch-redirect.test.ts @@ -1,5 +1,6 @@ import { expect, it } from "bun:test"; import { bunEnv, bunExe, isASAN } from "harness"; +import net from "node:net"; // https://github.com/oven-sh/bun/issues/12701 it("fetch() preserves body on redirect", async () => { @@ -32,6 +33,99 @@ it("fetch() preserves body on redirect", async () => { expect(await res.text()).toBe("hello"); }); +it.each(["file:/etc/hosts", "file:hosts"])( + "fetch() rejects following a redirect to a Location with a non-HTTP scheme (%s)", + async location => { + let requestsAfterRedirect = 0; + using server = Bun.serve({ + port: 0, + fetch(req) { + const { pathname } = new URL(req.url); + if (pathname === "/start") { + return new Response(null, { status: 302, headers: { Location: location } }); + } + requestsAfterRedirect++; + return new Response("unexpected", { status: 200 }); + }, + }); + + const outcome = await fetch(new URL("/start", server.url)).then( + () => ({ rejected: false as const }), + e => ({ rejected: true as const, code: e.code }), + ); + expect(outcome).toEqual({ rejected: true, code: "UnsupportedRedirectProtocol" }); + expect(requestsAfterRedirect).toBe(0); + }, +); + +// The followed request target must never contain a raw control byte: TAB is +// the only control byte accepted in a header value, and resolving the +// Location against the original URL strips it. +it.each([["tab", "\t", "/ab"]])( + "fetch() normalizes a redirect Location containing a raw %s character before re-requesting", + async (_name, char, expectedTarget) => { + const requests: string[] = []; + const server = net.createServer(socket => { + let data = ""; + socket.on("data", chunk => { + data += chunk.toString("latin1"); + if (data.includes("\r\n\r\n")) { + requests.push(data); + data = ""; + socket.end( + requests.length === 1 + ? `HTTP/1.1 302 Found\r\nLocation: /a${char}b\r\nContent-Length: 0\r\nConnection: close\r\n\r\n` + : `HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok`, + ); + } + }); + }); + try { + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as net.AddressInfo; + const response = await fetch(`http://127.0.0.1:${port}/start`); + expect(await response.text()).toBe("ok"); + expect(response.status).toBe(200); + expect(requests).toHaveLength(2); + const requestLine = requests[1].split("\r\n")[0]; + expect(requestLine).toBe(`GET ${expectedTarget} HTTP/1.1`); + // No byte of the emitted request target is a control character. + for (const byte of Buffer.from(requestLine.split(" ")[1], "latin1")) { + expect(byte).toBeGreaterThan(0x20); + expect(byte).not.toBe(0x7f); + } + } finally { + server.close(); + } + }, +); + +it.each([ + ["vertical tab", "\x0b"], + ["SOH", "\x01"], + ["DEL", "\x7f"], +])("fetch() rejects a redirect response whose Location contains a raw %s character", async (_name, char) => { + const requests: string[] = []; + const server = net.createServer(socket => { + socket.on("data", chunk => { + requests.push(chunk.toString("latin1")); + socket.end(`HTTP/1.1 302 Found\r\nLocation: /a${char}b\r\nContent-Length: 0\r\nConnection: close\r\n\r\n`); + }); + }); + try { + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as net.AddressInfo; + const outcome = await fetch(`http://127.0.0.1:${port}/start`).then( + () => ({ rejected: false as const, code: undefined }), + e => ({ rejected: true as const, code: e.code }), + ); + expect(outcome).toEqual({ rejected: true, code: "Malformed_HTTP_Response" }); + expect(requests).toHaveLength(1); + } finally { + server.close(); + } +}); + // The HTTP client allocates a new URL buffer for every Location hop and stores // it in HTTPClient.redirect so HTTPClient.url can borrow slices from it. Prior // to the fix, assigning the new buffer did not free the previous one, so only diff --git a/test/js/web/fetch/fetch.tls.test.ts b/test/js/web/fetch/fetch.tls.test.ts index f1dfab8b5fe5..fc72eab34134 100644 --- a/test/js/web/fetch/fetch.tls.test.ts +++ b/test/js/web/fetch/fetch.tls.test.ts @@ -421,6 +421,47 @@ describe.concurrent("fetch-tls", () => { } }); + it("runs checkServerIdentity on its own connection for each request that supplies it", async () => { + let connections = 0; + const server = tls.createServer({ key: validTls.key, cert: validTls.cert }, socket => { + connections++; + const chunks: Buffer[] = []; + socket.on("data", chunk => { + chunks.push(chunk); + if (Buffer.concat(chunks).includes("\r\n\r\n")) { + chunks.length = 0; + socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"); + } + }); + socket.on("error", () => {}); + }); + try { + const { promise: listening, resolve: onListening } = Promise.withResolvers(); + server.listen(0, onListening); + await listening; + const port = (server.address() as import("node:net").AddressInfo).port; + const url = `https://127.0.0.1:${port}/`; + + const verified: string[] = []; + const tlsWithCallback = { + ca: validTls.cert, + checkServerIdentity(hostname: string) { + verified.push(hostname); + return undefined; + }, + }; + + expect(await fetch(url, { tls: tlsWithCallback }).then(res => res.text())).toBe("ok"); + expect(await fetch(url, { tls: { ca: validTls.cert } }).then(res => res.text())).toBe("ok"); + expect(await fetch(url, { tls: tlsWithCallback }).then(res => res.text())).toBe("ok"); + + expect(verified).toEqual(["127.0.0.1", "127.0.0.1"]); + expect(connections).toBe(3); + } finally { + server.close(); + } + }); + it("fetch with self-sign certificate tls + rejectUnauthorized: false should not throw", async () => { await createServer(CERT_LOCALHOST_IP, async port => { const urls = [`https://localhost:${port}`, `https://127.0.0.1:${port}`]; diff --git a/test/js/web/fetch/headers.test.ts b/test/js/web/fetch/headers.test.ts index 047cdcdce059..b514a241674c 100644 --- a/test/js/web/fetch/headers.test.ts +++ b/test/js/web/fetch/headers.test.ts @@ -39,6 +39,98 @@ describe("Headers", () => { expect(headers.get("content-type")).toBeNull(); expect(headers.get("user-agent")).toBe("bun"); }); + // Web IDL record conversion interleaves Get with value conversion: mutations made by a + // value's toString() are observed by the keys that follow it. + test("constructing headers from an object interleaves Get with value conversion", () => { + const record: any = { + "x-first": { + toString() { + record["x-second"] = "replaced"; + delete record["x-third"]; + return "first"; + }, + }, + "x-second": "second", + "x-third": "third", + }; + const headers = new Headers(record); + expect(headers.get("x-first")).toBe("first"); + expect(headers.get("x-second")).toBe("replaced"); + expect(headers.get("x-third")).toBeNull(); + }); + test("constructing headers from an object with a getter interleaves Get with value conversion", () => { + const record: any = { + "x-first": { + toString() { + record["x-second"] = "replaced"; + delete record["x-third"]; + return "first"; + }, + }, + "x-second": "second", + "x-third": "third", + }; + Object.defineProperty(record, "x-fourth", { get: () => "fourth", enumerable: true }); + const headers = new Headers(record); + expect(headers.get("x-first")).toBe("first"); + expect(headers.get("x-second")).toBe("replaced"); + expect(headers.get("x-third")).toBeNull(); + expect(headers.get("x-fourth")).toBe("fourth"); + }); + // The literal takes the fast path; redefining "x-second" transitions the structure, so the + // remaining keys must be re-read through [[GetOwnProperty]], which invokes the new getter. + test("constructing headers from an object observes a getter installed by an earlier value's toString", () => { + const record: any = { + "x-first": { + toString() { + Object.defineProperty(record, "x-second", { get: () => "from-getter", enumerable: true }); + return "first"; + }, + }, + "x-second": "second", + "x-third": "third", + }; + expect([...new Headers(record)]).toEqual([ + ["x-first", "first"], + ["x-second", "from-getter"], + ["x-third", "third"], + ]); + }); + test("constructing headers from an object propagates an exception from a getter installed by an earlier value's toString", () => { + const record: any = { + "x-first": { + toString() { + Object.defineProperty(record, "x-second", { + get: () => { + throw new Error("getter boom"); + }, + enumerable: true, + }); + return "first"; + }, + }, + "x-second": "second", + }; + expect(() => new Headers(record)).toThrow("getter boom"); + }); + test("constructing headers from an object keeps own-property semantics after setPrototypeOf mid-conversion", () => { + const proto = { "x-second": "from-proto" }; + const record: any = { + "x-first": { + toString() { + delete record["x-second"]; + Object.setPrototypeOf(record, proto); + return "first"; + }, + }, + "x-second": "second", + "x-third": "third", + }; + expect([...new Headers(record)]).toEqual([ + ["x-first", "first"], + ["x-third", "third"], + ]); + }); test("can create headers from object with duplicates", () => { const headers = new Headers({ "accept": "*/*", diff --git a/test/js/web/url/url.test.ts b/test/js/web/url/url.test.ts index 71bca8c413db..818b1152c45a 100755 --- a/test/js/web/url/url.test.ts +++ b/test/js/web/url/url.test.ts @@ -236,4 +236,24 @@ describe("url", () => { expect(URL.canParse.length).toBe(1); }); }); + + // Web IDL record conversion interleaves Get with value conversion: mutations made by a + // value's toString() are observed by the keys that follow it. Node agrees. + it("URLSearchParams constructed from an object interleaves Get with value conversion", () => { + const record: any = { + first: { + toString() { + record.second = "replaced"; + delete record.third; + return "1"; + }, + }, + second: "2", + third: "3", + }; + const params = new URLSearchParams(record); + expect(params.get("first")).toBe("1"); + expect(params.get("second")).toBe("replaced"); + expect(params.get("third")).toBeNull(); + }); }); diff --git a/test/js/web/websocket/websocket-subprotocol-strict.test.ts b/test/js/web/websocket/websocket-subprotocol-strict.test.ts index 0cb1c35dd7c2..1d48911a424b 100644 --- a/test/js/web/websocket/websocket-subprotocol-strict.test.ts +++ b/test/js/web/websocket/websocket-subprotocol-strict.test.ts @@ -192,4 +192,31 @@ describe("WebSocket strict RFC 6455 subprotocol handling", () => { await using server = await createTestServer(["Sec-WebSocket-Protocol: com.example.chat"]); await expectConnectionSuccess(server.port, ["com.example.chat", "other"], "com.example.chat"); }); + + it("should fail the connection when subprotocols were requested but the server omits the Sec-WebSocket-Protocol header, and should connect without a subprotocol when none were requested and the server sends none", async () => { + await using server = await createTestServer([]); + const { promise: closePromise, resolve: resolveClose } = Promise.withResolvers(); + + const ws = new WebSocket(`ws://localhost:${server.port}`, ["chat", "echo"]); + const onopenMock = mock(() => {}); + ws.onopen = onopenMock; + ws.onclose = close => resolveClose(close); + + const close = await closePromise; + expect(close.code).toBe(1002); + expect(close.reason).toBe("Missing client protocol"); + expect(onopenMock).not.toHaveBeenCalled(); + + const { promise: openPromise, resolve: resolveOpen, reject } = Promise.withResolvers(); + const bare = new WebSocket(`ws://localhost:${server.port}`); + try { + bare.onopen = () => resolveOpen(); + bare.onerror = reject; + bare.onclose = close => reject(new Error(`unexpected close: ${close.code} ${close.reason}`)); + await openPromise; + expect(bare.protocol).toBe(""); + } finally { + bare.terminate(); + } + }); }); diff --git a/test/napi/napi-app/standalone_tests.cpp b/test/napi/napi-app/standalone_tests.cpp index 66a964e55eae..c4d56b006b12 100644 --- a/test/napi/napi-app/standalone_tests.cpp +++ b/test/napi/napi-app/standalone_tests.cpp @@ -2388,7 +2388,109 @@ static napi_value test_napi_create_tsfn_async_context_frame(const Napi::Callback return env.Undefined(); } +static napi_value +test_typedarray_info_byte_offset(const Napi::CallbackInfo &info) { + Napi::Env env = info.Env(); + napi_value typedarray = info[1]; + + napi_typedarray_type type; + size_t length = 0; + void *data = nullptr; + napi_value arraybuffer = nullptr; + size_t byte_offset = SIZE_MAX; + NODE_API_CALL(env, + napi_get_typedarray_info(env, typedarray, &type, &length, &data, + &arraybuffer, &byte_offset)); + + void *arraybuffer_data = nullptr; + size_t arraybuffer_byte_length = 0; + NODE_API_CALL(env, + napi_get_arraybuffer_info(env, arraybuffer, &arraybuffer_data, + &arraybuffer_byte_length)); + + bool data_at_offset = + static_cast(arraybuffer_data) + byte_offset == + static_cast(data); + printf("byte_offset=%zu length=%zu arraybuffer_byte_length=%zu " + "data_is_arraybuffer_data_plus_byte_offset=%s\n", + byte_offset, length, arraybuffer_byte_length, + data_at_offset ? "true" : "false"); + return ok(env); +} + +static napi_value +test_dataview_info_byte_offset(const Napi::CallbackInfo &info) { + Napi::Env env = info.Env(); + napi_value dataview = info[1]; + + size_t byte_length = 0; + void *data = nullptr; + napi_value arraybuffer = nullptr; + size_t byte_offset = SIZE_MAX; + NODE_API_CALL(env, napi_get_dataview_info(env, dataview, &byte_length, &data, + &arraybuffer, &byte_offset)); + + void *arraybuffer_data = nullptr; + size_t arraybuffer_byte_length = 0; + NODE_API_CALL(env, + napi_get_arraybuffer_info(env, arraybuffer, &arraybuffer_data, + &arraybuffer_byte_length)); + + bool data_at_offset = + static_cast(arraybuffer_data) + byte_offset == + static_cast(data); + printf("byte_offset=%zu byte_length=%zu arraybuffer_byte_length=%zu " + "data_is_arraybuffer_data_plus_byte_offset=%s\n", + byte_offset, byte_length, arraybuffer_byte_length, + data_at_offset ? "true" : "false"); + return ok(env); +} + +static napi_value +test_create_arraybuffer_zeroed(const Napi::CallbackInfo &info) { + Napi::Env env = info.Env(); + const size_t size = 1024; + const int rounds = 1024; + int buffers_with_nonzero_bytes = 0; + + for (int i = 0; i < rounds; i++) { + napi_value scratch; + void *scratch_data = nullptr; + NODE_API_CALL(env, + napi_create_arraybuffer(env, size, &scratch_data, &scratch)); + memset(scratch_data, 0xEE, size); + NODE_API_CALL(env, napi_detach_arraybuffer(env, scratch)); + + napi_value probe; + void *probe_data = nullptr; + NODE_API_CALL(env, napi_create_arraybuffer(env, size, &probe_data, &probe)); + const uint8_t *bytes = static_cast(probe_data); + bool all_zero = true; + for (size_t j = 0; j < size; j++) { + if (bytes[j] != 0) { + all_zero = false; + break; + } + } + if (!all_zero) { + buffers_with_nonzero_bytes++; + } + NODE_API_CALL(env, napi_detach_arraybuffer(env, probe)); + } + + if (buffers_with_nonzero_bytes == 0) { + printf("PASS: napi_create_arraybuffer memory is zero-filled\n"); + } else { + printf("FAIL: napi_create_arraybuffer returned memory with nonzero " + "bytes\n"); + } + return ok(env); +} + void register_standalone_tests(Napi::Env env, Napi::Object exports) { + REGISTER_FUNCTION(env, exports, test_typedarray_info_byte_offset); + REGISTER_FUNCTION(env, exports, test_dataview_info_byte_offset); + REGISTER_FUNCTION(env, exports, test_create_arraybuffer_zeroed); REGISTER_FUNCTION(env, exports, test_issue_7685); REGISTER_FUNCTION(env, exports, test_issue_11949); REGISTER_FUNCTION(env, exports, test_napi_get_value_string_utf8_with_buffer); diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index 106294d69063..e5d547fa0641 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -475,6 +475,61 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { }); }); + describe("napi_create_arraybuffer", () => { + it("returns zero-filled memory", async () => { + const output = await checkSameOutput("test_create_arraybuffer_zeroed", []); + expect(output).toBe("PASS: napi_create_arraybuffer memory is zero-filled"); + }); + }); + + describe("napi_get_typedarray_info", () => { + it("reports a zero byte offset for a view over the whole buffer and the view's byte offset for an offset view", async () => { + const whole = await checkSameOutput("test_typedarray_info_byte_offset", "[new Uint8Array(new ArrayBuffer(64))]"); + expect(whole).toBe( + "byte_offset=0 length=64 arraybuffer_byte_length=64 data_is_arraybuffer_data_plus_byte_offset=true", + ); + const offset = await checkSameOutput( + "test_typedarray_info_byte_offset", + "[new Uint8Array(new ArrayBuffer(64), 48)]", + ); + expect(offset).toBe( + "byte_offset=48 length=16 arraybuffer_byte_length=64 data_is_arraybuffer_data_plus_byte_offset=true", + ); + }); + + it("reports the view's byte offset into its backing buffer", async () => { + const output = await checkSameOutput( + "test_typedarray_info_byte_offset", + "[new Uint8Array(new ArrayBuffer(64), 16, 8)]", + ); + expect(output).toBe( + "byte_offset=16 length=8 arraybuffer_byte_length=64 data_is_arraybuffer_data_plus_byte_offset=true", + ); + }); + + it("reports the byte offset in bytes for an element type wider than one byte", async () => { + const output = await checkSameOutput( + "test_typedarray_info_byte_offset", + "[new Int32Array(new ArrayBuffer(64), 32, 4)]", + ); + expect(output).toBe( + "byte_offset=32 length=4 arraybuffer_byte_length=64 data_is_arraybuffer_data_plus_byte_offset=true", + ); + }); + }); + + describe("napi_get_dataview_info", () => { + it("reports the view's byte offset into its backing buffer", async () => { + const output = await checkSameOutput( + "test_dataview_info_byte_offset", + "[new DataView(new ArrayBuffer(64), 24, 8)]", + ); + expect(output).toBe( + "byte_offset=24 byte_length=8 arraybuffer_byte_length=64 data_is_arraybuffer_data_plus_byte_offset=true", + ); + }); + }); + // TODO(@190n) test allocating in a finalizer from a napi module with the right version describe("napi_wrap", () => { @@ -576,12 +631,10 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { ["[1, 2, 3]", false], ["'hello'", false], ]; - it("returns consistent values with node.js", async () => { - for (const [value, expected] of tests) { - // main.js does eval then spread so to pass a single value we need to wrap in an array - const output = await checkSameOutput(`test_is_${kind}`, "[" + value + "]"); - expect(output).toBe(`napi_is_${kind} -> ${expected.toString()}`); - } + // main.js does eval then spread so to pass a single value we need to wrap in an array + it.each(tests)("returns consistent values with node.js for %s", async (value, expected) => { + const output = await checkSameOutput(`test_is_${kind}`, "[" + value + "]"); + expect(output).toBe(`napi_is_${kind} -> ${expected.toString()}`); }); }); diff --git a/test/v8/v8.test.ts b/test/v8/v8.test.ts index 77a4575fc1ea..df73e20ae456 100644 --- a/test/v8/v8.test.ts +++ b/test/v8/v8.test.ts @@ -427,6 +427,252 @@ async function runOn(runtime: Runtime, buildMode: BuildMode, testName: string, j return out.trim(); } +function standaloneAddonFiles(targetName: string, addonCpp: string, runJs: string) { + return { + "package.json": JSON.stringify({ + name: `${targetName}-test`, + version: "1.0.0", + devDependencies: { "node-gyp": "~11.2.0" }, + }), + "binding.gyp": JSON.stringify({ + targets: [ + { + target_name: targetName, + sources: ["addon.cpp"], + cflags: ["-Wno-deprecated-declarations"], + cflags_cc: ["-Wno-deprecated-declarations"], + xcode_settings: { + OTHER_CFLAGS: ["-Wno-deprecated-declarations"], + OTHER_CPLUSPLUSFLAGS: ["-Wno-deprecated-declarations"], + }, + }, + ], + }), + "addon.cpp": addonCpp, + "run.js": runJs, + }; +} + +async function buildStandaloneAddon(cwd: string) { + { + await using install = spawn({ + cmd: [bunExe(), "install", "--ignore-scripts"], + cwd, + env: bunEnv, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await install.exited; + if (exitCode !== 0) { + throw new Error(`install failed: ${exitCode}`); + } + } + await using build = spawn({ + cmd: [ + bunExe(), + "--bun", + "run", + "node-gyp", + "rebuild", + "--release", + "-j", + "max", + "--", + "-Denable_lto=false", + "-Denable_thin_lto=false", + "-Dlto_jobs=", + ], + cwd, + env: bunEnv, + stdin: "inherit", + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, out, err] = await Promise.all([ + build.exited, + new Response(build.stdout).text(), + new Response(build.stderr).text(), + ]); + if (exitCode !== 0) { + throw new Error(`node-gyp rebuild failed with code ${exitCode}:\n${err}\n${out}`); + } +} + +async function runStandaloneAddon(cwd: string) { + await using proc = spawn({ + cmd: [bunExe(), join(cwd, "run.js")], + cwd, + env: bunEnv, + stdin: "inherit", + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const lines = out + .replaceAll(/^\[\w+\].+$/gm, "") + .trim() + .split(/\r?\n/) + .filter(Boolean); + return { lines, err, exitCode }; +} + +describe.skipIf(!canBuildNodeAddons()).todoIf(isBroken && isMusl)("String::Utf8Length surrogates", () => { + it( + "counts each unpaired surrogate as three bytes", + async () => { + using dir = tempDir( + "v8-utf8-length-surrogate", + standaloneAddonFiles( + "utf8lensurrogate", + `#include +#include +#ifdef _WIN32 +#include +#else +#include +#endif + +using namespace v8; + +namespace utf8len_surrogate_test { + +using LegacyUtf8Length = int (*)(const String *, Isolate *); + +LegacyUtf8Length resolve_legacy_utf8_length() { +#ifdef _WIN32 + return reinterpret_cast(reinterpret_cast( + GetProcAddress(GetModuleHandleW(nullptr), + "?Utf8Length@String@v8@@QEBAHPEAVIsolate@2@@Z"))); +#else + return reinterpret_cast( + dlsym(RTLD_DEFAULT, "_ZNK2v86String10Utf8LengthEPNS_7IsolateE")); +#endif +} + +void string_utf8_length(const FunctionCallbackInfo &info) { + Isolate *isolate = info.GetIsolate(); + Local s = info[0].As(); + static const LegacyUtf8Length legacy_utf8_length = resolve_legacy_utf8_length(); + if (legacy_utf8_length == nullptr) { + printf("Utf8Length symbol missing\\n"); + fflush(stdout); + return; + } + printf("Utf8Length = %d, Utf8LengthV2 = %zu\\n", legacy_utf8_length(*s, isolate), + s->Utf8LengthV2(isolate)); + fflush(stdout); +} + +void initialize(Local exports, Local module, + Local context) { + NODE_SET_METHOD(exports, "string_utf8_length", string_utf8_length); +} + +NODE_MODULE_CONTEXT_AWARE(NODE_GYP_MODULE_NAME, initialize) + +} // namespace utf8len_surrogate_test +`, + `const addon = require("./build/Release/utf8lensurrogate"); +addon.string_utf8_length("a\\u00e9b"); +addon.string_utf8_length("a\\ud83d\\ude00b"); +addon.string_utf8_length("a\\ud800b"); +addon.string_utf8_length("\\ud800"); +addon.string_utf8_length("a\\udfffb"); +`, + ), + ); + const cwd = String(dir); + await buildStandaloneAddon(cwd); + const { lines, err, exitCode } = await runStandaloneAddon(cwd); + expect(lines, `stderr:\n${err}`).toEqual([ + "Utf8Length = 4, Utf8LengthV2 = 4", + "Utf8Length = 6, Utf8LengthV2 = 6", + "Utf8Length = 5, Utf8LengthV2 = 5", + "Utf8Length = 3, Utf8LengthV2 = 3", + "Utf8Length = 5, Utf8LengthV2 = 5", + ]); + expect(exitCode).toBe(0); + }, + 10 * 60 * 1000, + ); +}); + +describe.skipIf(!canBuildNodeAddons()).todoIf(isBroken && isMusl)("Number::New", () => { + it( + "returns a numeric NaN for every NaN bit pattern", + async () => { + using dir = tempDir( + "v8-number-nan", + standaloneAddonFiles( + "numbernan", + `#include +#include +#include +#include +#include + +using namespace v8; + +namespace number_nan_test { + +void number_from_bits(const FunctionCallbackInfo &info) { + Isolate *isolate = info.GetIsolate(); + uint64_t hi = static_cast(info[0].As()->Value()); + uint64_t lo = static_cast(info[1].As()->Value()); + uint64_t bits = (hi << 32) | lo; + double value; + memcpy(&value, &bits, sizeof value); + Local num = Number::New(isolate, value); + printf("isnan = %d\\n", std::isnan(num->Value()) ? 1 : 0); + fflush(stdout); + info.GetReturnValue().Set(num); +} + +void initialize(Local exports, Local module, + Local context) { + NODE_SET_METHOD(exports, "number_from_bits", number_from_bits); +} + +NODE_MODULE_CONTEXT_AWARE(NODE_GYP_MODULE_NAME, initialize) + +} // namespace number_nan_test +`, + `const addon = require("./build/Release/numbernan"); +for (const [hi, lo] of [ + [0x7ff80000, 0x00000000], + [0xfffe0000, 0x00010000], + [0xfffe0000, 0x00000000], + [0xffffffff, 0xffffffff], + [0x7ff40000, 0x00000001], +]) { + const value = addon.number_from_bits(hi, lo); + console.log(typeof value, Number.isNaN(value)); +} +`, + ), + ); + const cwd = String(dir); + await buildStandaloneAddon(cwd); + const { lines, err, exitCode } = await runStandaloneAddon(cwd); + expect(lines, `stderr:\n${err}`).toEqual([ + "isnan = 1", + "number true", + "isnan = 1", + "number true", + "isnan = 1", + "number true", + "isnan = 1", + "number true", + "isnan = 1", + "number true", + ]); + expect(exitCode).toBe(0); + }, + 10 * 60 * 1000, + ); +}); + describe.skipIf(!canBuildNodeAddons()).todoIf(isBroken && isMusl)("String::Utf8Length bounds", () => { it( "reports sizes beyond INT32_MAX without wrapping",