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
↓ peach-browser-43d2fd.tar.gzatlas at this save
files changed11 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 −4manifest.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": {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",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": {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 {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({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"}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) {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 });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 });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,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