254 lines
9.4 KiB
TypeScript
254 lines
9.4 KiB
TypeScript
// Regression test for https://github.com/oven-sh/bun/issues/17793
|
|||
|
|
//
|
||
|
|
// Bug: In handleResponseMetadata (src/http.zig), the RFC 9112 §6.3 logic
|
||
|
|
// setting content_length=0 for 304 responses was inside `if (!proxy_tunneling)`,
|
||
|
|
// so 304 through CONNECT tunnels left content_length=null → continue_streaming → hang.
|
||
|
|
//
|
||
|
|
// This test is fully self-contained (no external proxy or internet needed):
|
||
|
|
// - Mock HTTPS registry (raw TLS server for full control over response headers)
|
||
|
|
// - Mock CONNECT proxy
|
||
|
|
// - Runs `bun install` twice: first populates cache, second triggers 304
|
||
|
|
|
||
|
|
import { expect, test } from "bun:test";
|
||
|
|
import { bunEnv, bunExe, tempDir, tls as tlsCert } from "harness";
|
||
|
|
import { once } from "node:events";
|
||
|
|
import { readdir, rm } from "node:fs/promises";
|
||
|
|
import net from "node:net";
|
||
|
|
import { join } from "node:path";
|
||
|
|
import tls from "node:tls";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Creates a minimal valid npm tarball (.tgz) containing only package/package.json.
|
||
|
|
* Constructs a USTAR tar archive and gzip-compresses it.
|
||
|
|
*/
|
||
|
|
function createMinimalTarball(pkgJson: object): Buffer {
|
||
|
|
const content = Buffer.from(JSON.stringify(pkgJson));
|
||
|
|
const filename = "package/package.json";
|
||
|
|
|
||
|
|
const header = Buffer.alloc(512, 0);
|
||
|
|
header.write(filename, 0);
|
||
|
|
header.write("0000644\0", 100); // mode
|
||
|
|
header.write("0001000\0", 108); // uid
|
||
|
|
header.write("0001000\0", 116); // gid
|
||
|
|
header.write(content.length.toString(8).padStart(11, "0") + "\0", 124); // size
|
||
|
|
header.write(
|
||
|
|
Math.floor(Date.now() / 1000)
|
||
|
|
.toString(8)
|
||
|
|
.padStart(11, "0") + "\0",
|
||
|
|
136,
|
||
|
|
); // mtime
|
||
|
|
header.write(" ", 148); // checksum placeholder (8 spaces)
|
||
|
|
header.write("0", 156); // typeflag: regular file
|
||
|
|
header.write("ustar\0", 257); // magic
|
||
|
|
header.write("00", 263); // version
|
||
|
|
|
||
|
|
let checksum = 0;
|
||
|
|
for (let i = 0; i < 512; i++) checksum += header[i];
|
||
|
|
header.write(checksum.toString(8).padStart(6, "0") + "\0 ", 148);
|
||
|
|
|
||
|
|
const dataBlocks = Buffer.alloc(Math.ceil(content.length / 512) * 512, 0);
|
||
|
|
content.copy(dataBlocks);
|
||
|
|
|
||
|
|
return Buffer.from(Bun.gzipSync(Buffer.concat([header, dataBlocks, Buffer.alloc(1024, 0)])));
|
||
|
|
}
|
||
|
|
|
||
|
|
test("bun install with proxy does not hang on 304 cached response", async () => {
|
||
|
|
const pkgName = "test-pkg-304";
|
||
|
|
const pkgVersion = "1.0.0";
|
||
|
|
const etag = '"test-etag-304-regression"';
|
||
|
|
const requests: Array<{ path: string; ifNoneMatch: boolean }> = [];
|
||
|
|
|
||
|
|
const tarball = createMinimalTarball({ name: pkgName, version: pkgVersion });
|
||
|
|
const shasum = new Bun.CryptoHasher("sha1").update(tarball).digest("hex");
|
||
|
|
const integrity = "sha512-" + new Bun.CryptoHasher("sha512").update(tarball).digest("base64");
|
||
|
|
|
||
|
|
// --- Mock HTTPS registry (raw TLS for full control over headers) ---
|
||
|
|
// Using raw tls.createServer instead of Bun.serve because we need to send
|
||
|
|
// 304 responses WITHOUT a Content-Length header. Bun.serve may auto-add it.
|
||
|
|
let registryPort = 0;
|
||
|
|
|
||
|
|
const registryServer = tls.createServer({ key: tlsCert.key, cert: tlsCert.cert }, (socket: tls.TLSSocket) => {
|
||
|
|
let buf = Buffer.alloc(0);
|
||
|
|
|
||
|
|
socket.on("data", (chunk: Buffer) => {
|
||
|
|
buf = Buffer.concat([buf, chunk]);
|
||
|
|
|
||
|
|
while (true) {
|
||
|
|
const end = buf.indexOf("\r\n\r\n");
|
||
|
|
if (end === -1) break;
|
||
|
|
|
||
|
|
const headerStr = buf.subarray(0, end).toString();
|
||
|
|
buf = buf.subarray(end + 4);
|
||
|
|
|
||
|
|
const lines = headerStr.split("\r\n");
|
||
|
|
const path = lines[0].split(" ")[1];
|
||
|
|
const ifNoneMatch = lines.some(l => l.toLowerCase().startsWith("if-none-match:"));
|
||
|
|
requests.push({ path, ifNoneMatch });
|
||
|
|
|
||
|
|
if (path.endsWith(".tgz")) {
|
||
|
|
socket.write(
|
||
|
|
`HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: ${tarball.length}\r\n\r\n`,
|
||
|
|
);
|
||
|
|
socket.write(tarball);
|
||
|
|
} else if (path.startsWith(`/${pkgName}`)) {
|
||
|
|
if (ifNoneMatch) {
|
||
|
|
// 304 WITHOUT Content-Length — this is what triggers the bug
|
||
|
|
// when going through a CONNECT tunnel without the fix.
|
||
|
|
socket.write(`HTTP/1.1 304 Not Modified\r\nETag: ${etag}\r\n\r\n`);
|
||
|
|
} else {
|
||
|
|
const manifest = JSON.stringify({
|
||
|
|
name: pkgName,
|
||
|
|
"dist-tags": { latest: pkgVersion },
|
||
|
|
versions: {
|
||
|
|
[pkgVersion]: {
|
||
|
|
name: pkgName,
|
||
|
|
version: pkgVersion,
|
||
|
|
dist: {
|
||
|
|
tarball: `https://localhost:${registryPort}/${pkgName}/-/${pkgName}-${pkgVersion}.tgz`,
|
||
|
|
shasum,
|
||
|
|
integrity,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
socket.write(
|
||
|
|
`HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(manifest)}\r\nETag: ${etag}\r\n\r\n${manifest}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
socket.write("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
socket.on("error", () => {});
|
||
|
|
});
|
||
|
|
|
||
|
|
registryServer.listen(0);
|
||
|
|
await once(registryServer, "listening");
|
||
|
|
registryPort = (registryServer.address() as net.AddressInfo).port;
|
||
|
|
|
||
|
|
// --- Mock CONNECT proxy ---
|
||
|
|
const proxyServer = net.createServer((clientSocket: net.Socket) => {
|
||
|
|
clientSocket.once("data", (data: Buffer) => {
|
||
|
|
const match = data.toString().match(/^CONNECT\s+([^:]+):(\d+)\s+HTTP/);
|
||
|
|
if (!match) {
|
||
|
|
clientSocket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
|
||
|
|
clientSocket.end();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const [, host, port] = match;
|
||
|
|
const serverSocket = net.connect(parseInt(port), host, () => {
|
||
|
|
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||
|
|
serverSocket.pipe(clientSocket, { end: false });
|
||
|
|
clientSocket.pipe(serverSocket, { end: false });
|
||
|
|
});
|
||
|
|
|
||
|
|
serverSocket.on("error", () => clientSocket.destroy());
|
||
|
|
clientSocket.on("error", () => serverSocket.destroy());
|
||
|
|
clientSocket.on("close", () => serverSocket.destroy());
|
||
|
|
serverSocket.on("close", () => clientSocket.destroy());
|
||
|
|
});
|
||
|
|
|
||
|
|
clientSocket.on("error", () => {});
|
||
|
|
});
|
||
|
|
|
||
|
|
proxyServer.listen(0);
|
||
|
|
await once(proxyServer, "listening");
|
||
|
|
const proxyPort = (proxyServer.address() as net.AddressInfo).port;
|
||
|
|
|
||
|
|
// --- Test project ---
|
||
|
|
// Use a version range (^) so bun revalidates the manifest on the second install.
|
||
|
|
// Exact versions bypass revalidation even when the cache is expired.
|
||
|
|
using dir = tempDir("proxy-304-regression", {
|
||
|
|
"package.json": JSON.stringify({
|
||
|
|
name: "test-project",
|
||
|
|
version: "1.0.0",
|
||
|
|
dependencies: { [pkgName]: `^${pkgVersion}` },
|
||
|
|
}),
|
||
|
|
"bunfig.toml": `[install]\nregistry = "https://localhost:${registryPort}/"\n`,
|
||
|
|
});
|
||
|
|
|
||
|
|
const installEnv = {
|
||
|
|
...bunEnv,
|
||
|
|
HTTPS_PROXY: `http://localhost:${proxyPort}`,
|
||
|
|
HTTP_PROXY: `http://localhost:${proxyPort}`,
|
||
|
|
https_proxy: `http://localhost:${proxyPort}`,
|
||
|
|
http_proxy: `http://localhost:${proxyPort}`,
|
||
|
|
NO_PROXY: "",
|
||
|
|
no_proxy: "",
|
||
|
|
NODE_TLS_REJECT_UNAUTHORIZED: "0",
|
||
|
|
BUN_INSTALL_CACHE_DIR: join(String(dir), ".bun-cache"),
|
||
|
|
};
|
||
|
|
|
||
|
|
const spawnInstall = (extraArgs: string[] = []) =>
|
||
|
|
Bun.spawn({
|
||
|
|
cmd: [bunExe(), "install", ...extraArgs],
|
||
|
|
cwd: String(dir),
|
||
|
|
env: installEnv,
|
||
|
|
stdout: "pipe",
|
||
|
|
stderr: "pipe",
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
// First install: registry returns 200 + ETag → populates manifest cache
|
||
|
|
{
|
||
|
|
await using proc = spawnInstall();
|
||
|
|
const [stdout, stderr, exitCode] = await Promise.all([
|
||
|
|
proc.stdout.text(),
|
||
|
|
proc.stderr.text(),
|
||
|
|
Promise.race([proc.exited, new Promise<"timeout">(r => setTimeout(() => r("timeout"), 15000))]),
|
||
|
|
]);
|
||
|
|
|
||
|
|
if (exitCode === "timeout") proc.kill();
|
||
|
|
expect(exitCode).not.toBe("timeout");
|
||
|
|
expect(exitCode).toBe(0);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Wait for the async manifest cache save to complete.
|
||
|
|
// bun saves manifests asynchronously; without this, the second install
|
||
|
|
// may not find the cached manifest and skip the If-None-Match path.
|
||
|
|
const cacheDir = join(String(dir), ".bun-cache");
|
||
|
|
let cachePopulated = false;
|
||
|
|
for (let i = 0; i < 40; i++) {
|
||
|
|
const entries = await readdir(cacheDir).catch(() => []);
|
||
|
|
if (entries.length > 0) {
|
||
|
|
cachePopulated = true;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
await Bun.sleep(50);
|
||
|
|
}
|
||
|
|
expect(cachePopulated).toBe(true);
|
||
|
|
|
||
|
|
// Clear local artifacts but keep global manifest cache
|
||
|
|
await rm(join(String(dir), "node_modules"), { recursive: true, force: true });
|
||
|
|
await rm(join(String(dir), "bun.lock"), { force: true });
|
||
|
|
|
||
|
|
// Second install with --force: bypasses manifest cache freshness check, so bun
|
||
|
|
// revalidates with If-None-Match → 304 through CONNECT tunnel.
|
||
|
|
// Without the fix, content_length stays null for 304 responses through proxy tunnels,
|
||
|
|
// causing the connection to hang in continue_streaming.
|
||
|
|
{
|
||
|
|
await using proc = spawnInstall(["--force"]);
|
||
|
|
const [stdout, stderr, exitCode] = await Promise.all([
|
||
|
|
proc.stdout.text(),
|
||
|
|
proc.stderr.text(),
|
||
|
|
Promise.race([proc.exited, new Promise<"timeout">(r => setTimeout(() => r("timeout"), 15000))]),
|
||
|
|
]);
|
||
|
|
|
||
|
|
if (exitCode === "timeout") proc.kill();
|
||
|
|
expect(exitCode).not.toBe("timeout");
|
||
|
|
expect(exitCode).toBe(0);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify that the 304 code path was actually exercised
|
||
|
|
const manifestReqs = requests.filter(r => r.path.startsWith(`/${pkgName}`) && !r.path.endsWith(".tgz"));
|
||
|
|
expect(manifestReqs.length).toBeGreaterThanOrEqual(2);
|
||
|
|
expect(manifestReqs.some(r => r.ifNoneMatch)).toBe(true);
|
||
|
|
} finally {
|
||
|
|
registryServer.close();
|
||
|
|
proxyServer.close();
|
||
|
|
}
|
||
|
|
});
|