diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..f5b957e05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,11 @@ - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. ### Changed - - Pinned npm `10.9.9` as the approved lockfile generator, activated it through Node-bundled Corepack before dependency consumption, and fail closed unless its bundled `tar` is at least `7.5.19`; primary CI still consumes the committed lock only through frozen `npm ci` validation, rejects mutable npm resolution in the lock gate, requires integrity evidence for public-registry lock entries, and preserves generator-sensitive root `@esbuild/*` peer metadata. - +- Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance. ### Fixed -- Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance. +- Reject malformed, shorter-than-`%PDF-`, or oversized PDF bridge responses before allocation/copy, enforcing the desktop bridge's 5-byte PDF-magic minimum and 25 MiB cap while snapshotting attach metadata, array length, and every returned PDF byte during authoritative reads or into fresh owned buffers so coercion, accessor-driven changes, or later bridge-side mutation cannot change validated results. ## [0.1.3] - 2026-04-29 diff --git a/apps/desktop/src/features/score/ScoreView.test.tsx b/apps/desktop/src/features/score/ScoreView.test.tsx index de4ccb95c..73573316a 100644 --- a/apps/desktop/src/features/score/ScoreView.test.tsx +++ b/apps/desktop/src/features/score/ScoreView.test.tsx @@ -47,6 +47,7 @@ const tauriWindow = window as TauriWindow; const mockInvoke = vi.mocked(invoke); const SCORE_ID = "3f2c8f0e-1a2b-4c3d-8e9f-001122334455"; +const MINIMAL_PDF_BYTES = [37, 80, 68, 70, 45] as const; function makeSong(scoreAttachments?: ScoreAttachment[]): RehearsalSong { return { @@ -106,7 +107,7 @@ describe("ScoreView", () => { it("attaches a score, persists the metadata, and opens the new PDF", async () => { mockInvoke .mockResolvedValueOnce(attachResponse()) - .mockResolvedValueOnce([1, 2, 3]); + .mockResolvedValueOnce([...MINIMAL_PDF_BYTES]); const onSongUpdate = vi.fn(); const song = makeSong(); @@ -115,7 +116,7 @@ describe("ScoreView", () => { fireEvent.click(screen.getByRole("button", { name: "Add score" })); await waitFor(() => { - expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:3:opener.pdf"); + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:5:opener.pdf"); }); expect(mockInvoke).toHaveBeenNthCalledWith(1, "attach_score_pdf", { projectId: "project-1-2", @@ -157,7 +158,7 @@ describe("ScoreView", () => { }); it("opens an existing attachment through the read command", async () => { - const bytes = new Uint8Array([9, 9, 9, 9]).buffer; + const bytes = new Uint8Array(MINIMAL_PDF_BYTES).buffer; let resolveRead!: (value: unknown) => void; mockInvoke.mockImplementationOnce( () => new Promise((resolve) => { resolveRead = resolve; }) @@ -172,7 +173,7 @@ describe("ScoreView", () => { resolveRead(bytes); await waitFor(() => { - expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:4:opener.pdf"); + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:5:opener.pdf"); }); expect(mockInvoke).toHaveBeenCalledWith("read_score_pdf", { projectId: "project-1-2", @@ -181,7 +182,7 @@ describe("ScoreView", () => { }); it("accepts Uint8Array read responses from the bridge", async () => { - mockInvoke.mockResolvedValueOnce(new Uint8Array([7, 7])); + mockInvoke.mockResolvedValueOnce(new Uint8Array(MINIMAL_PDF_BYTES)); const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); render(); @@ -189,7 +190,7 @@ describe("ScoreView", () => { fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" })); await waitFor(() => { - expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:2:opener.pdf"); + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:5:opener.pdf"); }); }); @@ -222,7 +223,7 @@ describe("ScoreView", () => { it("removes an attachment after confirmation and resets the open viewer", async () => { mockInvoke - .mockResolvedValueOnce([1, 2]) + .mockResolvedValueOnce([...MINIMAL_PDF_BYTES]) .mockResolvedValueOnce(true); vi.spyOn(window, "confirm").mockReturnValue(true); const onSongUpdate = vi.fn(); @@ -232,7 +233,7 @@ describe("ScoreView", () => { fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" })); await waitFor(() => { - expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:2:opener.pdf"); + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:5:opener.pdf"); }); fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" })); @@ -307,7 +308,7 @@ describe("ScoreView", () => { it("uses the legacy invoke shim when Tauri internals are absent", async () => { delete tauriWindow.__TAURI_INTERNALS__; - const legacyInvoke = vi.fn().mockResolvedValueOnce([5]); + const legacyInvoke = vi.fn().mockResolvedValueOnce([...MINIMAL_PDF_BYTES]); tauriWindow.__TAURI_INVOKE__ = legacyInvoke; const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); @@ -316,7 +317,7 @@ describe("ScoreView", () => { fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" })); await waitFor(() => { - expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:1:opener.pdf"); + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:5:opener.pdf"); }); expect(legacyInvoke).toHaveBeenCalledWith("read_score_pdf", { projectId: "project-1-2", @@ -344,7 +345,7 @@ describe("ScoreView", () => { let resolveStale!: (value: unknown) => void; mockInvoke .mockImplementationOnce(() => new Promise((resolve) => { resolveStale = resolve; })) - .mockResolvedValueOnce([9, 9]); + .mockResolvedValueOnce([...MINIMAL_PDF_BYTES]); const song = makeSong([ { id: "id-1", fileName: "first.pdf" }, { id: "id-2", fileName: "second.pdf" } @@ -356,14 +357,14 @@ describe("ScoreView", () => { fireEvent.click(screen.getByRole("button", { name: "Open score: second.pdf" })); await waitFor(() => { - expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:2:second.pdf"); + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:5:second.pdf"); }); await act(async () => { resolveStale([1, 1, 1, 1, 1]); }); - expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:2:second.pdf"); + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:5:second.pdf"); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); @@ -373,7 +374,7 @@ describe("ScoreView", () => { let rejectStale!: (reason: unknown) => void; mockInvoke .mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectStale = reject; })) - .mockResolvedValueOnce([4, 4]); + .mockResolvedValueOnce([...MINIMAL_PDF_BYTES]); const song = makeSong([ { id: "id-1", fileName: "first.pdf" }, { id: "id-2", fileName: "second.pdf" } @@ -385,14 +386,14 @@ describe("ScoreView", () => { fireEvent.click(screen.getByRole("button", { name: "Open score: second.pdf" })); await waitFor(() => { - expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:2:second.pdf"); + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:5:second.pdf"); }); await act(async () => { rejectStale(new Error("Stale read failed.")); }); - expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:2:second.pdf"); + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:5:second.pdf"); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); diff --git a/apps/desktop/src/features/score/scoreStorage.minimum-size.test.ts b/apps/desktop/src/features/score/scoreStorage.minimum-size.test.ts new file mode 100644 index 000000000..2f7b6acdb --- /dev/null +++ b/apps/desktop/src/features/score/scoreStorage.minimum-size.test.ts @@ -0,0 +1,67 @@ +import { afterEach, expect, it, vi } from "vitest"; + +import { attachScorePdf, readScorePdf } from "./scoreStorage"; + +const INVALID_RESPONSE_MESSAGE = "Invalid score bridge response"; +const MAX_SCORE_PDF_BYTES = 25 * 1024 * 1024; + +function stubReadResponse(response: unknown): void { + vi.stubGlobal("window", { + __TAURI_INTERNALS__: { + invoke: async () => response + } + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +it.each([0, 4])( + "rejects attach metadata smaller than the Rust PDF magic boundary (%i bytes)", + async (fileSizeBytes) => { + stubReadResponse({ scoreId: "score-1", fileName: "score.pdf", fileSizeBytes }); + + await expect(attachScorePdf("project-1", "song-1")).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + } +); + +it.each([ + ["numeric array", [0, 1, 2, 3]], + ["Uint8Array", new Uint8Array([0, 1, 2, 3])], + ["ArrayBuffer", new Uint8Array([0, 1, 2, 3]).buffer] +])("rejects a %s bridge payload shorter than the PDF magic boundary", async (_label, response) => { + stubReadResponse(response); + + await expect(readScorePdf("project-1", "score-1")).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); +}); + +it("rejects an oversized Uint8Array even when an own byteLength accessor lies", async () => { + const response = new Uint8Array(MAX_SCORE_PDF_BYTES + 1); + Object.defineProperty(response, "byteLength", { + configurable: true, + get: () => 5 + }); + stubReadResponse(response); + + await expect(readScorePdf("project-1", "score-1")).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); +}); + +it("rejects an oversized ArrayBuffer even when an own byteLength accessor lies", async () => { + const response = new ArrayBuffer(MAX_SCORE_PDF_BYTES + 1); + Object.defineProperty(response, "byteLength", { + configurable: true, + get: () => 5 + }); + stubReadResponse(response); + + await expect(readScorePdf("project-1", "score-1")).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); +}); diff --git a/apps/desktop/src/features/score/scoreStorage.test.ts b/apps/desktop/src/features/score/scoreStorage.test.ts index 0feec199e..ad161505b 100644 --- a/apps/desktop/src/features/score/scoreStorage.test.ts +++ b/apps/desktop/src/features/score/scoreStorage.test.ts @@ -7,6 +7,17 @@ type TauriWindow = Window & { }; const BRIDGE_UNAVAILABLE_MESSAGE = "Score PDFs are only available in the desktop app."; +const INVALID_RESPONSE_MESSAGE = "Invalid score bridge response"; +const MAX_SCORE_PDF_BYTES = 25 * 1024 * 1024; +const MINIMAL_PDF_BYTES = [37, 80, 68, 70, 45] as const; + +function stubReadResponse(response: unknown): void { + vi.stubGlobal("window", { + __TAURI_INTERNALS__: { + invoke: async () => response + } + }); +} describe("scoreStorage bridge resolution", () => { afterEach(() => { @@ -16,6 +27,237 @@ describe("scoreStorage bridge resolution", () => { delete tauriWindow.__TAURI_INVOKE__; }); + it("copies validated attach metadata from the same property reads", async () => { + const reads = { scoreId: 0, fileName: 0, fileSizeBytes: 0 }; + const response = Object.create(null) as Record; + Object.defineProperties(response, { + scoreId: { + enumerable: true, + get: () => { + reads.scoreId += 1; + return reads.scoreId === 1 ? "score-1" : 42; + } + }, + fileName: { + enumerable: true, + get: () => { + reads.fileName += 1; + return reads.fileName === 1 ? "score.pdf" : null; + } + }, + fileSizeBytes: { + enumerable: true, + get: () => { + reads.fileSizeBytes += 1; + return reads.fileSizeBytes === 1 ? 512 : Number.NaN; + } + } + }); + stubReadResponse(response); + + await expect(attachScorePdf("project-1", "song-1")).resolves.toEqual({ + id: "score-1", + fileName: "score.pdf", + fileSizeBytes: 512 + }); + expect(reads).toEqual({ scoreId: 1, fileName: 1, fileSizeBytes: 1 }); + }); + + it.each([ + ["null response", null], + ["primitive response", "score-1"] + ])("rejects attach metadata with a %s", async (_label, response) => { + stubReadResponse(response); + + await expect(attachScorePdf("project-1", "song-1")).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + }); + + it.each([ + ["negative size", -1], + ["fractional size", 1.5], + ["NaN size", Number.NaN], + ["infinite size", Number.POSITIVE_INFINITY], + ["unsafe integer size", Number.MAX_SAFE_INTEGER + 1], + ["size above the Rust PDF cap", MAX_SCORE_PDF_BYTES + 1] + ])("rejects attach metadata with a %s", async (_label, fileSizeBytes) => { + stubReadResponse({ scoreId: "score-1", fileName: "score.pdf", fileSizeBytes }); + + await expect(attachScorePdf("project-1", "song-1")).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + }); + + 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("copies each validated bridge byte during the same read", async () => { + const response: unknown[] = [...MINIMAL_PDF_BYTES]; + let reads = 0; + Object.defineProperty(response, 0, { + configurable: true, + get: () => { + reads += 1; + return reads === 1 ? MINIMAL_PDF_BYTES[0] : 256; + } + }); + stubReadResponse(response); + + const result = await readScorePdf("project-1", "score-1"); + + expect(Array.from(result)).toEqual(MINIMAL_PDF_BYTES); + expect(reads).toBe(1); + }); + + it("snapshots the bridge array length before validating bytes", async () => { + const backing: unknown[] = [...MINIMAL_PDF_BYTES]; + let lengthReads = 0; + const response = new Proxy(backing, { + get(target, property, receiver) { + if (property === "length") { + lengthReads += 1; + return lengthReads === 1 ? MINIMAL_PDF_BYTES.length : MINIMAL_PDF_BYTES.length - 1; + } + return Reflect.get(target, property, receiver); + } + }); + stubReadResponse(response); + + const result = await readScorePdf("project-1", "score-1"); + + expect(Array.from(result)).toEqual(MINIMAL_PDF_BYTES); + expect(lengthReads).toBe(1); + }); + + it.each([ + ["NaN", Number.NaN], + ["fractional", 1.5] + ])("rejects a bridge array with a %s length before allocation", async (_label, length) => { + const response = new Proxy([1, 2], { + get(target, property, receiver) { + if (property === "length") { + return length; + } + return Reflect.get(target, property, receiver); + } + }); + stubReadResponse(response); + + await expect(readScorePdf("project-1", "score-1")).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + }); + + it("rejects a numeric bridge array above the Rust PDF cap before reading bytes", async () => { + const response = new Proxy([] as unknown[], { + get(target, property, receiver) { + if (property === "length") { + return MAX_SCORE_PDF_BYTES + 1; + } + if (property === "0") { + throw new Error("oversized bridge payload was read"); + } + return Reflect.get(target, property, receiver); + } + }); + stubReadResponse(response); + + await expect(readScorePdf("project-1", "score-1")).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + }); + + it("snapshots a Uint8Array bridge response before returning it", async () => { + const response = new Uint8Array(MINIMAL_PDF_BYTES); + stubReadResponse(response); + + const result = await readScorePdf("project-1", "score-1"); + response[0] = 9; + + expect(result).not.toBe(response); + expect(Array.from(result)).toEqual(MINIMAL_PDF_BYTES); + }); + + it("rejects an oversized Uint8Array-shaped bridge response before copying", async () => { + const response = new Proxy(new Uint8Array([1]), { + get(target, property, receiver) { + if (property === "byteLength") { + return MAX_SCORE_PDF_BYTES + 1; + } + return Reflect.get(target, property, receiver); + } + }); + stubReadResponse(response); + + await expect(readScorePdf("project-1", "score-1")).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + }); + + it("snapshots an ArrayBuffer bridge response before returning its bytes", async () => { + const response = new Uint8Array(MINIMAL_PDF_BYTES); + stubReadResponse(response.buffer); + + const result = await readScorePdf("project-1", "score-1"); + response[0] = 9; + + expect(result.buffer).not.toBe(response.buffer); + expect(Array.from(result)).toEqual(MINIMAL_PDF_BYTES); + }); + + it("rejects an oversized ArrayBuffer-shaped bridge response before copying", async () => { + const response = new Proxy(new ArrayBuffer(1), { + get(target, property, receiver) { + if (property === "byteLength") { + return MAX_SCORE_PDF_BYTES + 1; + } + return Reflect.get(target, property, receiver); + } + }); + stubReadResponse(response); + + await expect(readScorePdf("project-1", "score-1")).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + }); + + it.each([ + ["string value", [104, "101", 108, 108, 111]], + ["negative integer", [0, -1, 255, 0, 0]], + ["integer above the byte range", [0, 256, 255, 0, 0]], + ["fractional number", [0, 1.5, 255, 0, 0]], + ["NaN", [0, Number.NaN, 255, 0, 0]], + ["infinity", [0, Number.POSITIVE_INFINITY, 255, 0, 0]] + ])("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, 0, 0, 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 492f12591..5517b2df5 100644 --- a/apps/desktop/src/features/score/scoreStorage.ts +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -16,6 +16,8 @@ export type ScoreAttachResult = ScoreAttachment & { fileSizeBytes: number }; const BRIDGE_UNAVAILABLE_MESSAGE = "Score PDFs are only available in the desktop app."; const INVALID_RESPONSE_MESSAGE = "Invalid score bridge response"; +const MIN_SCORE_PDF_BYTES = 5; +const MAX_SCORE_PDF_BYTES = 25 * 1024 * 1024; /** * Resolve the desktop invoke bridge following the same detection rules as @@ -52,6 +54,43 @@ async function invokeScoreCommand(command: string, args: Record return invokeCommand(command, args); } +/** + * Return whether a bridge-reported PDF byte count is safe to allocate/copy. + * + * The value must match the Rust desktop bridge's `%PDF-` minimum and 25 MiB + * cap. Keeping the same fail-closed bounds on the JavaScript side prevents + * malformed or accessor-backed bridge values from driving invalid or + * oversized allocations even when the privileged producer is replaced by a + * test/dev shim. + */ +function isValidPdfByteCount(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isSafeInteger(value) && + value >= MIN_SCORE_PDF_BYTES && + value <= MAX_SCORE_PDF_BYTES + ); +} + +/** + * Read a typed bridge payload's real byte length from the platform intrinsic. + * + * Own accessors and Proxy traps are untrusted bridge metadata: querying the + * prototype intrinsic with the candidate as receiver either returns the + * object's internal byte length or throws when the receiver lacks the native + * internal slot. The latter fails closed instead of trusting a forged length. + */ +function getIntrinsicPdfByteCount(value: Uint8Array | ArrayBuffer): number | null { + try { + if (value instanceof Uint8Array) { + return Reflect.get(Uint8Array.prototype, "byteLength", value) as number; + } + return Reflect.get(ArrayBuffer.prototype, "byteLength", value) as number; + } catch { + return null; + } +} + /** * Open the native PDF picker and copy the validated score into the * app-owned project workspace. Security Notes: the file path never crosses @@ -60,21 +99,26 @@ async function invokeScoreCommand(command: string, args: Record */ export async function attachScorePdf(projectId: string, songId: string): Promise { const response = await invokeScoreCommand("attach_score_pdf", { projectId, songId }); + if (typeof response !== "object" || response === null) { + throw new Error(INVALID_RESPONSE_MESSAGE); + } + + const payload = response as Record; + const scoreId = payload.scoreId; + const fileName = payload.fileName; + const fileSizeBytes = payload.fileSizeBytes; if ( - typeof response !== "object" || - response === null || - typeof (response as Record).scoreId !== "string" || - typeof (response as Record).fileName !== "string" || - typeof (response as Record).fileSizeBytes !== "number" + typeof scoreId !== "string" || + typeof fileName !== "string" || + !isValidPdfByteCount(fileSizeBytes) ) { throw new Error(INVALID_RESPONSE_MESSAGE); } - const payload = response as { scoreId: string; fileName: string; fileSizeBytes: number }; return { - id: payload.scoreId, - fileName: payload.fileName, - fileSizeBytes: payload.fileSizeBytes + id: scoreId, + fileName, + fileSizeBytes }; } @@ -86,13 +130,33 @@ export async function attachScorePdf(projectId: string, songId: string): Promise export async function readScorePdf(projectId: string, scoreId: string): Promise { const response = await invokeScoreCommand("read_score_pdf", { projectId, scoreId }); if (response instanceof Uint8Array) { - return response; + const byteCount = getIntrinsicPdfByteCount(response); + if (!isValidPdfByteCount(byteCount)) { + throw new Error(INVALID_RESPONSE_MESSAGE); + } + return new Uint8Array(response); } if (response instanceof ArrayBuffer) { - return new Uint8Array(response); + const byteCount = getIntrinsicPdfByteCount(response); + if (!isValidPdfByteCount(byteCount)) { + throw new Error(INVALID_RESPONSE_MESSAGE); + } + return new Uint8Array(response).slice(); } - if (Array.isArray(response) && response.every((byte) => typeof byte === "number")) { - return Uint8Array.from(response as number[]); + if (Array.isArray(response)) { + const byteCount = response.length; + if (!isValidPdfByteCount(byteCount)) { + throw new Error(INVALID_RESPONSE_MESSAGE); + } + const bytes = new Uint8Array(byteCount); + for (let index = 0; index < byteCount; index += 1) { + const byte = response[index]; + if (!Number.isInteger(byte) || byte < 0 || byte > 255) { + throw new Error(INVALID_RESPONSE_MESSAGE); + } + bytes[index] = byte; + } + return bytes; } throw new Error(INVALID_RESPONSE_MESSAGE);