diff --git a/.github/workflows/miri.yml b/.github/workflows/miri.yml index 1ee1447252a2..397c0d265b45 100644 --- a/.github/workflows/miri.yml +++ b/.github/workflows/miri.yml @@ -19,6 +19,7 @@ on: - "src/http_types/**" - "src/md/**" - "src/paths/**" + - "src/picohttp/**" - "src/ptr/**" - "src/resolve_builtins/**" - "src/shell_parser/**" diff --git a/.gitignore b/.gitignore index 87b01fe7fa5c..04d795a0e41d 100644 --- a/.gitignore +++ b/.gitignore @@ -159,7 +159,6 @@ codegen-for-zig-team.tar.gz /src/deps/lol*html /src/deps/ls*hpack /src/deps/mimalloc -/src/deps/picohttpparser /src/deps/tinycc /src/deps/WebKit /src/deps/zig diff --git a/CLAUDE.md b/CLAUDE.md index 0b28f133d3f6..9024ed39e277 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,7 +147,7 @@ The Rust side is a Cargo workspace of ~200 crates rooted at `Cargo.toml`. The ke #### Vendored Dependencies (`vendor/`) -Third-party C/C++ libraries are vendored locally and can be read from disk (not git submodules): boringssl (TLS/crypto), brotli, cares (async DNS), hdrhistogram, highway (SIMD), libarchive (tar/zip), libdeflate, libuv (Windows event loop), lolhtml (HTML rewriter), lshpack (HTTP/2 HPACK), lsqpack + lsquic (HTTP/3), mimalloc (allocator), nodejs (headers), picohttpparser, tinycc (FFI JIT, fork: oven-sh/tinycc), WebKit (JavaScriptCore), zlib (zlib-ng), zstd. Build configuration for these is in `scripts/build/deps/*.ts`. +Third-party C/C++ libraries are vendored locally and can be read from disk (not git submodules): boringssl (TLS/crypto), brotli, cares (async DNS), hdrhistogram, highway (SIMD), libarchive (tar/zip), libdeflate, libuv (Windows event loop), lolhtml (HTML rewriter), lshpack (HTTP/2 HPACK), lsqpack + lsquic (HTTP/3), mimalloc (allocator), nodejs (headers), tinycc (FFI JIT, fork: oven-sh/tinycc), WebKit (JavaScriptCore), zlib (zlib-ng), zstd. Build configuration for these is in `scripts/build/deps/*.ts`. ### JavaScript Class Implementation (C++) diff --git a/Cargo.lock b/Cargo.lock index 3f386b432a26..4af282512a23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1436,14 +1436,10 @@ dependencies = [ name = "bun_picohttp" version = "0.0.0" dependencies = [ - "bitflags", "bstr", "bun_core", - "const_format", - "enum-map", - "enumset", - "libc", - "scopeguard", + "bun_highway", + "bun_http_types", "strum", ] diff --git a/LICENSE.md b/LICENSE.md index 1c928cc17409..6083588384d3 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -28,7 +28,6 @@ Bun statically links these libraries: | [`ls-qpack`](https://github.com/litespeedtech/ls-qpack) | MIT | | [`lsquic`](https://github.com/litespeedtech/lsquic) | MIT (portions derived from [Chromium proto-quic](https://github.com/litespeedtech/lsquic/blob/master/LICENSE.chrome), BSD 3-Clause) | | [`mimalloc`](https://github.com/microsoft/mimalloc) | MIT | -| [`picohttp`](https://github.com/h2o/picohttpparser) | dual-licensed under the Perl License or the MIT License | | [`zstd`](https://github.com/facebook/zstd) | dual-licensed under the BSD License or GPLv2 license | | [`simdutf`](https://github.com/simdutf/simdutf) | Apache 2.0 | | [`tinycc`](https://github.com/tinycc/tinycc) | LGPL v2.1 | @@ -45,6 +44,7 @@ Bun statically links these libraries: | [`highway`](https://github.com/google/highway) | Apache 2.0 | | [`uucode`](https://github.com/jacobsandlund/uucode) | MIT | | A fork of [`uWebsockets`](https://github.com/jarred-sumner/uwebsockets) | Apache 2.0 licensed | +| HTTP/1.1 chunked-encoding decoder derived from [`picohttpparser`](https://github.com/h2o/picohttpparser) | MIT licensed | | Parts of [Tigerbeetle's IO code](https://github.com/tigerbeetle/tigerbeetle/blob/532c8b70b9142c17e07737ab6d3da68d7500cbca/src/io/windows.zig#L1) | Apache 2.0 licensed | | `__cxa_thread_atexit` fallback from [LLVM libc++abi](https://github.com/llvm/llvm-project/blob/llvmorg-19.1.0/libcxxabi/src/cxa_thread_atexit.cpp) | Apache 2.0 with LLVM exception | diff --git a/bench/fetch/response-headers-server.mjs b/bench/fetch/response-headers-server.mjs new file mode 100644 index 000000000000..a71af4d8f623 --- /dev/null +++ b/bench/fetch/response-headers-server.mjs @@ -0,0 +1,44 @@ +// Serves a 2-byte body under one of three response-header profiles, keyed by +// path: /small (2 headers), /nginx (14, API-gateway style), /cookies (long +// Location/Set-Cookie values). Pair with response-headers.mjs; run it in its +// own process (ideally pinned to other cores) so it doesn't share a CPU with +// the client: PORT=4001 bun bench/fetch/response-headers-server.mjs +const sets = { + small: { "Content-Type": "text/plain", "X-Id": "1" }, + nginx: { + "Server": "nginx/1.25.3", + "Content-Type": "application/json; charset=utf-8", + "Vary": "Accept-Encoding", + "Cache-Control": "private, max-age=0, no-cache, no-store, must-revalidate", + "ETag": 'W/"bc55-7Zci8Yc4Bq3vJk8pQ0h5nX0m9sE"', + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "SAMEORIGIN", + "X-Request-Id": "3f1c9a7e-5b2d-4c8e-9f0a-1b2c3d4e5f60", + "Access-Control-Allow-Origin": "*", + "Access-Control-Expose-Headers": "X-Request-Id, ETag, Link", + "X-RateLimit-Limit": "5000", + "X-RateLimit-Remaining": "4987", + "X-RateLimit-Reset": "1723025489", + }, + cookies: { + "Location": + "https://accounts.example.com/signin/v2/identifier?continue=https%3A%2F%2Fmail.example.com%2Fmail%2F&service=mail&flowName=GlifWebSignIn&flowEntry=ServiceLogin", + "Set-Cookie": + "__Host-SESSION=CgQIARAB.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=2592000", + "Content-Type": "text/html; charset=UTF-8", + "Alt-Svc": 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000', + }, +}; +const responses = Object.fromEntries(Object.entries(sets).map(([k, h]) => [k, { headers: new Headers(h) }])); +const server = Bun.serve({ + port: Number(process.env.PORT || 0), + reusePort: true, + fetch(req) { + const kind = new URL(req.url).pathname.slice(1) || "nginx"; + const init = responses[kind]; + if (!init) return new Response(`unknown profile "${kind}"; use one of ${Object.keys(responses)}`, { status: 404 }); + return new Response("ok", init); + }, +}); +console.log(server.port); diff --git a/bench/fetch/response-headers.mjs b/bench/fetch/response-headers.mjs new file mode 100644 index 000000000000..8f4f8de92553 --- /dev/null +++ b/bench/fetch/response-headers.mjs @@ -0,0 +1,57 @@ +// Client CPU per fetch() against response-headers-server.mjs. +// bun bench/fetch/response-headers.mjs [small|nginx|cookies] [body|get|iter] [requests] [concurrency] +// mode: body = read the body only; get = also headers.get(); iter = also iterate all headers. +// Reports requests/s and this process's CPU time per request (user+sys across +// all threads), which is the number to compare between builds when the server +// is the throughput bottleneck. +const [port, kind = "nginx", mode = "body", total = 200000, conc = 64] = process.argv.slice(2); +const url = `http://127.0.0.1:${port}/${kind}`; +const N = Number(total), + C = Number(conc); +const isCount = n => Number.isSafeInteger(n) && n >= 1; +if ( + !(isCount(Number(port)) && port <= 65535) || + !["body", "get", "iter"].includes(mode) || + !isCount(N) || + !isCount(C) +) { + console.error("usage: response-headers.mjs [small|nginx|cookies] [body|get|iter] [requests] [concurrency]"); + process.exit(1); +} +let sink = 0; +async function one() { + const r = await fetch(url); + if (mode === "get") sink += r.headers.get("content-type").length; + else if (mode === "iter") for (const [k, v] of r.headers) sink += k.length + v.length; + const b = await r.arrayBuffer(); + sink += b.byteLength; +} +// warmup +await Promise.all(Array.from({ length: C }, one)); +const cpu0 = process.cpuUsage(); +const t0 = performance.now(); +let started = 0; +async function worker() { + while (started < N) { + started++; + await one(); + } +} +await Promise.all(Array.from({ length: C }, worker)); +const t = performance.now() - t0; +const cpu = process.cpuUsage(cpu0); +const cpuMs = (cpu.user + cpu.system) / 1000; +console.log( + JSON.stringify({ + kind, + mode, + N, + C, + ms: +t.toFixed(0), + rps: Math.round(N / (t / 1000)), + cpu_us_per_req: +((cpuMs * 1000) / N).toFixed(2), + user_ms: Math.round(cpu.user / 1000), + sys_ms: Math.round(cpu.system / 1000), + sink, + }), +); diff --git a/docs/project/license.mdx b/docs/project/license.mdx index 8f4fa65e7c4d..654b6bc96fb4 100644 --- a/docs/project/license.mdx +++ b/docs/project/license.mdx @@ -30,7 +30,6 @@ Bun statically links these libraries: | [`libarchive`](https://github.com/libarchive/libarchive) | [several licenses](https://github.com/libarchive/libarchive/blob/master/COPYING) | | [`lol-html`](https://github.com/cloudflare/lol-html/tree/master/c-api) | BSD 3-Clause | | [`mimalloc`](https://github.com/microsoft/mimalloc) | MIT | -| [`picohttp`](https://github.com/h2o/picohttpparser) | dual-licensed under the Perl License or the MIT License | | [`zstd`](https://github.com/facebook/zstd) | dual-licensed under the BSD License or GPLv2 license | | [`simdutf`](https://github.com/simdutf/simdutf) | Apache 2.0 | | [`tinycc`](https://github.com/tinycc/tinycc) | LGPL v2.1 | @@ -42,6 +41,7 @@ Bun statically links these libraries: | [`libuv`](https://github.com/libuv/libuv) (on Windows) | MIT | | [`libdeflate`](https://github.com/ebiggers/libdeflate) | MIT | | A fork of [`uWebsockets`](https://github.com/jarred-sumner/uwebsockets) | Apache 2.0 licensed | +| HTTP/1.1 chunked-encoding decoder derived from [`picohttpparser`](https://github.com/h2o/picohttpparser) | MIT licensed | | Parts of [Tigerbeetle's IO code](https://github.com/tigerbeetle/tigerbeetle/blob/532c8b70b9142c17e07737ab6d3da68d7500cbca/src/io/windows.zig#L1) | Apache 2.0 licensed | ## Polyfills diff --git a/scripts/build/bun.ts b/scripts/build/bun.ts index be8aa5a23074..adba4e56161a 100644 --- a/scripts/build/bun.ts +++ b/scripts/build/bun.ts @@ -189,9 +189,7 @@ export function emitBun(n: Ninja, cfg: Config, sources: Sources): BunOutput { } } - // Collect all dep lib paths, include dirs, output stamps, and directly- - // compiled source files (deps like picohttpparser that provide .c files - // instead of a .a — we compile those alongside bun's own sources). + // Collect all dep lib paths, include dirs, and output stamps. const depLibs: string[] = []; const depObjects: string[] = []; const depIncludes: string[] = []; @@ -325,9 +323,6 @@ export function emitBun(n: Ninja, cfg: Config, sources: Sources): BunOutput { noPchSources.add(rescleBinding); } - // Deps with provides.sources compiled in the loop below so each dep's - // phony can point at its own .o files. - // Codegen .cpp files — compiled like regular sources. cxxSources.push(...codegen.cppSources); cxxSources.push(...codegen.bindgenV2Cpp); @@ -387,16 +382,15 @@ export function emitBun(n: Ninja, cfg: Config, sources: Sources): BunOutput { // Compile all .c files. No PCH — dep signal applied directly. const cObjects: string[] = []; - const compileC = (src: string): string => { - const obj = cc(n, cfg, src, { - flags: cFlagsFull, - implicitInputs: depHeaderSignal, - orderOnlyInputs: codegenOrderOnly, - }); - cObjects.push(obj); - return obj; - }; - for (const src of cSources) compileC(src); + for (const src of cSources) { + cObjects.push( + cc(n, cfg, src, { + flags: cFlagsFull, + implicitInputs: depHeaderSignal, + orderOnlyInputs: codegenOrderOnly, + }), + ); + } // InternalModuleRegistryConstants.S — `.incbin`s the bundled JS module sources // so InternalModuleRegistry.cpp sees a tiny {offset, length} table instead of @@ -411,16 +405,6 @@ export function emitBun(n: Ninja, cfg: Config, sources: Sources): BunOutput { }), ); - // Deps that contribute source files for bun to compile directly (via - // provides.sources) instead of building a lib. Compile them here with - // bun's full flag set and give each a phony so `--target ` builds - // its .o files. libs.length === 0 guard: deps with a build step already - // got a phony in resolveDep — don't emit a duplicate. - for (const d of deps) { - if (d.sources.length === 0 || d.libs.length > 0) continue; - n.phony(d.name, d.sources.map(compileC)); - } - // Dep objects (when !cfg.archiveDeps) are linked alongside bun's own // objects — same response file, same archive in cpp-only mode. With // cfg.archiveDeps they live in depLibs as .a files instead. diff --git a/scripts/build/deps/index.ts b/scripts/build/deps/index.ts index e30ce13fd789..57d7ce4b28fd 100644 --- a/scripts/build/deps/index.ts +++ b/scripts/build/deps/index.ts @@ -28,7 +28,6 @@ import { lsqpack } from "./lsqpack.ts"; import { lsquic } from "./lsquic.ts"; import { mimalloc } from "./mimalloc.ts"; import { nodejsHeaders } from "./nodejs-headers.ts"; -import { picohttpparser } from "./picohttpparser.ts"; import { sqlite } from "./sqlite.ts"; import { tinycc } from "./tinycc.ts"; import { webkit } from "./webkit.ts"; @@ -43,8 +42,7 @@ import { zstd } from "./zstd.ts"; * boringssl near the end — many things depend on crypto/ssl symbols. */ export const allDeps: readonly Dependency[] = [ - // Header-only / source-only first — no link order concerns. - picohttpparser, + // Header-only first — no link order concerns. nodejsHeaders, zlib, @@ -95,7 +93,6 @@ export { lsquic, mimalloc, nodejsHeaders, - picohttpparser, sqlite, tinycc, webkit, diff --git a/scripts/build/deps/picohttpparser.ts b/scripts/build/deps/picohttpparser.ts deleted file mode 100644 index 5b5dea6d1ba0..000000000000 --- a/scripts/build/deps/picohttpparser.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * picohttpparser — tiny HTTP parser. Single .c file, no build system. - * - * No `.a` produced — bun compiles `picohttpparser.c` directly into its - * binary. `provides.sources` tells the build system which files; they're - * declared as implicit outputs of the fetch rule so ninja knows they - * exist once fetch completes (otherwise: "missing and no known rule to - * make it" on fresh checkouts). - */ - -import type { Dependency } from "../source.ts"; - -const PICOHTTPPARSER_COMMIT = "066d2b1e9ab820703db0837a7255d92d30f0c9f5"; - -export const picohttpparser: Dependency = { - name: "picohttpparser", - versionMacro: "PICOHTTPPARSER", - - source: () => ({ - kind: "github-archive", - repo: "h2o/picohttpparser", - commit: PICOHTTPPARSER_COMMIT, - }), - - build: () => ({ kind: "none" }), - - provides: () => ({ - libs: [], - includes: ["."], - sources: ["picohttpparser.c"], - }), -}; diff --git a/scripts/build/flags.ts b/scripts/build/flags.ts index f0ba7dfe1f7b..b028d3bea065 100644 --- a/scripts/build/flags.ts +++ b/scripts/build/flags.ts @@ -1531,7 +1531,6 @@ export function bunIncludes(cfg: Config): string[] { join(cwd, "src/uws_sys"), codegenDir, vendorDir, - join(vendorDir, "picohttpparser"), join(vendorDir, "zlib"), // NODEJS_HEADERS_PATH comes from the nodejs dep; added separately ]; diff --git a/scripts/build/source.ts b/scripts/build/source.ts index 9e6a932da208..1fea50a6d1d3 100644 --- a/scripts/build/source.ts +++ b/scripts/build/source.ts @@ -390,16 +390,6 @@ export interface Provides { includes: string[]; /** Preprocessor defines to add to bun's compilation. */ defines?: string[]; - /** - * Source files (relative to the SOURCE dir) that bun compiles directly - * into its own binary — no nested build producing a `.a`. Declared as - * implicit outputs of the fetch rule so ninja knows where they come from; - * bun.ts adds them to its C/C++ source lists. - * - * Most deps provide `.a` files via `libs`. This is for the rare case of - * a single-file dep with no build system (picohttpparser: one .c file). - */ - sources?: string[]; } /** @@ -483,11 +473,6 @@ export interface ResolvedDep { /** Absolute include paths for -I flags. */ includes: string[]; defines: string[]; - /** - * Absolute paths to .c/.cpp files bun compiles directly (from - * Provides.sources). Empty for most deps — they provide .a files. - */ - sources: string[]; /** * The final build output(s). Use these as implicit inputs on anything * downstream that needs this dep built first. @@ -730,15 +715,10 @@ export function resolveDep( // we don't want patches changing between emitFetch and the hash check. const patches = dep.patches === undefined ? [] : typeof dep.patches === "function" ? dep.patches(cfg) : dep.patches; - // Sources bun compiles directly (from Provides.sources). Resolved to - // absolute paths for (a) the ResolvedDep return and (b) declaring as - // implicit outputs of fetch so ninja knows where they come from. - const resolvedSources = (provides.sources ?? []).map(s => resolve(srcDir, s)); - - // DirectBuild sources are ALSO compiled in our ninja graph, so they need - // the same implicit-output-of-fetch treatment. Include the codegen tool - // source, its input, and any HeaderSubst templates — all read at build - // time from the fetched tree. + // DirectBuild sources are compiled in our ninja graph, so they must be + // declared as implicit outputs of fetch so ninja knows where they come + // from. Include the codegen tool source, its input, and any HeaderSubst + // templates — all read at build time from the fetched tree. const directSources: string[] = []; if (buildSpec.kind === "direct") { for (const s of buildSpec.sources) { @@ -762,7 +742,7 @@ export function resolveDep( // (CMakeLists.txt) as the stamp. Editing it → reconfigure. let sourceStamp: string; if (source.kind === "github-archive") { - sourceStamp = emitFetch(n, cfg, dep.name, source, patches, [...resolvedSources, ...directSources]); + sourceStamp = emitFetch(n, cfg, dep.name, source, patches, directSources); } else { // Local/in-tree: no .ref to write. Use the build system's manifest file // as the stamp — touching it triggers reconfigure/rebuild. @@ -842,9 +822,7 @@ export function resolveDep( // are link inputs, not include-order dependencies). outputs = result.headerOutputs; } else { - // No build step. Source stamp is the only output. For deps with - // provides.sources (picohttpparser), emitBun adds a phony pointing at - // the compiled .o files so `--target ` actually compiles them. + // No build step (header-only). Source stamp is the only output. libs = []; outputs = [sourceStamp]; } @@ -868,7 +846,6 @@ export function resolveDep( objects, includes, defines: provides.defines ?? [], - sources: resolvedSources, outputs, }; } @@ -933,7 +910,7 @@ export function computeDepLibs(cfg: Config, dep: Dependency): string[] { return [resolve(buildDir, `${cfg.libPrefix}${dep.name}${cfg.libSuffix}`)]; } - // none: no libs (header-only or directly-compiled sources). + // none: no libs (header-only). return []; } @@ -974,7 +951,7 @@ function emitFetch( n.build({ outputs: [refStamp], - // Source files bun compiles directly (picohttpparser.c). Declaring + // Source files bun compiles directly (DirectBuild deps). Declaring // them as outputs tells ninja "fetch creates these" — otherwise ninja // errors "missing and no known rule to make it" on fresh checkouts. ...(compiledSources.length > 0 && { implicitOutputs: compiledSources }), @@ -1062,7 +1039,6 @@ function emitPrebuilt( objects: [], includes, defines: provides.defines ?? [], - sources: [], outputs, }; } diff --git a/scripts/rust-miri.ts b/scripts/rust-miri.ts index 6d690e4a82cf..527ff3d1186a 100644 --- a/scripts/rust-miri.ts +++ b/scripts/rust-miri.ts @@ -41,6 +41,7 @@ const MIRI_CRATES = [ "bun_http_types", "bun_md", "bun_paths", + "bun_picohttp", "bun_ptr", "bun_resolve_builtins", "bun_shell_parser", diff --git a/scripts/verify-baseline-static/allowlist-aarch64.txt b/scripts/verify-baseline-static/allowlist-aarch64.txt index 70a2d22281d5..59dc4624cdd3 100644 --- a/scripts/verify-baseline-static/allowlist-aarch64.txt +++ b/scripts/verify-baseline-static/allowlist-aarch64.txt @@ -5,7 +5,7 @@ # ---------------------------------------------------------------------------- # Bun's Highway SVE/SVE2 targets. Gate: hwy::SupportedTargets via getauxval(AT_HWCAP). -# (161 symbols) +# (165 symbols) # ---------------------------------------------------------------------------- _ZN3bun10N_SVE2_12810MemMemImplEPKhmS2_m [SVE] _ZN3bun10N_SVE2_12811MemRMemImplEPKhmS2_m [SVE] @@ -39,6 +39,7 @@ _ZN3bun10N_SVE2_12826IndexOfFirstAsciiUpperImplEPKhm [S _ZN3bun10N_SVE2_12826IndexOfHTMLEscapeChar8ImplEPKhm [SVE] _ZN3bun10N_SVE2_12827IndexOfHTMLEscapeChar16ImplEPKtm [SVE] _ZN3bun10N_SVE2_12828IndexOfFirstAsciiUpper16ImplEPKtm [SVE] +_ZN3bun10N_SVE2_12818IndexOfHttpCtlImplEPKhm [SVE] _ZN3bun10N_SVE2_12828IndexOfNewlineOrNonASCIIImplEPKhm [SVE] _ZN3bun10N_SVE2_12833VisibleLatin1WidthExcludeANSIImplEPKhm [SVE] _ZN3bun10N_SVE2_12835IndexOfSpaceOrNewlineOrNonASCIIImplEPKhm [SVE] @@ -79,6 +80,7 @@ _ZN3bun5N_SVE26IndexOfFirstAsciiUpperImplEPKhm [S _ZN3bun5N_SVE26IndexOfHTMLEscapeChar8ImplEPKhm [SVE] _ZN3bun5N_SVE27IndexOfHTMLEscapeChar16ImplEPKtm [SVE] _ZN3bun5N_SVE28IndexOfFirstAsciiUpper16ImplEPKtm [SVE] +_ZN3bun5N_SVE18IndexOfHttpCtlImplEPKhm [SVE] _ZN3bun5N_SVE28IndexOfNewlineOrNonASCIIImplEPKhm [SVE] _ZN3bun5N_SVE33VisibleLatin1WidthExcludeANSIImplEPKhm [SVE] _ZN3bun5N_SVE35IndexOfSpaceOrNewlineOrNonASCIIImplEPKhm [SVE] @@ -119,6 +121,7 @@ _ZN3bun6N_SVE226IndexOfFirstAsciiUpperImplEPKhm [S _ZN3bun6N_SVE226IndexOfHTMLEscapeChar8ImplEPKhm [SVE] _ZN3bun6N_SVE227IndexOfHTMLEscapeChar16ImplEPKtm [SVE] _ZN3bun6N_SVE228IndexOfFirstAsciiUpper16ImplEPKtm [SVE] +_ZN3bun6N_SVE218IndexOfHttpCtlImplEPKhm [SVE] _ZN3bun6N_SVE228IndexOfNewlineOrNonASCIIImplEPKhm [SVE] _ZN3bun6N_SVE233VisibleLatin1WidthExcludeANSIImplEPKhm [SVE] _ZN3bun6N_SVE235IndexOfSpaceOrNewlineOrNonASCIIImplEPKhm [SVE] @@ -159,6 +162,7 @@ _ZN3bun9N_SVE_25626IndexOfFirstAsciiUpperImplEPKhm [S _ZN3bun9N_SVE_25626IndexOfHTMLEscapeChar8ImplEPKhm [SVE] _ZN3bun9N_SVE_25627IndexOfHTMLEscapeChar16ImplEPKtm [SVE] _ZN3bun9N_SVE_25628IndexOfFirstAsciiUpper16ImplEPKtm [SVE] +_ZN3bun9N_SVE_25618IndexOfHttpCtlImplEPKhm [SVE] _ZN3bun9N_SVE_25628IndexOfNewlineOrNonASCIIImplEPKhm [SVE] _ZN3bun9N_SVE_25633VisibleLatin1WidthExcludeANSIImplEPKhm [SVE] _ZN3bun9N_SVE_25635IndexOfSpaceOrNewlineOrNonASCIIImplEPKhm [SVE] diff --git a/scripts/verify-baseline-static/allowlist-x64-windows.txt b/scripts/verify-baseline-static/allowlist-x64-windows.txt index 103da35b999d..054a6e07d005 100644 --- a/scripts/verify-baseline-static/allowlist-x64-windows.txt +++ b/scripts/verify-baseline-static/allowlist-x64-windows.txt @@ -437,7 +437,7 @@ ctiMasmProbeTrampolineAVX [AVX] # ---------------------------------------------------------------------------- # Highway. MSVC-mangled bun::N_AVX* names. -# (243 symbols) +# (249 symbols) # ---------------------------------------------------------------------------- bun::N_AVX10_2::ContainsNewlineOrNonASCIIOrQuoteImpl [AVX, AVX512BW, AVX512F] bun::N_AVX10_2::CopyAsciiPrefixImpl [AVX, AVX512BW, AVX512F, AVX512VL] @@ -467,6 +467,7 @@ bun::N_AVX10_2::IndexOfInterestingCharacterInMultilineCommentImpl [AVX, AVX51 bun::N_AVX10_2::IndexOfInterestingCharacterInStringLiteralImpl [AVX, AVX512BW, AVX512F] bun::N_AVX10_2::IndexOfNeedsEscapeForJavaScriptStringImplBacktick [AVX, AVX512BW, AVX512F] bun::N_AVX10_2::IndexOfNeedsEscapeForJavaScriptStringImplQuote [AVX, AVX512BW, AVX512F] +bun::N_AVX10_2::IndexOfHttpCtlImpl [AVX, AVX512BW, AVX512F] bun::N_AVX10_2::IndexOfNewlineOrNonASCIIImpl [AVX, AVX512BW, AVX512F] bun::N_AVX10_2::IndexOfNewlineOrNonASCIIOrHashOrAtImpl [AVX, AVX512BW, AVX512F] bun::N_AVX10_2::IndexOfSpaceOrNewlineOrNonASCIIImpl [AVX, AVX512BW, AVX512F] @@ -504,6 +505,7 @@ bun::N_AVX2::IndexOfInterestingCharacterInMultilineCommentImpl [AVX, AVX2] bun::N_AVX2::IndexOfInterestingCharacterInStringLiteralImpl [AVX, AVX2] bun::N_AVX2::IndexOfNeedsEscapeForJavaScriptStringImplBacktick [AVX, AVX2] bun::N_AVX2::IndexOfNeedsEscapeForJavaScriptStringImplQuote [AVX, AVX2] +bun::N_AVX2::IndexOfHttpCtlImpl [AVX, AVX2] bun::N_AVX2::IndexOfNewlineOrNonASCIIImpl [AVX, AVX2] bun::N_AVX2::IndexOfNewlineOrNonASCIIOrHashOrAtImpl [AVX, AVX2] bun::N_AVX2::IndexOfSpaceOrNewlineOrNonASCIIImpl [AVX, AVX2] @@ -545,6 +547,7 @@ bun::N_AVX3::IndexOfInterestingCharacterInMultilineCommentImpl [AVX, AVX51 bun::N_AVX3::IndexOfInterestingCharacterInStringLiteralImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3::IndexOfNeedsEscapeForJavaScriptStringImplBacktick [AVX, AVX512BW, AVX512F] bun::N_AVX3::IndexOfNeedsEscapeForJavaScriptStringImplQuote [AVX, AVX512BW, AVX512F] +bun::N_AVX3::IndexOfHttpCtlImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3::IndexOfNewlineOrNonASCIIImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3::IndexOfNewlineOrNonASCIIOrHashOrAtImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3::IndexOfSpaceOrNewlineOrNonASCIIImpl [AVX, AVX512BW, AVX512F] @@ -587,6 +590,7 @@ bun::N_AVX3_DL::IndexOfInterestingCharacterInMultilineCommentImpl [AVX, AVX51 bun::N_AVX3_DL::IndexOfInterestingCharacterInStringLiteralImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3_DL::IndexOfNeedsEscapeForJavaScriptStringImplBacktick [AVX, AVX512BW, AVX512F] bun::N_AVX3_DL::IndexOfNeedsEscapeForJavaScriptStringImplQuote [AVX, AVX512BW, AVX512F] +bun::N_AVX3_DL::IndexOfHttpCtlImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3_DL::IndexOfNewlineOrNonASCIIImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3_DL::IndexOfNewlineOrNonASCIIOrHashOrAtImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3_DL::IndexOfSpaceOrNewlineOrNonASCIIImpl [AVX, AVX512BW, AVX512F] @@ -628,6 +632,7 @@ bun::N_AVX3_SPR::IndexOfInterestingCharacterInMultilineCommentImpl [AVX, AVX51 bun::N_AVX3_SPR::IndexOfInterestingCharacterInStringLiteralImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3_SPR::IndexOfNeedsEscapeForJavaScriptStringImplBacktick [AVX, AVX512BW, AVX512F] bun::N_AVX3_SPR::IndexOfNeedsEscapeForJavaScriptStringImplQuote [AVX, AVX512BW, AVX512F] +bun::N_AVX3_SPR::IndexOfHttpCtlImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3_SPR::IndexOfNewlineOrNonASCIIImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3_SPR::IndexOfNewlineOrNonASCIIOrHashOrAtImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3_SPR::IndexOfSpaceOrNewlineOrNonASCIIImpl [AVX, AVX512BW, AVX512F] @@ -669,6 +674,7 @@ bun::N_AVX3_ZEN4::IndexOfInterestingCharacterInMultilineCommentImpl [AVX, AVX51 bun::N_AVX3_ZEN4::IndexOfInterestingCharacterInStringLiteralImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3_ZEN4::IndexOfNeedsEscapeForJavaScriptStringImplBacktick [AVX, AVX512BW, AVX512F] bun::N_AVX3_ZEN4::IndexOfNeedsEscapeForJavaScriptStringImplQuote [AVX, AVX512BW, AVX512F] +bun::N_AVX3_ZEN4::IndexOfHttpCtlImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3_ZEN4::IndexOfNewlineOrNonASCIIImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3_ZEN4::IndexOfNewlineOrNonASCIIOrHashOrAtImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3_ZEN4::IndexOfSpaceOrNewlineOrNonASCIIImpl [AVX, AVX512BW, AVX512F] diff --git a/scripts/verify-baseline-static/allowlist-x64.txt b/scripts/verify-baseline-static/allowlist-x64.txt index 4606dd92bfdb..168440cec043 100644 --- a/scripts/verify-baseline-static/allowlist-x64.txt +++ b/scripts/verify-baseline-static/allowlist-x64.txt @@ -459,7 +459,7 @@ ctiMasmProbeTrampolineAVX [AVX] # ---------------------------------------------------------------------------- # Bun's Highway SIMD. Gate: HWY_DYNAMIC_DISPATCH via hwy::SupportedTargets. -# (243 symbols) +# (249 symbols) # ---------------------------------------------------------------------------- _ZN3bun10N_AVX3_SPR10MemMemImplEPKhmS2_m [AVX, AVX512BW, AVX512F, BMI1] _ZN3bun10N_AVX3_SPR11MemRMemImplEPKhmS2_m [AVX, AVX512BW, AVX512F, BMI2] @@ -493,6 +493,7 @@ _ZN3bun10N_AVX3_SPR26IndexOfFirstAsciiUpperImplEPKhm [A _ZN3bun10N_AVX3_SPR26IndexOfHTMLEscapeChar8ImplEPKhm [AVX, AVX512BW, AVX512F] _ZN3bun10N_AVX3_SPR27IndexOfHTMLEscapeChar16ImplEPKtm [AVX, AVX512BW, AVX512F] _ZN3bun10N_AVX3_SPR28IndexOfFirstAsciiUpper16ImplEPKtm [AVX, AVX512BW, AVX512F] +_ZN3bun10N_AVX3_SPR18IndexOfHttpCtlImplEPKhm [AVX, AVX512BW, AVX512F] _ZN3bun10N_AVX3_SPR28IndexOfNewlineOrNonASCIIImplEPKhm [AVX, AVX512BW, AVX512F] _ZN3bun10N_AVX3_SPR33VisibleLatin1WidthExcludeANSIImplEPKhm [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, BMI1, BMI2] _ZN3bun10N_AVX3_SPR35IndexOfSpaceOrNewlineOrNonASCIIImplEPKhm [AVX, AVX512BW, AVX512F] @@ -534,6 +535,7 @@ _ZN3bun11N_AVX3_ZEN426IndexOfFirstAsciiUpperImplEPKhm [A _ZN3bun11N_AVX3_ZEN426IndexOfHTMLEscapeChar8ImplEPKhm [AVX, AVX512BW, AVX512F] _ZN3bun11N_AVX3_ZEN427IndexOfHTMLEscapeChar16ImplEPKtm [AVX, AVX512BW, AVX512F] _ZN3bun11N_AVX3_ZEN428IndexOfFirstAsciiUpper16ImplEPKtm [AVX, AVX512BW, AVX512F] +_ZN3bun11N_AVX3_ZEN418IndexOfHttpCtlImplEPKhm [AVX, AVX512BW, AVX512F] _ZN3bun11N_AVX3_ZEN428IndexOfNewlineOrNonASCIIImplEPKhm [AVX, AVX512BW, AVX512F] _ZN3bun11N_AVX3_ZEN433VisibleLatin1WidthExcludeANSIImplEPKhm [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, BMI1, BMI2] _ZN3bun11N_AVX3_ZEN435IndexOfSpaceOrNewlineOrNonASCIIImplEPKhm [AVX, AVX512BW, AVX512F] @@ -575,6 +577,7 @@ _ZN3bun6N_AVX226IndexOfFirstAsciiUpperImplEPKhm [A _ZN3bun6N_AVX226IndexOfHTMLEscapeChar8ImplEPKhm [AVX, AVX2] _ZN3bun6N_AVX227IndexOfHTMLEscapeChar16ImplEPKtm [AVX, AVX2, BMI2] _ZN3bun6N_AVX228IndexOfFirstAsciiUpper16ImplEPKtm [AVX, AVX2, BMI2] +_ZN3bun6N_AVX218IndexOfHttpCtlImplEPKhm [AVX, AVX2] _ZN3bun6N_AVX228IndexOfNewlineOrNonASCIIImplEPKhm [AVX, AVX2] _ZN3bun6N_AVX233VisibleLatin1WidthExcludeANSIImplEPKhm [AVX, AVX2, BMI1, BMI2] _ZN3bun6N_AVX235IndexOfSpaceOrNewlineOrNonASCIIImplEPKhm [AVX, AVX2] @@ -617,6 +620,7 @@ _ZN3bun6N_AVX326IndexOfFirstAsciiUpperImplEPKhm [A _ZN3bun6N_AVX326IndexOfHTMLEscapeChar8ImplEPKhm [AVX, AVX512BW, AVX512F] _ZN3bun6N_AVX327IndexOfHTMLEscapeChar16ImplEPKtm [AVX, AVX512BW, AVX512F] _ZN3bun6N_AVX328IndexOfFirstAsciiUpper16ImplEPKtm [AVX, AVX512BW, AVX512F] +_ZN3bun6N_AVX318IndexOfHttpCtlImplEPKhm [AVX, AVX512BW, AVX512F] _ZN3bun6N_AVX328IndexOfNewlineOrNonASCIIImplEPKhm [AVX, AVX512BW, AVX512F] _ZN3bun6N_AVX333VisibleLatin1WidthExcludeANSIImplEPKhm [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, BMI1, BMI2] _ZN3bun6N_AVX335IndexOfSpaceOrNewlineOrNonASCIIImplEPKhm [AVX, AVX512BW, AVX512F] @@ -654,6 +658,7 @@ _ZN3bun9N_AVX10_226IndexOfFirstAsciiUpperImplEPKhm [A _ZN3bun9N_AVX10_226IndexOfHTMLEscapeChar8ImplEPKhm [AVX, AVX512BW, AVX512F] _ZN3bun9N_AVX10_227IndexOfHTMLEscapeChar16ImplEPKtm [AVX, AVX512BW, AVX512F] _ZN3bun9N_AVX10_228IndexOfFirstAsciiUpper16ImplEPKtm [AVX, AVX512BW, AVX512F] +_ZN3bun9N_AVX10_218IndexOfHttpCtlImplEPKhm [AVX, AVX512BW, AVX512F] _ZN3bun9N_AVX10_228IndexOfNewlineOrNonASCIIImplEPKhm [AVX, AVX512BW, AVX512F] _ZN3bun9N_AVX10_233VisibleLatin1WidthExcludeANSIImplEPKhm [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, BMI1, BMI2] _ZN3bun9N_AVX10_235IndexOfSpaceOrNewlineOrNonASCIIImplEPKhm [AVX, AVX512BW, AVX512F] @@ -695,6 +700,7 @@ _ZN3bun9N_AVX3_DL26IndexOfFirstAsciiUpperImplEPKhm [A _ZN3bun9N_AVX3_DL26IndexOfHTMLEscapeChar8ImplEPKhm [AVX, AVX512BW, AVX512F] _ZN3bun9N_AVX3_DL27IndexOfHTMLEscapeChar16ImplEPKtm [AVX, AVX512BW, AVX512F] _ZN3bun9N_AVX3_DL28IndexOfFirstAsciiUpper16ImplEPKtm [AVX, AVX512BW, AVX512F] +_ZN3bun9N_AVX3_DL18IndexOfHttpCtlImplEPKhm [AVX, AVX512BW, AVX512F] _ZN3bun9N_AVX3_DL28IndexOfNewlineOrNonASCIIImplEPKhm [AVX, AVX512BW, AVX512F] _ZN3bun9N_AVX3_DL33VisibleLatin1WidthExcludeANSIImplEPKhm [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, BMI1, BMI2] _ZN3bun9N_AVX3_DL35IndexOfSpaceOrNewlineOrNonASCIIImplEPKhm [AVX, AVX512BW, AVX512F] diff --git a/src/highway/lib.rs b/src/highway/lib.rs index fb2ca825210c..64695571a090 100644 --- a/src/highway/lib.rs +++ b/src/highway/lib.rs @@ -67,6 +67,8 @@ unsafe extern "C" { haystack_len: usize, ) -> usize; + fn highway_index_of_http_ctl(haystack: *const u8, haystack_len: usize) -> usize; + fn highway_contains_newline_or_non_ascii_or_quote(text: *const u8, text_len: usize) -> bool; fn highway_index_of_needs_escape_for_javascript_string( @@ -756,6 +758,28 @@ pub fn index_of_space_or_newline_or_non_ascii(haystack: &[u8]) -> Option Some(result) } +/// True for the octets [`index_of_http_ctl`] stops at: CTL (0x00-0x1F, 0x7F) +/// other than HTAB. +#[inline(always)] +pub const fn is_http_ctl(c: u8) -> bool { + (c < 0x20 && c != b'\t') || c == 0x7F +} + +/// Index of the first CTL octet other than HTAB — the octets that terminate +/// (CR/LF) or invalidate an HTTP/1.x field value or reason phrase. SP, HTAB, +/// VCHAR and obs-text (0x80-0xFF) are skipped. +#[inline(always)] +pub fn index_of_http_ctl(haystack: &[u8]) -> Option { + if scalar_only(haystack.len()) { + return haystack.iter().position(|&c| is_http_ctl(c)); + } + // SAFETY: haystack.ptr/len are a valid readable range. + let result = unsafe { highway_index_of_http_ctl(haystack.as_ptr(), haystack.len()) }; + let found = found_at(result, haystack.len()); + debug_assert!(found.is_none_or(|i| is_http_ctl(haystack[i]))); + found +} + /// XxHash3 (`XXH3_64bits_withSeed`), runtime-dispatched to the widest SIMD ISA /// the CPU supports. Output is bit-identical to the xxHash reference for every /// input — only the long-input stripe loop is vectorized and its per-64-bit- diff --git a/src/http/InternalState.rs b/src/http/InternalState.rs index 2bef8005cbb6..0258afc4812d 100644 --- a/src/http/InternalState.rs +++ b/src/http/InternalState.rs @@ -21,7 +21,7 @@ pub struct InternalState<'a> { pub(crate) transfer_encoding: Encoding, pub(crate) encoding: Encoding, pub(crate) content_encoding_i: u8, - pub(crate) chunked_decoder: bun_picohttp::phr_chunked_decoder, + pub(crate) chunked_decoder: bun_picohttp::ChunkedDecoder, pub(crate) decompressor: Decompressor, pub(crate) stage: Stage, /// Decoded (post-decompression / post-chunked-decode) body bytes accumulate @@ -120,7 +120,7 @@ impl Default for InternalState<'_> { transfer_encoding: Encoding::Identity, encoding: Encoding::Identity, content_encoding_i: u8::MAX, - chunked_decoder: bun_picohttp::phr_chunked_decoder::default(), + chunked_decoder: bun_picohttp::ChunkedDecoder::default(), decompressor: Decompressor::None, stage: Stage::Pending, decoded_body: MutableString::init_empty(), @@ -191,14 +191,12 @@ impl<'a> InternalState<'a> { } /// Split-borrow `chunked_decoder` and the body buffer (which is either - /// `compressed_body` or `decoded_body`). Both targets are disjoint from - /// each other and from every other field touched by `phr_decode_chunked` - /// callers, so this lets the chunked-decode hot path in `lib.rs` operate - /// on safe references instead of repeated raw-ptr place expressions. + /// `compressed_body` or `decoded_body`) so the decoder can run in place + /// on the buffer's tail. #[inline] pub(crate) fn chunked_decoder_and_body_buffer( &mut self, - ) -> (&mut bun_picohttp::phr_chunked_decoder, &mut MutableString) { + ) -> (&mut bun_picohttp::ChunkedDecoder, &mut MutableString) { if self.encoding.is_compressed() { (&mut self.chunked_decoder, &mut self.compressed_body) } else { @@ -224,8 +222,7 @@ impl<'a> InternalState<'a> { /// close-delimited response (no Content-Length, no Transfer-Encoding). pub(crate) fn is_body_complete_on_close(&self) -> bool { if self.is_chunked_encoding() { - // 4 = CHUNKED_IN_TRAILERS_LINE_HEAD, 5 = CHUNKED_IN_TRAILERS_LINE_MIDDLE - return matches!(self.chunked_decoder._state, 4 | 5); + return self.chunked_decoder.is_in_trailers(); } self.content_length.is_none() && self.response_stage == HTTPStage::Body } diff --git a/src/http/lib.rs b/src/http/lib.rs index fc09c8b32956..03af7da8de14 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -87,6 +87,7 @@ pub type ThreadlocalAsyncHttp<'a> = ThreadlocalAsyncHTTP<'a>; pub use bun_http_types::FetchRedirect::FetchRedirect; pub use bun_http_types::Method::Method; pub use bun_picohttp as picohttp; +use picohttp::HeaderName; #[repr(u8)] #[derive(Copy, Clone, PartialEq, Eq, Default)] @@ -631,14 +632,6 @@ trait SocketTimeout { fn set_timeout(&self, seconds: core::ffi::c_uint); } -// lowercase hash header names so that we can be sure -pub(crate) fn hash_header_name(name: &[u8]) -> u64 { - // Uses the std Wyhash algorithm; safe — - // every comparison hash is computed by this same fn at runtime, no - // persisted hashes. - bun_wyhash::hash_ascii_lowercase(0, name) -} - // ───────────────────────────── HTTPClient struct ───────────────────────────── // The heavy `impl HTTPClient` (socket dispatch / state machine) remains // gated below until the missing @@ -1048,15 +1041,6 @@ fn get_user_agent_header() -> picohttp::Header { ) } -// ── header-hash constants ─────────────────────────────────────────────── -// `Wyhash` is not `const fn`, so the per-header `match` arms inside -// `build_request` / `handle_response_metadata` call this runtime alias of -// `hash_header_name`. -#[inline(always)] -fn hash_header_const(name: &[u8]) -> u64 { - hash_header_name(name) -} - bun_core::comptime_string_map! { /// Request-body-header names /// (https://fetch.spec.whatwg.org/#request-body-header-name). @@ -1423,7 +1407,6 @@ pub(crate) fn print_request( path: url, minor_version: request.minor_version, headers: request.headers, - bytes_read: request.bytes_read, }; bun_core::pretty_errorln!("{}", request_.curl(ignore_insecure, body)); } @@ -2380,9 +2363,8 @@ impl<'a> HTTPClient<'a> { const MAX_USER_HEADERS: usize = MAX_REQUEST_HEADERS - MAX_DEFAULT_HEADERS; for (i, head) in header_names.iter().enumerate() { - let name = self.header_str(*head); - // Hash it as lowercase - let hash = hash_header_name(name); + let header = + picohttp::Header::new(self.header_str(*head), self.header_str(header_values[i])); // Whether this header will actually be written to the buffer. // Override flags must only be set when the header is kept, otherwise @@ -2392,13 +2374,13 @@ impl<'a> HTTPClient<'a> { // Skip host and connection header // we manage those - match hash { - h if h == hash_header_const(b"Content-Length") => { + match header.well_known() { + Some(HeaderName::ContentLength) => { // Content-Length is always consumed (never written to the buffer). original_content_length = Some(self.header_str(header_values[i])); continue; } - h if h == hash_header_const(b"Connection") => { + Some(HeaderName::Connection) => { if will_append { override_connection_header = true; match connection_header_keep_alive(self.header_str(header_values[i])) { @@ -2413,7 +2395,7 @@ impl<'a> HTTPClient<'a> { } } } - h if h == hash_header_const(b"if-modified-since") => { + Some(HeaderName::IfModifiedSince) => { if self.flags.force_last_modified && self.if_modified_since.is_empty() { // SAFETY: header_str() returns a slice into self.header_buf which outlives // this client; lifetime is erased here only because we don't yet thread @@ -2422,34 +2404,34 @@ impl<'a> HTTPClient<'a> { unsafe { bun_ptr::detach_lifetime(self.header_str(header_values[i])) }; } } - h if h == hash_header_const(HOST_HEADER_NAME) => { + Some(HeaderName::Host) => { if will_append { override_host_header = true; } } - h if h == hash_header_const(b"Accept") => { + Some(HeaderName::Accept) => { if will_append { override_accept_header = true; } } - h if h == hash_header_const(b"User-Agent") => { + Some(HeaderName::UserAgent) => { if will_append { override_user_agent = true; } } - h if h == hash_header_const(b"Accept-Encoding") => { + Some(HeaderName::AcceptEncoding) => { if will_append { override_accept_encoding = true; } } - h if h == hash_header_const(b"Upgrade") => { + Some(HeaderName::Upgrade) => { if will_append { if upgrade_header_is_not_h2(self.header_str(header_values[i])) { self.flags.upgrade_state = HTTPUpgradeState::Pending; } } } - h if h == hash_header_const(CHUNKED_ENCODED_HEADER.name()) => { + Some(HeaderName::TransferEncoding) => { if !self.flags.is_streaming_request_body { continue; } @@ -2466,8 +2448,7 @@ impl<'a> HTTPClient<'a> { continue; } - request_headers_buf[header_count] = - picohttp::Header::new(name, self.header_str(header_values[i])); + request_headers_buf[header_count] = header; header_count += 1; } @@ -2555,7 +2536,6 @@ impl<'a> HTTPClient<'a> { // SAFETY: `request_headers_buf` is the per-HTTP-thread // `SHARED_REQUEST_HEADERS_BUF` static, outliving the returned `Request`. headers: unsafe { bun_ptr::detach_lifetime(&request_headers_buf[0..header_count]) }, - bytes_read: 0, } } @@ -3645,6 +3625,8 @@ impl<'a> HTTPClient<'a> { // here once `clone_metadata()` has deep-copied the parsed headers. let mut buffer = std::mem::take(&mut self.state.response_message_buffer); let needs_move = buffer.list.is_empty(); + // Bytes already known to be an incomplete (but so far valid) head. + let mut already_seen = buffer.list.len(); let mut to_read: &[u8] = if needs_move { incoming_data } else { @@ -3677,49 +3659,49 @@ impl<'a> HTTPClient<'a> { return; }}; } + // An incomplete head: keep accumulating unless it has outgrown the cap. + // `MAX_HTTP_HEADER_SIZE` (default 16 KB) is the *server*/request-side + // knob (Node `--max-http-header-size`); reusing it here rejects + // legitimate responses with large `Location`/`Set-Cookie` headers. The + // intent is to bound `response_message_buffer` growth, so use a + // generous fixed cap independent of that knob. + macro_rules! incomplete_head { + () => {{ + const MAX_RESPONSE_HEADER_BUFFER: usize = 1024 * 1024; + if to_read.len() > MAX_RESPONSE_HEADER_BUFFER { + self.close_and_fail::(crate::Error::ResponseHeadersTooLarge, socket); + return; + } + short_read!(); + }}; + } let shared_resp = scratch::response_headers(); let mut response = loop { - let mut amount_read: usize = 0; - // minimal http/1.1 response is 16 bytes ("HTTP/1.1 200\r\n\r\n") // if less than 16 it will always be a ShortRead if to_read.len() < 16 { short_read!(); } + // Don't re-parse an accumulating head from the start on every read + // unless the new bytes could have completed it (quadratic under a + // trickling server otherwise). Anything stored below the 16-byte + // floor above was never parsed, so it doesn't count as seen. + let seen = core::mem::take(&mut already_seen); + if seen >= 16 && !picohttp::Response::may_be_complete(to_read, seen) { + incomplete_head!(); + } - let parsed = match picohttp::Response::parse_parts( - to_read, - &mut shared_resp[..], - Some(&mut amount_read), - ) { + let parsed = match picohttp::Response::parse(to_read, &mut shared_resp[..]) { Ok(r) => r, - Err(picohttp::ParseResponseError::ShortRead) => { - // `MAX_HTTP_HEADER_SIZE` (default 16 KB) is the *server*/ - // request-side knob (Node `--max-http-header-size`); reusing - // it here rejects legitimate responses with large - // `Location`/`Set-Cookie` headers. The intent is to bound - // `response_message_buffer` growth, so use a generous fixed - // cap independent of that knob. - const MAX_RESPONSE_HEADER_BUFFER: usize = 1024 * 1024; - if to_read.len() > MAX_RESPONSE_HEADER_BUFFER { - self.close_and_fail::( - crate::Error::ResponseHeadersTooLarge, - socket, - ); - return; - } - short_read!(); - } + Err(picohttp::ParseResponseError::ShortRead) => incomplete_head!(), Err(e) => { self.close_and_fail::(e.into(), socket); return; } }; - let bytes_read = - (usize::try_from(parsed.bytes_read).expect("int cast")).min(to_read.len()); - to_read = &to_read[bytes_read..]; + to_read = &to_read[parsed.bytes_read..]; if parsed.status_code == 101 { if self.flags.upgrade_state == HTTPUpgradeState::None @@ -4659,79 +4641,48 @@ impl<'a> HTTPClient<'a> { &mut self, incoming_data: &[u8], ) -> crate::Result { - // reshaped for borrowck — `chunked_decoder` and the body - // buffer (`compressed_body` / `decoded_body`) are disjoint fields of - // `self.state`, so borrow them once together via the split accessor and - // operate on safe references. Deep-cloning the buffer here would - // diverge (mutations from process_body_buffer would be lost). let (decoder, body_buf) = self.state.chunked_decoder_and_body_buffer(); body_buf.append_slice(incoming_data)?; - // set consume_trailer to 1 to discard the trailing header - // using content-encoding per chunk is not supported - decoder.consume_trailer = 1; - - let mut bytes_decoded = incoming_data.len(); - // phr_decode_chunked mutates in-place - // SAFETY: body_buf.list is initialized for [0..len()) and uniquely - // borrowed here; the offset is len() - incoming_data.len() (the - // just-appended tail), which is in bounds. - let pret = unsafe { - picohttp::phr_decode_chunked( - &raw mut *decoder, - body_buf - .list - .as_mut_ptr() - .add(body_buf.list.len().saturating_sub(incoming_data.len())), - &raw mut bytes_decoded, - ) + let start = body_buf.list.len() - incoming_data.len(); + let Ok(decoded) = decoder.decode(&mut body_buf.list[start..]) else { + return Err(crate::Error::InvalidHTTPResponse); }; - let new_len = body_buf - .list - .len() - .saturating_sub(incoming_data.len() - bytes_decoded); - body_buf.list.truncate(new_len); + body_buf.list.truncate(start + decoded.written); let buffer_len = body_buf.list.len(); - self.state.total_body_received += bytes_decoded; + self.state.total_body_received += decoded.written; bun_core::scoped_log!( fetch, "handleResponseBodyChunkedEncodingFromMultiplePackets {}", self.state.total_body_received ); - match pret { - // Invalid HTTP response body - -1 => return Err(crate::Error::InvalidHTTPResponse), - // Needs more data - -2 => { - self.report_progress(buffer_len); - // streaming chunks - if self.signals.get(signals::Field::ResponseBodyStreaming) - || self.signals.body_receive_mode.is_some() - { - // If we're streaming, we cannot use the libdeflate fast path - self.state.flags.is_libdeflate_fast_path_disabled = true; - // Move the - // bytes out so no `&` into self.state aliases the `&mut self.state` call. - let buffer_snap = core::mem::take(&mut self.state.get_body_buffer().list); - return self.state.process_body_buffer(buffer_snap, false); - } - - return Ok(false); - } - // Done - _ => { - self.state.flags.received_last_chunk = true; + if !decoded.complete { + self.report_progress(buffer_len); + // streaming chunks + if self.signals.get(signals::Field::ResponseBodyStreaming) + || self.signals.body_receive_mode.is_some() + { + // If we're streaming, we cannot use the libdeflate fast path + self.state.flags.is_libdeflate_fast_path_disabled = true; // Move the // bytes out so no `&` into self.state aliases the `&mut self.state` call. let buffer_snap = core::mem::take(&mut self.state.get_body_buffer().list); - let _ = self.state.process_body_buffer(buffer_snap, true)?; - - self.report_progress(buffer_len); - - return Ok(true); + return self.state.process_body_buffer(buffer_snap, false); } + + return Ok(false); } + + self.state.flags.received_last_chunk = true; + // Move the + // bytes out so no `&` into self.state aliases the `&mut self.state` call. + let buffer_snap = core::mem::take(&mut self.state.get_body_buffer().list); + let _ = self.state.process_body_buffer(buffer_snap, true)?; + + self.report_progress(buffer_len); + + Ok(true) } fn handle_response_body_chunked_encoding_from_single_packet( @@ -4741,72 +4692,50 @@ impl<'a> HTTPClient<'a> { let small = scratch::single_packet_small_buffer(); debug_assert!(incoming_data.len() <= small.len()); - // set consume_trailer to 1 to discard the trailing header - // using content-encoding per chunk is not supported - self.state.chunked_decoder.consume_trailer = 1; - // `handle_on_data_headers` moves `response_message_buffer` into a // local before dispatching here, so `incoming_data` never aliases // `self` and the scratch copy is always sufficient (the dispatcher // bounds `incoming_data.len()` to the scratch size). - let in_len = incoming_data.len(); - let buffer = &mut small[0..in_len]; + let buffer = &mut small[0..incoming_data.len()]; buffer.copy_from_slice(incoming_data); - let mut bytes_decoded = in_len; - // phr_decode_chunked mutates in-place - // SAFETY: `buffer` is an exclusive &mut [u8] of len == in_len; offset - // len - in_len == 0 is trivially in bounds. `chunked_decoder` is a - // disjoint field of `self.state` (`buffer` borrows `small`). - let pret = unsafe { - picohttp::phr_decode_chunked( - &raw mut self.state.chunked_decoder, - buffer.as_mut_ptr().add(buffer.len().saturating_sub(in_len)), - &raw mut bytes_decoded, - ) + let Ok(decoded) = self.state.chunked_decoder.decode(buffer) else { + return Err(crate::Error::InvalidHTTPResponse); }; - let new_len = buffer.len().saturating_sub(in_len - bytes_decoded); - let buffer = &mut buffer[..new_len]; - self.state.total_body_received += bytes_decoded; + let buffer = &mut buffer[..decoded.written]; + self.state.total_body_received += decoded.written; bun_core::scoped_log!( fetch, "handleResponseBodyChunkedEncodingFromSinglePacket {}", self.state.total_body_received ); - match pret { - // Invalid HTTP response body - -1 => Err(crate::Error::InvalidHTTPResponse), - // Needs more data - -2 => { - self.report_progress(buffer.len()); - self.state.get_body_buffer().append_slice_exact(buffer)?; - - // streaming chunks - if self.signals.get(signals::Field::ResponseBodyStreaming) - || self.signals.body_receive_mode.is_some() - { - // If we're streaming, we cannot use the libdeflate fast path - self.state.flags.is_libdeflate_fast_path_disabled = true; - - // Move - // the bytes out so no `&` into self.state aliases the `&mut self.state` - // taken by process_body_buffer (which mutates compressed_body/decoded_body). - let buffer_snap = core::mem::take(&mut self.state.get_body_buffer().list); - return self.state.process_body_buffer(buffer_snap, false); - } + if !decoded.complete { + self.report_progress(buffer.len()); + self.state.get_body_buffer().append_slice_exact(buffer)?; - Ok(false) - } - // Done - _ => { - self.state.flags.received_last_chunk = true; - self.handle_response_body_from_single_packet(buffer)?; - debug_assert!(self.state.decoded_body.list.as_ptr() != buffer.as_ptr()); - self.report_progress(buffer.len()); + // streaming chunks + if self.signals.get(signals::Field::ResponseBodyStreaming) + || self.signals.body_receive_mode.is_some() + { + // If we're streaming, we cannot use the libdeflate fast path + self.state.flags.is_libdeflate_fast_path_disabled = true; - Ok(true) + // Move + // the bytes out so no `&` into self.state aliases the `&mut self.state` + // taken by process_body_buffer (which mutates compressed_body/decoded_body). + let buffer_snap = core::mem::take(&mut self.state.get_body_buffer().list); + return self.state.process_body_buffer(buffer_snap, false); } + + return Ok(false); } + + self.state.flags.received_last_chunk = true; + self.handle_response_body_from_single_packet(buffer)?; + debug_assert!(self.state.decoded_body.list.as_ptr() != buffer.as_ptr()); + self.report_progress(buffer.len()); + + Ok(true) } pub(crate) fn handle_response_metadata( @@ -4818,8 +4747,8 @@ impl<'a> HTTPClient<'a> { let mut is_server_sent_events = false; let mut content_codings: u32 = 0; for (header_i, header) in response.headers.list.iter().enumerate() { - match hash_header_name(header.name()) { - h if h == hash_header_const(b"Content-Length") => { + match header.well_known() { + Some(HeaderName::ContentLength) => { // RFC 9110 section 9.3.6: a client MUST ignore // Content-Length in a successful response to CONNECT — // the connection becomes an opaque tunnel and is never @@ -4858,12 +4787,17 @@ impl<'a> HTTPClient<'a> { self.state.content_length = Some(0); } } - h if h == hash_header_const(b"Content-Type") => { - if strings::index_of(header.value(), b"text/event-stream").is_some() { + Some(HeaderName::ContentType) => { + const SSE: &[u8] = b"text/event-stream"; + let value = header.value(); + // The media type itself, optionally followed by parameters. + if strings::starts_with_case_insensitive_ascii(value, SSE) + && matches!(value.get(SSE.len()), None | Some(b';' | b' ' | b'\t')) + { is_server_sent_events = true; } } - h if h == hash_header_const(b"Content-Encoding") => { + Some(HeaderName::ContentEncoding) => { if !self.flags.disable_decompression { for token in HeaderValueIterator::init(header.value()) { match Encoding::from_token(token) { @@ -4883,7 +4817,7 @@ impl<'a> HTTPClient<'a> { } } } - h if h == hash_header_const(b"Transfer-Encoding") => { + Some(HeaderName::TransferEncoding) => { // RFC 9110 section 9.3.6: as with Content-Length above, a // client MUST ignore Transfer-Encoding in a successful // response to CONNECT. @@ -4907,23 +4841,23 @@ impl<'a> HTTPClient<'a> { } } } - h if h == hash_header_const(b"Location") => { + Some(HeaderName::Location) => { location = header.value(); } - h if h == hash_header_const(b"Connection") => { + Some(HeaderName::Connection) => { // `close` on any field line, any status, is sticky (RFC 9110 §5.3, RFC 9112 §9.6). if connection_header_keep_alive(header.value()) == Some(false) { self.state.flags.allow_keepalive = false; } } - h if h == hash_header_const(b"Last-Modified") => { + Some(HeaderName::LastModified) => { pretend_304 = self.flags.force_last_modified && response.status_code > 199 && response.status_code < 300 && !self.if_modified_since.is_empty() && self.if_modified_since == header.value(); } - h if h == hash_header_const(b"Alt-Svc") => { + None if header.name().eq_ignore_ascii_case(b"alt-svc") => { // Record regardless of *this* request's shape — a future // request to the same origin may be h3-eligible even if this // one was pinned/proxied/sendfile. diff --git a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs index 3b466c6f711f..897ebc3b1108 100644 --- a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs +++ b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs @@ -875,7 +875,7 @@ impl HTTPClient { match picohttp::Response::parse(body, &mut self.headers_buf) { Ok(response) => HeadParse::Done { status_code: response.status_code, - head_len: usize::try_from(response.bytes_read).expect("int cast"), + head_len: response.bytes_read, full: body.to_vec(), }, Err(picohttp::ParseResponseError::MalformedHttpResponse) => HeadParse::Invalid, @@ -998,7 +998,7 @@ impl HTTPClient { // SAFETY: forwards to the existing teardown path. return unsafe { Self::terminate(this.as_ptr(), ErrorCode::InvalidResponse) }; }; - let head_len = usize::try_from(response.bytes_read).expect("int cast"); + let head_len = response.bytes_read; let is_101 = response.status_code == 101; // 101: one scope across 'upgrade'+'open' so microtasks drain after open. diff --git a/src/http_types/HeaderName.rs b/src/http_types/HeaderName.rs new file mode 100644 index 000000000000..3c42e9d72aa4 --- /dev/null +++ b/src/http_types/HeaderName.rs @@ -0,0 +1,537 @@ +//! WebCore's `HTTPHeaderName`: the well-known field names `FetchHeaders` +//! stores by enum rather than by string. +//! +//! The `u8` discriminant crosses FFI to `WebCore__FetchHeaders__put` / +//! `fastGet` / `fastHas` and to `PicoHTTPHeader.name_id`, so the order MUST +//! match `src/jsc/bindings/webcore/HTTPHeaderNames.in` exactly. + +#[repr(u8)] +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum HeaderName { + Accept, + AcceptCharset, + AcceptEncoding, + AcceptLanguage, + AcceptRanges, + AccessControlAllowCredentials, + AccessControlAllowHeaders, + AccessControlAllowMethods, + AccessControlAllowOrigin, + AccessControlExposeHeaders, + AccessControlMaxAge, + AccessControlRequestHeaders, + AccessControlRequestMethod, + Age, + Authorization, + CacheControl, + Connection, + ContentDisposition, + ContentEncoding, + ContentLanguage, + ContentLength, + ContentLocation, + ContentRange, + ContentSecurityPolicy, + ContentSecurityPolicyReportOnly, + ContentType, + Cookie, + Cookie2, + CrossOriginEmbedderPolicy, + CrossOriginEmbedderPolicyReportOnly, + CrossOriginOpenerPolicy, + CrossOriginOpenerPolicyReportOnly, + CrossOriginResourcePolicy, + DNT, + Date, + DefaultStyle, + ETag, + Expect, + Expires, + Host, + IcyMetaInt, + IcyMetadata, + IfMatch, + IfModifiedSince, + IfNoneMatch, + IfRange, + IfUnmodifiedSince, + KeepAlive, + LastEventID, + LastModified, + Link, + Location, + Origin, + PingFrom, + PingTo, + Pragma, + ProxyAuthorization, + ProxyConnection, + Purpose, + Range, + Referer, + ReferrerPolicy, + Refresh, + ReportTo, + SecFetchDest, + SecFetchMode, + SecWebSocketAccept, + SecWebSocketExtensions, + SecWebSocketKey, + SecWebSocketProtocol, + SecWebSocketVersion, + ServerTiming, + ServiceWorker, + ServiceWorkerAllowed, + ServiceWorkerNavigationPreload, + SetCookie, + SetCookie2, + SourceMap, + StrictTransportSecurity, + TE, + TimingAllowOrigin, + Trailer, + TransferEncoding, + Upgrade, + UpgradeInsecureRequests, + UserAgent, + Vary, + Via, + XContentTypeOptions, + XDNSPrefetchControl, + XFrameOptions, + XSourceMap, + XTempTablet, + XXSSProtection, +} + +impl HeaderName { + pub const COUNT: usize = Self::NAMES.len(); + + #[inline] + pub const fn as_str(self) -> &'static str { + Self::NAMES[self as usize] + } + + #[inline] + pub const fn from_index(index: u8) -> Option { + if (index as usize) < Self::COUNT { + // SAFETY: `#[repr(u8)]` with contiguous discriminants `0..COUNT`. + Some(unsafe { core::mem::transmute::(index) }) + } else { + None + } + } + + /// Case-insensitive lookup of a wire field name. Generated from + /// `HTTPHeaderNames.in` (bucket by length, branch on one distinguishing + /// byte, then a single case-insensitive compare). + pub const fn classify(name: &[u8]) -> Option { + let (variant, expect): (Self, &[u8]) = match name.len() { + 2 => (Self::TE, b"te"), + 3 => match name[0] | 0x20 { + b'a' => (Self::Age, b"age"), + b'd' => (Self::DNT, b"dnt"), + b'v' => (Self::Via, b"via"), + _ => return None, + }, + 4 => match name[0] | 0x20 { + b'd' => (Self::Date, b"date"), + b'e' => (Self::ETag, b"etag"), + b'h' => (Self::Host, b"host"), + b'l' => (Self::Link, b"link"), + b'v' => (Self::Vary, b"vary"), + _ => return None, + }, + 5 => (Self::Range, b"range"), + 6 => match name[0] | 0x20 { + b'a' => (Self::Accept, b"accept"), + b'c' => (Self::Cookie, b"cookie"), + b'e' => (Self::Expect, b"expect"), + b'o' => (Self::Origin, b"origin"), + b'p' => (Self::Pragma, b"pragma"), + _ => return None, + }, + 7 => match name[1] | 0x20 { + b'e' => match name[3] | 0x20 { + b'e' => (Self::Referer, b"referer"), + b'r' => (Self::Refresh, b"refresh"), + _ => return None, + }, + b'i' => (Self::PingTo, b"ping-to"), + b'o' => (Self::Cookie2, b"cookie2"), + b'p' => (Self::Upgrade, b"upgrade"), + b'r' => (Self::Trailer, b"trailer"), + b'u' => (Self::Purpose, b"purpose"), + b'x' => (Self::Expires, b"expires"), + _ => return None, + }, + 8 => match name[3] | 0x20 { + b'a' => (Self::Location, b"location"), + b'm' => (Self::IfMatch, b"if-match"), + b'r' => (Self::IfRange, b"if-range"), + _ => return None, + }, + 9 => match name[0] | 0x20 { + b'p' => (Self::PingFrom, b"ping-from"), + b'r' => (Self::ReportTo, b"report-to"), + b's' => (Self::SourceMap, b"sourcemap"), + _ => return None, + }, + 10 => match name[0] | 0x20 { + b'c' => (Self::Connection, b"connection"), + b'k' => (Self::KeepAlive, b"keep-alive"), + b's' => (Self::SetCookie, b"set-cookie"), + b'u' => (Self::UserAgent, b"user-agent"), + _ => return None, + }, + 11 => match name[0] | 0x20 { + b'i' => (Self::IcyMetaInt, b"icy-metaint"), + b's' => (Self::SetCookie2, b"set-cookie2"), + b'x' => (Self::XSourceMap, b"x-sourcemap"), + _ => return None, + }, + 12 => match name[0] | 0x20 { + b'c' => (Self::ContentType, b"content-type"), + b'i' => (Self::IcyMetadata, b"icy-metadata"), + _ => return None, + }, + 13 => match name[5] | 0x20 { + b'-' => (Self::CacheControl, b"cache-control"), + b'e' => (Self::LastEventID, b"last-event-id"), + b'l' => (Self::DefaultStyle, b"default-style"), + b'm' => (Self::LastModified, b"last-modified"), + b'n' => match name[0] | 0x20 { + b'c' => (Self::ContentRange, b"content-range"), + b'i' => (Self::IfNoneMatch, b"if-none-match"), + _ => return None, + }, + b'p' => (Self::XTempTablet, b"x-temp-tablet"), + b'r' => match name[0] | 0x20 { + b'a' => (Self::Authorization, b"authorization"), + b's' => (Self::ServerTiming, b"server-timing"), + _ => return None, + }, + b't' => (Self::AcceptRanges, b"accept-ranges"), + _ => return None, + }, + 14 => match name[11] | 0x20 { + b'e' => (Self::SecFetchDest, b"sec-fetch-dest"), + b'g' => (Self::ContentLength, b"content-length"), + b'k' => (Self::ServiceWorker, b"service-worker"), + b'o' => (Self::SecFetchMode, b"sec-fetch-mode"), + b's' => (Self::AcceptCharset, b"accept-charset"), + _ => return None, + }, + 15 => match name[7] | 0x20 { + b'-' => (Self::XFrameOptions, b"x-frame-options"), + b'e' => (Self::AcceptEncoding, b"accept-encoding"), + b'l' => (Self::AcceptLanguage, b"accept-language"), + b'r' => (Self::ReferrerPolicy, b"referrer-policy"), + _ => return None, + }, + 16 => match name[8] | 0x20 { + b'e' => (Self::ContentEncoding, b"content-encoding"), + b'l' => match name[9] | 0x20 { + b'a' => (Self::ContentLanguage, b"content-language"), + b'o' => (Self::ContentLocation, b"content-location"), + _ => return None, + }, + b'n' => (Self::ProxyConnection, b"proxy-connection"), + b'o' => (Self::XXSSProtection, b"x-xss-protection"), + _ => return None, + }, + 17 => match name[0] | 0x20 { + b'i' => (Self::IfModifiedSince, b"if-modified-since"), + b's' => (Self::SecWebSocketKey, b"sec-websocket-key"), + b't' => (Self::TransferEncoding, b"transfer-encoding"), + _ => return None, + }, + 19 => match name[0] | 0x20 { + b'c' => (Self::ContentDisposition, b"content-disposition"), + b'i' => (Self::IfUnmodifiedSince, b"if-unmodified-since"), + b'p' => (Self::ProxyAuthorization, b"proxy-authorization"), + b't' => (Self::TimingAllowOrigin, b"timing-allow-origin"), + _ => return None, + }, + 20 => (Self::SecWebSocketAccept, b"sec-websocket-accept"), + 21 => (Self::SecWebSocketVersion, b"sec-websocket-version"), + 22 => match name[3] | 0x20 { + b'-' => (Self::SecWebSocketProtocol, b"sec-websocket-protocol"), + b'e' => (Self::AccessControlMaxAge, b"access-control-max-age"), + b'n' => (Self::XDNSPrefetchControl, b"x-dns-prefetch-control"), + b'o' => (Self::XContentTypeOptions, b"x-content-type-options"), + b'v' => (Self::ServiceWorkerAllowed, b"service-worker-allowed"), + _ => return None, + }, + 23 => (Self::ContentSecurityPolicy, b"content-security-policy"), + 24 => (Self::SecWebSocketExtensions, b"sec-websocket-extensions"), + 25 => match name[0] | 0x20 { + b's' => (Self::StrictTransportSecurity, b"strict-transport-security"), + b'u' => (Self::UpgradeInsecureRequests, b"upgrade-insecure-requests"), + _ => return None, + }, + 26 => (Self::CrossOriginOpenerPolicy, b"cross-origin-opener-policy"), + 27 => ( + Self::AccessControlAllowOrigin, + b"access-control-allow-origin", + ), + 28 => match name[13] | 0x20 { + b'e' => ( + Self::CrossOriginEmbedderPolicy, + b"cross-origin-embedder-policy", + ), + b'l' => match name[21] | 0x20 { + b'h' => ( + Self::AccessControlAllowHeaders, + b"access-control-allow-headers", + ), + b'm' => ( + Self::AccessControlAllowMethods, + b"access-control-allow-methods", + ), + _ => return None, + }, + b'r' => ( + Self::CrossOriginResourcePolicy, + b"cross-origin-resource-policy", + ), + _ => return None, + }, + 29 => match name[15] | 0x20 { + b'e' => ( + Self::AccessControlExposeHeaders, + b"access-control-expose-headers", + ), + b'r' => ( + Self::AccessControlRequestMethod, + b"access-control-request-method", + ), + _ => return None, + }, + 30 => ( + Self::AccessControlRequestHeaders, + b"access-control-request-headers", + ), + 32 => ( + Self::AccessControlAllowCredentials, + b"access-control-allow-credentials", + ), + 33 => ( + Self::ServiceWorkerNavigationPreload, + b"service-worker-navigation-preload", + ), + 35 => ( + Self::ContentSecurityPolicyReportOnly, + b"content-security-policy-report-only", + ), + 38 => ( + Self::CrossOriginOpenerPolicyReportOnly, + b"cross-origin-opener-policy-report-only", + ), + 40 => ( + Self::CrossOriginEmbedderPolicyReportOnly, + b"cross-origin-embedder-policy-report-only", + ), + _ => return None, + }; + if eq_ignore_ascii_case_lower(name, expect) { + Some(variant) + } else { + None + } + } + + /// Canonical spelling, indexed by discriminant (matches WebCore `headerNameStrings`). + pub const NAMES: [&'static str; 94] = [ + "Accept", + "Accept-Charset", + "Accept-Encoding", + "Accept-Language", + "Accept-Ranges", + "Access-Control-Allow-Credentials", + "Access-Control-Allow-Headers", + "Access-Control-Allow-Methods", + "Access-Control-Allow-Origin", + "Access-Control-Expose-Headers", + "Access-Control-Max-Age", + "Access-Control-Request-Headers", + "Access-Control-Request-Method", + "Age", + "Authorization", + "Cache-Control", + "Connection", + "Content-Disposition", + "Content-Encoding", + "Content-Language", + "Content-Length", + "Content-Location", + "Content-Range", + "Content-Security-Policy", + "Content-Security-Policy-Report-Only", + "Content-Type", + "Cookie", + "Cookie2", + "Cross-Origin-Embedder-Policy", + "Cross-Origin-Embedder-Policy-Report-Only", + "Cross-Origin-Opener-Policy", + "Cross-Origin-Opener-Policy-Report-Only", + "Cross-Origin-Resource-Policy", + "DNT", + "Date", + "Default-Style", + "ETag", + "Expect", + "Expires", + "Host", + "Icy-MetaInt", + "Icy-Metadata", + "If-Match", + "If-Modified-Since", + "If-None-Match", + "If-Range", + "If-Unmodified-Since", + "Keep-Alive", + "Last-Event-ID", + "Last-Modified", + "Link", + "Location", + "Origin", + "Ping-From", + "Ping-To", + "Pragma", + "Proxy-Authorization", + "Proxy-Connection", + "Purpose", + "Range", + "Referer", + "Referrer-Policy", + "Refresh", + "Report-To", + "Sec-Fetch-Dest", + "Sec-Fetch-Mode", + "Sec-WebSocket-Accept", + "Sec-WebSocket-Extensions", + "Sec-WebSocket-Key", + "Sec-WebSocket-Protocol", + "Sec-WebSocket-Version", + "Server-Timing", + "Service-Worker", + "Service-Worker-Allowed", + "Service-Worker-Navigation-Preload", + "Set-Cookie", + "Set-Cookie2", + "SourceMap", + "Strict-Transport-Security", + "TE", + "Timing-Allow-Origin", + "Trailer", + "Transfer-Encoding", + "Upgrade", + "Upgrade-Insecure-Requests", + "User-Agent", + "Vary", + "Via", + "X-Content-Type-Options", + "X-DNS-Prefetch-Control", + "X-Frame-Options", + "X-SourceMap", + "X-Temp-Tablet", + "X-XSS-Protection", + ]; +} + +// `from_index` transmutes anything below `COUNT`. +const _: () = assert!(HeaderName::COUNT == HeaderName::XXSSProtection as usize + 1); + +/// `a` compared ASCII-case-insensitively against already-lowercase `lower`. +const fn eq_ignore_ascii_case_lower(a: &[u8], lower: &[u8]) -> bool { + if a.len() != lower.len() { + return false; + } + let mut i = 0; + while i < a.len() { + if a[i].to_ascii_lowercase() != lower[i] { + return false; + } + i += 1; + } + true +} + +#[cfg(test)] +mod tests { + use super::HeaderName; + + #[test] + fn classify_round_trips_every_name() { + for i in 0..HeaderName::COUNT as u8 { + let name = HeaderName::from_index(i).unwrap(); + assert_eq!(name as u8, i); + let s = name.as_str(); + assert_eq!(HeaderName::classify(s.as_bytes()), Some(name), "{s}"); + assert_eq!( + HeaderName::classify(s.to_ascii_lowercase().as_bytes()), + Some(name), + "{s}" + ); + assert_eq!( + HeaderName::classify(s.to_ascii_uppercase().as_bytes()), + Some(name), + "{s}" + ); + // One byte off in either direction is a different (or no) name. + let mut longer = s.as_bytes().to_vec(); + longer.push(b'x'); + assert_ne!(HeaderName::classify(&longer), Some(name), "{s}"); + assert_ne!( + HeaderName::classify(&s.as_bytes()[..s.len() - 1]), + Some(name), + "{s}" + ); + // Flipping any single byte to '_' misses. + for j in 0..s.len() { + let mut m = s.as_bytes().to_vec(); + m[j] = b'_'; + assert_eq!(HeaderName::classify(&m), None, "{s} @{j}"); + } + } + assert_eq!(HeaderName::from_index(HeaderName::COUNT as u8), None); + } + + #[test] + fn classify_rejects_near_misses() { + for name in [ + &b""[..], + b"x", + b"Server", + b"Alt-Svc", + b"Content_Type", + b"Content-Typ", + b"Content-Types", + b"X-Request-Id", + b"Sec-WebSocket-Kez", + b" + +", + b"t\xc5", // 0xC5 | 0x20 == 'e' + ] { + assert_eq!( + HeaderName::classify(name), + None, + "{:?}", + bstr::BStr::new(name) + ); + } + } + + #[test] + fn discriminants_match_webcore_order() { + // Spot-check anchors of the WebCore enum; a reordering in + // HTTPHeaderNames.in must be mirrored here and in C++ together. + assert_eq!(HeaderName::Accept as u8, 0); + assert_eq!(HeaderName::ContentType as u8, 25); + assert_eq!(HeaderName::SetCookie as u8, 75); + assert_eq!(HeaderName::XXSSProtection as u8, 93); + assert_eq!(HeaderName::COUNT, 94); + } +} diff --git a/src/http_types/Method.rs b/src/http_types/Method.rs index 45983b9b4ebe..3cec6e4283ea 100644 --- a/src/http_types/Method.rs +++ b/src/http_types/Method.rs @@ -258,114 +258,6 @@ unsafe extern "C" fn Bun__HTTPMethod__from(str: *const u8, len: usize) -> i16 { method as i16 } -// ═══════════════════════════════════════════════════════════════════════ -// HTTPHeaderName — moved from bun_runtime::webcore::FetchHeaders. -// -// `enum(u8)` discriminant crosses the FFI boundary to -// `WebCore__FetchHeaders__put`/`fastHas`/`fastGet` — order MUST match -// WebCore's `HTTPHeaderNames.in` exactly. The `fastGet`/`fastHas`/`put` -// methods that consume this enum stay on `FetchHeaders` (T6). -// ═══════════════════════════════════════════════════════════════════════ - -#[repr(u8)] -#[derive(Copy, Clone, PartialEq, Eq, Debug)] -pub enum HeaderName { - Accept, - AcceptCharset, - AcceptEncoding, - AcceptLanguage, - AcceptRanges, - AccessControlAllowCredentials, - AccessControlAllowHeaders, - AccessControlAllowMethods, - AccessControlAllowOrigin, - AccessControlExposeHeaders, - AccessControlMaxAge, - AccessControlRequestHeaders, - AccessControlRequestMethod, - Age, - Authorization, - CacheControl, - Connection, - ContentDisposition, - ContentEncoding, - ContentLanguage, - ContentLength, - ContentLocation, - ContentRange, - ContentSecurityPolicy, - ContentSecurityPolicyReportOnly, - ContentType, - Cookie, - Cookie2, - CrossOriginEmbedderPolicy, - CrossOriginEmbedderPolicyReportOnly, - CrossOriginOpenerPolicy, - CrossOriginOpenerPolicyReportOnly, - CrossOriginResourcePolicy, - DNT, - Date, - DefaultStyle, - ETag, - Expect, - Expires, - Host, - IcyMetaInt, - IcyMetadata, - IfMatch, - IfModifiedSince, - IfNoneMatch, - IfRange, - IfUnmodifiedSince, - KeepAlive, - LastEventID, - LastModified, - Link, - Location, - Origin, - PingFrom, - PingTo, - Pragma, - ProxyAuthorization, - ProxyConnection, - Purpose, - Range, - Referer, - ReferrerPolicy, - Refresh, - ReportTo, - SecFetchDest, - SecFetchMode, - SecWebSocketAccept, - SecWebSocketExtensions, - SecWebSocketKey, - SecWebSocketProtocol, - SecWebSocketVersion, - ServerTiming, - ServiceWorker, - ServiceWorkerAllowed, - ServiceWorkerNavigationPreload, - SetCookie, - SetCookie2, - SourceMap, - StrictTransportSecurity, - TE, - TimingAllowOrigin, - Trailer, - TransferEncoding, - Upgrade, - UpgradeInsecureRequests, - UserAgent, - Vary, - Via, - XContentTypeOptions, - XDNSPrefetchControl, - XFrameOptions, - XSourceMap, - XTempTablet, - XXSSProtection, -} - #[cfg(test)] mod tests { use super::Method; diff --git a/src/http_types/lib.rs b/src/http_types/lib.rs index 13e38fbd5b66..3236fef718eb 100644 --- a/src/http_types/lib.rs +++ b/src/http_types/lib.rs @@ -5,6 +5,7 @@ pub mod Encoding; pub mod FetchCacheMode; pub mod FetchRedirect; pub mod FetchRequestMode; +pub mod HeaderName; pub mod Method; pub mod URLPath; pub mod h2; diff --git a/src/jsc/FetchHeaders.rs b/src/jsc/FetchHeaders.rs index 5f019f49bd6e..2bddee5202e1 100644 --- a/src/jsc/FetchHeaders.rs +++ b/src/jsc/FetchHeaders.rs @@ -292,8 +292,7 @@ impl FetchHeaders { } } -// Canonical enum lives in `bun_http_types::Method::HeaderName` (same 92 -// `#[repr(u8)]` discriminants mirroring WebCore's `HTTPHeaderNames.in`). The +// Same `#[repr(u8)]` discriminants as WebCore's `HTTPHeaderNames.in`; the // `WebCore__FetchHeaders__put` extern decl above and the `fast_*` methods take // it by value, so the re-export is ABI-transparent. -pub use bun_http_types::Method::HeaderName as HTTPHeaderName; +pub use bun_http_types::HeaderName::HeaderName as HTTPHeaderName; diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 24462959c223..b6c151bec529 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -232,7 +232,6 @@ static JSValue constructVersions(VM& vm, JSObject* processObject) object->putDirect(vm, JSC::Identifier::fromString(vm, "llhttp"_s), JSC::jsOwnedString(vm, String("9.3.0"_s))); object->putDirect(vm, JSC::Identifier::fromString(vm, "libarchive"_s), JSC::jsOwnedString(vm, ASCIILiteral::fromLiteralUnsafe(BUN_VERSION_LIBARCHIVE)), 0); object->putDirect(vm, JSC::Identifier::fromString(vm, "mimalloc"_s), JSC::jsOwnedString(vm, ASCIILiteral::fromLiteralUnsafe(BUN_VERSION_MIMALLOC)), 0); - object->putDirect(vm, JSC::Identifier::fromString(vm, "picohttpparser"_s), JSC::jsOwnedString(vm, ASCIILiteral::fromLiteralUnsafe(BUN_VERSION_PICOHTTPPARSER)), 0); object->putDirect(vm, JSC::Identifier::fromString(vm, "uwebsockets"_s), JSC::jsOwnedString(vm, ASCIILiteral::fromLiteralUnsafe(BUN_VERSION_UWS)), 0); object->putDirect(vm, JSC::Identifier::fromString(vm, "webkit"_s), JSC::jsOwnedString(vm, ASCIILiteral::fromLiteralUnsafe(BUN_VERSION_WEBKIT)), 0); object->putDirect(vm, JSC::Identifier::fromString(vm, "zig"_s), JSC::jsOwnedString(vm, ASCIILiteral::fromLiteralUnsafe(BUN_VERSION_ZIG)), 0); diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 48e9cad873ec..fa3abd38153a 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -139,6 +139,7 @@ #include "wtf/text/AtomString.h" #include "wtf/Scope.h" #include "HTTPHeaderNames.h" +#include #include "JSDOMPromiseDeferred.h" #include "JavaScriptCore/TestRunnerUtils.h" #include "JavaScriptCore/DateInstance.h" @@ -2097,10 +2098,13 @@ typedef struct ZigSliceString { size_t len; } ZigSliceString; +// bun_picohttp::Header typedef struct PicoHTTPHeader { ZigSliceString name; ZigSliceString value; + uint8_t name_id; // HTTPHeaderName + 1, or 0 when not well-known } PicoHTTPHeader; +static_assert(sizeof(PicoHTTPHeader) == 40, "bun_picohttp::Header"); typedef struct PicoHTTPHeaders { const PicoHTTPHeader* ptr; @@ -2117,34 +2121,42 @@ WebCore::FetchHeaders* WebCore__FetchHeaders__createFromPicoHeaders_(const void* HTTPHeaderMap map = HTTPHeaderMap(); size_t end = pico_headers.len; + size_t commonCount = 0; + for (size_t j = 0; j < end; j++) + commonCount += pico_headers.ptr[j].name_id != 0; + map.commonHeaders().reserveInitialCapacity(commonCount); + map.uncommonHeaders().reserveInitialCapacity(end - commonCount); + std::bitset seen; for (size_t j = 0; j < end; j++) { PicoHTTPHeader header = pico_headers.ptr[j]; - // picohttpparser reports obs-fold continuation lines with an empty - // name; skip those. Empty *values* must flow through so duplicate - // headers combine per the Fetch spec ("a, , c") and a lone empty - // header is still visible to JS, matching the uWS/H3 paths. + // Only nameless entries are skipped; empty *values* must flow + // through so duplicate headers combine per the Fetch spec + // ("a, , c") and a lone empty header is still visible to JS, + // matching the uWS/H3 paths. if (header.name.len == 0) continue; - StringView nameView = StringView(std::span { reinterpret_cast(header.name.ptr), header.name.len }); - std::span data; auto value = String::createUninitialized(header.value.len, data); if (header.value.len > 0) memcpy(data.data(), header.value.ptr, header.value.len); - HTTPHeaderName name; - - // memory safety: the header names must be cloned if they're not statically known - // the value must also be cloned - // isolatedCopy() doesn't actually clone, it's only for threadlocal isolation - if (WebCore::findHTTPHeaderName(nameView, name)) { - map.add(name, value); + if (header.name_id != 0) { + unsigned index = header.name_id - 1; + ASSERT(index < numHTTPHeaderNames); + auto name = static_cast(index); + // First occurrence appends without the map's linear duplicate + // scan; repeats go through add() so values combine. + if (name != HTTPHeaderName::SetCookie && !seen[index]) { + seen.set(index); + map.commonHeaders().append(HTTPHeaderMap::CommonHeader { name, WTF::move(value) }); + } else { + map.add(name, value); + } } else { - // the case where we do not need to clone the name - // when the header name is already present in the list - // we don't have that information here, so map.addUncommonHeaderCloneName exists + // Clones the name unless an entry for it already exists. + StringView nameView = StringView(std::span { reinterpret_cast(header.name.ptr), header.name.len }); map.addUncommonHeaderCloneName(nameView, value); } } diff --git a/src/jsc/bindings/highway_strings.cpp b/src/jsc/bindings/highway_strings.cpp index 78485db85533..e0ad29eb6e6f 100644 --- a/src/jsc/bindings/highway_strings.cpp +++ b/src/jsc/bindings/highway_strings.cpp @@ -865,6 +865,40 @@ size_t IndexOfSpaceOrNewlineOrNonASCIIImpl(const uint8_t* HWY_RESTRICT start_ptr return search_len; } +// Index of the first CTL octet (0x00-0x1F or 0x7F) other than HTAB, or +// search_len if there is none. These are exactly the octets that end (CR/LF) +// or invalidate an HTTP/1.x field value / reason phrase; SP, HTAB, VCHAR and +// obs-text (0x80-0xFF) are skipped. +size_t IndexOfHttpCtlImpl(const uint8_t* HWY_RESTRICT start_ptr, size_t search_len) +{ + D8 d; + const size_t N = hn::Lanes(d); + + const auto vec_sp = hn::Set(d, uint8_t { 0x20 }); + const auto vec_ht = hn::Set(d, uint8_t { '\t' }); + const auto vec_del = hn::Set(d, uint8_t { 0x7F }); + const size_t simd_text_len = search_len - (search_len % N); + + size_t i = 0; + for (; i < simd_text_len; i += N) { + const auto vec = hn::LoadU(d, start_ptr + i); + const auto ctl = hn::Or(hn::AndNot(hn::Eq(vec, vec_ht), hn::Lt(vec, vec_sp)), hn::Eq(vec, vec_del)); + const intptr_t pos = hn::FindFirstTrue(d, ctl); + if (pos >= 0) { + return i + pos; + } + } + + for (; i < search_len; ++i) { + const uint8_t c = start_ptr[i]; + if ((c < 0x20 && c != '\t') || c == 0x7F) { + return i; + } + } + + return search_len; +} + bool ContainsNewlineOrNonASCIIOrQuoteImpl(const uint8_t* HWY_RESTRICT text, size_t text_len) { ASSERT(text_len > 0); @@ -2208,6 +2242,7 @@ HWY_EXPORT(IndexOfFirstAsciiUpper16Impl); HWY_EXPORT(IndexOfFirstAsciiUpperImpl); HWY_EXPORT(IndexOfHTMLEscapeChar8Impl); HWY_EXPORT(IndexOfHTMLEscapeChar16Impl); +HWY_EXPORT(IndexOfHttpCtlImpl); HWY_EXPORT(IndexOfInterestingCharacterInMultilineCommentImpl); HWY_EXPORT(IndexOfInterestingCharacterInStringLiteralImpl); HWY_EXPORT(IndexOfNeedsEscapeForJavaScriptStringImplBacktick); @@ -2407,6 +2442,11 @@ size_t highway_index_of_space_or_newline_or_non_ascii(const uint8_t* HWY_RESTRIC return HWY_DYNAMIC_DISPATCH(IndexOfSpaceOrNewlineOrNonASCIIImpl)(text, text_len); } +size_t highway_index_of_http_ctl(const uint8_t* HWY_RESTRICT text, size_t text_len) +{ + return HWY_DYNAMIC_DISPATCH(IndexOfHttpCtlImpl)(text, text_len); +} + void highway_fill_with_skip_mask( const uint8_t* mask, // 4-byte mask array size_t mask_len, // Should be 4 diff --git a/src/picohttp/Cargo.toml b/src/picohttp/Cargo.toml index e95e42c91f13..aca223cb9209 100644 --- a/src/picohttp/Cargo.toml +++ b/src/picohttp/Cargo.toml @@ -12,10 +12,6 @@ workspace = true [dependencies] strum.workspace = true bstr.workspace = true -scopeguard.workspace = true -const_format.workspace = true -enum-map.workspace = true -enumset.workspace = true -libc.workspace = true -bitflags.workspace = true bun_core.workspace = true +bun_highway.workspace = true +bun_http_types.workspace = true diff --git a/src/picohttp/chunked.rs b/src/picohttp/chunked.rs new file mode 100644 index 000000000000..aaa2edda9c91 --- /dev/null +++ b/src/picohttp/chunked.rs @@ -0,0 +1,449 @@ +//! Incremental, in-place `Transfer-Encoding: chunked` decoder (RFC 9112 §7.1). +//! +//! The state machine is derived from picohttpparser's `phr_decode_chunked` +//! (Copyright (c) 2009-2014 Kazuho Oku, Tokuhiro Matsuno, Daisuke Murase, +//! Shigeo Mitsunari; MIT licensed). + +use bun_core::fmt::hex_digit_value; +use bun_core::strings; + +/// Feed successive buffers to [`ChunkedDecoder::decode`]; each call strips the +/// chunk framing in place and reports how many body bytes are now at the front +/// of that buffer. The trailer section is consumed and discarded. +#[derive(Clone, Copy, Default)] +pub struct ChunkedDecoder { + bytes_left_in_chunk: usize, + hex_count: u8, + state: State, +} + +#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)] +enum State { + #[default] + Size, + Ext, + SizeLf, + Data, + DataCr, + DataLf, + TrailerLineHead, + TrailerLineMiddle, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct ChunkedEncodingError; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Decoded { + /// Body bytes now at `buf[..written]`. + pub written: usize, + /// The last chunk and trailer section were consumed. Any bytes that + /// followed the message are dropped; otherwise all of `buf` was consumed. + pub complete: bool, +} + +/// `chunk-size` digits accepted before the value could overflow `usize`. +const MAX_HEX_DIGITS: u8 = (usize::BITS / 4) as u8; + +impl ChunkedDecoder { + /// True once the terminating zero-length chunk has been seen and only the + /// (discarded) trailer section remains, i.e. the body itself is complete. + #[inline] + pub fn is_in_trailers(&self) -> bool { + matches!( + self.state, + State::TrailerLineHead | State::TrailerLineMiddle + ) + } + + pub fn decode(&mut self, buf: &mut [u8]) -> Result { + let len = buf.len(); + let mut src = 0usize; + let mut dst = 0usize; + // Locals so the loop state stays in registers across the per-chunk + // memmove; the `if st == ..` chain (rather than a `match` per + // transition) lets consecutive states fall straight through. + let Self { + mut bytes_left_in_chunk, + mut hex_count, + state: mut st, + } = *self; + + let complete = 'run: loop { + if st == State::Size { + loop { + let Some(&c) = buf.get(src) else { + break 'run Ok(false); + }; + match hex_digit_value(c) { + Some(v) => { + if hex_count == MAX_HEX_DIGITS { + break 'run Err(ChunkedEncodingError); + } + bytes_left_in_chunk = bytes_left_in_chunk * 16 + v as usize; + hex_count += 1; + src += 1; + } + None => { + if hex_count == 0 || !matches!(c, b' ' | b'\t' | b';' | b'\r' | b'\n') { + break 'run Err(ChunkedEncodingError); + } + break; + } + } + } + hex_count = 0; + st = State::Ext; + } + // BWS / chunk-ext up to the CR. A bare LF is rejected (RFC 9112 §2.2). + if st == State::Ext { + let cr = match buf.get(src) { + None => break 'run Ok(false), + Some(b'\r') => 0, + Some(_) => match strings::index_of_any(&buf[src..], b"\r\n") { + None => { + src = len; + break 'run Ok(false); + } + Some(i) if buf[src + i] == b'\r' => i, + Some(_) => break 'run Err(ChunkedEncodingError), + }, + }; + src += cr + 1; + st = State::SizeLf; + } + if st == State::SizeLf { + let Some(&c) = buf.get(src) else { + break 'run Ok(false); + }; + if c != b'\n' { + break 'run Err(ChunkedEncodingError); + } + src += 1; + st = if bytes_left_in_chunk == 0 { + State::TrailerLineHead + } else { + State::Data + }; + } + if st == State::Data { + let avail = len - src; + if avail < bytes_left_in_chunk { + buf.copy_within(src..len, dst); + dst += avail; + src = len; + bytes_left_in_chunk -= avail; + break 'run Ok(false); + } + let n = bytes_left_in_chunk; + buf.copy_within(src..src + n, dst); + dst += n; + src += n; + bytes_left_in_chunk = 0; + st = State::DataCr; + } + if st == State::DataCr { + let Some(&c) = buf.get(src) else { + break 'run Ok(false); + }; + if c != b'\r' { + break 'run Err(ChunkedEncodingError); + } + src += 1; + st = State::DataLf; + } + if st == State::DataLf { + let Some(&c) = buf.get(src) else { + break 'run Ok(false); + }; + if c != b'\n' { + break 'run Err(ChunkedEncodingError); + } + src += 1; + st = State::Size; + continue; + } + // Trailer fields are discarded, so they are only scanned for the + // blank line that ends them; a bare LF is tolerated here. + if st == State::TrailerLineHead { + while buf.get(src) == Some(&b'\r') { + src += 1; + } + let Some(&c) = buf.get(src) else { + break 'run Ok(false); + }; + src += 1; + if c == b'\n' { + break 'run Ok(true); + } + st = State::TrailerLineMiddle; + } + debug_assert_eq!(st, State::TrailerLineMiddle); + match strings::index_of_char_usize(&buf[src..], b'\n') { + None => { + src = len; + break 'run Ok(false); + } + Some(i) => { + src += i + 1; + st = State::TrailerLineHead; + } + } + }; + *self = Self { + bytes_left_in_chunk, + hex_count, + state: st, + }; + + let complete = complete?; + debug_assert!(complete || src == len); + Ok(Decoded { + written: dst, + complete, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const INCOMPLETE: Result = Ok(Decoded { + written: 0, + complete: false, + }); + fn incomplete(written: usize) -> Result { + Ok(Decoded { + written, + complete: false, + }) + } + fn complete(written: usize) -> Result { + Ok(Decoded { + written, + complete: true, + }) + } + + /// (wire bytes, body, bytes left over after the message) + const COMPLETE_CASES: &[(&str, &str, usize)] = &[ + ("b\r\nhello world\r\n0\r\n\r\n", "hello world", 0), + ("6\r\nhello \r\n5\r\nworld\r\n0\r\n\r\n", "hello world", 0), + ( + "6;comment=hi\r\nhello \r\n5\r\nworld\r\n0\r\n\r\n", + "hello world", + 0, + ), + ( + "6 ; comment\r\nhello \r\n5\r\nworld\r\n0\r\n\r\n", + "hello world", + 0, + ), + ( + "6\r\nhello \r\n5\r\nworld\r\n0\r\na: b\r\nc: d\r\n\r\n", + "hello world", + 0, + ), + ("B\r\nhello world\r\n0\r\n\r\n", "hello world", 0), + ("b\r\nhello world\r\n0\r\n\n", "hello world", 0), + ( + "6\r\nhello \r\n5\r\nworld\r\n0\r\na: b\nc: d\n\n", + "hello world", + 0, + ), + ("b\t\r\nhello world\r\n000\r\n\r\n", "hello world", 0), + ("5\r\nabcde\r\n0\r\n\r\nGET / HTTP/1.1\r\n\r\n", "abcde", 18), + ("0\r\n\r\n", "", 0), + ("0;ext\r\nTrailer: x\r\n\r\nnext", "", 4), + ]; + + const INCOMPLETE_CASES: &[(&str, &str)] = &[ + ("b\r\nhello world\r\n0\r\n", "hello world"), + ("6\r\nhello \r\n5\r\nworld\r\n0\r\n", "hello world"), + ( + "6;comment=hi\r\nhello \r\n5\r\nworld\r\n0\r\n", + "hello world", + ), + ( + "6\r\nhello \r\nffffffffffffffff\r\nabcdefg", + "hello abcdefg", + ), + ("6\r\nhel", "hel"), + ("6\r\nhello \r", "hello "), + ("6\r\nhello \r\n5", "hello "), + ("10", ""), + ]; + + const INVALID_CASES: &[&str] = &[ + "z\r\nabcdefg", + "6\r\nhello \r\nfffffffffffffffff\r\nabcdefg", + "1x\r\na\r\n0\r\n", + "6\nhello \r\n5\r\nworld\r\n0\r\n", + "6\r\nhello \n5\r\nworld\r\n0\r\n", + "6\r\nhello \r\n5\r\nworld\n0\r\n", + "6\r\nhello \r\n5\r\nworld\r\n0\n", + "6\rX\nhello \n5\r\nworld\r\n0\r\n", + "6\r\nhello \r\r\n0\r\n\r\n", + "\r\n6\r\nhello \r\n0\r\n\r\n", + " 6\r\nhello \r\n0\r\n\r\n", + "6\r\nhello X\r\n0\r\n\r\n", + "6\r\nhello \rX0\r\n\r\n", + "-1\r\n", + "0x6\r\nhello \r\n0\r\n\r\n", + ]; + + fn at_once(encoded: &str) -> (Result, Vec) { + let mut buf = encoded.as_bytes().to_vec(); + let r = ChunkedDecoder::default().decode(&mut buf); + (r, buf) + } + + /// Feeds `encoded` one byte at a time until `stop_early` bytes remain, + /// then the rest in one call. Returns the last result and the body so far. + fn per_byte( + encoded: &str, + stop_early: usize, + ) -> (Result, Vec) { + let bytes = encoded.as_bytes(); + let mut dec = ChunkedDecoder::default(); + let mut out = Vec::new(); + let split = bytes.len() - stop_early; + for i in 0..split.saturating_sub(1) { + let mut b = [bytes[i]]; + let r = dec.decode(&mut b); + match r { + Ok(Decoded { + written, + complete: false, + }) => out.extend_from_slice(&b[..written]), + _ => return (r, out), + } + } + let mut tail = bytes[split.saturating_sub(1)..].to_vec(); + let r = dec.decode(&mut tail); + if let Ok(d) = r { + out.extend_from_slice(&tail[..d.written]); + } + (r, out) + } + + #[test] + fn complete_messages() { + for &(encoded, body, left_over) in COMPLETE_CASES { + let (r, buf) = at_once(encoded); + assert_eq!(r, complete(body.len()), "{encoded:?}"); + assert_eq!(&buf[..body.len()], body.as_bytes(), "{encoded:?}"); + + let (r, out) = per_byte(encoded, left_over); + assert!( + matches!(r, Ok(Decoded { complete: true, .. })), + "{encoded:?}: {r:?}" + ); + assert_eq!(out, body.as_bytes(), "{encoded:?}"); + } + } + + #[test] + fn incomplete_messages() { + for &(encoded, body) in INCOMPLETE_CASES { + let (r, buf) = at_once(encoded); + assert_eq!(r, incomplete(body.len()), "{encoded:?}"); + assert_eq!(&buf[..body.len()], body.as_bytes(), "{encoded:?}"); + + let (r, out) = per_byte(encoded, 0); + assert!( + matches!( + r, + Ok(Decoded { + complete: false, + .. + }) + ), + "{encoded:?}: {r:?}" + ); + assert_eq!(out, body.as_bytes(), "{encoded:?}"); + } + } + + #[test] + fn invalid_messages() { + for &encoded in INVALID_CASES { + assert_eq!( + at_once(encoded).0, + Err(ChunkedEncodingError), + "at once: {encoded:?}" + ); + assert_eq!( + per_byte(encoded, 0).0, + Err(ChunkedEncodingError), + "per byte: {encoded:?}" + ); + } + } + + #[test] + fn is_in_trailers() { + let mut dec = ChunkedDecoder::default(); + let mut buf = b"5\r\nab".to_vec(); + assert_eq!(dec.decode(&mut buf), incomplete(2)); + assert!(!dec.is_in_trailers()); + let mut buf = b"cde\r\n0\r\n".to_vec(); + assert_eq!(dec.decode(&mut buf), incomplete(3)); + assert!(dec.is_in_trailers()); + let mut buf = b"X-Trailer: 1\r".to_vec(); + assert_eq!(dec.decode(&mut buf), INCOMPLETE); + assert!(dec.is_in_trailers()); + let mut buf = b"\n\r\nNEXT".to_vec(); + assert_eq!(dec.decode(&mut buf), complete(0)); + } + + #[test] + fn resumes_across_every_split_point() { + let encoded = b"3;x=y\r\nabc\r\n1A\r\nabcdefghijklmnopqrstuvwxyz\r\n0\r\nT: v\r\n\r\n"; + let expected = b"abcabcdefghijklmnopqrstuvwxyz"; + for split in 0..=encoded.len() { + let mut dec = ChunkedDecoder::default(); + let mut a = encoded[..split].to_vec(); + let mut b = encoded[split..].to_vec(); + let first = dec + .decode(&mut a) + .unwrap_or_else(|e| panic!("split {split}: {e:?}")); + let mut out = a[..first.written].to_vec(); + assert_eq!(first.complete, split == encoded.len(), "split {split}"); + if !first.complete { + let second = dec + .decode(&mut b) + .unwrap_or_else(|e| panic!("split {split}: {e:?}")); + assert!(second.complete, "split {split}"); + out.extend_from_slice(&b[..second.written]); + } + assert_eq!(out, expected, "split {split}"); + } + } + + #[test] + fn max_chunk_size_digits() { + let ok = format!("{}\r\n", "f".repeat(MAX_HEX_DIGITS as usize)); + assert_eq!(at_once(&ok).0, INCOMPLETE); + let overflow = format!("{}\r\n", "1".repeat(MAX_HEX_DIGITS as usize + 1)); + assert_eq!(at_once(&overflow).0, Err(ChunkedEncodingError)); + let leading_zeros = format!("{}1\r\nX\r\n0\r\n\r\n", "0".repeat(MAX_HEX_DIGITS as usize)); + assert_eq!(at_once(&leading_zeros).0, Err(ChunkedEncodingError)); + } + + /// Framing-heavy but well-formed streams (1-byte chunks) are not an error; + /// unlike a server, a client gains nothing from an overhead heuristic. + #[test] + fn tiny_chunks_in_bulk() { + let mut dec = ChunkedDecoder::default(); + let rounds = if cfg!(miri) { 4 } else { 64 }; + for _ in 0..rounds { + let mut buf = b"1\r\nx\r\n".repeat(512); + assert_eq!(dec.decode(&mut buf), incomplete(512)); + assert_eq!(&buf[..512], &[b'x'; 512]); + } + let mut end = b"0\r\n\r\n".to_vec(); + assert_eq!(dec.decode(&mut end), complete(0)); + } +} diff --git a/src/picohttp/lib.rs b/src/picohttp/lib.rs index b8a99b3f29c3..c2151bf5d189 100644 --- a/src/picohttp/lib.rs +++ b/src/picohttp/lib.rs @@ -1,5 +1,7 @@ +//! HTTP/1.x wire types shared by the HTTP client: request/response heads, +//! header lists, the response-head parser and the chunked-body decoder. + #![warn(unused_must_use)] -use core::ffi::c_int; use core::fmt; use bstr::BStr; @@ -13,49 +15,11 @@ use bun_core::pretty_fmt; // while the returned slices are in use. pub use bun_core::StringBuilder; -// FFI surface over vendor/picohttpparser. Hand-written rather than -// bindgen-generated. -#[allow(non_camel_case_types)] -mod c { - use core::ffi::{c_char, c_int}; - #[repr(C)] - pub(super) struct phr_header { - pub name: *const c_char, - pub name_len: usize, - pub value: *const c_char, - pub value_len: usize, - } - /// Mirrors `struct phr_chunked_decoder` from picohttpparser.h. The HTTP - /// client writes `consume_trailer` and reads `_state` directly, so the - /// layout must match C exactly. - #[repr(C)] - #[derive(Clone, Copy, Default)] - pub struct phr_chunked_decoder { - pub(crate) bytes_left_in_chunk: usize, - /// Set to 1 to discard trailing headers after the terminal `0\r\n` chunk. - pub consume_trailer: core::ffi::c_char, - pub(crate) _hex_count: core::ffi::c_char, - pub _state: core::ffi::c_char, - } - unsafe extern "C" { - pub(super) fn phr_parse_response( - buf: *const u8, - len: usize, - minor_version: *mut c_int, - status: *mut c_int, - msg: *mut *const c_char, - msg_len: *mut usize, - headers: *mut phr_header, - num_headers: *mut usize, - last_len: usize, - ) -> c_int; - pub fn phr_decode_chunked( - decoder: *mut phr_chunked_decoder, - buf: *mut u8, - len: *mut usize, - ) -> isize; - } -} +mod chunked; +mod parse; + +pub use bun_http_types::HeaderName::HeaderName; +pub use chunked::{ChunkedDecoder, ChunkedEncodingError, Decoded}; use bun_core::strings; @@ -63,9 +27,14 @@ use bun_core::strings; // Header // ────────────────────────────────────────────────────────────────────────── -/// NOTE: layout MUST match `c::phr_header` exactly (see static asserts below). -/// Rust `&[u8]` has no guaranteed field order in -/// `#[repr(C)]`, so we spell the fields out and expose `.name()` / `.value()`. +/// A borrowed `name: value` pair. Stored as raw ptr/len rather than `&[u8]` +/// so it can sit in `'static` scratch arrays and be handed to C++ as +/// `PicoHTTPHeader` (bindings.cpp); the backing bytes are owned by whoever +/// built the header (parse buffer, `StringBuilder`, HPACK decode buffer). +/// +/// The name is classified against WebCore's well-known set once, at +/// construction, so the HTTP client and `FetchHeaders` can switch on +/// [`Header::well_known`] instead of re-comparing strings. #[repr(C)] #[derive(Clone, Copy)] pub struct Header { @@ -73,8 +42,16 @@ pub struct Header { name_len: usize, value_ptr: *const u8, value_len: usize, + /// `HeaderName as u8 + 1`, or 0 when not well-known (so `ZERO` stays + /// all-zero bytes). + name_id: u8, } +const _: () = assert!( + core::mem::size_of::
() == 40, + "PicoHTTPHeader in bindings.cpp" +); + impl Default for Header { #[inline] fn default() -> Self { @@ -94,6 +71,7 @@ impl Header { name_len: 0, value_ptr: core::ptr::null(), value_len: 0, + name_id: 0, }; /// Construct a `Header` from borrowed name/value slices. The caller is @@ -106,30 +84,31 @@ impl Header { name_len: name.len(), value_ptr: value.as_ptr(), value_len: value.len(), + name_id: match HeaderName::classify(name) { + Some(known) => known as u8 + 1, + None => 0, + }, } } + #[inline] + pub const fn well_known(&self) -> Option { + HeaderName::from_index(self.name_id.wrapping_sub(1)) + } + #[inline] pub fn name(&self) -> &[u8] { - // picohttpparser sets `name = NULL, name_len = 0` for multiline / - // continuation headers. `ffi::slice` tolerates the (null, 0) shape. - // SAFETY: ptr/len originate from picohttpparser pointing into the - // caller-provided buffer, or from StringBuilder::append. + // SAFETY: ptr/len came from a live slice in `new`/`clone`, or are the + // (null, 0) of `ZERO`, which `ffi::slice` tolerates. unsafe { bun_core::ffi::slice(self.name_ptr, self.name_len) } } #[inline] pub fn value(&self) -> &[u8] { - // Defensive: picohttpparser always points `value` into `buf` on - // success; `ffi::slice` tolerates the (null, 0) shape. // SAFETY: same as name() unsafe { bun_core::ffi::slice(self.value_ptr, self.value_len) } } - pub(crate) fn is_multiline(&self) -> bool { - self.name_len == 0 - } - pub(crate) fn count(&self, builder: &mut StringBuilder) { builder.count(self.name()); builder.count(self.value()); @@ -147,6 +126,7 @@ impl Header { name_len: name.len(), value_ptr: value.as_ptr(), value_len: value.len(), + name_id: self.name_id, } } @@ -160,38 +140,23 @@ impl fmt::Display for Header { // NOTE: pretty_fmt! is the compile-time ANSI-tag expander (`` → escape // codes). if enable_ansi_colors_stderr() { - if self.is_multiline() { - write!(f, pretty_fmt!("{}", true), BStr::new(self.value())) - } else { - write!( - f, - pretty_fmt!("{}: {}", true), - BStr::new(self.name()), - BStr::new(self.value()), - ) - } + write!( + f, + pretty_fmt!("{}: {}", true), + BStr::new(self.name()), + BStr::new(self.value()), + ) } else { - if self.is_multiline() { - write!( - f, - pretty_fmt!("{}", false), - BStr::new(self.value()) - ) - } else { - write!( - f, - pretty_fmt!("{}: {}", false), - BStr::new(self.name()), - BStr::new(self.value()), - ) - } + write!( + f, + pretty_fmt!("{}: {}", false), + BStr::new(self.name()), + BStr::new(self.value()), + ) } } } -const _: () = assert!(core::mem::size_of::
() == core::mem::size_of::()); -const _: () = assert!(core::mem::align_of::
() == core::mem::align_of::()); - struct HeaderCurlFormatter<'a> { header: &'a Header, } @@ -222,6 +187,16 @@ pub struct HeaderList<'a> { } impl<'a> HeaderList<'a> { + /// First value for a well-known name, by tag rather than string compare. + #[inline] + pub fn find(&self, name: HeaderName) -> Option<&'a [u8]> { + let id = name as u8 + 1; + self.list + .iter() + .find(|h| h.name_id == id) + .map(Header::value) + } + pub fn get(&self, name: &[u8]) -> Option<&'a [u8]> { for header in self.list { if strings::eql_case_insensitive_ascii(header.name(), name, true) { @@ -262,7 +237,6 @@ pub struct Request<'a> { pub path: &'a [u8], pub minor_version: usize, pub headers: &'a [Header], - pub bytes_read: u32, } impl<'a> Request<'a> { @@ -293,7 +267,6 @@ impl<'a> Request<'a> { minor_version: self.minor_version, // SAFETY: caller contract. headers: unsafe { &*core::ptr::from_ref::<[Header]>(self.headers) }, - bytes_read: self.bytes_read, } } } @@ -435,7 +408,8 @@ pub struct Response<'a> { pub status_code: u32, pub status: &'a [u8], pub headers: HeaderList<'a>, - pub bytes_read: c_int, + /// Length of the response head, including the blank line that ends it. + pub bytes_read: usize, } impl<'a> Default for Response<'a> { @@ -478,78 +452,6 @@ impl<'a> Response<'a> { bytes_read: self.bytes_read, } } - - pub fn parse_parts( - buf: &'a [u8], - src: &'a mut [Header], - offset: Option<&mut usize>, - ) -> Result, ParseResponseError> { - let mut minor_version: c_int = 1; - let mut status_code: c_int = 0; - let mut status_ptr: *const u8 = b"".as_ptr(); - let mut status_len: usize = 0; - let mut num_headers: usize = src.len(); - - let offset = offset.unwrap(); - - // SAFETY: src is layout-compatible with phr_header (asserted above); - // out-params are valid for write. - let rc = unsafe { - c::phr_parse_response( - buf.as_ptr(), - buf.len(), - &raw mut minor_version, - &raw mut status_code, - (&raw mut status_ptr).cast::<*const core::ffi::c_char>(), - &raw mut status_len, - src.as_mut_ptr().cast::(), - &raw mut num_headers, - *offset, - ) - }; - - match rc { - -1 => { - bun_core::debug!("Malformed HTTP response:\n{}", BStr::new(buf)); - Err(ParseResponseError::MalformedHttpResponse) - } - -2 => { - *offset += buf.len(); - Err(ParseResponseError::ShortRead) - } - _ => { - // RFC 9112 section 5.2: picohttpparser surfaces an obs-fold - // continuation line as a separate entry with an empty name. - // `Response` has no way to splice it back into the preceding - // field value, and silently dropping it corrupts the value - // (and, for Transfer-Encoding / Content-Length, the message - // framing). Treat the fold as malformed; Node does the same. - if src[0..num_headers.min(src.len())] - .iter() - .any(Header::is_multiline) - { - bun_core::debug!("obs-fold in HTTP response:\n{}", BStr::new(buf)); - return Err(ParseResponseError::MalformedHttpResponse); - } - Ok(Response { - minor_version: usize::try_from(minor_version).expect("int cast"), - status_code: u32::try_from(status_code).expect("int cast"), - // SAFETY: on success, ptr/len point into `buf`. - status: unsafe { bun_core::ffi::slice(status_ptr, status_len) }, - headers: HeaderList { - list: &src[0..num_headers.min(src.len())], - }, - bytes_read: rc, - }) - } - } - } - - pub fn parse(buf: &'a [u8], src: &'a mut [Header]) -> Result, ParseResponseError> { - let mut offset: usize = 0; - let response = Self::parse_parts(buf, src, Some(&mut offset))?; - Ok(response) - } } impl fmt::Display for Response<'_> { @@ -599,10 +501,3 @@ impl fmt::Display for Headers<'_> { Ok(()) } } - -// ────────────────────────────────────────────────────────────────────────── -// Re-exports from picohttp_sys -// ────────────────────────────────────────────────────────────────────────── - -pub use c::phr_chunked_decoder; -pub use c::phr_decode_chunked; diff --git a/src/picohttp/parse.rs b/src/picohttp/parse.rs new file mode 100644 index 000000000000..d3b7090678b3 --- /dev/null +++ b/src/picohttp/parse.rs @@ -0,0 +1,611 @@ +//! HTTP/1.x response head parser: status line plus field lines, up to and +//! including the blank line that ends the head. + +use bstr::BStr; +use bun_core::strings; + +use crate::{Header, HeaderList, ParseResponseError, Response}; +use ParseResponseError::{MalformedHttpResponse as Invalid, ShortRead as Partial}; + +bun_core::define_scoped_log!(log, picohttp, hidden); + +/// RFC 9110 §5.6.2 `tchar`. +const fn is_tchar(c: u8) -> bool { + matches!(c, + b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~' + | b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z') +} + +static TCHAR: [bool; 256] = { + let mut t = [false; 256]; + let mut i = 0; + while i < 256 { + t[i] = is_tchar(i as u8); + i += 1; + } + t +}; + +struct Cursor<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Cursor<'a> { + #[inline(always)] + fn peek(&self) -> Result { + self.buf.get(self.pos).copied().ok_or(Partial) + } + + #[inline(always)] + fn expect(&mut self, c: u8) -> Result<(), ParseResponseError> { + if self.peek()? != c { + return Err(Invalid); + } + self.pos += 1; + Ok(()) + } + + #[inline(always)] + fn digit(&mut self) -> Result { + let c = self.peek()?; + if !c.is_ascii_digit() { + return Err(Invalid); + } + self.pos += 1; + Ok(u16::from(c - b'0')) + } + + /// Skips a run of bytes matching `f`; `Partial` if the run reaches the end + /// of the buffer (the caller always needs at least one byte after it). + #[inline(always)] + fn skip_while(&mut self, f: impl Fn(u8) -> bool) -> Result<(), ParseResponseError> { + while f(self.peek()?) { + self.pos += 1; + } + Ok(()) + } + + /// Rest of the current line, excluding the CRLF (or bare LF) terminator. + /// SP, HTAB, VCHAR and obs-text are content; any other CTL is invalid. + #[inline(always)] + fn line(&mut self) -> Result<&'a [u8], ParseResponseError> { + let start = self.pos; + let rest = &self.buf[start..]; + let i = index_of_line_end(rest).ok_or(Partial)?; + match rest[i] { + b'\r' => match rest.get(i + 1) { + None => Err(Partial), + Some(b'\n') => { + self.pos = start + i + 2; + Ok(&rest[..i]) + } + Some(_) => Err(Invalid), + }, + b'\n' => { + self.pos = start + i + 1; + Ok(&rest[..i]) + } + _ => Err(Invalid), + } + } + + /// `field-name ":"`, returning the name. Whitespace before the colon is + /// not a `tchar` and so is rejected (RFC 9112 §5.1). + #[inline(always)] + fn field_name(&mut self) -> Result<&'a [u8], ParseResponseError> { + let start = self.pos; + let rest = &self.buf[start..]; + // Names are short; a table scan beats a SIMD call here. + let n = rest + .iter() + .position(|&c| !TCHAR[c as usize]) + .ok_or(Partial)?; + if n == 0 || rest[n] != b':' { + return Err(Invalid); + } + self.pos = start + n + 1; + Ok(&rest[..n]) + } +} + +/// Offset of the first byte of little-endian `word` that is `< 0x20` or +/// `== 0x7F` (so HTAB is included), or 8 if there is none. +#[inline(always)] +fn swar_index_of_ctl_or_htab(word: u64) -> usize { + const ONES: u64 = u64::MAX / 0xFF; + const HIGH_BITS: u64 = ONES << 7; + let below_0x20 = word.wrapping_sub(ONES * 0x20) & !word; + let xor_0x7f = word ^ (ONES * 0x7F); + let equals_0x7f = xor_0x7f.wrapping_sub(ONES) & !xor_0x7f; + // Borrow propagation can set spurious bits, but only in bytes above a + // genuine match, so the lowest set bit is exact. + (((below_0x20 | equals_0x7f) & HIGH_BITS).trailing_zeros() / 8) as usize +} + +/// [`bun_highway::index_of_http_ctl`], with the first two words checked +/// inline: reason phrases and short field values then never pay for the FFI +/// call and dispatch. +#[inline(always)] +fn index_of_line_end(s: &[u8]) -> Option { + let mut i = 0; + while i < 16 { + let Some(word) = s[i..].first_chunk::<8>() else { + break; + }; + let j = swar_index_of_ctl_or_htab(u64::from_le_bytes(*word)); + if j < 8 { + if s[i + j] != b'\t' { + return Some(i + j); + } + i += j + 1; + break; + } + i += 8; + } + bun_highway::index_of_http_ctl(&s[i..]).map(|j| i + j) +} + +impl<'a> Response<'a> { + /// Parses `buf` as the start of an HTTP/1.x response, borrowing `headers` + /// for the field lines. Every byte present is validated, so `ShortRead` + /// means `buf` is a proper prefix of some valid head. + /// + /// Line folding (obs-fold, RFC 9112 §5.2) is rejected rather than + /// unfolded: splicing the continuation into the previous value would need + /// an allocation, and silently dropping it would corrupt that value. Node + /// rejects it too. + pub fn parse( + buf: &'a [u8], + headers: &'a mut [Header], + ) -> Result, ParseResponseError> { + let result = parse_response(buf, headers); + if result.is_err() { + log!("{:?}:\n{}", result.as_ref().err(), BStr::new(buf)); + } + let (response, header_count) = result?; + Ok(Response { + headers: HeaderList { + list: &headers[..header_count], + }, + ..response + }) + } +} + +impl Response<'_> { + /// Whether `buf` could now hold a complete head, given that its first + /// `already_seen` bytes were a proper prefix of one (`parse` returned + /// `ShortRead` on them). Only the new bytes are examined, so a caller + /// accumulating a trickled response checks each byte a bounded number of + /// times instead of re-parsing from the start on every read. + /// + /// `true` means "run `parse`": either the blank line that ends a head is + /// present or a stray CR makes the input invalid anyway. + pub fn may_be_complete(buf: &[u8], already_seen: usize) -> bool { + // A terminator ending in the new bytes starts at most 3 bytes earlier. + let mut from = already_seen.saturating_sub(3); + let mut line_breaks = 0; + while let Some(i) = strings::index_of_any_pos(buf, b"\r\n", from) { + if i != from { + line_breaks = 0; + } + if buf[i] == b'\r' { + match buf.get(i + 1) { + None => return false, + Some(b'\n') => from = i + 2, + Some(_) => return true, + } + } else { + from = i + 1; + } + line_breaks += 1; + if line_breaks == 2 { + return true; + } + } + false + } +} + +/// Returns the response with an empty header list plus the number of entries +/// written to `headers`, so the caller can attach the borrow. +fn parse_response<'a>( + buf: &'a [u8], + headers: &mut [Header], +) -> Result<(Response<'a>, usize), ParseResponseError> { + let mut c = Cursor { buf, pos: 0 }; + + for &b in b"HTTP/1." { + c.expect(b)?; + } + let minor_version = c.digit()? as u8; + + c.expect(b' ')?; + c.skip_while(|b| b == b' ')?; + let status_code = c.digit()? * 100 + c.digit()? * 10 + c.digit()?; + let reason = match c.peek()? { + b' ' => { + c.skip_while(|b| b == b' ')?; + c.line()? + } + b'\r' | b'\n' => c.line()?, + _ => return Err(Invalid), + }; + + let mut header_count = 0; + loop { + match c.peek()? { + b'\r' => { + c.pos += 1; + c.expect(b'\n')?; + break; + } + b'\n' => { + c.pos += 1; + break; + } + b' ' | b'\t' => return Err(Invalid), + _ => {} + } + if header_count == headers.len() { + return Err(Invalid); + } + let name = c.field_name()?; + c.skip_while(|b| b == b' ' || b == b'\t')?; + let value = strings::trim_right(c.line()?, b" \t"); + headers[header_count] = Header::new(name, value); + header_count += 1; + } + + Ok(( + Response { + minor_version: usize::from(minor_version), + status_code: u32::from(status_code), + status: reason, + headers: HeaderList::default(), + bytes_read: c.pos, + }, + header_count, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + const OK: i32 = 0; + const PARTIAL: i32 = -2; + const INVALID: i32 = -1; + + struct Head<'a> { + minor_version: usize, + status_code: u32, + reason: &'a [u8], + len: usize, + } + + fn parse(buf: &[u8], max_headers: usize) -> (i32, Option>, Vec
) { + let mut headers = vec![Header::ZERO; max_headers]; + match parse_response(buf, &mut headers) { + Ok((r, n)) => { + headers.truncate(n); + let head = Head { + minor_version: r.minor_version, + status_code: r.status_code, + reason: r.status, + len: r.bytes_read, + }; + (OK, Some(head), headers) + } + Err(Partial) => (PARTIAL, None, vec![]), + Err(Invalid) => (INVALID, None, vec![]), + } + } + + fn check(buf: &[u8], expect: i32) -> Option<(Head<'_>, Vec
)> { + let (rc, head, headers) = parse(buf, 4); + assert_eq!(rc, expect, "{:?}", BStr::new(buf)); + if let Some(h) = &head { + assert_eq!(h.len, buf.len(), "{:?}", BStr::new(buf)); + } + head.map(|h| (h, headers)) + } + + fn hv(h: &Header) -> (&[u8], &[u8]) { + (h.name(), h.value()) + } + + // Ported from picohttpparser test.c `test_response`. + #[test] + fn upstream_response_cases() { + let (h, hd) = check(b"HTTP/1.0 200 OK\r\n\r\n", OK).unwrap(); + assert_eq!(hd.len(), 0); + assert_eq!(h.status_code, 200); + assert_eq!(h.minor_version, 0); + assert_eq!(h.reason, b"OK"); + + check(b"HTTP/1.0 200 OK\r\n\r", PARTIAL); + + let (h, hd) = check( + b"HTTP/1.1 200 OK\r\nHost: example.com\r\nCookie: \r\n\r\n", + OK, + ) + .unwrap(); + assert_eq!(hd.len(), 2); + assert_eq!(h.minor_version, 1); + assert_eq!(h.status_code, 200); + assert_eq!(h.reason, b"OK"); + assert_eq!(hv(&hd[0]), (&b"Host"[..], &b"example.com"[..])); + assert_eq!(hv(&hd[1]), (&b"Cookie"[..], &b""[..])); + + // Upstream unfolds this into a third nameless header; we reject obs-fold. + check( + b"HTTP/1.0 200 OK\r\nfoo: \r\nfoo: b\r\n \tc\r\n\r\n", + INVALID, + ); + + let (h, _) = check(b"HTTP/1.0 500 Internal Server Error\r\n\r\n", OK).unwrap(); + assert_eq!(h.status_code, 500); + assert_eq!(h.reason, b"Internal Server Error"); + + check(b"H", PARTIAL); + check(b"HTTP/1.", PARTIAL); + check(b"HTTP/1.1", PARTIAL); + check(b"HTTP/1.1 ", PARTIAL); + check(b"HTTP/1.1 2", PARTIAL); + check(b"HTTP/1.1 200", PARTIAL); + check(b"HTTP/1.1 200 ", PARTIAL); + check(b"HTTP/1.1 200 O", PARTIAL); + check(b"HTTP/1.1 200 OK\r", PARTIAL); + check(b"HTTP/1.1 200 OK\r\n", PARTIAL); + check(b"HTTP/1.1 200 OK\n", PARTIAL); + check(b"HTTP/1.1 200 OK\r\nA: 1\r", PARTIAL); + check(b"HTTP/1.1 200 OK\r\nA: 1\r\n", PARTIAL); + + check(b"HTTP/1. 200 OK\r\n\r\n", INVALID); + check(b"HTTP/1.2z 200 OK\r\n\r\n", INVALID); + check(b"HTTP/1.1 OK\r\n\r\n", INVALID); + + let (h, _) = check(b"HTTP/1.1 200\r\n\r\n", OK).unwrap(); + assert_eq!(h.reason, b""); + check(b"HTTP/1.1 200X\r\n\r\n", INVALID); + check(b"HTTP/1.1 200X \r\n\r\n", INVALID); + check(b"HTTP/1.1 200X OK\r\n\r\n", INVALID); + + let (_, hd) = check(b"HTTP/1.1 200 OK\r\nbar: \t b\t \t\r\n\r\n", OK).unwrap(); + assert_eq!(hv(&hd[0]), (&b"bar"[..], &b"b"[..])); + + let (h, _) = check(b"HTTP/1.1 200 OK\r\n\r\n", OK).unwrap(); + assert_eq!(h.status_code, 200); + assert_eq!(h.reason, b"OK"); + } + + // Ported from the header-related cases of picohttpparser `test_request`. + #[test] + fn upstream_header_cases() { + let (_, hd) = check( + b"HTTP/1.1 200 OK\r\nHost: example.com\r\nUser-Agent: \xe3\x81\xb2\xe3/1.0\r\n\r\n", + OK, + ) + .unwrap(); + assert_eq!( + hv(&hd[1]), + (&b"User-Agent"[..], &b"\xe3\x81\xb2\xe3/1.0"[..]) + ); + + check(b"HTTP/1.0 200 OK\r\nfoo : ab\r\n\r\n", INVALID); + check(b"HTTP/1.0 200 OK\r\n:a\r\n\r\n", INVALID); + check(b"HTTP/1.0 200 OK\r\n :a\r\n\r\n", INVALID); + check(b"HTTP/1.0 200 OK\r\na\0b: c\r\n\r\n", INVALID); + check(b"HTTP/1.0 200 OK\r\nab: c\0d\r\n\r\n", INVALID); + check(b"HTTP/1.0 200 OK\r\na\x1bb: c\r\n\r\n", INVALID); + check(b"HTTP/1.0 200 OK\r\nab: c\x1b\r\n\r\n", INVALID); + check(b"HTTP/1.0 200 OK\r\n/: 1\r\n\r\n", INVALID); + let (_, hd) = check(b"HTTP/1.0 200 OK\r\nh: c\xa2y\r\n\r\n", OK).unwrap(); + assert_eq!(hv(&hd[0]), (&b"h"[..], &b"c\xa2y"[..])); + let (_, hd) = check(b"HTTP/1.0 200 OK\r\n\x7c\x7e: 1\r\n\r\n", OK).unwrap(); + assert_eq!(hv(&hd[0]), (&b"|~"[..], &b"1"[..])); + check(b"HTTP/1.0 200 OK\r\n\x7b: 1\r\n\r\n", INVALID); + let (_, hd) = check(b"HTTP/1.0 200 OK\r\nfoo: a \t \r\n\r\n", OK).unwrap(); + assert_eq!(hv(&hd[0]), (&b"foo"[..], &b"a"[..])); + } + + #[test] + fn status_line() { + check(b"HTTP/2.0 200 OK\r\n\r\n", INVALID); + check(b"http/1.1 200 OK\r\n\r\n", INVALID); + check(b"ICY 200 OK\r\n\r\n", INVALID); + check(b"HTTP/1.1\t200 OK\r\n\r\n", INVALID); + check(b"HTTP/1.1 20 OK\r\n\r\n", INVALID); + check(b"HTTP/1.1 2000 OK\r\n\r\n", INVALID); + check(b"HTTP/1.1 200\tOK\r\n\r\n", INVALID); + check(b"HTTP/1.1 200 O\x00K\r\n\r\n", INVALID); + check(b"HTTP/1.1 200 O\x7fK\r\n\r\n", INVALID); + check(b"HTTP/1.1 200 OK\rX\r\n", INVALID); + // Anything shorter than the fixed-width prefix is validated as far as it goes. + check(b"X", INVALID); + check(b"HTTP/1.X", INVALID); + check(b"HTTP/1.1X", INVALID); + check(b"HTTP/1.1 2X", INVALID); + + let (h, _) = check(b"HTTP/1.9 999 \r\n\r\n", OK).unwrap(); + assert_eq!( + (h.minor_version, h.status_code, h.reason), + (9, 999, &b""[..]) + ); + let (h, _) = check(b"HTTP/1.1 000 a \tb\x80 \r\n\r\n", OK).unwrap(); + assert_eq!((h.status_code, h.reason), (0, &b"a \tb\x80 "[..])); + // Bare LF line endings are accepted throughout, as upstream did. + let (h, hd) = check(b"HTTP/1.1 204\nA: 1\n\n", OK).unwrap(); + assert_eq!(h.status_code, 204); + assert_eq!(hv(&hd[0]), (&b"A"[..], &b"1"[..])); + } + + #[test] + fn field_lines() { + let (_, hd) = check( + b"HTTP/1.1 200 OK\r\nA:1\r\nB:\t 2 \t\r\nC:\r\nD: \r\n\r\n", + OK, + ) + .unwrap(); + assert_eq!(hd.len(), 4); + assert_eq!(hv(&hd[0]), (&b"A"[..], &b"1"[..])); + assert_eq!(hv(&hd[1]), (&b"B"[..], &b"2"[..])); + assert_eq!(hv(&hd[2]), (&b"C"[..], &b""[..])); + assert_eq!(hv(&hd[3]), (&b"D"[..], &b""[..])); + + // Interior whitespace and obs-text are preserved verbatim. + let (_, hd) = check(b"HTTP/1.1 200 OK\r\nV: a b\t c\xff\r\n\r\n", OK).unwrap(); + assert_eq!(hd[0].value(), b"a b\t c\xff"); + + // obs-fold after any header, with SP or HTAB. + check(b"HTTP/1.1 200 OK\r\nA: 1\r\n B: 2\r\n\r\n", INVALID); + check(b"HTTP/1.1 200 OK\r\nA: 1\r\n\tB: 2\r\n\r\n", INVALID); + // Leading whitespace on the first field line. + check(b"HTTP/1.1 200 OK\r\n A: 1\r\n\r\n", INVALID); + check(b"HTTP/1.1 200 OK\r\n\tA: 1\r\n\r\n", INVALID); + + check(b"HTTP/1.1 200 OK\r\nA\r\n\r\n", INVALID); + check(b"HTTP/1.1 200 OK\r\nA B: 1\r\n\r\n", INVALID); + check(b"HTTP/1.1 200 OK\r\nA\xe9: 1\r\n\r\n", INVALID); + check(b"HTTP/1.1 200 OK\r\nA: 1\r2\r\n\r\n", INVALID); + check(b"HTTP/1.1 200 OK\r\nA: 1\x0c\r\n\r\n", INVALID); + + check(b"HTTP/1.1 200 OK\r\nA", PARTIAL); + check(b"HTTP/1.1 200 OK\r\nA:", PARTIAL); + check(b"HTTP/1.1 200 OK\r\nA: ", PARTIAL); + check(b"HTTP/1.1 200 OK\r\nA: 1", PARTIAL); + check(b"HTTP/1.1 200 OK\r\nA: 1\r\n\r", PARTIAL); + } + + #[test] + fn header_capacity() { + let two = b"HTTP/1.1 200 OK\r\nA: 1\r\nB: 2\r\n\r\n"; + assert_eq!(parse(two, 2).0, OK); + assert_eq!(parse(two, 1).0, INVALID); + assert_eq!(parse(two, 0).0, INVALID); + assert_eq!(parse(b"HTTP/1.1 200 OK\r\n\r\n", 0).0, OK); + // Capacity is only checked once another field line actually starts. + assert_eq!(parse(b"HTTP/1.1 200 OK\r\nA: 1\r\n", 1).0, PARTIAL); + assert_eq!(parse(b"HTTP/1.1 200 OK\r\nA: 1\r\nB", 1).0, INVALID); + } + + #[test] + fn names_are_classified() { + use crate::HeaderName; + let buf = b"HTTP/1.1 200 OK\r\ncontent-LENGTH: 5\r\nX-Custom: 1\r\nSet-Cookie: a=b\r\n\r\n"; + let mut headers = [Header::ZERO; 4]; + let r = Response::parse(buf, &mut headers).unwrap(); + let tags: Vec<_> = r.headers.list.iter().map(Header::well_known).collect(); + assert_eq!( + tags, + [ + Some(HeaderName::ContentLength), + None, + Some(HeaderName::SetCookie) + ] + ); + assert_eq!(r.headers.find(HeaderName::SetCookie), Some(&b"a=b"[..])); + assert_eq!(r.headers.find(HeaderName::ContentType), None); + assert_eq!(Header::ZERO.well_known(), None); + } + + #[test] + fn consumed_length_stops_at_blank_line() { + let buf = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhelloHTTP/1.1 "; + let mut headers = [Header::ZERO; 4]; + let r = Response::parse(buf, &mut headers).unwrap(); + assert_eq!(&buf[r.bytes_read..], b"helloHTTP/1.1 "); + assert_eq!(r.headers.list.len(), 1); + assert_eq!(r.headers.list[0].value(), b"5"); + } + + #[test] + fn swar_probe_matches_scalar() { + // Every byte value at every offset within the two probed words, with + // and without a preceding HTAB. + let bytes: Vec = if cfg!(miri) { + (0..=0x21).chain(0x7d..=0x82).chain([0xfe, 0xff]).collect() + } else { + (0..=255).collect() + }; + for pos in 0..20 { + for &b in &bytes { + for lead in [b'a', b'\t'] { + let mut line = [b'x'; 24]; + line[0] = lead; + line[pos.max(1)] = b; + let expect = line.iter().position(|&c| bun_highway::is_http_ctl(c)); + assert_eq!(index_of_line_end(&line), expect, "{pos} {b:#x} {lead:#x}"); + } + } + } + assert_eq!(index_of_line_end(b""), None); + assert_eq!(index_of_line_end(b"\r"), Some(0)); + assert_eq!(index_of_line_end(b"\t\t\t\t\t\t\t\t\t\r"), Some(9)); + } + + #[test] + fn long_values_cross_simd_width() { + let mut buf = b"HTTP/1.1 200 OK\r\nX: ".to_vec(); + let value: Vec = (0..200u8).map(|i| b'a' + (i % 26)).collect(); + buf.extend_from_slice(&value); + buf.extend_from_slice(b"\r\nY: \t"); + buf.extend_from_slice(&value); + buf.extend_from_slice(b"\x7f\r\n\r\n"); + assert_eq!(parse(&buf, 4).0, INVALID); + buf.truncate(buf.len() - 5); + assert_eq!(parse(&buf, 4).0, PARTIAL); + buf.extend_from_slice(b" \r\n\r\n"); + let (rc, _, hd) = parse(&buf, 4); + assert_eq!(rc, OK); + assert_eq!(hd[0].value(), &value[..]); + assert_eq!(hd[1].value(), &value[..]); + } + + /// Every proper prefix of a valid head is `Partial`, never `Invalid`, and + /// never reads out of bounds; `may_be_complete` agrees at every split. + #[test] + fn every_prefix_is_partial() { + let full = b"HTTP/1.1 301 Moved Permanently\r\nLocation: https://example.com/\r\nSet-Cookie: a=b; Path=/\r\nX-Empty:\r\nContent-Length: 0\r\n\r\n"; + for i in 0..full.len() { + assert_eq!(parse(&full[..i], 8).0, PARTIAL, "prefix {i}"); + for seen in [0, i / 2, i.saturating_sub(3), i.saturating_sub(1)] { + assert!(!Response::may_be_complete(&full[..i], seen), "{seen}..{i}"); + } + assert!(Response::may_be_complete(full, i), "{i}.."); + } + assert_eq!(parse(full, 8).0, OK); + } + + #[test] + fn may_be_complete() { + for (buf, expect) in [ + (&b""[..], false), + (b"\n", false), + (b"\n\n", true), + (b"\r\n\r\n", true), + (b"\n\r\n", true), + (b"\r\n\n", true), + (b"a\nb\n", false), + (b"a\r\n\r", false), + (b"a\rb", true), // invalid: let `parse` say so + (b"HTTP/1.1 200 OK\r\n\r\nbody", true), + (b"HTTP/1.1 200 OK\n\nbody", true), + ] { + for seen in 0..=buf.len() { + // Only meaningful when the seen prefix had no terminator itself. + if seen >= 2 && Response::may_be_complete(&buf[..seen], 0) { + continue; + } + assert_eq!( + Response::may_be_complete(buf, seen), + expect, + "{:?} seen={seen}", + BStr::new(buf) + ); + } + } + } +} diff --git a/src/runtime/api/html_rewriter.rs b/src/runtime/api/html_rewriter.rs index 8b97470966d0..cc0307c3c906 100644 --- a/src/runtime/api/html_rewriter.rs +++ b/src/runtime/api/html_rewriter.rs @@ -1450,7 +1450,7 @@ impl RewriterPipe { was_string: false, }), ); - let _ = webcore::body::Value::resolve(&mut prev_value, body_value, &self.global, None); + let _ = webcore::body::Value::resolve(&mut prev_value, body_value, &self.global, || None); } /// Feed the accumulated `pending_input` once unblocked, then maybe end, diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index bf000c8dc535..fc76685f2a3c 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -4218,7 +4218,7 @@ where if matches!(old, Body::Value::Locked(_)) { let _exit = vm.enter_event_loop_scope(); - let _ = Body::Value::resolve(&mut old, body, global_this, None); // TODO: properly propagate exception upwards + let _ = Body::Value::resolve(&mut old, body, global_this, || None); // TODO: properly propagate exception upwards } return; } @@ -4370,7 +4370,7 @@ where } let mut new_body: Body::Value = Body::Value::Null; let global_this = server.global_this(); - let _ = Body::Value::resolve(&mut old, &mut new_body, global_this, None); // TODO: properly propagate exception upwards + let _ = Body::Value::resolve(&mut old, &mut new_body, global_this, || None); // TODO: properly propagate exception upwards *body = new_body; } } diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index 93041722ba08..39bb691cc467 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -1,5 +1,6 @@ //! https://developer.mozilla.org/en-US/docs/Web/API/Body +use bun_core::ZigStringSlice; use core::ffi::c_void; use core::ptr::NonNull; @@ -1084,9 +1085,8 @@ impl Value { &mut self, new: &mut Value, global: &JSGlobalObject, - // Opaque C++ handle, mutated via FFI. Taking - // `NonNull` (not `&`/`&mut`) avoids manufacturing aliased Rust borrows. - headers: Option>, + // The owner's `Content-Type`, consulted only for a pending `blob()`. + content_type: impl FnOnce() -> Option, ) -> JsTerminated<()> { bun_core::scoped_log!(BodyValue, "resolve"); if let Value::Locked(locked) = self { @@ -1184,19 +1184,9 @@ impl Value { let blob_ptr = Blob::new(new.use_()); // SAFETY: `Blob::new` returns a freshly heap-allocated *mut Blob. let blob = unsafe { &mut *blob_ptr }; - if let Some(fetch_headers) = headers { - // `headers` is a live C++ FetchHeaders handle; - // `FetchHeaders` is an opaque ZST FFI handle (S008) — safe deref. - let fetch_headers = - bun_opaque::opaque_deref_mut(fetch_headers.as_ptr()); - if let Some(content_type) = - fetch_headers.fast_get(HTTPHeaderName::ContentType) - { - let content_slice = content_type.to_slice(); - let mime_type = MimeType::init(content_slice.slice(), true, None); - set_blob_content_type(blob, mime_type); - // content_slice dropped (replaces defer content_slice.deinit()) - } + if let Some(content_type) = content_type() { + let mime_type = MimeType::init(content_type.slice(), true, None); + set_blob_content_type(blob, mime_type); } if !blob.content_type_was_set.get() && blob.store.get().is_some() { set_blob_content_type(blob, bun_http_types::MimeType::TEXT); @@ -1666,6 +1656,15 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { /// (FFI signature is `*mut`). Returning `NonNull` instead of `&FetchHeaders` /// avoids deriving `&mut T` from `&T` at the call sites (UB). fn get_fetch_headers(&self) -> Option>; + /// The owner's `Content-Type`. Split out so `Response` can answer without + /// building its `FetchHeaders`. + fn get_content_type_header(&self) -> Option { + let headers = self.get_fetch_headers()?; + // `FetchHeaders` is an opaque ZST FFI handle (S008) — safe deref. + bun_opaque::opaque_deref_mut(headers.as_ptr()) + .fast_get(HTTPHeaderName::ContentType) + .map(|value| value.to_slice()) + } fn get_form_data_encoding(&self) -> JsResult>>; // ──────────────────────────────────────────────────────────────────── @@ -2209,16 +2208,9 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { // SAFETY: `Blob::new` returns a freshly heap-allocated, ref-counted Blob. let blob = unsafe { &mut *blob_ptr }; if blob.content_type().is_empty() { - if let Some(fetch_headers) = BodyMixin::get_fetch_headers(self) { - // `fetch_headers` is a live C++ FetchHeaders handle; - // `FetchHeaders` is an opaque ZST FFI handle (S008) — safe deref. - let fetch_headers = bun_opaque::opaque_deref_mut(fetch_headers.as_ptr()); - if let Some(content_type) = fetch_headers.fast_get(HTTPHeaderName::ContentType) { - let content_slice = content_type.to_slice(); - let mime_type = MimeType::init(content_slice.slice(), true, None); - set_blob_content_type(blob, mime_type); - // content_slice dropped (replaces defer content_slice.deinit()) - } + if let Some(content_type) = self.get_content_type_header() { + let mime_type = MimeType::init(content_type.slice(), true, None); + set_blob_content_type(blob, mime_type); } if !blob.content_type_was_set.get() && blob.store.get().is_some() { set_blob_content_type(blob, bun_http_types::MimeType::TEXT); diff --git a/src/runtime/webcore/Response.rs b/src/runtime/webcore/Response.rs index c77d927653dc..864da6410cce 100644 --- a/src/runtime/webcore/Response.rs +++ b/src/runtime/webcore/Response.rs @@ -13,6 +13,8 @@ use crate::webcore::jsc::{ }; use bun_core::Output; use bun_core::{OwnedString, String as BunString, WTFStringImplExt as _, ZigStringSlice}; +use bun_http::HTTPResponseMetadata; +use bun_http_types::HeaderName::HeaderName; use bun_http_types::Method::Method; use super::blob::Internal as InternalBlob; @@ -67,6 +69,14 @@ impl HeadersRef { unsafe { Self::adopt(FetchHeaders::create_from_uws(uws_request)) } } + /// `FetchHeaders.createFromPicoHeaders(list)` — fresh C++ allocation, + /// refcount 1; names and values are copied. + #[inline] + pub(crate) fn create_from_pico_headers(list: &[bun_picohttp::Header]) -> Self { + // SAFETY: C++ allocates a new FetchHeaders with refcount 1; never null. + unsafe { Self::adopt(FetchHeaders::create_from_pico_headers(list)) } + } + /// `FetchHeaders.createFromJS(global, value)` — may throw, may return null. #[inline] fn create_from_js(global: &JSGlobalObject, value: JSValue) -> JsResult> { @@ -296,12 +306,16 @@ impl BodyMixin for Response { // directly (via `HeadersRef::as_ptr`) so the provenance is mutable; // going through `as_deref()` would derive it from a `&FetchHeaders` // and make the later `as_mut()` UB under Stacked Borrows. - self.init.get().headers.as_ref().map(|h| { + self.realized_init().headers.as_ref().map(|h| { core::ptr::NonNull::new(h.as_ptr()) .expect("HeadersRef wraps a non-null *mut FetchHeaders") }) } #[inline] + fn get_content_type_header(&self) -> Option { + Response::get_content_type_header(self) + } + #[inline] fn get_form_data_encoding( &self, ) -> bun_jsc::JsResult>> { @@ -340,7 +354,10 @@ impl Response { #[inline] pub(crate) fn set_init_headers(&self, headers: Option) { // old headers dropped (HeadersRef::Drop derefs the C++ handle) - self.init.with_mut(|init| init.headers = headers); + self.init.with_mut(|init| { + init.wire_headers = None; + init.headers = headers; + }); } #[inline] @@ -381,7 +398,46 @@ impl Response { #[inline] pub(crate) fn get_init_headers(&self) -> Option<&FetchHeaders> { - self.init.get().headers.as_deref() + self.realized_init().headers.as_deref() + } + + /// `init` with a fetch response's parsed head turned into `FetchHeaders` + /// (once). Every path that hands `init.headers` out goes through here; + /// only [`get_content_type_header`] answers from the wire form directly. + #[inline] + fn realized_init(&self) -> &mut Init { + let init = self.init_mut(); + init.realize_headers(); + init + } + + /// `Content-Type` without materialising `FetchHeaders`: `blob()`, + /// `formData()` and body sniffing ask for it on responses whose headers + /// are otherwise never touched. + pub(crate) fn get_content_type_header(&self) -> Option { + let init = self.init_mut(); + if let Some(wire) = &init.wire_headers { + let mut values = wire + .response + .headers + .list + .iter() + .filter(|h| h.well_known() == Some(HeaderName::ContentType)); + match (values.next(), values.next()) { + (None, _) => return None, + // Owned copy: the wire buffer is freed when the headers are realised. + (Some(only), None) => { + return Some(ZigStringSlice::init_owned(only.value().to_vec())); + } + // Repeated fields combine; let the map do that so the answer + // doesn't depend on whether `.headers` was touched first. + (Some(_), Some(_)) => init.realize_headers(), + } + } + init.headers + .as_mut()? + .fast_get(HTTPHeaderName::ContentType) + .map(|value| value.to_slice()) } /// R-2 `JsCell` escape hatch — single-JS-thread invariant. Centralises the @@ -404,7 +460,7 @@ impl Response { #[inline] #[allow(clippy::mut_from_ref)] pub(crate) fn get_init_headers_mut(&self) -> Option<&mut FetchHeaders> { - self.init_mut().headers.as_deref_mut() + self.realized_init().headers.as_deref_mut() } /// Deep-copy this response's init headers (if any) into a fresh @@ -415,7 +471,7 @@ impl Response { &self, global: &JSGlobalObject, ) -> JsResult> { - match self.init_mut().headers.as_ref() { + match self.realized_init().headers.as_ref() { Some(headers) => headers.clone_this(global), None => Ok(None), } @@ -423,7 +479,7 @@ impl Response { #[inline] pub(crate) fn swap_init_headers(&self) -> Option { - self.init.with_mut(|init| init.headers.take()) + self.realized_init().headers.take() } #[inline] @@ -468,10 +524,12 @@ impl Response { } pub(crate) fn calculate_estimated_byte_size(&self) { + let init = self.init.get(); self.reported_estimated_size.set( self.body.get().value.get().estimated_size() + self.url.get().byte_slice().len() - + self.init.get().status_text.byte_slice().len() + + init.status_text.byte_slice().len() + + init.wire_headers.as_ref().map_or(0, |w| w.owned_buf.len()) + mem::size_of::(), ); } @@ -630,10 +688,6 @@ mod _jsc_host_fns { } // mod _jsc_host_fns impl Response { - pub(crate) fn get_fetch_headers(&self) -> Option<&FetchHeaders> { - self.init.get().headers.as_deref() - } - #[inline] pub(crate) fn status_code(&self) -> u16 { self.init.get().status_code @@ -692,7 +746,7 @@ impl Response { // R-2 escape hatch via `init_mut()` — the returned `&mut HeadersRef` // borrows `self.init`; callers (`get_headers`, `construct_*`) do not // hold the borrow across calls that re-enter Response host-fns. - let init = self.init_mut(); + let init = self.realized_init(); if init.headers.is_none() { init.headers = Some(HeadersRef::create_empty()); @@ -716,12 +770,8 @@ impl Response { } pub(crate) fn get_content_type(&self) -> JsResult> { - // R-2 escape hatch via `init_mut()` — `fast_get` (FFI out-param write) - // does not re-enter JS. - if let Some(headers) = self.init_mut().headers.as_mut() { - if let Some(value) = headers.fast_get(HTTPHeaderName::ContentType) { - return Ok(Some(value.to_slice())); - } + if let Some(value) = self.get_content_type_header() { + return Ok(Some(value)); } if let BodyValue::Blob(blob) = self.body.get().value.get() { @@ -899,7 +949,7 @@ impl Response { // `Body` has NO `Drop`; arm a guard so the // `?` below releases the cloned body payload. let body = scopeguard::guard(body, |b| b.reset()); - let init = self.init.get().clone(global_this)?; + let init = self.init_mut().clone(global_this)?; // Init's drop glue (HeadersRef + OwnedString) // handles cleanup on `?` below Ok(Response { @@ -1378,6 +1428,11 @@ impl Response { // the remaining `status_text` is still dropped at scope end via field drop glue. pub struct Init { pub(crate) headers: Option, + /// A fetch response's head as parsed on the HTTP thread. Building + /// `FetchHeaders` from it (a WTF string per value plus the map) is deferred + /// to [`Init::realize_headers`] because most consumers only read the + /// status and body. + pub(crate) wire_headers: Option, pub(crate) status_code: u16, pub(crate) status_text: OwnedString, pub method: Method, @@ -1387,6 +1442,7 @@ impl Default for Init { fn default() -> Self { Self { headers: None, + wire_headers: None, status_code: 0, status_text: OwnedString::new(BunString::empty()), method: Method::GET, @@ -1395,7 +1451,18 @@ impl Default for Init { } impl Init { - pub(crate) fn clone(&self, ctx: &JSGlobalObject) -> JsResult { + #[inline] + pub(crate) fn realize_headers(&mut self) { + if let Some(wire) = self.wire_headers.take() { + debug_assert!(self.headers.is_none()); + self.headers = Some(HeadersRef::create_from_pico_headers( + wire.response.headers.list, + )); + } + } + + pub(crate) fn clone(&mut self, ctx: &JSGlobalObject) -> JsResult { + self.realize_headers(); let headers = match &self.headers { // `clone_this` does a deep copy on the C++ side and may return // null on OOM/throw. Flatten the @@ -1405,6 +1472,7 @@ impl Init { }; Ok(Init { headers, + wire_headers: None, status_code: self.status_code, status_text: self.status_text.clone(), method: self.method, @@ -1453,7 +1521,7 @@ impl Init { // SAFETY: `as_direct` returned a live `*mut Response` owned by the // JS wrapper cell; rooted by `response_init` for this call. let resp = unsafe { &*resp }; - return Ok(Some(resp.init.get().clone(global_this)?)); + return Ok(Some(resp.init_mut().clone(global_this)?)); } } diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index f477af732475..60ee43c7ec14 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -30,10 +30,9 @@ use crate::webcore::blob::{Any as AnyBlob, Blob, SizeType as BlobSizeType, Store use crate::webcore::body::{self, Body, Value as BodyValue, ValueError as BodyValueError}; use crate::webcore::fetch::fetch_request_body_sink::{FetchRequestBodySink, RequestBodyChunk}; use crate::webcore::readable_stream::{ReadableStream, Strong as ReadableStreamStrong}; -use crate::webcore::response::HeadersRef; use crate::webcore::sink::JSSink; use crate::webcore::streams::{SourceHandle, StreamError, StreamResult, Writable}; -use crate::webcore::{AbortSignal, DrainResult, FetchHeaders, InternalBlob, Response, SinkHandle}; +use crate::webcore::{AbortSignal, DrainResult, InternalBlob, Response, SinkHandle}; use bun_jsc::JsTerminatedResult; // `bun_event_loop::JsResult` (cycle-broken erased error) — used by @@ -115,6 +114,9 @@ pub struct FetchTasklet { pub(crate) upgraded_connection: bool, // Custom Hostname pub(crate) hostname: Option>, + /// Set (under `mutex`) once the HTTP thread delivers the response head; + /// `metadata` itself moves into the `Response` when that is created. + pub(crate) response_head_received: bool, pub(crate) is_waiting_body: bool, /// Set by `on_start_buffering_callback` (JS thread) and read by /// `callback()` (HTTP thread, under `mutex`): the body is being @@ -859,7 +861,7 @@ impl FetchTasklet { } } - // raw ptr: `body` and `get_fetch_headers()` are disjoint fields but borrowck can't see through the accessors. + // raw ptr: `body` and `get_content_type_header()` are disjoint fields but borrowck can't see through the accessors. let body: *mut BodyValue = response.get_body_value(); // `BodyAbortListener::on_abort` may have set `Error` while this // callback was queued; checked before `buffer_reset.set(false)` so @@ -897,17 +899,15 @@ impl FetchTasklet { if matches!(old, BodyValue::Locked(_)) { bun_output::scoped_log!(FetchTasklet, "onBodyReceived old.resolve"); let mut old = old; - // BodyValue::resolve takes `Option>` (opaque C++ handle - // mutated via FFI); the inherent `get_fetch_headers` returns `Option<&_>`, so - // erase the borrow into a raw NonNull. Disjoint from `body` (response.init vs - // response.body) and outlives this block. - let headers = response.get_fetch_headers().map(core::ptr::NonNull::from); + // SAFETY: `body` points into `response.body`, disjoint from + // `response.init`; both live for this block. + let body = unsafe { &mut *body }; // Body.rs aliases its `JsTerminated` to `JsResult` for // now; narrow back to the real `JsTerminated` here. - // SAFETY: `body` points into `response.body`, disjoint from `headers` - // (response.init); both live for this block. - BodyValue::resolve(&mut old, unsafe { &mut *body }, &self.global_this, headers) - .map_err(|_| bun_jsc::JsTerminated::JSTerminated)?; + BodyValue::resolve(&mut old, body, &self.global_this, || { + response.get_content_type_header() + }) + .map_err(|_| bun_jsc::JsTerminated::JSTerminated)?; } } } @@ -984,7 +984,7 @@ impl FetchTasklet { // The JSC-only drain is `&self`, runs just promise reactions (sufficient // for the queued `endSink(err)` to land in `write_end_request` → // `abort_reason`), and leaves the Bun event loop untouched. - if self.metadata.is_some() && !self.is_waiting_body { + if self.response_head_received && !self.is_waiting_body { vm.jsc_vm().drain_microtasks(); } } @@ -1080,7 +1080,7 @@ impl FetchTasklet { // — falls through to the reject logic with `result.fail` set. } - if self.metadata.is_none() && self.result.is_success() { + if !self.response_head_received && self.result.is_success() { cleanup(self); return Ok(()); } @@ -1104,7 +1104,7 @@ impl FetchTasklet { // WHATWG fetch: once the response head is available the promise // resolves; post-head failures (body decompression etc.) surface on // the body reader regardless of whether head+body arrived in one read. - let success = self.result.is_success() || self.metadata.is_some(); + let success = self.result.is_success() || self.response_head_received; // Paired with the microtask drain after // startRequestStream above: the request-body sink may have set `abort_reason` @@ -1337,6 +1337,9 @@ impl FetchTasklet { // some times we don't have metadata so we also check http.url let path = if let Some(metadata) = &self.metadata { BunString::clone_utf8(metadata.url.slice()) + } else if let Some(response) = self.get_current_response() { + // SAFETY: live Response kept by `response`/`native_response`. + unsafe { (*response).url() }.clone() } else if let Some(http_) = &self.http { BunString::clone_utf8(http_.url.href) } else { @@ -1793,13 +1796,9 @@ impl FetchTasklet { fn to_response(&mut self) -> Response { bun_output::scoped_log!(FetchTasklet, "toResponse"); - debug_assert!(self.metadata.is_some()); - // at this point we always should have metadata - let metadata = self.metadata.as_ref().unwrap(); + let metadata = self.metadata.as_ref().expect("response head received"); let http_response = &metadata.response; self.is_waiting_body = self.result.has_more; - // reshaped for borrowck — capture metadata fields before to_body_value() takes &mut self - let headers = FetchHeaders::create_from_pico_headers(http_response.headers.list); let status_code = http_response.status_code as u16; // status_text and url must NOT be atomized: the Response can be // destroyed from the HTTP thread via deref_from_thread() -> deinit() @@ -1819,15 +1818,18 @@ impl FetchTasklet { }; let url = BunString::clone_utf8(metadata.url.slice()); let redirected = self.result.redirected; + // `to_body_value` may build an error that reads `self.metadata`, so + // the head moves into the Response only afterwards; the Response then + // builds `FetchHeaders` from it only if asked (`Init::wire_headers`). + let body = Body::new(self.to_body_value()); Response::init( crate::webcore::response::Init { - // SAFETY: create_from_pico_headers returns a fresh refcount=1 FetchHeaders*. - headers: Some(unsafe { HeadersRef::adopt(headers) }), + wire_headers: self.metadata.take(), status_code, status_text: status_text.into(), ..Default::default() }, - Body::new(self.to_body_value()), + body, url, redirected, ) @@ -1946,6 +1948,7 @@ impl FetchTasklet { reject_unauthorized: fetch_options.reject_unauthorized, upgraded_connection: fetch_options.upgraded_connection, hostname: fetch_options.hostname, + response_head_received: false, is_waiting_body: false, is_buffering_body: AtomicBool::new(false), is_waiting_abort: false, @@ -2468,7 +2471,8 @@ impl FetchTasklet { // metadata should be provided only once if let Some(metadata) = task_ref.result.metadata.take().or(prev_metadata) { bun_output::scoped_log!(FetchTasklet, "added callback metadata"); - if task_ref.metadata.is_none() { + if !task_ref.response_head_received { + task_ref.response_head_received = true; task_ref.metadata = Some(metadata); } diff --git a/src/wyhash/lib.rs b/src/wyhash/lib.rs index ae96362f6ed8..977bd42df515 100644 --- a/src/wyhash/lib.rs +++ b/src/wyhash/lib.rs @@ -806,9 +806,8 @@ pub fn hash_with_seed(seed: u64, bytes: &[u8]) -> u64 { /// /// Chunk size and "copy unconditionally" vs "borrow if already lowercase" are /// output-irrelevant — streaming Wyhash is chunk-invariant and the bytes fed -/// to the hasher are identical either way — so this collapses the three -/// open-coded copies in `http::hash_header_name`, -/// `s3_signing::S3Credentials::hash_const`, and +/// to the hasher are identical either way — so this collapses the +/// open-coded copies in `s3_signing::S3Credentials::hash_const` and /// `collections::CaseInsensitiveAsciiStringContext::hash_bytes`. #[inline] pub fn hash_ascii_lowercase(seed: u64, bytes: &[u8]) -> u64 { diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 67cc45c27da1..1fd2459a8cc1 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -577,7 +577,6 @@ it("process.versions", () => { boringssl: "1a41b9025c2c0a37edd07ff10f6944f03e028522", libarchive: "ded82291ab41d5e355831b96b0e1ff49e24d8939", mimalloc: "1803341d6241d8fa4b3f65fa68cb13a32ad92f04", - picohttpparser: "066d2b1e9ab820703db0837a7255d92d30f0c9f5", zlib: "12731092979c6d07f42da27da673a9f6c7b13586", tinycc: "05f0fafaa3be31e31d7b4b5c17dc60f62c991171", lolhtml: "725ce499aa9b71e38b7a2d0a9fbb6d7294a4079e", diff --git a/test/js/web/fetch/chunked-trailing.test.js b/test/js/web/fetch/chunked-trailing.test.js index 9e458160ad40..31d40227a51d 100644 --- a/test/js/web/fetch/chunked-trailing.test.js +++ b/test/js/web/fetch/chunked-trailing.test.js @@ -673,3 +673,56 @@ it("proper error if missing CRLF after chunk data", async () => { expect(e?.code).toBe("InvalidHTTPResponse"); } }); + +async function fetchRawChunked(...bodyWrites) { + const { promise, resolve } = Promise.withResolvers(); + await using server = net + .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) + socket.once("data", () => { + socket.write("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"); + for (const chunk of bodyWrites) socket.write(chunk); + socket.end(); + }); + }) + .listen(0, "localhost", () => { + resolve(server.address()); + }); + const address = await promise; + const res = await fetch(`http://localhost:${address.port}`); + return await res.text(); +} + +// RFC 9112 §7.1: chunk-size and chunk-data are each terminated by CRLF; a bare +// LF is not a line terminator (§2.2) and lenient parsing here enables response +// smuggling through intermediaries that disagree. +it.each([ + ["after chunk-size", ["5\nHello\r\n", "0\r\n\r\n"]], + ["after chunk-ext", ["5;a=b\nHello\r\n", "0\r\n\r\n"]], + ["after chunk-data", ["5\r\nHello\n", "0\r\n\r\n"]], + ["after last-chunk", ["5\r\nHello\r\n", "0\n\r\n"]], + ["CR without LF after chunk-size", ["5\rHello\r\n", "0\r\n\r\n"]], +])("rejects bare LF %s", async (_, writes) => { + await expect(fetchRawChunked(...writes)).rejects.toMatchObject({ code: "InvalidHTTPResponse" }); +}); + +it.each([ + ["trailing garbage", "5x"], + ["0x prefix", "0x5"], + ["sign", "+5"], + ["leading space", " 5"], +])("rejects malformed chunk-size (%s)", async (_, size) => { + await expect(fetchRawChunked(`${size}\r\nHello\r\n`, "0\r\n\r\n")).rejects.toMatchObject({ + code: "InvalidHTTPResponse", + }); +}); + +it("accepts BWS between chunk-size and extension", async () => { + expect(await fetchRawChunked("5 \t;a=b\r\nHello\r\n", "0\r\n\r\n")).toBe("Hello"); +}); + +it("accepts many small well-formed chunks", async () => { + const count = 50_000; + const body = Buffer.alloc(count * 6, "1\r\nx\r\n").toString(); + expect(await fetchRawChunked(body, "0\r\n\r\n")).toBe(Buffer.alloc(count, "x").toString()); +}); diff --git a/test/js/web/fetch/client-fetch.test.ts b/test/js/web/fetch/client-fetch.test.ts index 26443a1d7598..86b5e646ccd5 100644 --- a/test/js/web/fetch/client-fetch.test.ts +++ b/test/js/web/fetch/client-fetch.test.ts @@ -1,6 +1,6 @@ /* globals AbortController */ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; import { createHash, randomFillSync } from "node:crypto"; import { once } from "node:events"; @@ -697,3 +697,73 @@ test("a response Content-Length containing a digit separator is rejected instead ); expect(outcome).toEqual({ rejected: true, code: "InvalidContentLength" }); }); + +// Field values are `field-vchar / SP / HTAB / obs-text` (RFC 9110 section 5.5): +// HTAB inside a value is content, any other CTL invalidates the message. The +// values here are long enough to be scanned by the vectorized path rather than +// the short-value fast path. +describe("CTL octets in long response header values", () => { + const pad = Buffer.alloc(48, "v").toString(); + async function fetchWithHeaderValue(value: string) { + await using server = net + .createServer(sock => { + sock.on("error", () => {}); + sock.on("data", () => { + sock.end(Buffer.from(`HTTP/1.1 200 OK\r\nX-Value: ${value}\r\nContent-Length: 2\r\n\r\nok`, "latin1")); + }); + }) + .listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as net.AddressInfo; + return await fetch(`http://127.0.0.1:${port}/`).then( + r => ({ rejected: false as const, value: r.headers.get("x-value") }), + e => ({ rejected: true as const, code: e.code }), + ); + } + + test("HTAB and obs-text are preserved", async () => { + const value = `${pad}\t${pad}\xe9${pad}`; + expect(await fetchWithHeaderValue(value)).toEqual({ rejected: false, value }); + }); + + test.each([ + ["DEL", "\x7f"], + ["FF", "\x0c"], + ["NUL", "\x00"], + ["lone CR", "\r"], + ])("%s is rejected", async (_, ctl) => { + expect(await fetchWithHeaderValue(`${pad}${ctl}${pad}`)).toEqual({ + rejected: true, + code: "Malformed_HTTP_Response", + }); + }); +}); + +// The client accumulates a response head that arrives in pieces and must +// recognise its end however the terminator is split across reads. +test("response head trickled one byte per write", async () => { + const head = + "HTTP/1.1 200 OK\r\nX-One: 1\r\nX-Two: two \t\r\nSet-Cookie: a=b\r\nSet-Cookie: c=d\r\nContent-Length: 2\r\n\r\n"; + await using server = net + .createServer(sock => { + sock.on("error", () => {}); + sock.once("data", async () => { + for (const ch of head + "ok") { + if (!sock.write(ch)) await once(sock, "drain"); + await new Promise(r => setImmediate(r)); + } + sock.end(); + }); + }) + .listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as net.AddressInfo; + const res = await fetch(`http://127.0.0.1:${port}/`); + expect({ + status: res.status, + one: res.headers.get("x-one"), + two: res.headers.get("x-two"), + cookies: res.headers.getSetCookie(), + body: await res.text(), + }).toEqual({ status: 200, one: "1", two: "two", cookies: ["a=b", "c=d"], body: "ok" }); +}); diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index c0b1327b1fc4..bf2e3d89aa0a 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -1815,6 +1815,79 @@ it("cloned response headers are independent after accessing", () => { expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8"); }); +// A fetched Response builds its Headers object only when something asks for +// it; every path that can observe the headers without going through +// `response.headers` first must still see them. +describe("fetched response headers are available on every path", () => { + async function fetched() { + using server = Bun.serve({ + port: 0, + fetch: () => + new Response("

hi

", { + headers: { "Content-Type": "text/html; charset=utf-8", "X-Custom": "1", "Set-Cookie": "a=b" }, + }), + }); + // Fully buffered before the server goes away, headers untouched. + const res = await fetch(server.url); + const body = await res.clone().arrayBuffer(); + expect(body.byteLength).toBe(9); + return res; + } + + it("blob() type without touching .headers", async () => { + const res = await fetched(); + expect((await res.blob()).type).toBe("text/html;charset=utf-8"); + }); + + it("clone() before touching .headers", async () => { + const res = await fetched(); + const clone = res.clone(); + clone.headers.set("x-custom", "2"); + expect(clone.headers.get("set-cookie")).toBe("a=b"); + expect(res.headers.get("x-custom")).toBe("1"); + expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8"); + }); + + it("new Response(body, fetchedResponse)", async () => { + const res = await fetched(); + const derived = new Response("x", res); + expect(derived.headers.get("x-custom")).toBe("1"); + }); + + it("served back out of Bun.serve", async () => { + const upstream = await fetched(); + using proxy = Bun.serve({ port: 0, fetch: () => upstream }); + const res = await fetch(proxy.url); + expect(res.headers.get("x-custom")).toBe("1"); + expect(res.headers.getSetCookie()).toEqual(["a=b"]); + expect(await res.text()).toBe("

hi

"); + }); + + it("HTMLRewriter transform", async () => { + const res = new HTMLRewriter().on("p", { text: t => t.replace("bye") }).transform(await fetched()); + expect(res.headers.get("x-custom")).toBe("1"); + expect(await res.text()).toContain("bye"); + }); + + it("repeated Content-Type combines the same way whether or not .headers was read", async () => { + await using server = net + .createServer(sock => { + sock.on("error", () => {}); + sock.on("data", () => + sock.end("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Type: x/y\r\nContent-Length: 1\r\n\r\na"), + ); + }) + .listen(0, "127.0.0.1"); + await once(server, "listening"); + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/`; + const untouched = await fetch(url); + const touched = await fetch(url); + const combined = touched.headers.get("content-type"); + expect(combined).toBe("text/plain, x/y"); + expect((await untouched.blob()).type).toBe((await touched.blob()).type); + }); +}); + it("should work with http 100 continue", async () => { let server: net.Server | undefined; try {