Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
16c5905
fix(score): reject malformed PDF bridge byte arrays
seonghobae Aug 14, 2026
50127b7
ci(repair): add test-first PR 750 byte-copy repair
seonghobae Aug 15, 2026
b017de0
ci(repair): launch PR 750 byte-copy repair
seonghobae Aug 15, 2026
c92a2f4
ci(repair): provision Python dev tooling for quickcheck
seonghobae Aug 15, 2026
1183562
test(score): prevent bridge byte re-read coercion
Aug 15, 2026
c9b1694
fix(score): copy validated bridge bytes once
Aug 15, 2026
367229b
chore(ci): revalidate validated PDF byte copy
seonghobae Aug 15, 2026
8a34bd2
test(score): snapshot attach bridge metadata once
seonghobae Aug 16, 2026
c47f05d
fix(score): validate attach metadata without second reads
seonghobae Aug 16, 2026
7aa7df3
test(score): snapshot bridge array length once
seonghobae Aug 16, 2026
4eeedd5
fix(score): snapshot bridge array length once
seonghobae Aug 16, 2026
695b6d1
docs(changelog): record score bridge snapshots
seonghobae Aug 16, 2026
57af839
test(score): require immutable bridge byte snapshots
seonghobae Aug 16, 2026
e1bf65c
fix(score): snapshot typed PDF bridge bytes
seonghobae Aug 16, 2026
7c73dc6
docs(changelog): record owned PDF byte snapshots
seonghobae Aug 16, 2026
b584c3a
test(score): cover malformed attach bridge envelopes
seonghobae Aug 16, 2026
9af5be9
test(score): reject invalid bridge array lengths
seonghobae Aug 16, 2026
2792caa
fix(score): reject invalid bridge array lengths
seonghobae Aug 16, 2026
80b9305
test(score): bound privileged PDF bridge payloads
seonghobae Aug 16, 2026
42a7dcc
fix(score): enforce PDF bridge size cap before allocation
seonghobae Aug 16, 2026
b201e5a
docs(changelog): record bounded PDF bridge payloads
seonghobae Aug 16, 2026
040ab38
test(score): require bounded native PDF reads
seonghobae Aug 16, 2026
68cedfd
revert(test): keep native score read follow-up out of focused bridge …
seonghobae Aug 16, 2026
70a0339
test(score): reject bridge payloads shorter than PDF magic
seonghobae Aug 16, 2026
75b38cd
fix(score): align bridge byte counts with PDF magic minimum
seonghobae Aug 16, 2026
cfb9a2e
docs(changelog): record PDF magic minimum bridge guard
seonghobae Aug 16, 2026
1925bfb
test(score): reject forged typed bridge byte lengths
seonghobae Aug 16, 2026
1f01443
fix(score): bind typed byte caps to intrinsic lengths
seonghobae Aug 16, 2026
d982ade
test(score): align bridge fixtures with PDF byte floor
seonghobae Aug 16, 2026
1cf3d26
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 17 additions & 16 deletions apps/desktop/src/features/score/ScoreView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();

Expand All @@ -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",
Expand Down Expand Up @@ -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; })
Expand All @@ -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",
Expand All @@ -181,15 +182,15 @@ 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(<ScoreView song={song} projectId="project-1-2" onSongUpdate={vi.fn()} />);

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

Expand Down Expand Up @@ -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();
Expand All @@ -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" }));
Expand Down Expand Up @@ -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" }]);

Expand All @@ -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",
Expand Down Expand Up @@ -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" }
Expand All @@ -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();
});

Expand All @@ -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" }
Expand All @@ -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();
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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
);
});
Loading
Loading