From e1f3cc482a265aadd3dc88546c1a432a37581408 Mon Sep 17 00:00:00 2001 From: Mathieu Tarral Date: Wed, 9 Sep 2026 14:27:07 +0200 Subject: [PATCH] fix(winbindex): fall back to MinIO on a pre-send SHA-1 mismatch The digest-mismatch branch in streamFromSymbolServer destroyed the response and returned failed_after_send even when zero bytes had reached the client (a symbol server 200 with an empty body for a hash that is not the empty-file hash). The response is still pristine in that case, so it now mirrors the stream-error branch and returns not_available, letting the request fall through to MinIO. Found by Copilot on #72 after merge. winbindex.test.ts 38/38. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NmATMvFbdupfLRq7Ldwabv --- src/winbindex.ts | 8 ++++++++ tests/winbindex.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/winbindex.ts b/src/winbindex.ts index 7c55910..0b91c14 100644 --- a/src/winbindex.ts +++ b/src/winbindex.ts @@ -389,6 +389,14 @@ export async function streamFromSymbolServer( 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"; + } console.warn( `Winbindex: SHA-1 mismatch for ${name} (expected ${expectedSha1.toLowerCase()}, got ${digest}), destroying response`, ); diff --git a/tests/winbindex.test.ts b/tests/winbindex.test.ts index 7dbb00a..ed16957 100644 --- a/tests/winbindex.test.ts +++ b/tests/winbindex.test.ts @@ -54,6 +54,14 @@ const streamOf = (data: Uint8Array): ReadableStream => }, }); +/** A 200-body stream that closes without ever yielding a chunk. */ +const emptyStream = (): ReadableStream => + new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + /** A stream that yields `data` then errors, i.e. fails *after* the first byte. */ const streamThenError = (data: Uint8Array): ReadableStream => { let sent = false; @@ -526,6 +534,28 @@ describe("tryServeFromWinbindex", () => { expect(console.warn).toHaveBeenCalled(); }); + it("falls back to MinIO on a SHA-1 mismatch when no byte was sent (empty 200 body)", async () => { + const requestedHash = sha1Hex(bytes("a real, non-empty PE file")); + fetchMock.mockImplementation(async (input: unknown) => + String(input).includes(".json.gz") + ? jsonIndexResponse(entryIndex(requestedHash)) + : symbolResponse(emptyStream()), + ); + + const res = makeMockRes(); + const outcome = await tryServeFromWinbindex( + CONFIG, + requestedHash, + "kernel32.dll", + res, + ); + + expect(outcome).toBe("not_available"); + expect(res.destroyed).toBe(false); + expect(res.body).toHaveLength(0); + expect(res.headers).toEqual({}); + }); + it.each(["a?b.dll", "x#y.dll", "mal ware.dll", "%2e%2e.dll", "café.dll"])( "returns not_available without fetching for an unsafe filename %s", async (unsafe) => {