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:
@@ -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' };
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user