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).
This commit is contained in:
adlee-was-taken
2026-04-28 20:16:52 -04:00
parent f32c14f939
commit 5d9ea37b7f
2 changed files with 114 additions and 2 deletions

View File

@@ -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' };

View File

@@ -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/<aid>.bin` layout to the
* canonical `<item_id>/<aid>` 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/<id>.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/<aid>.bin`. Map aid -> item_id via the
// manifest's attachment_summaries.
const aidToItem: Record<string, string> = {};
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,