From 16c5905f2c66fab43ab2665365ab285d121a7615 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:16:59 +0900 Subject: [PATCH 01/29] fix(score): reject malformed PDF bridge byte arrays Validate every plain-array bridge element as an integer byte before conversion, preserve boundary bytes, stop at the first invalid value, and record the buyer-visible fail-closed behavior without carrying dependency or lockfile drift. --- CHANGELOG.md | 4 ++ .../src/features/score/scoreStorage.test.ts | 48 +++++++++++++++++++ .../src/features/score/scoreStorage.ts | 8 +++- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..6a1993c0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +### Fixed + +- Reject malformed plain-array PDF bridge responses instead of allowing `Uint8Array.from` to wrap, truncate, or coerce values outside the exact byte range. + ## [0.1.3] - 2026-04-29 ### Fixed diff --git a/apps/desktop/src/features/score/scoreStorage.test.ts b/apps/desktop/src/features/score/scoreStorage.test.ts index 0feec199e..84ff554db 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 492f12591..6bf888c83 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[]); } From 50127b7357c3f6ea0b6f163434d54526e609fb2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:07:27 +0900 Subject: [PATCH 02/29] ci(repair): add test-first PR 750 byte-copy repair --- .github/scripts/repair_pr_750.py | 117 +++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 .github/scripts/repair_pr_750.py diff --git a/.github/scripts/repair_pr_750.py b/.github/scripts/repair_pr_750.py new file mode 100644 index 000000000..4bb454734 --- /dev/null +++ b/.github/scripts/repair_pr_750.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run(args, cwd=ROOT, check=check, text=True) + + +def replace_once(path: Path, old: str, new: str, label: str) -> None: + text = path.read_text(encoding="utf-8") + if text.count(old) != 1: + raise RuntimeError(f"unexpected {label} shape") + path.write_text(text.replace(old, new), encoding="utf-8") + + +def main() -> None: + run("npm", "install", "--global", "npm@10.9.8") + run("npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund") + + test_path = ROOT / "apps/desktop/src/features/score/scoreStorage.test.ts" + marker = ' it.each([\n' + regression = ''' it("copies each validated bridge byte during the same read", async () => { + const response: unknown[] = [0]; + let reads = 0; + Object.defineProperty(response, 0, { + configurable: true, + get: () => { + reads += 1; + return reads === 1 ? 255 : 256; + } + }); + stubReadResponse(response); + + const result = await readScorePdf("project-1", "score-1"); + + expect(Array.from(result)).toEqual([255]); + expect(reads).toBe(1); + }); + +''' + replace_once(test_path, marker, regression + marker, "score regression insertion point") + run("git", "config", "user.name", "CWL repair bot") + run("git", "config", "user.email", "actions@users.noreply.github.com") + run("git", "add", str(test_path.relative_to(ROOT))) + run("git", "commit", "-m", "test(score): prevent bridge byte re-read coercion") + + red = run( + "npm", "exec", "--workspace", "@bandscope/desktop", "--", + "vitest", "run", "src/features/score/scoreStorage.test.ts", "--coverage=false", + check=False, + ) + if red.returncode == 0: + raise RuntimeError("expected byte re-read regression to fail before implementation") + + storage_path = ROOT / "apps/desktop/src/features/score/scoreStorage.ts" + replace_once( + storage_path, + ''' 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[]); + } +''', + ''' if (Array.isArray(response)) { + const bytes = new Uint8Array(response.length); + 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); + } + bytes[index] = byte; + } + return bytes; + } +''', + "plain-array byte conversion", + ) + replace_once( + ROOT / "CHANGELOG.md", + "- Reject malformed plain-array PDF bridge responses instead of allowing `Uint8Array.from` to wrap, truncate, or coerce values outside the exact byte range.", + "- Reject malformed plain-array PDF bridge responses and copy each validated byte during the same read, preventing coercion or accessor-driven value changes between validation and conversion.", + "score changelog", + ) + + run( + "npm", "exec", "--workspace", "@bandscope/desktop", "--", + "vitest", "run", "src/features/score/scoreStorage.test.ts", "--coverage=false", + ) + run("npm", "run", "lint", "--workspace", "@bandscope/desktop") + run("npm", "run", "typecheck", "--workspace", "@bandscope/desktop") + run("npm", "run", "test", "--workspace", "@bandscope/desktop") + run("npm", "run", "build", "--workspace", "@bandscope/desktop") + run("./scripts/harness/quickcheck.sh") + + (ROOT / ".github/workflows/repair-pr-750-byte-copy.yml").unlink() + Path(__file__).unlink() + run( + "git", "add", "CHANGELOG.md", + "apps/desktop/src/features/score/scoreStorage.ts", + "apps/desktop/src/features/score/scoreStorage.test.ts", + ".github/workflows/repair-pr-750-byte-copy.yml", + ".github/scripts/repair_pr_750.py", + ) + run("git", "commit", "-m", "fix(score): copy validated bridge bytes once") + run("git", "push", "origin", "HEAD:fix/score-pdf-byte-validation-clean") + + +if __name__ == "__main__": + main() From b017de090759ae249d45801e39d34b4171f27498 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:07:42 +0900 Subject: [PATCH 03/29] ci(repair): launch PR 750 byte-copy repair --- .github/workflows/repair-pr-750-byte-copy.yml | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/repair-pr-750-byte-copy.yml diff --git a/.github/workflows/repair-pr-750-byte-copy.yml b/.github/workflows/repair-pr-750-byte-copy.yml new file mode 100644 index 000000000..33ae020a3 --- /dev/null +++ b/.github/workflows/repair-pr-750-byte-copy.yml @@ -0,0 +1,34 @@ +name: Repair PR 750 validated byte copy + +on: + push: + branches: + - fix/score-pdf-byte-validation-clean + paths: + - .github/workflows/repair-pr-750-byte-copy.yml + +permissions: + contents: write + +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + +jobs: + repair: + if: >- + github.repository == 'ContextualWisdomLab/bandscope' && + github.ref == 'refs/heads/fix/score-pdf-byte-validation-clean' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + ref: fix/score-pdf-byte-validation-clean + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22.22.3 + cache: npm + - name: Execute the bounded test-first repair + run: python3 .github/scripts/repair_pr_750.py From c92a2f4ebc0b09172906700aa5000cc92fd4fe7c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 22:15:24 +0900 Subject: [PATCH 04/29] ci(repair): provision Python dev tooling for quickcheck --- .github/workflows/repair-pr-750-byte-copy.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/repair-pr-750-byte-copy.yml b/.github/workflows/repair-pr-750-byte-copy.yml index 33ae020a3..6ef840de7 100644 --- a/.github/workflows/repair-pr-750-byte-copy.yml +++ b/.github/workflows/repair-pr-750-byte-copy.yml @@ -30,5 +30,11 @@ jobs: with: node-version: 22.22.3 cache: npm + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + version: "0.8.6" + enable-cache: false + - name: Sync Python development dependencies + run: uv sync --project services/analysis-engine --group dev --frozen - name: Execute the bounded test-first repair - run: python3 .github/scripts/repair_pr_750.py + run: python3 .github/scripts/repair_pr_750.py \ No newline at end of file From 11835621aa5013db55c00d3c36233e58ff0a6881 Mon Sep 17 00:00:00 2001 From: CWL repair bot Date: Sat, 15 Aug 2026 14:45:36 +0000 Subject: [PATCH 05/29] test(score): prevent bridge byte re-read coercion --- .../src/features/score/scoreStorage.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/desktop/src/features/score/scoreStorage.test.ts b/apps/desktop/src/features/score/scoreStorage.test.ts index 84ff554db..25a217604 100644 --- a/apps/desktop/src/features/score/scoreStorage.test.ts +++ b/apps/desktop/src/features/score/scoreStorage.test.ts @@ -34,6 +34,24 @@ describe("scoreStorage bridge resolution", () => { expect(Array.from(result)).toEqual([0, 1, 127, 254, 255]); }); + it("copies each validated bridge byte during the same read", async () => { + const response: unknown[] = [0]; + let reads = 0; + Object.defineProperty(response, 0, { + configurable: true, + get: () => { + reads += 1; + return reads === 1 ? 255 : 256; + } + }); + stubReadResponse(response); + + const result = await readScorePdf("project-1", "score-1"); + + expect(Array.from(result)).toEqual([255]); + expect(reads).toBe(1); + }); + it.each([ ["string value", [104, "101", 108]], ["negative integer", [0, -1, 255]], From c9b1694e436ece8cc0b8109ca3a1e2a1ced1837c Mon Sep 17 00:00:00 2001 From: CWL repair bot Date: Sat, 15 Aug 2026 14:47:53 +0000 Subject: [PATCH 06/29] fix(score): copy validated bridge bytes once --- .github/scripts/repair_pr_750.py | 117 ------------------ .github/workflows/repair-pr-750-byte-copy.yml | 40 ------ CHANGELOG.md | 2 +- .../src/features/score/scoreStorage.ts | 4 +- 4 files changed, 4 insertions(+), 159 deletions(-) delete mode 100644 .github/scripts/repair_pr_750.py delete mode 100644 .github/workflows/repair-pr-750-byte-copy.yml diff --git a/.github/scripts/repair_pr_750.py b/.github/scripts/repair_pr_750.py deleted file mode 100644 index 4bb454734..000000000 --- a/.github/scripts/repair_pr_750.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -import subprocess -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] - - -def run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]: - return subprocess.run(args, cwd=ROOT, check=check, text=True) - - -def replace_once(path: Path, old: str, new: str, label: str) -> None: - text = path.read_text(encoding="utf-8") - if text.count(old) != 1: - raise RuntimeError(f"unexpected {label} shape") - path.write_text(text.replace(old, new), encoding="utf-8") - - -def main() -> None: - run("npm", "install", "--global", "npm@10.9.8") - run("npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund") - - test_path = ROOT / "apps/desktop/src/features/score/scoreStorage.test.ts" - marker = ' it.each([\n' - regression = ''' it("copies each validated bridge byte during the same read", async () => { - const response: unknown[] = [0]; - let reads = 0; - Object.defineProperty(response, 0, { - configurable: true, - get: () => { - reads += 1; - return reads === 1 ? 255 : 256; - } - }); - stubReadResponse(response); - - const result = await readScorePdf("project-1", "score-1"); - - expect(Array.from(result)).toEqual([255]); - expect(reads).toBe(1); - }); - -''' - replace_once(test_path, marker, regression + marker, "score regression insertion point") - run("git", "config", "user.name", "CWL repair bot") - run("git", "config", "user.email", "actions@users.noreply.github.com") - run("git", "add", str(test_path.relative_to(ROOT))) - run("git", "commit", "-m", "test(score): prevent bridge byte re-read coercion") - - red = run( - "npm", "exec", "--workspace", "@bandscope/desktop", "--", - "vitest", "run", "src/features/score/scoreStorage.test.ts", "--coverage=false", - check=False, - ) - if red.returncode == 0: - raise RuntimeError("expected byte re-read regression to fail before implementation") - - storage_path = ROOT / "apps/desktop/src/features/score/scoreStorage.ts" - replace_once( - storage_path, - ''' 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[]); - } -''', - ''' if (Array.isArray(response)) { - const bytes = new Uint8Array(response.length); - 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); - } - bytes[index] = byte; - } - return bytes; - } -''', - "plain-array byte conversion", - ) - replace_once( - ROOT / "CHANGELOG.md", - "- Reject malformed plain-array PDF bridge responses instead of allowing `Uint8Array.from` to wrap, truncate, or coerce values outside the exact byte range.", - "- Reject malformed plain-array PDF bridge responses and copy each validated byte during the same read, preventing coercion or accessor-driven value changes between validation and conversion.", - "score changelog", - ) - - run( - "npm", "exec", "--workspace", "@bandscope/desktop", "--", - "vitest", "run", "src/features/score/scoreStorage.test.ts", "--coverage=false", - ) - run("npm", "run", "lint", "--workspace", "@bandscope/desktop") - run("npm", "run", "typecheck", "--workspace", "@bandscope/desktop") - run("npm", "run", "test", "--workspace", "@bandscope/desktop") - run("npm", "run", "build", "--workspace", "@bandscope/desktop") - run("./scripts/harness/quickcheck.sh") - - (ROOT / ".github/workflows/repair-pr-750-byte-copy.yml").unlink() - Path(__file__).unlink() - run( - "git", "add", "CHANGELOG.md", - "apps/desktop/src/features/score/scoreStorage.ts", - "apps/desktop/src/features/score/scoreStorage.test.ts", - ".github/workflows/repair-pr-750-byte-copy.yml", - ".github/scripts/repair_pr_750.py", - ) - run("git", "commit", "-m", "fix(score): copy validated bridge bytes once") - run("git", "push", "origin", "HEAD:fix/score-pdf-byte-validation-clean") - - -if __name__ == "__main__": - main() diff --git a/.github/workflows/repair-pr-750-byte-copy.yml b/.github/workflows/repair-pr-750-byte-copy.yml deleted file mode 100644 index 6ef840de7..000000000 --- a/.github/workflows/repair-pr-750-byte-copy.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Repair PR 750 validated byte copy - -on: - push: - branches: - - fix/score-pdf-byte-validation-clean - paths: - - .github/workflows/repair-pr-750-byte-copy.yml - -permissions: - contents: write - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: init.defaultBranch - GIT_CONFIG_VALUE_0: develop - -jobs: - repair: - if: >- - github.repository == 'ContextualWisdomLab/bandscope' && - github.ref == 'refs/heads/fix/score-pdf-byte-validation-clean' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 0 - ref: fix/score-pdf-byte-validation-clean - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 22.22.3 - cache: npm - - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 - with: - version: "0.8.6" - enable-cache: false - - name: Sync Python development dependencies - run: uv sync --project services/analysis-engine --group dev --frozen - - name: Execute the bounded test-first repair - run: python3 .github/scripts/repair_pr_750.py \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a1993c0b..5dff11a30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ### Fixed -- Reject malformed plain-array PDF bridge responses instead of allowing `Uint8Array.from` to wrap, truncate, or coerce values outside the exact byte range. +- Reject malformed plain-array PDF bridge responses and copy each validated byte during the same read, preventing coercion or accessor-driven value changes between validation and conversion. ## [0.1.3] - 2026-04-29 diff --git a/apps/desktop/src/features/score/scoreStorage.ts b/apps/desktop/src/features/score/scoreStorage.ts index 6bf888c83..800affadc 100644 --- a/apps/desktop/src/features/score/scoreStorage.ts +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -92,13 +92,15 @@ export async function readScorePdf(projectId: string, scoreId: string): Promise< return new Uint8Array(response); } if (Array.isArray(response)) { + const bytes = new Uint8Array(response.length); 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); } + bytes[index] = byte; } - return Uint8Array.from(response as number[]); + return bytes; } throw new Error(INVALID_RESPONSE_MESSAGE); From 367229be7425849cd0e6b6f4f6c0ff1563efd0b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 00:15:03 +0900 Subject: [PATCH 07/29] chore(ci): revalidate validated PDF byte copy From 8a34bd205a2bcf347608c761a62ce426da168eea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:20:59 +0900 Subject: [PATCH 08/29] test(score): snapshot attach bridge metadata once --- .../src/features/score/scoreStorage.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/apps/desktop/src/features/score/scoreStorage.test.ts b/apps/desktop/src/features/score/scoreStorage.test.ts index 25a217604..85ad812ce 100644 --- a/apps/desktop/src/features/score/scoreStorage.test.ts +++ b/apps/desktop/src/features/score/scoreStorage.test.ts @@ -25,6 +25,56 @@ 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([ + ["negative size", -1], + ["fractional size", 1.5], + ["NaN size", Number.NaN], + ["infinite size", Number.POSITIVE_INFINITY], + ["unsafe integer size", Number.MAX_SAFE_INTEGER + 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]); From c47f05d736dd1b3c216041d8b795fac5a9e9b44a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:24:49 +0900 Subject: [PATCH 09/29] fix(score): validate attach metadata without second reads --- .../src/features/score/scoreStorage.ts | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/features/score/scoreStorage.ts b/apps/desktop/src/features/score/scoreStorage.ts index 800affadc..0391b1dab 100644 --- a/apps/desktop/src/features/score/scoreStorage.ts +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -60,21 +60,28 @@ 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" || + typeof fileSizeBytes !== "number" || + !Number.isSafeInteger(fileSizeBytes) || + fileSizeBytes < 0 ) { 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 }; } From 7aa7df31f2da5954c0a1ca057a3c7804467fac03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:46:15 +0900 Subject: [PATCH 10/29] test(score): snapshot bridge array length once --- .../src/features/score/scoreStorage.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/desktop/src/features/score/scoreStorage.test.ts b/apps/desktop/src/features/score/scoreStorage.test.ts index 85ad812ce..f51585e9b 100644 --- a/apps/desktop/src/features/score/scoreStorage.test.ts +++ b/apps/desktop/src/features/score/scoreStorage.test.ts @@ -102,6 +102,26 @@ describe("scoreStorage bridge resolution", () => { expect(reads).toBe(1); }); + it("snapshots the bridge array length before validating bytes", async () => { + const backing: unknown[] = [1, 2]; + let lengthReads = 0; + const response = new Proxy(backing, { + get(target, property, receiver) { + if (property === "length") { + lengthReads += 1; + return lengthReads === 1 ? 2 : 1; + } + return Reflect.get(target, property, receiver); + } + }); + stubReadResponse(response); + + const result = await readScorePdf("project-1", "score-1"); + + expect(Array.from(result)).toEqual([1, 2]); + expect(lengthReads).toBe(1); + }); + it.each([ ["string value", [104, "101", 108]], ["negative integer", [0, -1, 255]], From 4eeedd55c86a9d47b71e8695e2916eed84358b3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:46:39 +0900 Subject: [PATCH 11/29] fix(score): snapshot bridge array length once --- apps/desktop/src/features/score/scoreStorage.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/score/scoreStorage.ts b/apps/desktop/src/features/score/scoreStorage.ts index 0391b1dab..6434db108 100644 --- a/apps/desktop/src/features/score/scoreStorage.ts +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -99,8 +99,9 @@ export async function readScorePdf(projectId: string, scoreId: string): Promise< return new Uint8Array(response); } if (Array.isArray(response)) { - const bytes = new Uint8Array(response.length); - for (let index = 0; index < response.length; index += 1) { + const byteCount = response.length; + 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); From 695b6d1ba1a19b285c006e4e756b123d555cdba5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:47:02 +0900 Subject: [PATCH 12/29] docs(changelog): record score bridge snapshots --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dff11a30..498bc2e93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ### Fixed -- Reject malformed plain-array PDF bridge responses and copy each validated byte during the same read, preventing coercion or accessor-driven value changes between validation and conversion. +- Reject malformed plain-array PDF bridge responses and snapshot attach metadata, array length, and each validated byte during the same authoritative reads, preventing coercion or accessor-driven changes between validation and conversion. ## [0.1.3] - 2026-04-29 From 57af83962cef7a69cdf2b2bd19d88144f7909af5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 17:01:17 +0900 Subject: [PATCH 13/29] test(score): require immutable bridge byte snapshots --- .../src/features/score/scoreStorage.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/desktop/src/features/score/scoreStorage.test.ts b/apps/desktop/src/features/score/scoreStorage.test.ts index f51585e9b..37e3c5bba 100644 --- a/apps/desktop/src/features/score/scoreStorage.test.ts +++ b/apps/desktop/src/features/score/scoreStorage.test.ts @@ -122,6 +122,28 @@ describe("scoreStorage bridge resolution", () => { expect(lengthReads).toBe(1); }); + it("snapshots a Uint8Array bridge response before returning it", async () => { + const response = new Uint8Array([1, 2]); + stubReadResponse(response); + + const result = await readScorePdf("project-1", "score-1"); + response[0] = 9; + + expect(result).not.toBe(response); + expect(Array.from(result)).toEqual([1, 2]); + }); + + it("snapshots an ArrayBuffer bridge response before returning its bytes", async () => { + const response = new Uint8Array([3, 4]); + 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([3, 4]); + }); + it.each([ ["string value", [104, "101", 108]], ["negative integer", [0, -1, 255]], From e1bf65ccfb192af31595bf66bbb03e9128ddf925 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 17:04:04 +0900 Subject: [PATCH 14/29] fix(score): snapshot typed PDF bridge bytes --- apps/desktop/src/features/score/scoreStorage.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/score/scoreStorage.ts b/apps/desktop/src/features/score/scoreStorage.ts index 6434db108..52674cfc7 100644 --- a/apps/desktop/src/features/score/scoreStorage.ts +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -93,10 +93,10 @@ 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; + return new Uint8Array(response); } if (response instanceof ArrayBuffer) { - return new Uint8Array(response); + return new Uint8Array(response).slice(); } if (Array.isArray(response)) { const byteCount = response.length; From 7c73dc67d8fe93e5ee6ec0858296e4f04988343c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 17:04:19 +0900 Subject: [PATCH 15/29] docs(changelog): record owned PDF byte snapshots --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 498bc2e93..e28727c1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ### Fixed -- Reject malformed plain-array PDF bridge responses and snapshot attach metadata, array length, and each validated byte during the same authoritative reads, preventing coercion or accessor-driven changes between validation and conversion. +- Reject malformed plain-array PDF bridge responses and snapshot attach metadata, array length, and every returned PDF byte during the same authoritative reads or into fresh owned buffers, preventing coercion, accessor-driven changes, or later bridge-side mutation from changing validated results. ## [0.1.3] - 2026-04-29 From b584c3af8c723887daf544051b583d0ad3f175d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 17:09:47 +0900 Subject: [PATCH 16/29] test(score): cover malformed attach bridge envelopes --- apps/desktop/src/features/score/scoreStorage.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/desktop/src/features/score/scoreStorage.test.ts b/apps/desktop/src/features/score/scoreStorage.test.ts index 37e3c5bba..4eb601f1e 100644 --- a/apps/desktop/src/features/score/scoreStorage.test.ts +++ b/apps/desktop/src/features/score/scoreStorage.test.ts @@ -61,6 +61,17 @@ describe("scoreStorage bridge resolution", () => { 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], From 9af5be93c017ff8f82e31bfbfe16e9fddc1b7539 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:19:25 +0900 Subject: [PATCH 17/29] test(score): reject invalid bridge array lengths --- .../src/features/score/scoreStorage.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/desktop/src/features/score/scoreStorage.test.ts b/apps/desktop/src/features/score/scoreStorage.test.ts index 4eb601f1e..fb33cc78c 100644 --- a/apps/desktop/src/features/score/scoreStorage.test.ts +++ b/apps/desktop/src/features/score/scoreStorage.test.ts @@ -133,6 +133,25 @@ describe("scoreStorage bridge resolution", () => { 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("snapshots a Uint8Array bridge response before returning it", async () => { const response = new Uint8Array([1, 2]); stubReadResponse(response); From 2792caa5426820f5c322a692fd6e4569fb0723da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:28:54 +0900 Subject: [PATCH 18/29] fix(score): reject invalid bridge array lengths --- apps/desktop/src/features/score/scoreStorage.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/src/features/score/scoreStorage.ts b/apps/desktop/src/features/score/scoreStorage.ts index 52674cfc7..2d7facc11 100644 --- a/apps/desktop/src/features/score/scoreStorage.ts +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -100,6 +100,9 @@ export async function readScorePdf(projectId: string, scoreId: string): Promise< } if (Array.isArray(response)) { const byteCount = response.length; + if (!Number.isSafeInteger(byteCount) || byteCount < 0) { + throw new Error(INVALID_RESPONSE_MESSAGE); + } const bytes = new Uint8Array(byteCount); for (let index = 0; index < byteCount; index += 1) { const byte = response[index]; From 80b93055227a1316569eb6ea34e8faf610fc9056 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:17:08 +0900 Subject: [PATCH 19/29] test(score): bound privileged PDF bridge payloads --- .../src/features/score/scoreStorage.test.ts | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/score/scoreStorage.test.ts b/apps/desktop/src/features/score/scoreStorage.test.ts index fb33cc78c..7785d101b 100644 --- a/apps/desktop/src/features/score/scoreStorage.test.ts +++ b/apps/desktop/src/features/score/scoreStorage.test.ts @@ -8,6 +8,7 @@ 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; function stubReadResponse(response: unknown): void { vi.stubGlobal("window", { @@ -77,7 +78,8 @@ describe("scoreStorage bridge resolution", () => { ["fractional size", 1.5], ["NaN size", Number.NaN], ["infinite size", Number.POSITIVE_INFINITY], - ["unsafe integer size", Number.MAX_SAFE_INTEGER + 1] + ["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 }); @@ -152,6 +154,25 @@ describe("scoreStorage bridge resolution", () => { ); }); + 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([1, 2]); stubReadResponse(response); @@ -163,6 +184,22 @@ describe("scoreStorage bridge resolution", () => { expect(Array.from(result)).toEqual([1, 2]); }); + 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([3, 4]); stubReadResponse(response.buffer); @@ -174,6 +211,22 @@ describe("scoreStorage bridge resolution", () => { expect(Array.from(result)).toEqual([3, 4]); }); + 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]], ["negative integer", [0, -1, 255]], From 42a7dccd2b10f9a762a037a045feccc7a97aa7f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:36:53 +0900 Subject: [PATCH 20/29] fix(score): enforce PDF bridge size cap before allocation --- .../src/features/score/scoreStorage.ts | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/score/scoreStorage.ts b/apps/desktop/src/features/score/scoreStorage.ts index 2d7facc11..c68c26d31 100644 --- a/apps/desktop/src/features/score/scoreStorage.ts +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -16,6 +16,7 @@ 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 MAX_SCORE_PDF_BYTES = 25 * 1024 * 1024; /** * Resolve the desktop invoke bridge following the same detection rules as @@ -52,6 +53,23 @@ 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 25 MiB PDF cap. Keeping the + * same fail-closed bound on the JavaScript side prevents malformed or + * accessor-backed bridge values from driving 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 >= 0 && + value <= MAX_SCORE_PDF_BYTES + ); +} + /** * Open the native PDF picker and copy the validated score into the * app-owned project workspace. Security Notes: the file path never crosses @@ -71,9 +89,7 @@ export async function attachScorePdf(projectId: string, songId: string): Promise if ( typeof scoreId !== "string" || typeof fileName !== "string" || - typeof fileSizeBytes !== "number" || - !Number.isSafeInteger(fileSizeBytes) || - fileSizeBytes < 0 + !isValidPdfByteCount(fileSizeBytes) ) { throw new Error(INVALID_RESPONSE_MESSAGE); } @@ -93,14 +109,22 @@ 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) { + const byteCount = response.byteLength; + if (!isValidPdfByteCount(byteCount)) { + throw new Error(INVALID_RESPONSE_MESSAGE); + } return new Uint8Array(response); } if (response instanceof ArrayBuffer) { + const byteCount = response.byteLength; + if (!isValidPdfByteCount(byteCount)) { + throw new Error(INVALID_RESPONSE_MESSAGE); + } return new Uint8Array(response).slice(); } if (Array.isArray(response)) { const byteCount = response.length; - if (!Number.isSafeInteger(byteCount) || byteCount < 0) { + if (!isValidPdfByteCount(byteCount)) { throw new Error(INVALID_RESPONSE_MESSAGE); } const bytes = new Uint8Array(byteCount); From b201e5ab8d6d11bf1127cc87fe443cd19ecb07a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:37:22 +0900 Subject: [PATCH 21/29] docs(changelog): record bounded PDF bridge payloads --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e28727c1c..65f7fac12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ### Fixed -- Reject malformed plain-array PDF bridge responses and snapshot attach metadata, array length, and every returned PDF byte during the same authoritative reads or into fresh owned buffers, preventing coercion, accessor-driven changes, or later bridge-side mutation from changing validated results. +- Reject malformed or oversized PDF bridge responses before allocation/copy, enforcing the desktop bridge's 25 MiB PDF 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 From 040ab38b7745b5f3135e5f1796d391b9f7999ae7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:11:46 +0900 Subject: [PATCH 22/29] test(score): require bounded native PDF reads --- .../core/tests/score_pdf_read_contract.rs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 apps/desktop/core/tests/score_pdf_read_contract.rs diff --git a/apps/desktop/core/tests/score_pdf_read_contract.rs b/apps/desktop/core/tests/score_pdf_read_contract.rs new file mode 100644 index 000000000..67579cdc4 --- /dev/null +++ b/apps/desktop/core/tests/score_pdf_read_contract.rs @@ -0,0 +1,53 @@ +//! Regression tests for bounded score-PDF reads at the native trust boundary. + +use bandscope_desktop_core::{read_score_pdf_bytes, MAX_SCORE_PDF_BYTES}; +use std::fs; +use uuid::Uuid; + +fn temp_root() -> std::path::PathBuf { + let root = std::env::temp_dir().join(format!("bandscope-score-read-{}", Uuid::new_v4())); + fs::create_dir_all(&root).expect("score read test root should be created"); + root +} + +#[test] +fn reads_valid_pdf_bytes() { + let root = temp_root(); + let path = root.join("score.pdf"); + let expected = b"%PDF-1.7 bounded body"; + fs::write(&path, expected).expect("valid score should be written"); + + let bytes = read_score_pdf_bytes(&path).expect("valid score should be readable"); + + assert_eq!(bytes, expected); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn rejects_empty_short_wrong_magic_and_oversized_score_reads() { + let root = temp_root(); + + let empty = root.join("empty.pdf"); + fs::write(&empty, b"").expect("empty score should be written"); + assert!(read_score_pdf_bytes(&empty).is_err()); + + let short = root.join("short.pdf"); + fs::write(&short, b"%PD").expect("short score should be written"); + assert!(read_score_pdf_bytes(&short).is_err()); + + let wrong_magic = root.join("wrong.pdf"); + fs::write(&wrong_magic, b"PK\x03\x04 not a pdf").expect("wrong-magic score should be written"); + assert!(read_score_pdf_bytes(&wrong_magic).is_err()); + + let oversized = root.join("oversized.pdf"); + fs::write(&oversized, b"%PDF-").expect("oversized score header should be written"); + fs::OpenOptions::new() + .write(true) + .open(&oversized) + .expect("oversized score should reopen") + .set_len(MAX_SCORE_PDF_BYTES + 1) + .expect("oversized score should be extended sparsely"); + assert!(read_score_pdf_bytes(&oversized).is_err()); + + let _ = fs::remove_dir_all(root); +} From 68cedfdafd7ac4cf2f4059e6f000f60d71227530 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:12:22 +0900 Subject: [PATCH 23/29] revert(test): keep native score read follow-up out of focused bridge slice --- .../core/tests/score_pdf_read_contract.rs | 53 ------------------- 1 file changed, 53 deletions(-) delete mode 100644 apps/desktop/core/tests/score_pdf_read_contract.rs diff --git a/apps/desktop/core/tests/score_pdf_read_contract.rs b/apps/desktop/core/tests/score_pdf_read_contract.rs deleted file mode 100644 index 67579cdc4..000000000 --- a/apps/desktop/core/tests/score_pdf_read_contract.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Regression tests for bounded score-PDF reads at the native trust boundary. - -use bandscope_desktop_core::{read_score_pdf_bytes, MAX_SCORE_PDF_BYTES}; -use std::fs; -use uuid::Uuid; - -fn temp_root() -> std::path::PathBuf { - let root = std::env::temp_dir().join(format!("bandscope-score-read-{}", Uuid::new_v4())); - fs::create_dir_all(&root).expect("score read test root should be created"); - root -} - -#[test] -fn reads_valid_pdf_bytes() { - let root = temp_root(); - let path = root.join("score.pdf"); - let expected = b"%PDF-1.7 bounded body"; - fs::write(&path, expected).expect("valid score should be written"); - - let bytes = read_score_pdf_bytes(&path).expect("valid score should be readable"); - - assert_eq!(bytes, expected); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn rejects_empty_short_wrong_magic_and_oversized_score_reads() { - let root = temp_root(); - - let empty = root.join("empty.pdf"); - fs::write(&empty, b"").expect("empty score should be written"); - assert!(read_score_pdf_bytes(&empty).is_err()); - - let short = root.join("short.pdf"); - fs::write(&short, b"%PD").expect("short score should be written"); - assert!(read_score_pdf_bytes(&short).is_err()); - - let wrong_magic = root.join("wrong.pdf"); - fs::write(&wrong_magic, b"PK\x03\x04 not a pdf").expect("wrong-magic score should be written"); - assert!(read_score_pdf_bytes(&wrong_magic).is_err()); - - let oversized = root.join("oversized.pdf"); - fs::write(&oversized, b"%PDF-").expect("oversized score header should be written"); - fs::OpenOptions::new() - .write(true) - .open(&oversized) - .expect("oversized score should reopen") - .set_len(MAX_SCORE_PDF_BYTES + 1) - .expect("oversized score should be extended sparsely"); - assert!(read_score_pdf_bytes(&oversized).is_err()); - - let _ = fs::remove_dir_all(root); -} From 70a0339b72bce0a1f3c647ea99f96f0170775375 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:13:05 +0900 Subject: [PATCH 24/29] test(score): reject bridge payloads shorter than PDF magic --- .../score/scoreStorage.minimum-size.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 apps/desktop/src/features/score/scoreStorage.minimum-size.test.ts 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..06ba176a7 --- /dev/null +++ b/apps/desktop/src/features/score/scoreStorage.minimum-size.test.ts @@ -0,0 +1,40 @@ +import { afterEach, expect, it, vi } from "vitest"; + +import { attachScorePdf, readScorePdf } from "./scoreStorage"; + +const INVALID_RESPONSE_MESSAGE = "Invalid score bridge response"; + +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 + ); +}); From 75b38cd23fb4358beca19cd2e72c865f3ecc577b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:13:55 +0900 Subject: [PATCH 25/29] fix(score): align bridge byte counts with PDF magic minimum --- apps/desktop/src/features/score/scoreStorage.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/score/scoreStorage.ts b/apps/desktop/src/features/score/scoreStorage.ts index c68c26d31..601ec33e5 100644 --- a/apps/desktop/src/features/score/scoreStorage.ts +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -16,6 +16,7 @@ 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; /** @@ -56,16 +57,17 @@ async function invokeScoreCommand(command: string, args: Record /** * Return whether a bridge-reported PDF byte count is safe to allocate/copy. * - * The value must match the Rust desktop bridge's 25 MiB PDF cap. Keeping the - * same fail-closed bound on the JavaScript side prevents malformed or - * accessor-backed bridge values from driving oversized allocations even when - * the privileged producer is replaced by a test/dev shim. + * 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 >= 0 && + value >= MIN_SCORE_PDF_BYTES && value <= MAX_SCORE_PDF_BYTES ); } From cfb9a2ef66a3ef1e02ec9fcb00a7f21db6e493d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:15:59 +0900 Subject: [PATCH 26/29] docs(changelog): record PDF magic minimum bridge guard --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65f7fac12..5a1a56697 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ### Fixed -- Reject malformed or oversized PDF bridge responses before allocation/copy, enforcing the desktop bridge's 25 MiB PDF 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. +- 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 From 1925bfb94aa48f3fd26c97a2507e8d2fabfb61e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:21:49 +0900 Subject: [PATCH 27/29] test(score): reject forged typed bridge byte lengths --- .../score/scoreStorage.minimum-size.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/desktop/src/features/score/scoreStorage.minimum-size.test.ts b/apps/desktop/src/features/score/scoreStorage.minimum-size.test.ts index 06ba176a7..2f7b6acdb 100644 --- a/apps/desktop/src/features/score/scoreStorage.minimum-size.test.ts +++ b/apps/desktop/src/features/score/scoreStorage.minimum-size.test.ts @@ -3,6 +3,7 @@ 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", { @@ -38,3 +39,29 @@ it.each([ 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 + ); +}); From 1f01443c403a764f0351e96fa8ead7e47e0fb091 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:23:46 +0900 Subject: [PATCH 28/29] fix(score): bind typed byte caps to intrinsic lengths --- .../src/features/score/scoreStorage.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/score/scoreStorage.ts b/apps/desktop/src/features/score/scoreStorage.ts index 601ec33e5..5517b2df5 100644 --- a/apps/desktop/src/features/score/scoreStorage.ts +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -72,6 +72,25 @@ function isValidPdfByteCount(value: unknown): value is number { ); } +/** + * 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 @@ -111,14 +130,14 @@ 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) { - const byteCount = response.byteLength; + const byteCount = getIntrinsicPdfByteCount(response); if (!isValidPdfByteCount(byteCount)) { throw new Error(INVALID_RESPONSE_MESSAGE); } return new Uint8Array(response); } if (response instanceof ArrayBuffer) { - const byteCount = response.byteLength; + const byteCount = getIntrinsicPdfByteCount(response); if (!isValidPdfByteCount(byteCount)) { throw new Error(INVALID_RESPONSE_MESSAGE); } From d982adef81cd54adae37078f71a7976aa122e986 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 23:10:18 +0900 Subject: [PATCH 29/29] test(score): align bridge fixtures with PDF byte floor --- .../src/features/score/ScoreView.test.tsx | 33 ++++++++--------- .../src/features/score/scoreStorage.test.ts | 35 ++++++++++--------- 2 files changed, 35 insertions(+), 33 deletions(-) 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.test.ts b/apps/desktop/src/features/score/scoreStorage.test.ts index 7785d101b..ad161505b 100644 --- a/apps/desktop/src/features/score/scoreStorage.test.ts +++ b/apps/desktop/src/features/score/scoreStorage.test.ts @@ -9,6 +9,7 @@ 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", { @@ -98,31 +99,31 @@ describe("scoreStorage bridge resolution", () => { }); it("copies each validated bridge byte during the same read", async () => { - const response: unknown[] = [0]; + const response: unknown[] = [...MINIMAL_PDF_BYTES]; let reads = 0; Object.defineProperty(response, 0, { configurable: true, get: () => { reads += 1; - return reads === 1 ? 255 : 256; + return reads === 1 ? MINIMAL_PDF_BYTES[0] : 256; } }); stubReadResponse(response); const result = await readScorePdf("project-1", "score-1"); - expect(Array.from(result)).toEqual([255]); + 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[] = [1, 2]; + 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 ? 2 : 1; + return lengthReads === 1 ? MINIMAL_PDF_BYTES.length : MINIMAL_PDF_BYTES.length - 1; } return Reflect.get(target, property, receiver); } @@ -131,7 +132,7 @@ describe("scoreStorage bridge resolution", () => { const result = await readScorePdf("project-1", "score-1"); - expect(Array.from(result)).toEqual([1, 2]); + expect(Array.from(result)).toEqual(MINIMAL_PDF_BYTES); expect(lengthReads).toBe(1); }); @@ -174,14 +175,14 @@ describe("scoreStorage bridge resolution", () => { }); it("snapshots a Uint8Array bridge response before returning it", async () => { - const response = new Uint8Array([1, 2]); + 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([1, 2]); + expect(Array.from(result)).toEqual(MINIMAL_PDF_BYTES); }); it("rejects an oversized Uint8Array-shaped bridge response before copying", async () => { @@ -201,14 +202,14 @@ describe("scoreStorage bridge resolution", () => { }); it("snapshots an ArrayBuffer bridge response before returning its bytes", async () => { - const response = new Uint8Array([3, 4]); + 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([3, 4]); + expect(Array.from(result)).toEqual(MINIMAL_PDF_BYTES); }); it("rejects an oversized ArrayBuffer-shaped bridge response before copying", async () => { @@ -228,12 +229,12 @@ describe("scoreStorage bridge resolution", () => { }); 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]] + ["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); @@ -243,7 +244,7 @@ describe("scoreStorage bridge resolution", () => { }); it("stops validating after the first invalid byte", async () => { - const response: unknown[] = [-1, 0]; + const response: unknown[] = [-1, 0, 0, 0, 0]; Object.defineProperty(response, 1, { configurable: true, get: () => {