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
↓ peach-browser-2c116734.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.test.ts+38 −0src/background/vault.ts+20 −2manifest.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": {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",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": {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 });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