diff --git a/apps/desktop/src/features/score/scoreStorage.bench.ts b/apps/desktop/src/features/score/scoreStorage.bench.ts new file mode 100644 index 000000000..d9b62d3c8 --- /dev/null +++ b/apps/desktop/src/features/score/scoreStorage.bench.ts @@ -0,0 +1,46 @@ +import { bench, describe } from "vitest"; + +/** Documented. */ +function validateOld(response: unknown[]) { + if (Array.isArray(response) && response.every((byte) => typeof byte === "number")) { + return Uint8Array.from(response as number[]); + } + throw new Error("invalid"); +} + +/** Documented. */ +function validateNew(response: unknown[]) { + if (Array.isArray(response)) { + const len = response.length; + const arr = new Uint8Array(len); + let isValid = true; + for (let i = 0; i < len; i++) { + const byte = response[i]; + if (typeof byte !== "number" || !Number.isInteger(byte) || byte < 0 || byte > 255) { + isValid = false; + break; + } + arr[i] = byte; + } + if (isValid) { + return arr; + } + } + throw new Error("invalid"); +} + +describe("PDF byte array processing", () => { + const size = 5_000_000; + const payload = new Array(size); + for (let i = 0; i < size; i++) { + payload[i] = i % 256; + } + + bench("legacy Array.from and every", () => { + validateOld(payload); + }); + + bench("single pass pre-allocated loop", () => { + validateNew(payload); + }); +}); diff --git a/apps/desktop/src/features/score/scoreStorage.test.ts b/apps/desktop/src/features/score/scoreStorage.test.ts index 0feec199e..744238b56 100644 --- a/apps/desktop/src/features/score/scoreStorage.test.ts +++ b/apps/desktop/src/features/score/scoreStorage.test.ts @@ -7,6 +7,12 @@ 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_BYTES = 25 * 1024 * 1024; +const OVERSIZED_SCORE_BYTES = MAX_SCORE_BYTES + 1; +const VALID_PROJECT_ID = "project-1-2"; +const VALID_SONG_ID = "song-1"; +const VALID_SCORE_ID = "6fa459ea-ee8a-4ca4-894e-db77e160355e"; describe("scoreStorage bridge resolution", () => { afterEach(() => { @@ -22,14 +28,217 @@ describe("scoreStorage bridge resolution", () => { // return null so callers fail closed instead of dereferencing `window`. vi.stubGlobal("window", undefined); - await expect(attachScorePdf("project-1", "song-1")).rejects.toThrow( + await expect(attachScorePdf(VALID_PROJECT_ID, VALID_SONG_ID)).rejects.toThrow( BRIDGE_UNAVAILABLE_MESSAGE ); - await expect(readScorePdf("project-1", "score-1")).rejects.toThrow( + await expect(readScorePdf(VALID_PROJECT_ID, VALID_SCORE_ID)).rejects.toThrow( BRIDGE_UNAVAILABLE_MESSAGE ); - await expect(removeScorePdf("project-1", "score-1")).rejects.toThrow( + await expect(removeScorePdf(VALID_PROJECT_ID, VALID_SCORE_ID)).rejects.toThrow( BRIDGE_UNAVAILABLE_MESSAGE ); }); -}); + + it("preserves valid attachment metadata from the bridge", async () => { + (window as TauriWindow).__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + scoreId: VALID_SCORE_ID, + fileName: "chart.pdf", + fileSizeBytes: 2048 + }); + + await expect(attachScorePdf(VALID_PROJECT_ID, VALID_SONG_ID)).resolves.toEqual({ + id: VALID_SCORE_ID, + fileName: "chart.pdf", + fileSizeBytes: 2048 + }); + }); + + it.each(["project-1", "../project-1-2", "PROJECT-1-2", "project-1-2-extra"])( + "rejects malformed project id before every score IPC: %s", + async (projectId) => { + const mockInvoke = vi.fn().mockResolvedValue({ + scoreId: VALID_SCORE_ID, + fileName: "chart.pdf", + fileSizeBytes: 2048 + }); + (window as TauriWindow).__TAURI_INVOKE__ = mockInvoke; + + await expect(attachScorePdf(projectId, VALID_SONG_ID)).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + await expect(readScorePdf(projectId, VALID_SCORE_ID)).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + await expect(removeScorePdf(projectId, VALID_SCORE_ID)).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + expect(mockInvoke).not.toHaveBeenCalled(); + } + ); + + it.each(["", " ", "\t"])( + "rejects blank song id before attach IPC", + async (songId) => { + const mockInvoke = vi.fn().mockResolvedValue({ + scoreId: VALID_SCORE_ID, + fileName: "chart.pdf", + fileSizeBytes: 2048 + }); + (window as TauriWindow).__TAURI_INVOKE__ = mockInvoke; + + await expect(attachScorePdf(VALID_PROJECT_ID, songId)).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + expect(mockInvoke).not.toHaveBeenCalled(); + } + ); + + it.each([ + ["empty score id", "", "chart.pdf"], + ["non-canonical score id", "score-1", "chart.pdf"], + ["uppercase score id", VALID_SCORE_ID.toUpperCase(), "chart.pdf"], + ["empty file name", VALID_SCORE_ID, ""], + ["whitespace-only file name", VALID_SCORE_ID, " \t "] + ])("rejects %s attachment identity metadata", async (_label, scoreId, fileName) => { + (window as TauriWindow).__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + scoreId, + fileName, + fileSizeBytes: 2048 + }); + + await expect(attachScorePdf(VALID_PROJECT_ID, VALID_SONG_ID)).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + }); + + it.each([ + ["NaN", Number.NaN], + ["Infinity", Number.POSITIVE_INFINITY], + ["negative", -1], + ["fractional", 1.5], + ["zero", 0], + ["oversized", OVERSIZED_SCORE_BYTES] + ])("rejects %s attachment size metadata", async (_label, fileSizeBytes) => { + (window as TauriWindow).__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + scoreId: VALID_SCORE_ID, + fileName: "chart.pdf", + fileSizeBytes + }); + + await expect(attachScorePdf(VALID_PROJECT_ID, VALID_SONG_ID)).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + }); + + it.each(["score-1", VALID_SCORE_ID.toUpperCase()])( + "rejects malformed score id before read IPC: %s", + async (scoreId) => { + const mockInvoke = vi.fn().mockResolvedValue([37, 80, 68, 70, 45]); + (window as TauriWindow).__TAURI_INVOKE__ = mockInvoke; + + await expect(readScorePdf(VALID_PROJECT_ID, scoreId)).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + expect(mockInvoke).not.toHaveBeenCalled(); + } + ); + + it.each(["score-1", VALID_SCORE_ID.toUpperCase()])( + "rejects malformed score id before remove IPC: %s", + async (scoreId) => { + const mockInvoke = vi.fn().mockResolvedValue(true); + (window as TauriWindow).__TAURI_INVOKE__ = mockInvoke; + + await expect(removeScorePdf(VALID_PROJECT_ID, scoreId)).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + expect(mockInvoke).not.toHaveBeenCalled(); + } + ); + + it("preserves exact bytes from a valid bridge array", async () => { + (window as TauriWindow).__TAURI_INVOKE__ = vi.fn().mockResolvedValue([0, 1, 255]); + + await expect(readScorePdf(VALID_PROJECT_ID, VALID_SCORE_ID)).resolves.toEqual( + new Uint8Array([0, 1, 255]) + ); + }); + + it.each([ + ["array", () => []], + ["Uint8Array", () => new Uint8Array()], + ["ArrayBuffer", () => new ArrayBuffer(0)] + ])("rejects an empty %s bridge response", async (_label, createResponse) => { + (window as TauriWindow).__TAURI_INVOKE__ = vi.fn().mockResolvedValue(createResponse()); + + await expect(readScorePdf(VALID_PROJECT_ID, VALID_SCORE_ID)).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + }); + + it("rejects an oversized bridge array before allocating or reading its bytes", async () => { + const oversizedResponse = new Proxy(new Array(OVERSIZED_SCORE_BYTES), { + get(target, property, receiver) { + if (property !== "length") { + throw new Error("oversized bridge payload should be rejected before byte access"); + } + return Reflect.get(target, property, receiver); + } + }); + (window as TauriWindow).__TAURI_INVOKE__ = vi.fn().mockResolvedValue(oversizedResponse); + + await expect(readScorePdf(VALID_PROJECT_ID, VALID_SCORE_ID)).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + }); + + it.each([ + [ + "Uint8Array", + () => + new Proxy(new Uint8Array(), { + get(target, property) { + if (property === "byteLength") { + return OVERSIZED_SCORE_BYTES; + } + return Reflect.get(target, property, target); + } + }) + ], + [ + "ArrayBuffer", + () => + new Proxy(new ArrayBuffer(0), { + get(target, property) { + if (property === "byteLength") { + return OVERSIZED_SCORE_BYTES; + } + return Reflect.get(target, property, target); + } + }) + ] + ])("rejects an oversized %s bridge response", async (_label, createResponse) => { + (window as TauriWindow).__TAURI_INVOKE__ = vi.fn().mockResolvedValue(createResponse()); + + await expect(readScorePdf(VALID_PROJECT_ID, VALID_SCORE_ID)).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + }); + + it.each([ + ["NaN", Number.NaN], + ["Infinity", Number.POSITIVE_INFINITY], + ["negative", -1], + ["fractional", 1.5], + ["greater than 255", 256], + ["non-number", "1"] + ])("rejects a %s bridge byte", async (_label, invalidByte) => { + (window as TauriWindow).__TAURI_INVOKE__ = vi + .fn() + .mockResolvedValue([0, invalidByte, 255]); + + await expect(readScorePdf(VALID_PROJECT_ID, VALID_SCORE_ID)).rejects.toThrow( + INVALID_RESPONSE_MESSAGE + ); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/score/scoreStorage.ts b/apps/desktop/src/features/score/scoreStorage.ts index 492f12591..c896db037 100644 --- a/apps/desktop/src/features/score/scoreStorage.ts +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -16,6 +16,28 @@ 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"; +// Keep renderer-side admission bounded independently of the bridge implementation. +// The cap matches the native Score Storage maximum so malformed IPC cannot create +// a second oversized buffer or persist impossible attachment-size metadata. +const MAX_SCORE_PDF_BRIDGE_BYTES = 25 * 1024 * 1024; +// Native project storage mints `project--` and rejects any other +// shape before project identity can influence an app-owned filesystem path. Mirror +// that syntax at the WebView boundary so impossible project identities never cross +// a privileged score-storage IPC call; native validation remains authoritative. +const PROJECT_ID_PATTERN = /^project-[0-9]+-[0-9]+$/; +// Native Score Storage mints lowercase hyphenated UUID identities and later read/remove +// commands admit only that exact shape. Revalidate identities at both IPC directions so +// malformed project metadata cannot reach a privileged native score command and malformed +// bridge responses cannot enter project state. +const SCORE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +function isValidProjectId(value: unknown): value is string { + return typeof value === "string" && PROJECT_ID_PATTERN.test(value); +} + +function isValidScoreId(value: unknown): value is string { + return typeof value === "string" && SCORE_ID_PATTERN.test(value); +} /** * Resolve the desktop invoke bridge following the same detection rules as @@ -56,43 +78,89 @@ async function invokeScoreCommand(command: string, args: Record * Open the native PDF picker and copy the validated score into the * app-owned project workspace. Security Notes: the file path never crosses * the IPC boundary from JS; the Rust command owns the dialog, validation - * (magic bytes, size cap, no symlinks), and the copy destination. + * (magic bytes, size cap, no symlinks), and the copy destination. The + * renderer revalidates project/song call context plus returned identity, + * presentation, and size metadata before accepting it into project state. */ export async function attachScorePdf(projectId: string, songId: string): Promise { + if (!isValidProjectId(projectId) || songId.trim().length === 0) { + throw new Error(INVALID_RESPONSE_MESSAGE); + } + 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" + !isValidScoreId(scoreId) || + typeof fileName !== "string" || + fileName.trim().length === 0 || + typeof fileSizeBytes !== "number" || + !Number.isSafeInteger(fileSizeBytes) || + fileSizeBytes <= 0 || + fileSizeBytes > MAX_SCORE_PDF_BRIDGE_BYTES ) { 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 }; } /** * Read the validated score PDF bytes for a previously attached score. - * Security Notes: only allowlisted ids cross the IPC boundary; the Rust - * command rebuilds and canonicalizes the path inside the app-owned root. + * Security Notes: only allowlisted project/score ids cross the IPC boundary; + * the Rust command independently rebuilds and canonicalizes the path inside + * the app-owned root. The renderer rejects empty or oversized byte containers + * before copying or parsing. */ export async function readScorePdf(projectId: string, scoreId: string): Promise { + if (!isValidProjectId(projectId) || !isValidScoreId(scoreId)) { + throw new Error(INVALID_RESPONSE_MESSAGE); + } + const response = await invokeScoreCommand("read_score_pdf", { projectId, scoreId }); if (response instanceof Uint8Array) { - return response; + const byteLength = response.byteLength; + if (byteLength > 0 && byteLength <= MAX_SCORE_PDF_BRIDGE_BYTES) { + return response; + } + throw new Error(INVALID_RESPONSE_MESSAGE); } if (response instanceof ArrayBuffer) { - return new Uint8Array(response); + const byteLength = response.byteLength; + if (byteLength > 0 && byteLength <= MAX_SCORE_PDF_BRIDGE_BYTES) { + return new Uint8Array(response); + } + throw new Error(INVALID_RESPONSE_MESSAGE); } - if (Array.isArray(response) && response.every((byte) => typeof byte === "number")) { - return Uint8Array.from(response as number[]); + if (Array.isArray(response)) { + const len = response.length; + if (len === 0 || len > MAX_SCORE_PDF_BRIDGE_BYTES) { + throw new Error(INVALID_RESPONSE_MESSAGE); + } + + const arr = new Uint8Array(len); + let isValid = true; + for (let i = 0; i < len; i++) { + const byte = response[i]; + if (typeof byte !== "number" || !Number.isInteger(byte) || byte < 0 || byte > 255) { + isValid = false; + break; + } + arr[i] = byte; + } + if (isValid) { + return arr; + } } throw new Error(INVALID_RESPONSE_MESSAGE); @@ -100,13 +168,19 @@ export async function readScorePdf(projectId: string, scoreId: string): Promise< /** * Delete the stored score PDF copy. Resolves to false when the file was - * already gone so callers can treat removal as idempotent. + * already gone so callers can treat removal as idempotent. Malformed project + * or score identities are rejected before the renderer invokes the privileged + * command; native validation remains the authoritative filesystem guard. */ export async function removeScorePdf(projectId: string, scoreId: string): Promise { + if (!isValidProjectId(projectId) || !isValidScoreId(scoreId)) { + throw new Error(INVALID_RESPONSE_MESSAGE); + } + const response = await invokeScoreCommand("remove_score_pdf", { projectId, scoreId }); if (typeof response !== "boolean") { throw new Error(INVALID_RESPONSE_MESSAGE); } return response; -} +} \ No newline at end of file diff --git a/docs/traceability/score-bridge-resource-admission.md b/docs/traceability/score-bridge-resource-admission.md new file mode 100644 index 000000000..ea3b8a3c3 --- /dev/null +++ b/docs/traceability/score-bridge-resource-admission.md @@ -0,0 +1,95 @@ +# Score bridge resource-admission boundary + +Status: Proposed + +## Problem + +BandScope sends project/score identities to Tauri commands and accepts bytes/attachment metadata back across IPC before buyer-visible use. Native Score Storage already caps admitted PDFs at 25 MiB, admits project ids only as `project--`, and admits score ids only in its lowercase hyphenated UUID-shaped syntax, but the renderer historically treated several IPC values as trustworthy. + +The renderer formerly allocated a destination `Uint8Array` from an untrusted `number[]` length before validating the byte domain, accepted oversized typed containers, and admitted zero-byte content. The attach response also originally accepted impossible `fileSizeBytes` values and arbitrary string identity/presentation metadata. + +Score-id admission was then made two-sided, but project context remained asymmetric. `attachScorePdf()`, `readScorePdf()`, and `removeScorePdf()` still sent arbitrary project strings to native score commands even though native `is_valid_project_id()` rejects anything except the exact minted `project--` shape before project identity can influence a filesystem path. The focused bridge tests reinforced this mismatch by using `project-1`, a value that cannot be minted or admitted by the native project boundary. `attachScorePdf()` also sent blank/whitespace-only song ids even though the native command rejects them before publication. + +The current shared `ScoreAttachment` parser on this stack requires only a non-empty attachment `id`; it does not own native project-id or score-id syntax. Renderer project/score admission is therefore defense in depth at a privileged IPC boundary, not shared-schema authority. + +The renderer also rejects whitespace-only returned filenames. That rule is intentionally stricter than the current shared durable schema, which rejects only the empty string. It is renderer defense in depth, not shared-schema authority. + +## Constraints and ownership + +- Native Score Storage owns picker/path authority, native project/score-id admission, PDF magic, symlink handling, publication, content receipts, durability, recovery and destructive mutation. +- Canonical #865 owns descriptor-bounded read-time size/content validation. TypeScript must not duplicate its filesystem/PDF policy. +- Renderer byte containers must be non-empty and no larger than 25 MiB before downstream parsing or a second allocation. +- Oversized `number[]` input must fail before destination allocation or element access. +- Attachment size metadata must be a positive safe integer no larger than 25 MiB. +- Renderer-supplied project ids must satisfy the native `project--` syntax before score IPC; native validation remains authoritative. +- `attachScorePdf()` must reject a blank/whitespace-only song id before IPC, matching the native publication precondition without importing song semantics beyond nonblank identity. +- Native-returned and renderer-supplied score ids must satisfy the native lowercase hyphenated `8-4-4-4-12` hexadecimal syntax at the Score IPC boundary. +- Invalid bridge data/context is not reflected into diagnostics; callers receive `Invalid score bridge response`. +- Tightening/migrating durable shared project/attachment schemas remains shared-types/Project Persistence ownership. + +## Decision + +`MAX_SCORE_PDF_BRIDGE_BYTES` remains `25 * 1024 * 1024`. + +`PROJECT_ID_PATTERN` mirrors the native minted/admitted syntax as `^project-[0-9]+-[0-9]+$`. `attachScorePdf()`, `readScorePdf()`, and `removeScorePdf()` reject malformed project ids before Tauri invoke. `attachScorePdf()` also rejects a blank/whitespace-only song id before invoke. Accepted project/song strings are passed through unchanged. + +`readScorePdf()` rejects a noncanonical `scoreId` before Tauri invoke, rejects empty/oversized `Uint8Array`, `ArrayBuffer` and `number[]` values, bounds arrays before allocation/element access, and admits only integer array bytes from `0..255`. + +`attachScorePdf()` requires a positive safe-integer size at or below the ceiling, a native-compatible returned score id, and a nonblank returned filename while preserving accepted filename text verbatim. + +`removeScorePdf()` rejects a noncanonical `scoreId` before Tauri invoke and otherwise preserves the native boolean/idempotent deletion contract. + +These renderer checks do not authorize filesystem operations. Native command validation remains authoritative. + +## Alternatives rejected + +- **Trust native exclusively:** native remains authoritative, but renderer admission should not allocate from unbounded responses or invoke a privileged score command with syntax it already knows native will reject. +- **Validate only score ids:** leaves impossible project identity crossing every score-storage IPC call and keeps tests green with project ids native code can never admit. +- **Validate song ids with a product-specific syntax:** rejected. Native publication only requires nonblank song identity here; inventing a tighter renderer format would create a second song-identity owner. +- **Validate after allocation:** too late for the resource-admission objective. +- **Copy `%PDF-`, path or descriptor policy to TypeScript:** violates the native/#865 single-writer boundary. +- **Tighten shared project schema here:** broader compatibility/migration decision owned elsewhere. +- **Normalize filenames or ids:** would silently rewrite identity/presentation metadata rather than fail closed. + +## RED → repair evidence + +Retained earlier lineage: + +- `0067f8de5766adeccbe62466b56d496257b9b700` → `698d0dc253c005d5baab0b5c68a10701ed449fc0`: bound byte-container admission before allocation/read. +- `7c53c5414ef79f403ea051c3256b38eca4f034c0` → `bf97eda4f4cf599afb8aee96aa9d5b54ba01d3d9`: reject impossible attachment-size metadata. +- `333008b92b7db9844fe677e6b1d79a4172655599` → `335aba67edf6cd50f11cec172449f059c11a5e4e`: validate attachment-response identity/presentation metadata. +- `3b4bbe2607988e79f91000bdd742fe15b3373ce6` → `e454d52e75e6890781ed111337c99e038edaad2e`: reject zero-byte bridge content. +- `82e71a69f4da4bd540d0dd27417944744ef304fc` → `ebee7c1bbea5e91403d4608729c0ad4e06ba7d1e`: reject whitespace-only returned filenames. This is renderer defense in depth; the live shared schema itself only requires a non-empty filename. +- `745561478c6b89347f5514c1294101ef7ade6960` → `36a9a4859af760301d37ffa565e04837afc09052`: reject malformed/uppercase score ids before read/remove IPC and share one score-id predicate across both directions. + +Current project-context repair: + +- RED `b12fc68ed129067da781dcbb14f2986ce3b381c9`: replace impossible `project-1` happy-path fixtures with native-admissible `project-1-2`; require malformed/path-shaped/extra-segment project ids to fail before **all** score IPC calls; require blank/whitespace-only song ids to fail before attach IPC; assert the invoke shim is untouched. +- Repair `8989c969cd6fbd6ec6e2fec94231046382b9baf7`: add one renderer `PROJECT_ID_PATTERN`, apply it to attach/read/remove call admission, and mirror only the native nonblank song precondition for attach. + +The RED was immediately followed by repair; no hosted terminal RED is claimed. + +## Security Notes + +Tauri commands expose a frontend-to-Rust IPC call surface. Runtime authority/capability handling and Rust validation remain authoritative. The renderer adds accept-known-good input validation before score-storage invokes and postcondition validation on score responses. + +Native `is_valid_project_id()` documents that project ids are minted as `project--` and rejects any other shape before the id can influence an app-owned filesystem path. Before this repair the renderer could still send impossible or path-shaped project context to each score command. Native validation prevented path escape, so this is not evidence of a native traversal bypass; it is a bridge-contract consistency and unnecessary privileged-call finding. The same claim boundary applies to malformed score ids. + +MITRE CWE-770 maps to the former unbounded renderer allocation surface. CWE-1286 maps to the defined project/score-id syntax that the renderer previously failed to enforce consistently at the IPC boundary. + +References: + +- MITRE. (2026). *CWE-770: Allocation of Resources Without Limits or Throttling* (CWE 4.20). https://cwe.mitre.org/data/definitions/770.html +- MITRE. (2026). *CWE-1286: Improper Validation of Syntactic Correctness of Input* (CWE 4.20). https://cwe.mitre.org/data/definitions/1286.html +- Tauri. (2026). *Inter-process communication*. https://v2.tauri.app/concept/inter-process-communication/ +- Tauri. (2025). *Runtime authority*. https://v2.tauri.app/security/runtime-authority/ + +## Residual risk / follow-up + +The 25 MiB ceiling plus project/score-id syntax are mirrored across Rust and TypeScript and can drift if native policy changes. Such changes require paired contract review and tests. A future shared versioned identity contract may remove this duplication; this PR does not create one. + +The durable shared attachment parser remains less strict than this renderer boundary: it admits any non-empty attachment id and filename. This repair prevents malformed ids/context from reaching score IPC but does not migrate malformed persisted metadata. Shared-schema normalization/migration must be handled by its canonical owner. + +Renderer zero-byte rejection is not a substitute for #865 native read-time validation, and no latency/heap/GC improvement is claimed without packaged-path measurement. + +Normal order remains #1176 protected integration → #865 reconciliation/protected integration → this lane ordinary/non-force reconciliation to protected `develop` → fresh focused/repository/security evidence → independent approval. Representative rights-cleared PDFs near the admission limit still need packaged Score/PDF heap/GC acceptance. \ No newline at end of file