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