bc6cdb06f264
fix: PCH-01-026 ten minor code-quality items — remove dead save.ts MutationObserver; ChunkBuffer.add never-throws via tryBase64UrlToBytes; generate.ts step renumber; sum per-stripe t for PDF totalExpected; parseCSV single-parse {entries,totalItems}; chunk range-before-duplicate; reed-solomon try/finally frees; surface+close on sync WS error (drop dead errors array); drop SAVE_NOTE as-any cast (026-06 already resolved by PCH-01-019) (v3.8.1)
- time
- 2026-06-10 20:31 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- pch-01 fix integration
- parent
- 0511f34134fc
↓ peach-browser-bc6cdb06.tar.gzatlas at this save
files changed11 files
src/background/messages.ts+4 −3src/content/save.ts+2 −8src/popup/codex/codex.adversarial.test.ts+19 −0src/popup/codex/generate.ts+1 −1src/popup/codex/helpers.ts+14 −0src/popup/codex/pdf-import.ts+15 −4src/popup/codex/reed-solomon.ts+49 −40src/popup/codex/restore.ts+18 −5src/shared/chirp-sync-transport.ts+11 −5src/shared/import.test.ts+18 −0src/shared/import.ts+7 −7src/background/messages.ts+4 −3
@@ -542,13 +542,14 @@542542 await vault.updateEntry(id, { title: title || "Untitled", notes: body, modifiedAt: now });543543 return { ok: true, data: vault.getEntryById(id) };544544 } else {545+ // PCH-01-026 #10: addEntry stamps its own createdAt/modifiedAt546+ // (EntryDraft omits both), so passing them was the only reason the547+ // `as any` cast existed. Dropping them makes the call type-check.545548 const entry = await vault.addEntry({546549 type: "note",547550 title: title || "Untitled",548551 notes: body,549- createdAt: now,550- modifiedAt: now,551- } as any);552+ });552553 return { ok: true, data: entry };553554 }554555 }src/content/save.ts+2 −8
@@ -264,14 +264,8 @@264264 }265265 266266 export function setupSaveDetection(): void {267- let lastUrl = location.href;268-269- new MutationObserver(() => {270- if (location.href !== lastUrl) {271- lastUrl = location.href;272- }273- }).observe(document.body, { childList: true, subtree: true });274-267+ // PCH-01-026 #1: removed a dead no-op URL-tracking MutationObserver that ran268+ // on every DOM mutation only to update a `lastUrl` variable nothing consumed.275269 document.addEventListener("submit", async (e) => {276270 const form = e.target as HTMLFormElement;277271 if (!form) return;src/popup/codex/codex.adversarial.test.ts+19 −0
@@ -274,6 +274,25 @@274274 expect(parseRawScan(JSON.stringify({ ...env, s: 0.5 }))).toEqual({ error: "parse_error" });275275 expect(parseRawScan(JSON.stringify({ ...env, S: 1.5 }))).toEqual({ error: "parse_error" });276276 });277+278+ // PCH-01-026 #2: ChunkBuffer.add must never throw — an out-of-alphabet base64279+ // payload becomes a parse_error rejection.280+ it("chunk_buffer_add_does_not_throw_on_invalid_base64", async () => {281+ const buf = new ChunkBuffer();282+ const env = await makeEnv({ index: 0, total: 5 });283+ const res = await buf.add({ ...env, d: "!!!@@" });284+ expect(res).toEqual({ accepted: false, reason: "parse_error" });285+ expect(buf.getContext()).toBeNull();286+ });287+288+ // PCH-01-026 #7: an out-of-range index is malformed (parse_error), never289+ // mis-reported as a duplicate.290+ it("chunk_buffer_out_of_range_index_reports_parse_error", async () => {291+ const buf = new ChunkBuffer();292+ const env = await makeEnv({ index: 5, total: 5 }); // i === t, one past the end293+ const res = await buf.add(env);294+ expect(res).toEqual({ accepted: false, reason: "parse_error" });295+ });277296 });278297 279298 // ── verifyChunkIntegrity adversarial ────────────────────────────────────────src/popup/codex/generate.ts+1 −1
@@ -169,7 +169,7 @@169169 };170170 const pdfBytes = await renderCodexPDF(aztecPngs, manifest);171171 172- // STEP 10 — sanity check the PDF172+ // STEP 9 — sanity check the PDF173173 if (!pdfBytes || pdfBytes.length < 1000) {174174 throw new CodexError(175175 "PDF_GENERATION_FAILED",src/popup/codex/helpers.ts+14 −0
@@ -48,6 +48,20 @@4848 return out;4949 }5050 51+/**52+ * Total variant of base64UrlToBytes: returns null instead of throwing on53+ * invalid input (atob throws InvalidCharacterError on out-of-alphabet chars).54+ * Used by ChunkBuffer.add, whose documented contract is "never throws"55+ * (PCH-01-026 #2).56+ */57+export function tryBase64UrlToBytes(s: string): Uint8Array | null {58+ try {59+ return base64UrlToBytes(s);60+ } catch {61+ return null;62+ }63+}64+5165 // ── SHA-256 ─────────────────────────────────────────────────────────────────5266 5367 /**src/popup/codex/pdf-import.ts+15 −4
@@ -85,7 +85,7 @@8585 export interface PDFImportResult {8686 /** Number of envelopes successfully decoded across all pages. */8787 chunksFound: number;88- /** Total chunk count printed on the Codex, taken from the first envelope. */88+ /** Total chunk count printed on the Codex: sum of every stripe's `t`. */8989 totalExpected: number | null;9090 /** Decoded envelopes. Caller feeds these into ChunkBuffer. */9191 envelopes: ChunkEnvelope[];@@ -288,7 +288,10 @@288288 // Key is `${stripe}:${chunkIndex}` so multi-stripe Codexes don't treat289289 // stripe 1's chunk 0 as a duplicate of stripe 0's chunk 0.290290 const seenIndices = new Set<string>();291- let totalExpected: number | null = null;291+ // PCH-01-026 #4: `t` is the per-stripe chunk total, not the Codex total. Track292+ // each stripe's declared total once and sum after the scan so a multi-stripe293+ // Codex reports the true global count, not just stripe 0's.294+ const stripeTotalsForExpected = new Map<number, number>();292295 293296 let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;294297 try {@@ -344,8 +347,10 @@344347 const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices, renderScale);345348 for (const env of pageEnvelopes) envelopes.push(env);346349 347- if (totalExpected === null && pageEnvelopes.length > 0) {348- totalExpected = pageEnvelopes[0]!.t;350+ for (const env of pageEnvelopes) {351+ const stripe = env.v === 3 ? (env.s ?? 0) : 0;352+ // Each stripe's `t` is constant across its envelopes; last write wins.353+ stripeTotalsForExpected.set(stripe, env.t);349354 }350355 } catch (err) {351356 errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);@@ -384,6 +389,12 @@384389 : [],385390 };386391 }392+393+ // PCH-01-026 #4: the Codex total is the sum of every stripe's `t`.394+ const totalExpected =395+ stripeTotalsForExpected.size > 0396+ ? Array.from(stripeTotalsForExpected.values()).reduce((sum, t) => sum + t, 0)397+ : null;387398 388399 return {389400 chunksFound: envelopes.length,src/popup/codex/reed-solomon.ts+49 −40
@@ -76,26 +76,30 @@7676 const exports = this.exports;7777 const shardsLength = shards.length;7878 const shardsPointer = exports.__wbindgen_malloc(shardsLength);79- this.getUint8Memory().set(shards, shardsPointer);79+ // PCH-01-026 #8: free the malloc'd region even if a step below traps.80+ try {81+ this.getUint8Memory().set(shards, shardsPointer);8082 81- const shardSize = shardsLength / (dataShards + parityShards);82- const result = exports.encode(83- shardsPointer,84- shardsLength,85- dataShards,86- parityShards,87- );88- if (result === ReedSolomonErasure.RESULT_OK) {89- shards.set(90- this.getUint8Memory().subarray(91- shardsPointer + shardSize * dataShards,92- shardsPointer + shardsLength,93- ),94- shardSize * dataShards,83+ const shardSize = shardsLength / (dataShards + parityShards);84+ const result = exports.encode(85+ shardsPointer,86+ shardsLength,87+ dataShards,88+ parityShards,9589 );90+ if (result === ReedSolomonErasure.RESULT_OK) {91+ shards.set(92+ this.getUint8Memory().subarray(93+ shardsPointer + shardSize * dataShards,94+ shardsPointer + shardsLength,95+ ),96+ shardSize * dataShards,97+ );98+ }99+ return result;100+ } finally {101+ exports.__wbindgen_free(shardsPointer, shardsLength);96102 }97- exports.__wbindgen_free(shardsPointer, shardsLength);98- return result;99103 }100104 101105 reconstruct(@@ -107,38 +111,43 @@107111 const exports = this.exports;108112 const shardsLength = shards.length;109113 const shardsPointer = exports.__wbindgen_malloc(shardsLength);110- this.getUint8Memory().set(shards, shardsPointer);111114 112115 const shardsAvailableBytes = new Uint8Array(shardsAvailable.length);113116 shardsAvailableBytes.set(shardsAvailable.map((value) => (value ? 1 : 0)));114117 const shardsAvailablePointer = exports.__wbindgen_malloc(115118 shardsAvailableBytes.length,116119 );117- this.getUint8Memory().set(shardsAvailableBytes, shardsAvailablePointer);120+ // PCH-01-026 #8: both allocations are freed in finally so a trap between the121+ // malloc and the call below cannot leak either pointer.122+ try {123+ this.getUint8Memory().set(shards, shardsPointer);124+ this.getUint8Memory().set(shardsAvailableBytes, shardsAvailablePointer);118125 119- const shardSize = shardsLength / (dataShards + parityShards);120- const result = exports.reconstruct(121- shardsPointer,122- shardsLength,123- dataShards,124- parityShards,125- shardsAvailablePointer,126- shardsAvailableBytes.length,127- );128- if (result === ReedSolomonErasure.RESULT_OK) {129- shards.set(130- this.getUint8Memory().subarray(131- shardsPointer,132- shardsPointer + shardSize * dataShards,133- ),126+ const shardSize = shardsLength / (dataShards + parityShards);127+ const result = exports.reconstruct(128+ shardsPointer,129+ shardsLength,130+ dataShards,131+ parityShards,132+ shardsAvailablePointer,133+ shardsAvailableBytes.length,134134 );135+ if (result === ReedSolomonErasure.RESULT_OK) {136+ shards.set(137+ this.getUint8Memory().subarray(138+ shardsPointer,139+ shardsPointer + shardSize * dataShards,140+ ),141+ );142+ }143+ return result;144+ } finally {145+ exports.__wbindgen_free(shardsPointer, shardsLength);146+ exports.__wbindgen_free(147+ shardsAvailablePointer,148+ shardsAvailableBytes.length,149+ );135150 }136- exports.__wbindgen_free(shardsPointer, shardsLength);137- exports.__wbindgen_free(138- shardsAvailablePointer,139- shardsAvailableBytes.length,140- );141- return result;142151 }143152 144153 private getUint8Memory(): Uint8Array {src/popup/codex/restore.ts+18 −5
@@ -30,8 +30,8 @@3030 } from "../../shared/codex-types";3131 import { reconstructPayloadFromStripes, type StripeChunks } from "./generate";3232 import {33- base64UrlToBytes,3433 sha256Hex,34+ tryBase64UrlToBytes,3535 verifyChunkIntegrity,3636 } from "./helpers";3737 @@ -105,6 +105,16 @@105105 return { accepted: false, reason: "parse_error" };106106 }107107 108+ // PCH-01-026 #2: decode `d` once, up front, so an out-of-alphabet base64109+ // payload becomes a parse_error rejection instead of a thrown exception out110+ // of add() (honouring the documented "never throws" contract).111+ // verifyChunkIntegrity also decodes `d`, so guarding here covers both that112+ // decode and the accept-path decode below, which reuses `decoded`.113+ const decoded = tryBase64UrlToBytes(envelope.d);114+ if (decoded === null) {115+ return { accepted: false, reason: "parse_error" };116+ }117+108118 if (!(await verifyChunkIntegrity(envelope))) {109119 return { accepted: false, reason: "bad_integrity" };110120 }@@ -155,16 +165,19 @@155165 return { accepted: false, reason: "parse_error" };156166 }157167 158- if (stripeState.chunks.has(envelope.i)) {159- return { accepted: false, reason: "duplicate" };160- }168+ // PCH-01-026 #7: validate the index range BEFORE the duplicate lookup. An169+ // out-of-range index is malformed, not a duplicate — classifying it by map170+ // membership would mis-report it as "duplicate".161171 if (envelope.i < 0 || envelope.i >= envelope.t) {162172 return { accepted: false, reason: "parse_error" };163173 }174+ if (stripeState.chunks.has(envelope.i)) {175+ return { accepted: false, reason: "duplicate" };176+ }164177 165178 stripeState.chunks.set(envelope.i, {166179 envelope,167- bytes: base64UrlToBytes(envelope.d),180+ bytes: decoded, // PCH-01-026 #2: reuse the single successful decode168181 });169182 return { accepted: true };170183 }src/shared/chirp-sync-transport.ts+11 −5
@@ -58,7 +58,6 @@5858 const ws = await connectWebSocket(endpoint, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);5959 const inbox: Uint8Array[] = [];6060 const readers: Array<(b: Uint8Array) => void> = [];61- const errors: Error[] = [];6261 let closed = false;6362 6463 ws.binaryType = "arraybuffer";@@ -69,9 +68,12 @@6968 // A peer that sends one is misbehaving — tear the session down rather7069 // than letting the inbox grow toward OOM.7170 if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {72- errors.push(new Error(73- `sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes)`,74- ));71+ // MV-3: oversized frame — tear the session down. close() routes through72+ // onclose, which rejects any parked receive(), so no error array is73+ // needed (PCH-01-026 #9).74+ console.warn(75+ `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,76+ );7577 try { ws.close(); } catch { /* ignore */ }7678 return;7779 }@@ -82,7 +84,11 @@8284 }8385 };8486 ws.onerror = () => {85- errors.push(new Error("WebSocket error"));87+ // PCH-01-026 #9: surface the error and unblock any parked receive() by88+ // closing. Browsers usually follow error with close, but not always; an89+ // explicit close guarantees the handshake fails fast instead of hanging.90+ console.warn("[Peach] sync WebSocket error; closing transport");91+ try { ws.close(); } catch { /* ignore */ }8692 };8793 ws.onclose = () => {8894 closed = true;src/shared/import.test.ts+18 −0
@@ -78,6 +78,24 @@7878 expect(result.entries[0].totpPeriod).toBeUndefined();7979 });8080 81+ it("parse_csv_returns_total_items_matching_rows (PCH-01-026 #5)", async () => {82+ // 5 data rows; one has no password and is skipped.83+ const result = await parseImportSource(84+ [85+ "name,url,email,password,note,totp",86+ '"A","https://a.test","[email protected]","pw","",""',87+ '"B","https://b.test","[email protected]","pw","",""',88+ '"C","https://c.test","[email protected]","pw","",""',89+ '"D","https://d.test","[email protected]","pw","",""',90+ '"E","https://e.test","[email protected]","","",""',91+ ].join("\n")92+ );93+ expect(result.format).toBe("csv");94+ expect(result.entries).toHaveLength(4);95+ expect(result.totalItems).toBe(5);96+ expect(result.skippedItems).toBe(1);97+ });98+8199 it("preserves non-default TOTP params from an otpauth URI (PCH-01-019)", async () => {82100 const result = await parseImportSource(83101 [src/shared/import.ts+7 −7
@@ -53,11 +53,7 @@5353 5454 switch (format) {5555 case "csv": {56- const entries = parseCSV(raw);57- const totalItems = Papa.parse<Record<string, string>>(raw, {58- header: true,59- skipEmptyLines: true,60- }).data.length;56+ const { entries, totalItems } = parseCSV(raw);6157 return {6258 format,6359 label: "CSV import",@@ -212,14 +208,16 @@212208 return entries;213209 }214210 215-function parseCSV(csvText: string): ImportableEntry[] {211+function parseCSV(csvText: string): { entries: ImportableEntry[]; totalItems: number } {216212 const result = Papa.parse<Record<string, string>>(csvText, {217213 header: true,218214 skipEmptyLines: true,219215 transformHeader: (header) => header.trim(),220216 });221217 222- return result.data.reduce<ImportableEntry[]>((entries, row) => {218+ // PCH-01-026 #5: return totalItems from this single parse so parseImportSource219+ // doesn't re-run Papa.parse over the whole file just to count rows.220+ const entries = result.data.reduce<ImportableEntry[]>((entries, row) => {223221 const title = pickFirstField(row, [224222 "name",225223 "title",@@ -281,6 +279,8 @@281279 });282280 return entries;283281 }, []);282+283+ return { entries, totalItems: result.data.length };284284 }285285 286286 function parseBitwardenJSON(raw: string): {