diff --git a/CLAUDE.md b/CLAUDE.md index 8b83b1e..499054b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,9 +88,9 @@ Custom resolvers for operations not auto-generated by Neo4j GraphQL: - Uses S3 SDK to fetch blobs from MinIO with authenticated requests - No per-blob access control: every stored blob is downloadable by hash - Winbindex fast path (`src/winbindex.ts`): when `?filename=` names a Windows PE - file (`.exe`/`.dll`/`.sys`), the blob is resolved on Winbindex and streamed - verified from Microsoft's symbol server instead of MinIO; every miss or failure - falls back to MinIO unchanged. See `docs/reference/winbindex-source.md`. + file (`.exe`/`.dll`/`.sys`), the blob is resolved on Winbindex, downloaded from + Microsoft's symbol server and SHA-1-verified before a single byte is sent; + every miss or failure (including a hash mismatch) falls back to MinIO unchanged. See `docs/reference/winbindex-source.md`. #### Data Processing diff --git a/docs/reference/winbindex-source.md b/docs/reference/winbindex-source.md index 4eb2b4c..7f82271 100644 --- a/docs/reference/winbindex-source.md +++ b/docs/reference/winbindex-source.md @@ -4,9 +4,9 @@ A fast path in front of the `GET /blob/:hash` handler (`src/rest-routes.ts`). For Windows PE files the API resolves the file on -[Winbindex](https://winbindex.m417z.com/) and streams the verified bytes -straight from Microsoft's public symbol server, instead of proxying them from -MinIO. The public corpus therefore never has to re-host Windows binaries. +[Winbindex](https://winbindex.m417z.com/) and serves the bytes from +Microsoft's public symbol server, verified against the requested SHA-1, instead +of proxying them from MinIO. The public corpus therefore never has to re-host Windows binaries. Implementation: `src/winbindex.ts`. @@ -47,16 +47,20 @@ ignores `filename`. `TS = timestamp.toString(16).toUpperCase().padStart(8, "0")` and `VS = virtualSize.toString(16)` (lowercase, unpadded). Sent with `User-Agent: Microsoft-Symbol-Server/10.0.0.0`, redirects followed. -5. The body is streamed to the client as +5. The body is read into memory, up to `WINBINDEX_MAX_PE_BYTES` (256 MiB), and + its SHA-1 is compared with `` **before anything is sent**. Only a + matching file is written to the client, as `Content-Type: application/octet-stream`, - `Content-Disposition: attachment; filename=""`, `ETag: ""`, and - `Content-Length` when the upstream provides it and did not content-encode the - body (undici may have transparently decompressed it). `res.write()` - backpressure is honoured so a slow client cannot force the whole PE to buffer - in memory. The SHA-1 is recomputed on the fly; if the final digest does not - match ``, the response is destroyed mid-transfer so the client sees a - failed download. A stream error *before* the first byte falls back to MinIO; - an error after bytes were sent ends the response as a failure. + `Content-Disposition: attachment; filename=""`, `ETag: ""` and + `Content-Length: `. A mismatch, a stream error, a + timeout or an oversized body leaves the response untouched and falls back to + MinIO. + + The file is buffered rather than streamed because a mismatch can only be + detected once the last byte is in. By then a streamed response is already + complete on the client side and can no longer be turned into a failure, so + the client would silently keep the wrong bytes. Each in-flight download + therefore holds one PE file (a few MB to a few tens of MB) in memory. The `symbols` plugin in the OSWatcher collector already downloads PDBs from this same server, so no new external trust boundary is introduced. @@ -79,9 +83,9 @@ All four variables are optional and have defaults (`src/index.ts`). | Feature disabled, no `filename`, or non-PE extension | MinIO, no external call. | | Winbindex index 404 / network error / timeout / bad JSON | MinIO. | | No matching `fileInfo.sha1`, or match missing `timestamp` / `virtualSize` | MinIO. | -| Symbol server returns non-2xx, the fetch fails, or the stream errors before any byte is sent | MinIO; nothing was written to the response. | -| Symbol-server stream errors after bytes were sent | Response destroyed; MinIO is **not** retried. | -| Downloaded bytes hash to something other than `` | Response destroyed after the fact; a warning is logged; MinIO is **not** retried. | +| Symbol server returns non-2xx, the fetch fails, the stream errors or times out | MinIO; nothing was written to the response. | +| Body larger than `WINBINDEX_MAX_PE_BYTES` | MinIO; the upstream download is cancelled. | +| Downloaded bytes hash to something other than `` | MinIO; a warning is logged; nothing was written to the response. | MinIO is never written to by this feature — it is a pure proxy, `GetObject` only. diff --git a/src/rest-routes.ts b/src/rest-routes.ts index 94f1828..b6efe8a 100644 --- a/src/rest-routes.ts +++ b/src/rest-routes.ts @@ -33,10 +33,9 @@ export const createRestRouter = ( console.log(`Blob download requested: ${hash}`); // Winbindex fast path: for Windows PE files the frontend passes - // `?filename=`, letting us resolve the file on Winbindex and stream - // verified bytes from Microsoft's symbol server instead of MinIO. - // Any miss or failure falls through to MinIO unchanged; a failure - // after bytes were already streamed ends the response here. + // `?filename=`, letting us resolve the file on Winbindex and serve + // SHA-1-verified bytes from Microsoft's symbol server instead of + // MinIO. Any miss or failure falls through to MinIO unchanged. const filename = typeof req.query.filename === "string" ? req.query.filename @@ -49,10 +48,7 @@ export const createRestRouter = ( filename, res, ); - if ( - outcome === "served" || - outcome === "failed_after_send" - ) { + if (outcome === "served") { return; } } catch (winbindexError) { diff --git a/src/winbindex.ts b/src/winbindex.ts index 0b91c14..46751c1 100644 --- a/src/winbindex.ts +++ b/src/winbindex.ts @@ -17,10 +17,11 @@ function discardBody(response: Response): void { * Winbindex fast path for Windows PE blob downloads. * * For a `GET /blob/:hash?filename=` request the API resolves the file on - * Winbindex (per-filename index) and streams the verified bytes straight from - * Microsoft's public symbol server, so the public corpus never re-hosts Windows - * binaries. Every miss or failure falls back to MinIO, except once bytes have - * already been streamed to the client. + * Winbindex (per-filename index) and serves the bytes from Microsoft's public + * symbol server, so the public corpus never re-hosts Windows binaries. The file + * is buffered and its SHA-1 verified before anything is sent, so a client never + * receives bytes that do not match the requested hash. Every miss or failure + * falls back to MinIO. * * See docs/reference/winbindex-source.md. */ @@ -44,6 +45,12 @@ const WINBINDEX_JSON_CACHE_MAX = 200; const SYMBOL_SERVER_USER_AGENT = "Microsoft-Symbol-Server/10.0.0.0"; +/** + * Upper bound on a symbol-server download buffered for verification. Windows PE + * files are at most a few tens of MB; anything larger falls back to MinIO. + */ +export const WINBINDEX_MAX_PE_BYTES = 256 * 1024 * 1024; + export interface WinbindexConfig { enabled: boolean; dataUrl: string; @@ -60,21 +67,14 @@ export interface WinbindexEntry { * Outcome of an attempt to serve a blob from Winbindex. * - `not_available`: nothing was written to the response, the caller must fall * back to MinIO. - * - `served`: the response has been fully sent, the caller must not touch it. - * - `failed_after_send`: bytes were already streamed then something failed - * (post-send hash mismatch, upstream stream error); the response has been - * destroyed and the caller must not retry MinIO. + * - `served`: the verified file has been sent, the caller must not touch it. */ -export type WinbindexOutcome = "not_available" | "served" | "failed_after_send"; +export type WinbindexOutcome = "not_available" | "served"; -/** Minimal view of the HTTP response the streaming step needs. */ +/** Minimal view of the HTTP response the serving step needs. */ export interface BlobResponse { setHeader(name: string, value: string | number): void; - write(chunk: Uint8Array): boolean; - once(event: string, listener: (...args: unknown[]) => void): void; - off(event: string, listener: (...args: unknown[]) => void): void; - end(): void; - destroy(): void; + end(chunk: Uint8Array): void; } interface WinbindexFileInfo { @@ -247,63 +247,58 @@ export async function resolveEntry( } /** - * Resolve once the response has drained and can take more data, or reject if the - * client went away first (so the caller stops pulling from the upstream instead - * of buffering forever). Rejects after `timeoutMs` as well, so a socket that - * never emits `drain`, `close` or `error` cannot hang the request handler. - * Listeners and the timer are always cleared before settling. + * Read the whole upstream body, giving up (returns `null`) past `maxBytes`. + * Stream errors propagate to the caller. */ -function waitForDrain(res: BlobResponse, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - let timer: ReturnType | undefined; - const cleanup = (): void => { - if (timer !== undefined) { - clearTimeout(timer); +async function readBody( + body: ReadableStream, + maxBytes: number, +): Promise { + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + let chunk = await reader.read(); + while (!chunk.done) { + total += chunk.value.byteLength; + if (total > maxBytes) { + return null; } - res.off("drain", onDrain); - res.off("close", onClose); - res.off("error", onError); - }; - const onDrain = (): void => { - cleanup(); - resolve(); - }; - const onClose = (): void => { - cleanup(); - reject(new Error("response closed before drain")); - }; - const onError = (err: unknown): void => { - cleanup(); - reject(err instanceof Error ? err : new Error(String(err))); - }; - timer = setTimeout(() => { - cleanup(); - reject(new Error("timed out waiting for the response to drain")); - }, timeoutMs); - res.once("drain", onDrain); - res.once("close", onClose); - res.once("error", onError); - }); + chunks.push(chunk.value); + chunk = await reader.read(); + } + } finally { + // Stops the symbol-server download when we bail out early; a no-op once + // the body is fully read. + void reader.cancel().catch(() => {}); + } + const data = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + data.set(chunk, offset); + offset += chunk.byteLength; + } + return data; } /** - * Fetch the PE file from the symbol server and stream it to `res` while - * verifying its SHA-1. Returns `not_available` (nothing written) on a non-2xx - * upstream response, a fetch error, or a stream error before the first byte - * reached the client; `served` on a verified transfer; and `failed_after_send` - * if the stream errored or the digest did not match *after* bytes were already - * sent (the response is destroyed in that case). + * Fetch the PE file from the symbol server, verify its SHA-1 against + * `expectedSha1`, and only then send it. Nothing is written to `res` unless the + * digest matches, so every failure (non-2xx, fetch or stream error, timeout, + * oversized body, SHA-1 mismatch) returns `not_available` with the response + * untouched for the MinIO fallback. * - * `res.write()` backpressure is honoured: on a `false` return the loop awaits - * `drain` before reading more, so a slow client cannot make Node buffer the - * whole (potentially tens-of-MB) PE in memory. + * Buffering trades streaming for correctness: a mismatch can only be detected + * once the last byte is in, and by then a streamed response is already complete + * on the client side, so it cannot be turned into a failure. */ -export async function streamFromSymbolServer( +export async function serveFromSymbolServer( cfg: WinbindexConfig, entry: WinbindexEntry, name: string, expectedSha1: string, res: BlobResponse, + maxBytes: number = WINBINDEX_MAX_PE_BYTES, ): Promise { const url = symbolServerUrl( cfg.symbolServerUrl, @@ -312,103 +307,49 @@ export async function streamFromSymbolServer( entry.virtualSize, ); - let upstream: Response; + let data: Uint8Array | null; try { - upstream = await fetch(url, { + const upstream = await fetch(url, { headers: { "User-Agent": SYMBOL_SERVER_USER_AGENT }, redirect: "follow", signal: AbortSignal.timeout(cfg.timeoutMs), }); - } catch { - return "not_available"; - } - - if (!upstream.ok || !upstream.body) { - discardBody(upstream); - return "not_available"; - } - - // Only forward the upstream Content-Length when the bytes are not - // content-encoded: undici may have transparently decompressed the body, in - // which case the upstream length no longer matches what the client receives. - const contentLength = upstream.headers.get("content-length"); - const contentEncoding = ( - upstream.headers.get("content-encoding") ?? "" - ).toLowerCase(); - const forwardContentLength = - contentLength !== null && - (contentEncoding === "" || contentEncoding === "identity"); - - // Headers are set only once bytes are actually in hand, so a failure before - // the first byte leaves the response pristine for the MinIO fallback. - const setStreamHeaders = (): void => { - res.setHeader("Content-Type", "application/octet-stream"); - res.setHeader("Content-Disposition", `attachment; filename="${name}"`); - // The requested hash is exactly what the streamed bytes are verified - // against below; this gives the winbindex path ETag parity with MinIO. - res.setHeader("ETag", `"${expectedSha1.toLowerCase()}"`); - if (forwardContentLength && contentLength !== null) { - res.setHeader("Content-Length", contentLength); - } - }; - - const hash = createHash("sha1"); - const reader = upstream.body.getReader(); - let wrote = false; - try { - let chunk = await reader.read(); - while (!chunk.done) { - hash.update(chunk.value); - if (!wrote) { - setStreamHeaders(); - } - const flushed = res.write(chunk.value); - wrote = true; - if (!flushed) { - await waitForDrain(res, cfg.timeoutMs); - } - chunk = await reader.read(); - } - } catch (error) { - if (!wrote) { - // Nothing reached the client yet: fall back to MinIO. + if (!upstream.ok || !upstream.body) { + discardBody(upstream); return "not_available"; } + data = await readBody(upstream.body, maxBytes); + } catch (error) { console.warn( - `Winbindex: symbol-server stream for ${name} errored mid-transfer, destroying response:`, + `Winbindex: symbol-server download for ${name} failed, falling back:`, error, ); - res.destroy(); - return "failed_after_send"; - } finally { - // Abort the symbol-server download on any exit: a no-op once the body is - // fully read, but on a mid-stream error or drain timeout it stops the - // upstream transfer instead of leaving it running in the background. - void reader.cancel().catch(() => {}); + return "not_available"; } - const digest = hash.digest("hex"); - if (digest !== expectedSha1.toLowerCase()) { - if (!wrote) { - // Nothing reached the client (e.g. an empty 200 body): the response - // is still pristine, so fall back to MinIO rather than fail it. - console.warn( - `Winbindex: SHA-1 mismatch for ${name} before any byte was sent (expected ${expectedSha1.toLowerCase()}, got ${digest}), falling back`, - ); - return "not_available"; - } + if (data === null) { console.warn( - `Winbindex: SHA-1 mismatch for ${name} (expected ${expectedSha1.toLowerCase()}, got ${digest}), destroying response`, + `Winbindex: ${name} exceeds ${maxBytes} bytes, falling back`, ); - res.destroy(); - return "failed_after_send"; + return "not_available"; } - if (!wrote) { - // Zero-byte body that still verified: emit the headers before ending. - setStreamHeaders(); + const expected = expectedSha1.toLowerCase(); + const digest = createHash("sha1").update(data).digest("hex"); + if (digest !== expected) { + console.warn( + `Winbindex: SHA-1 mismatch for ${name} (expected ${expected}, got ${digest}), falling back`, + ); + return "not_available"; } - res.end(); + + res.setHeader("Content-Type", "application/octet-stream"); + res.setHeader("Content-Disposition", `attachment; filename="${name}"`); + // The bytes were verified against the requested hash just above; this gives + // the winbindex path ETag parity with MinIO. + res.setHeader("ETag", `"${expected}"`); + res.setHeader("Content-Length", data.byteLength); + res.end(data); return "served"; } @@ -416,7 +357,7 @@ export async function streamFromSymbolServer( * Orchestrator for the Winbindex fast path. Returns `not_available` (caller * falls back to MinIO) unless the feature is enabled, the filename is a Windows * PE file, and Winbindex resolves the hash; otherwise delegates to - * {@link streamFromSymbolServer}. Never throws. + * {@link serveFromSymbolServer}. Never throws. */ export async function tryServeFromWinbindex( cfg: WinbindexConfig, @@ -444,7 +385,7 @@ export async function tryServeFromWinbindex( if (!entry) { return "not_available"; } - return await streamFromSymbolServer(cfg, entry, name, hash, res); + return await serveFromSymbolServer(cfg, entry, name, hash, res); } catch (error) { console.warn(`Winbindex: unexpected error serving ${name}:`, error); return "not_available"; diff --git a/tests/winbindex.test.ts b/tests/winbindex.test.ts index ed16957..709b013 100644 --- a/tests/winbindex.test.ts +++ b/tests/winbindex.test.ts @@ -1,4 +1,11 @@ -import { describe, it, expect, jest, beforeEach, afterEach } from "@jest/globals"; +import { + describe, + it, + expect, + jest, + beforeEach, + afterEach, +} from "@jest/globals"; import { createHash } from "node:crypto"; import { gzipSync } from "node:zlib"; import { @@ -6,6 +13,7 @@ import { symbolServerUrl, resolveEntry, tryServeFromWinbindex, + serveFromSymbolServer, __clearWinbindexCache, WINBINDEX_JSON_TTL_MS, WinbindexConfig, @@ -26,7 +34,8 @@ const bytes = (text: string): Uint8Array => new TextEncoder().encode(text); const sha1Hex = (data: Uint8Array): string => createHash("sha1").update(data).digest("hex"); -const gzip = (text: string): Uint8Array => Uint8Array.from(gzipSync(bytes(text))); +const gzip = (text: string): Uint8Array => + Uint8Array.from(gzipSync(bytes(text))); const toArrayBuffer = (data: Uint8Array): ArrayBuffer => { const ab = new ArrayBuffer(data.byteLength); @@ -38,7 +47,8 @@ const toArrayBuffer = (data: Uint8Array): ArrayBuffer => { const indexResponse = (gunzipped: string, status = 200) => ({ status, ok: status >= 200 && status < 300, - arrayBuffer: async (): Promise => toArrayBuffer(gzip(gunzipped)), + arrayBuffer: async (): Promise => + toArrayBuffer(gzip(gunzipped)), }); const jsonIndexResponse = (value: unknown): ReturnType => @@ -130,57 +140,22 @@ interface MockRes extends BlobResponse { headers: Record; body: Uint8Array[]; ended: boolean; - destroyed: boolean; - emit(event: string, ...args: unknown[]): void; } -/** - * `writeReturns` feeds the boolean each `write()` call returns (default `true`). - * A `false` schedules a `drain` on the next microtask so the code under test - * resumes, exercising the backpressure path. - */ -const makeMockRes = (writeReturns: boolean[] = []): MockRes => { +const makeMockRes = (): MockRes => { const headers: Record = {}; const body: Uint8Array[] = []; - const pending = [...writeReturns]; - const listeners: Record void>> = {}; const res: MockRes = { headers, body, ended: false, - destroyed: false, setHeader(name, value) { headers[name] = value; }, - write(chunk) { + end(chunk) { body.push(chunk); - const ok = pending.length ? (pending.shift() as boolean) : true; - if (!ok) { - queueMicrotask(() => res.emit("drain")); - } - return ok; - }, - once(event, listener) { - (listeners[event] ??= []).push(listener); - }, - off(event, listener) { - listeners[event] = (listeners[event] ?? []).filter( - (l) => l !== listener, - ); - }, - emit(event, ...args) { - const ls = listeners[event] ?? []; - listeners[event] = []; - for (const l of ls) { - l(...args); - } - }, - end() { res.ended = true; }, - destroy() { - res.destroyed = true; - }, }; return res; }; @@ -452,7 +427,7 @@ describe("tryServeFromWinbindex", () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it("streams verified bytes and returns served on the happy path", async () => { + it("serves verified bytes and returns served on the happy path", async () => { const bodyText = "MZ...fake portable executable bytes..."; const body = bytes(bodyText); const hash = sha1Hex(body); @@ -476,9 +451,8 @@ describe("tryServeFromWinbindex", () => { expect(res.headers["Content-Disposition"]).toBe( 'attachment; filename="kernel32.dll"', ); - expect(res.headers["Content-Length"]).toBe(String(body.byteLength)); + expect(res.headers["Content-Length"]).toBe(body.byteLength); expect(res.ended).toBe(true); - expect(res.destroyed).toBe(false); expect(fetchMock).toHaveBeenLastCalledWith( "https://symbols.example/download/symbols/kernel32.dll/5E6AFCC51d000/kernel32.dll", expect.objectContaining({ @@ -508,10 +482,9 @@ describe("tryServeFromWinbindex", () => { expect(res.body).toHaveLength(0); expect(res.headers).toEqual({}); expect(res.ended).toBe(false); - expect(res.destroyed).toBe(false); }); - it("destroys the response and returns failed_after_send on a post-send SHA-1 mismatch", async () => { + it("sends nothing and falls back to MinIO on a SHA-1 mismatch", async () => { const requestedHash = sha1Hex(bytes("what the caller asked for")); const servedBody = bytes("something else entirely"); fetchMock.mockImplementation(async (input: unknown) => { @@ -528,8 +501,9 @@ describe("tryServeFromWinbindex", () => { res, ); - expect(outcome).toBe("failed_after_send"); - expect(res.destroyed).toBe(true); + expect(outcome).toBe("not_available"); + expect(res.body).toHaveLength(0); + expect(res.headers).toEqual({}); expect(res.ended).toBe(false); expect(console.warn).toHaveBeenCalled(); }); @@ -551,7 +525,6 @@ describe("tryServeFromWinbindex", () => { ); expect(outcome).toBe("not_available"); - expect(res.destroyed).toBe(false); expect(res.body).toHaveLength(0); expect(res.headers).toEqual({}); }); @@ -590,7 +563,7 @@ describe("tryServeFromWinbindex", () => { expect(res.headers["ETag"]).toBe(`"${hash}"`); }); - it("omits Content-Length when the symbol server sent Content-Encoding: gzip", async () => { + it("sets Content-Length to the verified size, not the upstream content-encoded length", async () => { const body = bytes("MZ decompressed pe bytes"); const hash = sha1Hex(body); fetchMock.mockImplementation(async (input: unknown) => @@ -611,10 +584,10 @@ describe("tryServeFromWinbindex", () => { ); expect(outcome).toBe("served"); - expect(res.headers).not.toHaveProperty("Content-Length"); + expect(res.headers["Content-Length"]).toBe(body.byteLength); }); - it("returns not_available (no destroy) when the stream errors before the first byte", async () => { + it("returns not_available and sends nothing when the stream errors before the first byte", async () => { const hash = sha1Hex(bytes("kernel32 body")); fetchMock.mockImplementation(async (input: unknown) => String(input).includes(".json.gz") @@ -632,13 +605,11 @@ describe("tryServeFromWinbindex", () => { expect(outcome).toBe("not_available"); expect(res.body).toHaveLength(0); - expect(res.destroyed).toBe(false); expect(res.ended).toBe(false); }); - it("returns failed_after_send and destroys the response when the stream errors after bytes were sent", async () => { + it("returns not_available and sends nothing when the stream errors mid-transfer", async () => { const first = bytes("first chunk of the pe"); - // Requested hash need not match; the stream error fires first. const hash = sha1Hex(bytes("whole file")); fetchMock.mockImplementation(async (input: unknown) => String(input).includes(".json.gz") @@ -654,60 +625,30 @@ describe("tryServeFromWinbindex", () => { res, ); - expect(outcome).toBe("failed_after_send"); - expect(res.destroyed).toBe(true); - expect(res.body).toHaveLength(1); + expect(outcome).toBe("not_available"); + expect(res.body).toHaveLength(0); + expect(res.headers).toEqual({}); expect(console.warn).toHaveBeenCalled(); }); - it("honours write() backpressure and still serves the whole body", async () => { - const body = bytes("MZ...a portable executable that needs draining..."); - const hash = sha1Hex(body); - fetchMock.mockImplementation(async (input: unknown) => - String(input).includes(".json.gz") - ? jsonIndexResponse(entryIndex(hash)) - : symbolResponse(body), - ); - - // First write() reports the buffer is full -> code must await "drain". - const res = makeMockRes([false]); - const outcome = await tryServeFromWinbindex( - CONFIG, - hash, - "kernel32.dll", - res, - ); - - expect(outcome).toBe("served"); - expect(receivedText(res)).toBe( - "MZ...a portable executable that needs draining...", - ); - expect(res.ended).toBe(true); - }); - - it("tears the response and the upstream down if the client never drains", async () => { - const body = bytes("MZ...a client that stops reading..."); + it("falls back without sending when the body exceeds the size cap, and cancels the upstream", async () => { + const body = bytes("MZ...larger than the cap..."); const hash = sha1Hex(body); const upstream = stallingStream(body); - fetchMock.mockImplementation(async (input: unknown) => - String(input).includes(".json.gz") - ? jsonIndexResponse(entryIndex(hash)) - : symbolResponse(upstream.stream), - ); + fetchMock.mockResolvedValue(symbolResponse(upstream.stream)); - // write() reports backpressure but "drain" is never emitted. const res = makeMockRes(); - res.write = () => false; - - const outcome = await tryServeFromWinbindex( - { ...CONFIG, timeoutMs: 10 }, - hash, + const outcome = await serveFromSymbolServer( + CONFIG, + { timestamp: 1584069829, virtualSize: 118784 }, "kernel32.dll", + hash, res, + body.byteLength - 1, ); - expect(outcome).toBe("failed_after_send"); - expect(res.destroyed).toBe(true); + expect(outcome).toBe("not_available"); + expect(res.body).toHaveLength(0); expect(upstream.cancelled()).toBe(true); }); });