From 5d9ea37b7f0d98a1f87defbe295a109b6b801d09 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Tue, 28 Apr 2026 20:16:52 -0400 Subject: [PATCH] feat(ext/sw): export_backup handler Reads vault state via GitHost, calls pack_backup_json in WASM, returns the .relbak bytes back to the panel for chrome.downloads.download. Reference image inclusion comes from chrome.storage.local.imageBase64. Git history is never bundled from the extension (CLI is the source of full backups). --- .../src/service-worker/router/popup-only.ts | 36 ++++++++- extension/src/service-worker/vault.ts | 80 +++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 1f1e63a..ad6958a 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -383,8 +383,40 @@ export async function handle( return { ok: true }; } - case 'export_backup': - return { ok: false, error: 'export_backup not yet implemented' }; + case 'export_backup': { + if (!state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + + try { + const blob = await vault.fetchVaultStateForBackup(state.gitHost, state.manifest); + + let reference_jpg: string | null = null; + if (msg.includeImage) { + const stored = await chrome.storage.local.get('imageBase64'); + const b64 = stored.imageBase64 as string | undefined; + if (!b64) return { ok: false, error: 'no reference image stored locally' }; + reference_jpg = b64; + } + + const inputJson = JSON.stringify({ + salt: blob.salt_b64, + params_json: blob.params_json, + devices_json: blob.devices_json, + manifest_enc: blob.manifest_enc_b64, + settings_enc: blob.settings_enc_b64, + items: blob.items.map(i => ({ id: i.id, ciphertext: i.ciphertext_b64 })), + attachments: blob.attachments.map(a => ({ + item_id: a.item_id, attachment_id: a.attachment_id, ciphertext: a.ciphertext_b64 + })), + reference_jpg, + git_archive: null, // Extension never bundles git history. + }); + + const bytes: Uint8Array = state.wasm.pack_backup_json(inputJson, msg.passphrase); + return { ok: true, data: { bytes: bytes.buffer } }; + } catch (e) { + return { ok: false, error: (e as Error).message }; + } + } case 'restore_backup': return { ok: false, error: 'restore_backup not yet implemented' }; diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index 2b8c3b2..a252554 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -3,6 +3,7 @@ import type { SessionHandle } from '../../wasm/relicario_wasm'; import type { GitHost } from './git-host'; +import { uint8ArrayToBase64 } from './git-host'; import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -39,6 +40,85 @@ export async function fetchAndDecryptManifest( return w.manifest_decrypt(handle, ciphertext) as Manifest; } +/** + * Read every byte the .relbak envelope needs from the remote vault repo. + * Returns base64 strings for binary blobs (matching the WASM JSON shape). + * + * Translates the extension's flat `attachments/.bin` layout to the + * canonical `/` envelope-key form by walking the decrypted + * manifest. Attachments referenced by the manifest but missing on-disk + * are skipped with a console warning (the user already lost them; the + * backup just records what's there). + */ +export async function fetchVaultStateForBackup( + gitHost: GitHost, + manifest: Manifest, +): Promise<{ + salt_b64: string; + params_json: string; + devices_json: string; + manifest_enc_b64: string; + settings_enc_b64: string; + items: Array<{ id: string; ciphertext_b64: string }>; + attachments: Array<{ item_id: string; attachment_id: string; ciphertext_b64: string }>; +}> { + const meta = await fetchVaultMeta(gitHost); + const devicesBytes = await gitHost.readFile('.relicario/devices.json'); + const devicesText = new TextDecoder().decode(devicesBytes); + const manifestEnc = await gitHost.readFile('manifest.enc'); + const settingsEnc = await gitHost.readFile('settings.enc'); + + // Items: items/.enc, flat directory. + const itemNames = await gitHost.listDir('items'); + const items = await Promise.all(itemNames + .filter((name) => name.endsWith('.enc')) + .map(async (name) => { + const id = name.replace(/\.enc$/, ''); + const ct = await gitHost.readFile(`items/${name}`); + return { id, ciphertext_b64: uint8ArrayToBase64(ct) }; + })); + + // Attachments live at `attachments/.bin`. Map aid -> item_id via the + // manifest's attachment_summaries. + const aidToItem: Record = {}; + for (const [itemId, entry] of Object.entries(manifest.items)) { + for (const summary of entry.attachment_summaries ?? []) { + aidToItem[summary.id] = itemId; + } + } + + let attachments: Array<{ item_id: string; attachment_id: string; ciphertext_b64: string }> = []; + try { + const blobNames = await gitHost.listDir('attachments'); + for (const name of blobNames.filter((n) => n.endsWith('.bin'))) { + const aid = name.replace(/\.bin$/, ''); + const item_id = aidToItem[aid]; + if (!item_id) { + console.warn('[relicario] backup: attachment', aid, 'is orphan (no manifest entry); skipping'); + continue; + } + const ct = await gitHost.getBlob(`attachments/${name}`); + attachments.push({ + item_id, + attachment_id: aid, + ciphertext_b64: uint8ArrayToBase64(ct), + }); + } + } catch { + // attachments/ may not exist yet — fine. + } + + return { + salt_b64: uint8ArrayToBase64(meta.salt), + params_json: meta.paramsJson, + devices_json: devicesText, + manifest_enc_b64: uint8ArrayToBase64(manifestEnc), + settings_enc_b64: uint8ArrayToBase64(settingsEnc), + items, + attachments, + }; +} + export async function encryptAndWriteManifest( git: GitHost, handle: SessionHandle,