diff --git a/apps/desktop/src/features/score/scoreStorage.test.ts b/apps/desktop/src/features/score/scoreStorage.test.ts index 0feec199..84ff554d 100644 --- a/apps/desktop/src/features/score/scoreStorage.test.ts +++ b/apps/desktop/src/features/score/scoreStorage.test.ts @@ -7,6 +7,15 @@ type TauriWindow = Window & { }; const BRIDGE_UNAVAILABLE_MESSAGE = "Score PDFs are only available in the desktop app."; +const INVALID_RESPONSE_MESSAGE = "Invalid score bridge response"; + +function stubReadResponse(response: unknown): void { + vi.stubGlobal("window", { + __TAURI_INTERNALS__: { + invoke: async () => response + } + }); +} describe("scoreStorage bridge resolution", () => { afterEach(() => { @@ -16,6 +25,45 @@ describe("scoreStorage bridge resolution", () => { delete tauriWindow.__TAURI_INVOKE__; }); + it("converts a validated numeric byte array without coercing its values", async () => { + stubReadResponse([0, 1, 127, 254, 255]); + + const result = await readScorePdf("project-1", "score-1"); + + expect(result).toBeInstanceOf(Uint8Array); + expect(Array.from(result)).toEqual([0, 1, 127, 254, 255]); + }); + + it.each([ + ["string value", [104, "101", 108]], + ["negative integer", [0, -1, 255]], + ["integer above the byte range", [0, 256, 255]], + ["fractional number", [0, 1.5, 255]], + ["NaN", [0, Number.NaN, 255]], + ["infinity", [0, Number.POSITIVE_INFINITY, 255]] + ])("rejects a bridge array containing a %s", async (_label, response) => { + stubReadResponse(response); + + await expect(readScorePdf("project-1", "score-1")).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + }); + + it("stops validating after the first invalid byte", async () => { + const response: unknown[] = [-1, 0]; + Object.defineProperty(response, 1, { + configurable: true, + get: () => { + throw new Error("validation read past the first invalid byte"); + } + }); + stubReadResponse(response); + + await expect(readScorePdf("project-1", "score-1")).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + }); + it("fails closed on every command when there is no window (non-browser runtime)", async () => { // Simulate a runtime without a DOM window (e.g. SSR / bundler prerender): // getInvoke() must take the `typeof window === "undefined"` branch and diff --git a/apps/desktop/src/features/score/scoreStorage.ts b/apps/desktop/src/features/score/scoreStorage.ts index 492f1259..6bf888c8 100644 --- a/apps/desktop/src/features/score/scoreStorage.ts +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -91,7 +91,13 @@ export async function readScorePdf(projectId: string, scoreId: string): Promise< if (response instanceof ArrayBuffer) { return new Uint8Array(response); } - if (Array.isArray(response) && response.every((byte) => typeof byte === "number")) { + if (Array.isArray(response)) { + for (let index = 0; index < response.length; index += 1) { + const byte = response[index]; + if (!Number.isInteger(byte) || byte < 0 || byte > 255) { + throw new Error(INVALID_RESPONSE_MESSAGE); + } + } return Uint8Array.from(response as number[]); } diff --git a/package-lock.json b/package-lock.json index cf1c991c..d18d6dee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7179,9 +7179,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": {