2c1167340654

fix(data): updateEntry and deleteEntry roll back in-memory state on persist failure — no disk/memory divergence on quota error (v3.6.114)

time
2026-05-28 20:12 UTC · 5 months ago
machine
koh2
parent
b0d0f5e59a28
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.test.ts+38 −0src/background/vault.ts+20 −2
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "3.6.113",5+  "version": "3.6.114",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "3.6.113",4+  "version": "3.6.114",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "3.6.113",3+  "version": "3.6.114",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/vault.test.ts+38 −0
@@ -993,4 +993,42 @@993993     const tomb = vault.getDeletedEntries().find((t) => t.id === e.id)!;994994     expect(tomb.deletedAt!).toBeGreaterThan(modAt);995995   });996+997+  it("updateEntry rolls back in-memory state when the disk write fails (SEV-3-A)", async () => {998+    const vault = await import("./vault");999+    await vault.createVault("rollback-pass-aaaaa");1000+    const e = await vault.addEntry({ title: "R", username: "june", password: "original", url: "https://r.test" });1001+1002+    const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1003+    // Fail only the slot-A write (the history snapshot write is swallowed by design).1004+    setSpy.mockImplementation(async (next: Record<string, unknown>) => {1005+      if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1006+      Object.assign(storage, next);1007+    });1008+1009+    await expect(vault.updateEntry(e.id, { password: "changed" })).rejects.toThrow();1010+1011+    setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1012+    // In-memory entry must still hold the original password, not the failed edit.1013+    expect(vault.getEntryById(e.id)!.password).toBe("original");1014+  });1015+1016+  it("deleteEntry rolls back the tombstone when the disk write fails (SEV-3-A)", async () => {1017+    const vault = await import("./vault");1018+    await vault.createVault("rollback-pass-bbbbb");1019+    const e = await vault.addEntry({ title: "R", username: "june", password: "p", url: "https://r.test" });1020+1021+    const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1022+    setSpy.mockImplementation(async (next: Record<string, unknown>) => {1023+      if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1024+      Object.assign(storage, next);1025+    });1026+1027+    await expect(vault.deleteEntry(e.id)).rejects.toThrow();1028+1029+    setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1030+    // Entry must still be live (not tombstoned) since the delete never persisted.1031+    expect(vault.getEntryById(e.id)).toBeDefined();1032+    expect(vault.getDeletedEntries().find((t) => t.id === e.id)).toBeUndefined();1033+  });9961034 });

view file

src/background/vault.ts+20 −2
@@ -1640,7 +1640,16 @@16401640     throw new Error("card number is required for card entries");16411641   }16421642 1643-  await persistEntries();1643+  // Roll the in-memory entry back to its pre-edit state if the disk write1644+  // fails (e.g. storage quota). Without this the module-level `entries` array1645+  // would hold an edit that never landed on disk, diverging from the vault1646+  // until the next unlock silently reverts it (SEV-3-A). Mirrors addEntry.1647+  try {1648+    await persistEntries();1649+  } catch (err) {1650+    entries[idx] = current;1651+    throw err;1652+  }16441653 16451654   // Breach check when password changes: clear immediately so old breach doesn't linger,16461655   // then async-check the new password and only re-set if it's also found.@@ -1673,10 +1682,19 @@16731682   // Tombstone timestamp must be strictly greater than the entry's last16741683   // modification so the deletion wins LWW against any in-flight edit, even16751684   // under clock skew (SEV-2-E).1685+  const previousModifiedAt = entry.modifiedAt;16761686   const stamp = monotonicEntryTimestamp(entry.modifiedAt);16771687   entry.deletedAt = stamp;16781688   entry.modifiedAt = stamp;1679-  await persistEntries();1689+  // Roll the tombstone back if the disk write fails, so a quota error doesn't1690+  // leave the entry deleted in memory but alive on disk (SEV-3-A).1691+  try {1692+    await persistEntries();1693+  } catch (err) {1694+    delete entry.deletedAt;1695+    entry.modifiedAt = previousModifiedAt;1696+    throw err;1697+  }16801698   fireSyncTrigger("vault_change");16811699 }16821700 

view file