43d2fd

feat: trash entries included in CJK and JSON vault exports (v1.5.1)

time
2026-04-23 21:39 UTC · 6 months ago
machine
koh2
parent
47559c
11 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/crypto/cjkExport.ts+19 −12src/crypto/cjkRoundTrip.test.ts+38 −0src/popup/views/CJKExport.tsx+9 −3src/popup/views/Export.tsx+9 −4src/popup/views/cjk-export.test.tsx+6 −3src/popup/views/contracts.test.tsx+4 −1src/shared/backup.test.ts+31 −0src/shared/backup.ts+7 −4
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.5.0",4+  "version": "1.5.1",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.5.0",4+  "version": "1.5.1",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": "1.5.0",3+  "version": "1.5.1",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/crypto/cjkExport.ts+19 −12
@@ -145,30 +145,36 @@145145 146146 export interface ExportResult {147147   phrase: string;148+  /** Total entries in the export, including trashed. */148149   entryCount: number;149150   cjkCharCount: number;151+  /** Active (non-trashed) login count. */150152   loginCount: number;153+  /** Active (non-trashed) note count. */151154   noteCount: number;155+  /** Active (non-trashed) passkey count. */152156   passkeyCount: number;157+  /** Entries with deletedAt set — land back in trash on import. */158+  trashedCount: number;153159   /** The raw binary that `cjkEncode` processed (checksum appended). Useful for tests. */154160   binary: Uint8Array;155161 }156162 157163 /**158- * Run the full export pipeline end-to-end. The entries array may include159- * tombstones (VaultEntry with `deletedAt` set) — they are filtered out160- * here so the invariant "entries in the payload are live only" stays161- * local. `settings` is the complete in-memory VaultSettings snapshot;162- * it's round-tripped verbatim so a restore carries the user's theme,163- * auto-lock preference, breach-monitoring toggle, etc.164+ * Run the full export pipeline end-to-end. Includes all entries — active165+ * and trashed — so a restore is a complete, lossless round-trip. Trashed166+ * entries arrive back in trash (deletedAt preserved). `settings` is167+ * round-tripped verbatim so a restore carries theme, auto-lock, etc.164168  */165169 export async function exportToCJK(input: ExportInput): Promise<ExportResult> {166170   const params = input.argon2 ?? ARGON2_DEFAULT;167171 168-  const liveEntries = input.entries.filter((e) => !e.deletedAt);169-  const { logins, notes, passkeys } = countsByType(liveEntries);172+  const allEntries = input.entries;173+  const activeEntries = allEntries.filter((e) => !e.deletedAt);174+  const { logins, notes, passkeys } = countsByType(activeEntries);175+  const trashedCount = allEntries.length - activeEntries.length;170176 171-  const serialised = serialiseVault(liveEntries, input.settings ?? {});177+  const serialised = serialiseVault(allEntries, input.settings ?? {});172178 173179   // Step 3: compress174180   const compressed = await compressZstd(serialised, 19);@@ -188,7 +194,7 @@188194 189195     // Step 7: assemble header190196     const headerFields: HeaderFields = {191-      entryCount: liveEntries.length,197+      entryCount: allEntries.length,192198       argon2Memory: params.memoryKiB,193199       argon2Time: params.iterations,194200       argon2Parallel: params.parallelism,@@ -204,15 +210,16 @@204210     const cjkChars = cjkEncode(binary);205211 206212     // Step 12: format207-    const phrase = formatPhrase(cjkChars, liveEntries.length);213+    const phrase = formatPhrase(cjkChars, allEntries.length);208214 209215     return {210216       phrase,211-      entryCount: liveEntries.length,217+      entryCount: allEntries.length,212218       cjkCharCount: [...cjkChars].length,213219       loginCount: logins,214220       noteCount: notes,215221       passkeyCount: passkeys,222+      trashedCount,216223       binary,217224     };218225   } finally {

view file

src/crypto/cjkRoundTrip.test.ts+38 −0
@@ -191,6 +191,44 @@191191     );192192   }, 30000);193193 194+  it("round-trip preserves trashed entries with deletedAt intact", async () => {195+    const trashedAt = 1_700_000_010_000;196+    const withTrash: VaultEntry[] = [197+      makeLogin({ id: "active-1", title: "Active Login", password: "pw1" }),198+      makeLogin({ id: "active-2", title: "Active Note", password: "pw2" }),199+      makeLogin({ id: "trashed-1", title: "Old Account", password: "old", deletedAt: trashedAt }),200+    ];201+202+    const { phrase, entryCount, loginCount, trashedCount } = await exportToCJK({203+      entries: withTrash,204+      settings: {},205+      passphrase,206+      argon2: FAST_ARGON2,207+    });208+209+    expect(entryCount).toBe(3);210+    expect(loginCount).toBe(2);211+    expect(trashedCount).toBe(1);212+213+    const firstLine = phrase.split("\n")[0];214+    expect(firstLine).toMatch(/^peach:v3:entries=3:zstd:aes256gcm:argon2id$/);215+216+    const { entries: restored, announcedEntryCount, binaryEntryCount } =217+      await importFromCJK(phrase, passphrase);218+219+    expect(announcedEntryCount).toBe(3);220+    expect(binaryEntryCount).toBe(3);221+    expect(restored).toHaveLength(3);222+223+    const active = restored.filter((e) => !e.deletedAt);224+    const trashed = restored.filter((e) => !!e.deletedAt);225+226+    expect(active).toHaveLength(2);227+    expect(trashed).toHaveLength(1);228+    expect(trashed[0].title).toBe("Old Account");229+    expect(trashed[0].deletedAt).toBe(trashedAt);230+  }, 30000);231+194232   it("round-trips 0 entries — phrase encodes and decodes back to an empty vault", async () => {195233     const empty: VaultEntry[] = [];196234     const { phrase } = await exportToCJK({

view file

src/popup/views/CJKExport.tsx+9 −3
@@ -125,6 +125,7 @@125125   const [passkeyCount, setPasskeyCount] = createSignal(0);126126   const [noteCount, setNoteCount] = createSignal(0);127127   const [loginCount, setLoginCount] = createSignal(0);128+  const [trashedCount, setTrashedCount] = createSignal(0);128129   const [copied, setCopied] = createSignal(false);129130   const [pipelineError, setPipelineError] = createSignal<string | null>(null);130131 @@ -241,7 +242,7 @@241242         throw new Error(settingsResponse.error ?? "Couldn't read vault settings.");242243       }243244 244-      const entries = (entriesResponse.data ?? []).filter((entry) => !entry.deletedAt);245+      const entries = entriesResponse.data ?? [];245246       if (entries.length === 0) {246247         throw new Error("EMPTY_VAULT_EXPORT");247248       }@@ -267,6 +268,7 @@267268       setLoginCount(result.loginCount);268269       setNoteCount(result.noteCount);269270       setPasskeyCount(result.passkeyCount);271+      setTrashedCount(result.trashedCount);270272       setProgress("done");271273       setScreen("phrase");272274     } catch (err) {@@ -400,6 +402,7 @@400402             loginCount={loginCount()}401403             noteCount={noteCount()}402404             passkeyCount={passkeyCount()}405+            trashedCount={trashedCount()}403406             copied={copied()}404407             onCopy={() => void handleCopy()}405408             onSaveFile={handleSaveFile}@@ -578,6 +581,7 @@578581   loginCount: number;579582   noteCount: number;580583   passkeyCount: number;584+  trashedCount: number;581585   copied: boolean;582586   onCopy: () => void;583587   onSaveFile: () => void;@@ -589,6 +593,7 @@589593 }590594 591595 function PhraseScreen(props: PhraseScreenProps) {596+  const activeCount = () => props.loginCount + props.noteCount + props.passkeyCount;592597   const phraseTooLongForQr = () => new TextEncoder().encode(props.phrase).length > 2800;593598 594599   return (@@ -603,8 +608,9 @@603608         <div class="peach-cjk-phrase-box">{props.phrase}</div>604609 605610         <div class="peach-inline-feedback peach-inline-feedback--muted">606-          {props.entryCount} entr{props.entryCount === 1 ? "y" : "ies"} ·{" "}607-          {props.cjkCharCount.toLocaleString()} characters611+          {activeCount()} entr{activeCount() === 1 ? "y" : "ies"}612+          {props.trashedCount > 0 ? ` · ${props.trashedCount} in trash` : ""}613+          {" · "}{props.cjkCharCount.toLocaleString()} characters608614         </div>609615         <div class="peach-inline-feedback peach-inline-feedback--muted">610616           {props.loginCount} login{props.loginCount === 1 ? "" : "s"}

view file

src/popup/views/Export.tsx+9 −4
@@ -47,16 +47,21 @@4747 4848     const response = await sendMessage({ type: "GET_ENTRIES" });4949     const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];50-    const active = entries.filter((e) => !e.deletedAt);5150 52-    if (active.length === 0) {51+    if (entries.length === 0) {5352       setError("Your vault has no entries to export.");5453       setExporting(false);5554       return;5655     }5756 57+    const trashedCount = entries.filter((e) => !!e.deletedAt).length;58+    const activeCount = entries.length - trashedCount;59+    const countStr = trashedCount > 060+      ? `${activeCount} entr${activeCount === 1 ? "y" : "ies"} · ${trashedCount} in trash`61+      : `${entries.length} entr${entries.length === 1 ? "y" : "ies"}`;62+5863     try {59-      const blob = await exportEntriesToPeachJson(active, exportPassword());64+      const blob = await exportEntriesToPeachJson(entries, exportPassword());6065       const filename = `peach-backup-${dateStamp()}.json`;6166       downloadBlob(6267         JSON.stringify(blob, null, 2),@@ -64,7 +69,7 @@6469         filename6570       );6671       setResult(67-        `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"}. Filename: ${filename}. Store this file and your export password somewhere safe — neither can be recovered.`72+        `Exported ${countStr}. Filename: ${filename}. Store this file and your export password somewhere safe — neither can be recovered.`6873       );6974       setExportPassword("");7075     } catch (err) {

view file

src/popup/views/cjk-export.test.tsx+6 −3
@@ -126,7 +126,7 @@126126     expect(onRequiresUnlock).toHaveBeenCalled();127127   });128128 129-  it("exports only live entries and shows the returned counts", async () => {129+  it("exports all entries including trashed and shows counts", async () => {130130     sendMessageMock.mockImplementation(async (msg: unknown) => {131131       const type = (msg as { type?: string }).type;132132       if (type === "GET_ENTRIES") {@@ -157,12 +157,13 @@157157       return { ok: true };158158     });159159     exportToCJKMock.mockResolvedValue({160-      phrase: "peach:v3:entries=2:zstd:aes256gcm:argon2id\n漢字漢字",161-      entryCount: 2,160+      phrase: "peach:v3:entries=3:zstd:aes256gcm:argon2id\n漢字漢字",161+      entryCount: 3,162162       cjkCharCount: 4,163163       loginCount: 1,164164       noteCount: 1,165165       passkeyCount: 0,166+      trashedCount: 1,166167       binary: new Uint8Array([1, 2, 3]),167168     });168169     const container = mount();@@ -177,12 +178,14 @@177178       entries: [178179         expect.objectContaining({ id: "entry-1", title: "GitHub" }),179180         expect.objectContaining({ id: "note-1", title: "Recovery codes" }),181+        expect.objectContaining({ id: "deleted-1", title: "Old account", deletedAt: 2 }),180182       ],181183       settings: { autoLockMinutes: 30 },182184       passphrase: "correct horse battery staple",183185       argon2: expect.objectContaining({ memoryKiB: 65536 }),184186     });185187     expect(container.textContent).toContain("2 entries");188+    expect(container.textContent).toContain("1 in trash");186189     expect(container.textContent).toContain("1 login");187190     expect(container.textContent).toContain("1 note");188191   });

view file

src/popup/views/contracts.test.tsx+4 −1
@@ -273,7 +273,10 @@273273 274274     expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_ENTRIES" });275275     expect(exportEntriesToPeachJsonMock).toHaveBeenCalledWith(276-      [expect.objectContaining({ id: "entry-1" })],276+      [277+        expect.objectContaining({ id: "entry-1" }),278+        expect.objectContaining({ id: "deleted", deletedAt: 2 }),279+      ],277280       "correct horse battery staple"278281     );279282   });

view file

src/shared/backup.test.ts+31 −0
@@ -51,6 +51,37 @@5151     );5252   });5353 54+  it("export includes trashed entries; import preserves deletedAt", async () => {55+    const trashedAt = 1_700_000_010_000;56+    const entries = [57+      ...sampleEntries,58+      {59+        id: "login-2",60+        type: "login" as const,61+        title: "Old Service",62+        username: "june",63+        password: "oldpass",64+        url: "https://old.example.com",65+        createdAt: 1,66+        modifiedAt: 1,67+        deletedAt: trashedAt,68+      },69+    ];70+71+    const envelope = await exportEntriesToPeachJson(entries, "export-pass");72+    const imported = await importEntriesFromPeachJson(JSON.stringify(envelope), "export-pass");73+74+    expect(imported).toHaveLength(2);75+76+    const active = imported.filter((e) => !e.deletedAt);77+    const trashed = imported.filter((e) => !!e.deletedAt);78+79+    expect(active).toHaveLength(1);80+    expect(trashed).toHaveLength(1);81+    expect(trashed[0].title).toBe("Old Service");82+    expect(trashed[0].deletedAt).toBe(trashedAt);83+  });84+5485   it("importEntriesFromPeachJson rejects envelopes with missing required fields", async () => {5586     const envelope: Partial<PeachExportEnvelope> = {5687       version: 2,

view file

src/shared/backup.ts+7 −4
@@ -11,10 +11,7 @@1111   tag: string;1212 }1313 14-export type ImportableEntry = Omit<15-  VaultEntry,16-  "id" | "createdAt" | "modifiedAt" | "deletedAt"17->;14+export type ImportableEntry = Omit<VaultEntry, "id" | "createdAt" | "modifiedAt">;1815 1916 export async function exportEntriesToPeachJson(2017   entries: VaultEntry[],@@ -120,6 +117,9 @@120117     email?: string;121118   };122119 120+  const deletedAt =121+    typeof candidate.deletedAt === "number" ? candidate.deletedAt : undefined;122+123123   if (candidate.type === "note") {124124     const title = firstNonEmptyString(125125       candidate.title,@@ -132,6 +132,7 @@132132       title,133133       notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,134134       tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,135+      deletedAt,135136     };136137   }137138 @@ -169,6 +170,7 @@169170       backupState: candidate.backupState === true,170171       lastUsedAt:171172         typeof candidate.lastUsedAt === "number" ? candidate.lastUsedAt : undefined,173+      deletedAt,172174     };173175   }174176 @@ -199,6 +201,7 @@199201         : undefined,200202     canary: candidate.canary ?? undefined,201203     tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,204+    deletedAt,202205   };203206 }204207 

view file