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);