diff --git a/docs/superpowers/plans/2026-04-27-relicario-backup-restore.md b/docs/superpowers/plans/2026-04-27-relicario-backup-restore.md new file mode 100644 index 0000000..b0a6b48 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-relicario-backup-restore.md @@ -0,0 +1,2797 @@ +# relicario Backup & Restore — Implementation Plan (v0.3.0 — Plan 3A) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the `.relbak` backup container, the CLI `export` / `restore` commands, the WASM bridge, and the vault-tab Backup & Restore panel — full CLI / extension parity. Closes the disaster-recovery gap. + +**Architecture:** The backup format and pack/unpack pipeline live in a new `relicario-core::backup` module — pure bytes-in / bytes-out. CLI and extension wrap it: CLI does filesystem + git-tar, the extension does file-picker + remote-host upload. The backup container is **independent of the live vault key** (its own Argon2id-derived key from a user-chosen backup passphrase), so backups can be created without unlocking the vault and restored on a fresh device without the reference image. Reference image and `.git/` history are opt-in inclusions inside the encrypted envelope. + +**Tech Stack:** Rust (`zstd`, `tar`, `base64`, `xchacha20poly1305`, `argon2`); TypeScript (extension vault tab + service-worker handlers); vitest for SW + UI tests. + +**Spec:** `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` (D1–D14 decisions, file format, data flow, error tables, test strategy). + +**Sibling plan:** Plan 3B will cover the LastPass CSV importer. v0.3.0 ships when both 3A and 3B are merged. + +--- + +## Scope decisions (locked in by this plan) + +These are calls made on top of the spec — record them here so the executor doesn't re-litigate them mid-task. + +- **Extension restore is a full first-class flow**, not a "kick to CLI." The vault-tab Restore panel prompts for a fresh remote (host type / URL / repo / token), pushes the unpacked artifacts via `writeFileCreateOnly`, downloads the reference image (if included), and updates `chrome.storage.local.{vaultConfig, imageBase64}`. The user then unlocks normally — no setup wizard detour. +- **Extension export does NOT include git history.** A SW cannot tar a remote git repo without fetching every blob over the Contents/Git-Data API, which is impractical. The vault-tab "Include git history" checkbox is greyed-out with a hover tooltip ("CLI only"); CLI is the source of full backups. This is documented in the panel UI text and in the CHANGELOG. +- **`relicario status` gains a `last_backup_age` line** ("last `relicario export`: 4 days ago" or "last `relicario export`: never"). Detected by scanning git history for the most recent `export:` commit message OR a `.relicario/last_backup` marker file written by `cmd_export`. We use the marker-file approach — simpler than parsing log subjects and works even when the user pushes the vault elsewhere. + +### Cross-tool attachment-layout compatibility + +The CLI and extension store attachments at **different on-disk paths**: + +- CLI: `attachments//.enc` (per-item subdirectory, `.enc` extension). +- Extension: `attachments/.bin` (flat directory, `.bin` extension). + +This divergence pre-dates Plan 3A and is out of scope to fix here. To make backups round-trip across tools, the `.relbak` envelope uses a **canonical key form** — `/` — regardless of source tool. Each tool translates between its native layout and the canonical form at the export / restore boundary: + +- **CLI export** reads `attachments//.enc` directly → envelope key `/`. 1:1. +- **Extension export** reads the manifest first to build an `aid → item_id` map (each `manifest.items[id].attachment_summaries[].id`), then reads `attachments/.bin` and emits envelope key `/`. +- **CLI restore** writes envelope key `/` → `attachments//.enc`. 1:1. +- **Extension restore** writes envelope key `/` → `attachments/.bin` (drops the item-id segment from the path; the manifest already records the binding). + +The actual ciphertext bytes are identical between the two on-disk formats, so the envelope is layout-agnostic. The plan's tasks include this translation explicitly. + +--- + +## File map + +**Created:** +- `crates/relicario-core/src/backup.rs` — module with `pack_backup`, `unpack_backup`, `BackupEnvelope`, magic / version constants. +- `crates/relicario-core/tests/backup.rs` — round-trip + error-path coverage. +- `crates/relicario-cli/tests/backup.rs` — end-to-end via `TestVault`. +- `extension/src/vault/components/backup-panel.ts` — vault-tab Backup & Restore UI. +- `extension/src/vault/components/__tests__/backup-panel.test.ts` — vitest. +- `extension/src/service-worker/__tests__/backup.test.ts` — SW handler unit tests. + +**Modified:** +- `crates/relicario-core/Cargo.toml` — add `zstd`, `tar`, `base64`. +- `crates/relicario-core/src/lib.rs` — `pub mod backup` + re-exports. +- `crates/relicario-core/src/error.rs` — new variants (`BackupBadMagic`, `BackupUnsupportedVersion`, `BackupSchemaMismatch`). +- `crates/relicario-cli/src/main.rs` — `Export` / `Restore` clap variants + handlers; `cmd_status` last-backup-age. +- `crates/relicario-cli/tests/settings.rs` — extend `cmd_status` test for `last_backup` row. +- `crates/relicario-wasm/src/lib.rs` — `pack_backup_json`, `unpack_backup_json` exports. +- `extension/src/shared/messages.ts` — `export_backup`, `restore_backup` request types. +- `extension/src/service-worker/router/popup-only.ts` — handler arms. +- `extension/src/service-worker/router/__tests__/router.test.ts` — sender matrix for the new types. +- `extension/src/vault/vault.ts` — register the panel + add hash route `#backup`. +- `extension/src/popup/components/settings-vault.ts` — link "Backup & restore →" deep-links to the vault tab. +- `CHANGELOG.md` — `Unreleased` entry. + +--- + +## Pre-flight check + +Run before starting Task 1: + +```bash +cargo build && cargo test +cd extension && pnpm install && pnpm test +``` + +Both must be green. The plan starts from a clean `main`. + +--- + +## Task 1: Add core dependencies + error variants + +**Files:** +- Modify: `crates/relicario-core/Cargo.toml` +- Modify: `crates/relicario-core/src/error.rs` + +Add the three crates we need (`zstd` for compression, `tar` for git-archive packing/unpacking inside core, `base64` for JSON envelope binary fields). Add three new error variants. + +- [ ] **Step 1: Add deps** + +Append to `crates/relicario-core/Cargo.toml` under `[dependencies]`: + +```toml +zstd = { version = "0.13", default-features = false } +tar = { version = "0.4", default-features = false } +base64 = "0.22" +``` + +`zstd` default-features is off because it pulls in `zstd-sys` C bindings; we use the pure-Rust path (it falls back automatically). `tar` default-features is off because we don't need the std xattr/extended-attribute features. + +- [ ] **Step 2: Add error variants** + +Open `crates/relicario-core/src/error.rs`. Insert these variants after `UnsupportedFormatVersion` (around line 41): + +```rust + /// Backup file's first 4 bytes don't match the "RBAK" magic. + #[error("not a relicario backup file")] + BackupBadMagic, + + /// Backup format version is newer than this binary supports. + #[error("backup created by a newer relicario; upgrade required")] + BackupUnsupportedVersion { found: u8, expected: u8 }, + + /// Backup envelope schema version doesn't match. + #[error("backup envelope schema v{found}; this relicario reads v{expected}")] + BackupSchemaMismatch { found: u32, expected: u32 }, +``` + +- [ ] **Step 3: Verify compile** + +Run: `cargo check -p relicario-core` +Expected: PASS, no warnings about unused variants (they're `pub` via `RelicarioError`). + +- [ ] **Step 4: Add error test** + +Append to the `#[cfg(test)] mod tests` block in `error.rs`: + +```rust + #[test] + fn backup_errors_carry_useful_messages() { + let bad = RelicarioError::BackupBadMagic; + assert!(format!("{}", bad).contains("not a relicario backup file")); + + let ver = RelicarioError::BackupUnsupportedVersion { found: 0x02, expected: 0x01 }; + let s = format!("{}", ver); + assert!(s.contains("newer")); + + let schema = RelicarioError::BackupSchemaMismatch { found: 2, expected: 1 }; + let s = format!("{}", schema); + assert!(s.contains("v2") && s.contains("v1")); + } +``` + +- [ ] **Step 5: Run tests** + +Run: `cargo test -p relicario-core error::tests` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/relicario-core/Cargo.toml crates/relicario-core/src/error.rs Cargo.lock +git commit -m "feat(core): add backup deps + error variants + +Adds zstd, tar, base64 to relicario-core; introduces +BackupBadMagic / BackupUnsupportedVersion / BackupSchemaMismatch. +Foundation for the backup module landing in Task 2." +``` + +--- + +## Task 2: Backup module — empty-vault round-trip (TDD) + +**Files:** +- Create: `crates/relicario-core/src/backup.rs` +- Create: `crates/relicario-core/tests/backup.rs` +- Modify: `crates/relicario-core/src/lib.rs` + +Builds the pipeline end-to-end on the smallest input — empty vault, no items, no attachments, no image, no history. This pins the magic header, version byte, salt + nonce + ciphertext layout, and the JSON envelope shape. + +- [ ] **Step 1: Write the failing test** + +Create `crates/relicario-core/tests/backup.rs`: + +```rust +//! Backup container round-trip + error-path coverage. + +use relicario_core::backup::{pack_backup, unpack_backup, BackupInput, BackupOutput}; + +fn empty_input() -> BackupInput<'static> { + BackupInput { + salt: &[0u8; 32], + params_json: r#"{"format_version":2,"kdf":{"argon2_m":256,"argon2_t":1,"argon2_p":1},"aead":"xchacha20poly1305","salt_path":".relicario/salt"}"#, + devices_json: "[]", + manifest_enc: &[], + settings_enc: &[], + items: vec![], + attachments: vec![], + reference_jpg: None, + git_archive: None, + } +} + +#[test] +fn empty_vault_round_trip() { + let out = pack_backup(empty_input(), "test-passphrase-1234").unwrap(); + assert_eq!(&out[..4], b"RBAK", "magic header"); + assert_eq!(out[4], 0x01, "format version"); + + let unpacked = unpack_backup(&out, "test-passphrase-1234").unwrap(); + assert_eq!(unpacked.salt, [0u8; 32]); + assert!(unpacked.devices_json.contains("[]")); + assert!(unpacked.items.is_empty()); + assert!(unpacked.attachments.is_empty()); + assert!(unpacked.reference_jpg.is_none()); + assert!(unpacked.git_archive.is_none()); +} +``` + +The `BackupInput` and `BackupOutput` types and the two functions are defined in step 4. The test fails to compile until then. + +- [ ] **Step 2: Verify it fails** + +Run: `cargo test -p relicario-core --test backup empty_vault_round_trip` +Expected: COMPILE ERROR ("unresolved import `relicario_core::backup::pack_backup`"). + +- [ ] **Step 3: Wire the module** + +Open `crates/relicario-core/src/lib.rs`. Add after the `pub mod imgsecret;` line (around line 79): + +```rust +pub mod backup; +pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupItem, BackupAttachment}; +``` + +- [ ] **Step 4: Implement the module** + +Create `crates/relicario-core/src/backup.rs`: + +```rust +//! Backup container — encrypted, compressed, single-file archive of a vault. +//! +//! ## Format (v1) +//! +//! ```text +//! [magic "RBAK" 4 bytes][version 0x01 1 byte][salt 32 bytes][nonce 24 bytes][ciphertext+tag] +//! ``` +//! +//! After AEAD decryption, the plaintext is zstd-compressed bytes whose +//! decompressed form is a UTF-8 JSON document — see [`Envelope`]. +//! +//! The backup container key is **independent** of any vault master key. +//! The user picks a backup passphrase at export and types it at restore. +//! Argon2id parameters are pinned to v1-of-this-format (m=64MiB, t=3, p=4) +//! so a v1 reader does not need to negotiate them. + +use argon2::{Algorithm, Argon2, Params, Version}; +use base64::Engine; +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + XChaCha20Poly1305, XNonce, +}; +use rand::{rngs::OsRng, RngCore}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::error::{RelicarioError, Result}; + +/// File-level magic. Four bytes so a `file(1)` rule can identify it. +pub const MAGIC: [u8; 4] = *b"RBAK"; + +/// Container format version. Bumped if the on-disk layout of the +/// salt/nonce/ciphertext header or the AEAD primitive changes. +pub const FORMAT_VERSION: u8 = 0x01; + +/// JSON envelope schema version. Bumped if the JSON shape changes +/// without an underlying-format change (e.g. new optional fields whose +/// absence v1 readers can tolerate would NOT bump this; renames or +/// removals would). +pub const SCHEMA_VERSION: u32 = 1; + +const SALT_LEN: usize = 32; +const NONCE_LEN: usize = 24; +const TAG_LEN: usize = 16; +const HEADER_LEN: usize = 4 + 1 + SALT_LEN + NONCE_LEN; // magic + version + salt + nonce + +const ARGON2_M_KIB: u32 = 65_536; // 64 MiB +const ARGON2_T: u32 = 3; +const ARGON2_P: u32 = 4; + +/// Zstd compression level. 3 is the speed/size sweet spot. +const ZSTD_LEVEL: i32 = 3; + +/// Inputs to [`pack_backup`]. Borrow-only — the caller retains ownership of +/// every byte slice. +pub struct BackupInput<'a> { + /// Raw 32-byte vault salt (`.relicario/salt` contents). + pub salt: &'a [u8], + /// Verbatim string contents of `.relicario/params.json`. + pub params_json: &'a str, + /// Verbatim string contents of `.relicario/devices.json`. + pub devices_json: &'a str, + /// Encrypted manifest bytes (verbatim `manifest.enc`). + pub manifest_enc: &'a [u8], + /// Encrypted vault settings bytes (verbatim `settings.enc`). + pub settings_enc: &'a [u8], + /// One entry per item file (verbatim ciphertext). + pub items: Vec>, + /// One entry per attachment blob (verbatim ciphertext). + pub attachments: Vec>, + /// Reference JPEG bytes — included iff caller wants to bundle the + /// second factor. + pub reference_jpg: Option<&'a [u8]>, + /// Tarred `.git/` directory — included iff caller wants the audit log. + /// The caller (CLI) does the actual tarring; core just transports the + /// opaque bytes. + pub git_archive: Option<&'a [u8]>, +} + +/// One vault item ciphertext, keyed by the item id (16-char hex). +pub struct BackupItem<'a> { + pub id: String, + pub ciphertext: &'a [u8], +} + +/// One attachment blob, keyed by `/` so the +/// per-item directory layout round-trips. +pub struct BackupAttachment<'a> { + pub item_id: String, + pub attachment_id: String, + pub ciphertext: &'a [u8], +} + +/// Output of [`unpack_backup`]. Owned bytes — the caller decides where to +/// persist them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackupOutput { + pub salt: [u8; 32], + pub params_json: String, + pub devices_json: String, + pub manifest_enc: Vec, + pub settings_enc: Vec, + pub items: Vec, + pub attachments: Vec, + pub reference_jpg: Option>, + pub git_archive: Option>, + pub created_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnpackedItem { + pub id: String, + pub ciphertext: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnpackedAttachment { + pub item_id: String, + pub attachment_id: String, + pub ciphertext: Vec, +} + +#[derive(Serialize, Deserialize)] +struct Envelope { + schema_version: u32, + created_at: i64, + vault: VaultEnvelope, +} + +#[derive(Serialize, Deserialize)] +struct VaultEnvelope { + /// base64-encoded 32-byte vault salt. + salt: String, + /// Verbatim params.json contents (string, not nested object — keeps + /// forward-compat with future params.json schema changes opaque to + /// the backup format). + params: String, + /// Verbatim devices.json contents (string for the same reason). + devices: String, + /// base64-encoded ciphertext of `manifest.enc`. + manifest: String, + /// base64-encoded ciphertext of `settings.enc`. + settings: String, + /// Map of `item_id` → base64-encoded item ciphertext. + items: std::collections::BTreeMap, + /// Map of `/` → base64-encoded ciphertext. + attachments: std::collections::BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + reference_jpg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + git_archive: Option, +} + +/// Pack a vault into the `.relbak` container. +/// +/// Generates fresh 32-byte salt + 24-byte nonce via OsRng. Derives a +/// 32-byte key via Argon2id with the format-pinned parameters, then +/// XChaCha20-Poly1305 encrypts the zstd-compressed JSON envelope. +pub fn pack_backup(input: BackupInput<'_>, passphrase: &str) -> Result> { + let mut salt = [0u8; SALT_LEN]; + OsRng.fill_bytes(&mut salt); + let mut nonce_bytes = [0u8; NONCE_LEN]; + OsRng.fill_bytes(&mut nonce_bytes); + + let key = derive_backup_key(passphrase.as_bytes(), &salt)?; + + let envelope = build_envelope(input, crate::time::now_unix())?; + let json = serde_json::to_vec(&envelope)?; + + let compressed = zstd::encode_all(&json[..], ZSTD_LEVEL) + .map_err(|e| RelicarioError::Format(format!("zstd compress: {e}")))?; + + let cipher = XChaCha20Poly1305::new((&*key).into()); + let nonce = XNonce::from(nonce_bytes); + let ciphertext = cipher + .encrypt(&nonce, compressed.as_slice()) + .map_err(|e| RelicarioError::Encrypt(e.to_string()))?; + + let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len()); + out.extend_from_slice(&MAGIC); + out.push(FORMAT_VERSION); + out.extend_from_slice(&salt); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + Ok(out) +} + +/// Unpack a `.relbak` container, verifying magic + version, decrypting, +/// decompressing, and parsing the JSON envelope. +pub fn unpack_backup(data: &[u8], passphrase: &str) -> Result { + if data.len() < HEADER_LEN + TAG_LEN { + return Err(RelicarioError::Format( + "backup file truncated".into(), + )); + } + if data[0..4] != MAGIC { + return Err(RelicarioError::BackupBadMagic); + } + let version = data[4]; + if version != FORMAT_VERSION { + return Err(RelicarioError::BackupUnsupportedVersion { + found: version, + expected: FORMAT_VERSION, + }); + } + + let mut salt = [0u8; SALT_LEN]; + salt.copy_from_slice(&data[5..5 + SALT_LEN]); + let nonce_start = 5 + SALT_LEN; + let nonce_bytes: &[u8] = &data[nonce_start..nonce_start + NONCE_LEN]; + let ciphertext = &data[HEADER_LEN..]; + + let key = derive_backup_key(passphrase.as_bytes(), &salt)?; + + let cipher = XChaCha20Poly1305::new((&*key).into()); + let nonce = XNonce::from_slice(nonce_bytes); + let compressed = cipher + .decrypt(nonce, ciphertext) + .map_err(|_| RelicarioError::Decrypt)?; + + let json_bytes = zstd::decode_all(compressed.as_slice()) + .map_err(|e| RelicarioError::Format(format!("zstd decompress: {e}")))?; + + let env: Envelope = serde_json::from_slice(&json_bytes)?; + if env.schema_version != SCHEMA_VERSION { + return Err(RelicarioError::BackupSchemaMismatch { + found: env.schema_version, + expected: SCHEMA_VERSION, + }); + } + + let b64 = base64::engine::general_purpose::STANDARD; + let mut salt_out = [0u8; 32]; + let salt_decoded = b64 + .decode(&env.vault.salt) + .map_err(|e| RelicarioError::Format(format!("base64 salt: {e}")))?; + if salt_decoded.len() != 32 { + return Err(RelicarioError::Format(format!( + "salt length: expected 32, got {}", + salt_decoded.len() + ))); + } + salt_out.copy_from_slice(&salt_decoded); + + let manifest_enc = b64 + .decode(&env.vault.manifest) + .map_err(|e| RelicarioError::Format(format!("base64 manifest: {e}")))?; + let settings_enc = b64 + .decode(&env.vault.settings) + .map_err(|e| RelicarioError::Format(format!("base64 settings: {e}")))?; + + let mut items = Vec::with_capacity(env.vault.items.len()); + for (id, b64_ct) in env.vault.items { + let ct = b64 + .decode(&b64_ct) + .map_err(|e| RelicarioError::Format(format!("base64 item {id}: {e}")))?; + items.push(UnpackedItem { id, ciphertext: ct }); + } + + let mut attachments = Vec::with_capacity(env.vault.attachments.len()); + for (combined, b64_ct) in env.vault.attachments { + let (item_id, attachment_id) = combined + .split_once('/') + .map(|(a, b)| (a.to_string(), b.to_string())) + .ok_or_else(|| { + RelicarioError::Format(format!("bad attachment key '{combined}'")) + })?; + let ct = b64 + .decode(&b64_ct) + .map_err(|e| RelicarioError::Format(format!("base64 attachment {combined}: {e}")))?; + attachments.push(UnpackedAttachment { item_id, attachment_id, ciphertext: ct }); + } + + let reference_jpg = env + .vault + .reference_jpg + .as_deref() + .map(|s| b64.decode(s)) + .transpose() + .map_err(|e| RelicarioError::Format(format!("base64 reference_jpg: {e}")))?; + let git_archive = env + .vault + .git_archive + .as_deref() + .map(|s| b64.decode(s)) + .transpose() + .map_err(|e| RelicarioError::Format(format!("base64 git_archive: {e}")))?; + + Ok(BackupOutput { + salt: salt_out, + params_json: env.vault.params, + devices_json: env.vault.devices, + manifest_enc, + settings_enc, + items, + attachments, + reference_jpg, + git_archive, + created_at: env.created_at, + }) +} + +fn derive_backup_key(passphrase: &[u8], salt: &[u8]) -> Result> { + let params = Params::new(ARGON2_M_KIB, ARGON2_T, ARGON2_P, Some(32)) + .map_err(|e| RelicarioError::Kdf(format!("argon2 params: {e}")))?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut key = Zeroizing::new([0u8; 32]); + argon + .hash_password_into(passphrase, salt, key.as_mut_slice()) + .map_err(|e| RelicarioError::Kdf(format!("argon2 hash: {e}")))?; + Ok(key) +} + +fn build_envelope(input: BackupInput<'_>, created_at: i64) -> Result { + let b64 = base64::engine::general_purpose::STANDARD; + let mut items = std::collections::BTreeMap::new(); + for it in input.items { + items.insert(it.id, b64.encode(it.ciphertext)); + } + let mut attachments = std::collections::BTreeMap::new(); + for a in input.attachments { + let key = format!("{}/{}", a.item_id, a.attachment_id); + attachments.insert(key, b64.encode(a.ciphertext)); + } + Ok(Envelope { + schema_version: SCHEMA_VERSION, + created_at, + vault: VaultEnvelope { + salt: b64.encode(input.salt), + params: input.params_json.to_string(), + devices: input.devices_json.to_string(), + manifest: b64.encode(input.manifest_enc), + settings: b64.encode(input.settings_enc), + items, + attachments, + reference_jpg: input.reference_jpg.map(|b| b64.encode(b)), + git_archive: input.git_archive.map(|b| b64.encode(b)), + }, + }) +} +``` + +- [ ] **Step 5: Verify the test passes** + +Run: `cargo test -p relicario-core --test backup empty_vault_round_trip` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/relicario-core/src/backup.rs crates/relicario-core/src/lib.rs crates/relicario-core/tests/backup.rs +git commit -m "feat(core): backup module — empty-vault round-trip + +pack_backup / unpack_backup ship the magic header, format version, +Argon2id KDF, XChaCha20-Poly1305 AEAD, and zstd-compressed JSON +envelope. Empty-vault round-trip is the foundation; later tasks +add items, attachments, image, and git history." +``` + +--- + +## Task 3: Round-trip with items, attachments, settings, devices + +**Files:** +- Modify: `crates/relicario-core/tests/backup.rs` + +Add coverage so a populated vault state round-trips byte-for-byte. + +- [ ] **Step 1: Write the failing test** + +Append to `crates/relicario-core/tests/backup.rs`: + +```rust +use relicario_core::backup::{BackupAttachment, BackupItem}; + +#[test] +fn populated_vault_round_trip() { + let manifest_enc = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42]; + let settings_enc = vec![0x01, 0x02, 0x03]; + let item_a_ct = vec![0xAA; 100]; + let item_b_ct = vec![0xBB; 200]; + let attach_x_ct = vec![0xCC; 4096]; + let attach_y_ct = vec![0xDD; 8192]; + + let input = BackupInput { + salt: &[0x77u8; 32], + params_json: r#"{"format_version":2,"kdf":{"argon2_m":256,"argon2_t":1,"argon2_p":1},"aead":"xchacha20poly1305","salt_path":".relicario/salt"}"#, + devices_json: r#"[{"name":"laptop","public_key":"deadbeef"}]"#, + manifest_enc: &manifest_enc, + settings_enc: &settings_enc, + items: vec![ + BackupItem { id: "1111111111111111".to_string(), ciphertext: &item_a_ct }, + BackupItem { id: "2222222222222222".to_string(), ciphertext: &item_b_ct }, + ], + attachments: vec![ + BackupAttachment { + item_id: "1111111111111111".to_string(), + attachment_id: "aaaa1111".to_string(), + ciphertext: &attach_x_ct, + }, + BackupAttachment { + item_id: "2222222222222222".to_string(), + attachment_id: "bbbb2222".to_string(), + ciphertext: &attach_y_ct, + }, + ], + reference_jpg: None, + git_archive: None, + }; + + let out = pack_backup(input, "another-strong-passphrase").unwrap(); + let unpacked = unpack_backup(&out, "another-strong-passphrase").unwrap(); + + assert_eq!(unpacked.salt, [0x77u8; 32]); + assert!(unpacked.devices_json.contains("laptop")); + assert_eq!(unpacked.manifest_enc, manifest_enc); + assert_eq!(unpacked.settings_enc, settings_enc); + + assert_eq!(unpacked.items.len(), 2); + let by_id: std::collections::HashMap<_, _> = + unpacked.items.iter().map(|i| (i.id.as_str(), &i.ciphertext)).collect(); + assert_eq!(by_id.get("1111111111111111").unwrap(), &&item_a_ct); + assert_eq!(by_id.get("2222222222222222").unwrap(), &&item_b_ct); + + assert_eq!(unpacked.attachments.len(), 2); + let by_aid: std::collections::HashMap<_, _> = unpacked + .attachments + .iter() + .map(|a| ((a.item_id.as_str(), a.attachment_id.as_str()), &a.ciphertext)) + .collect(); + assert_eq!(by_aid.get(&("1111111111111111", "aaaa1111")).unwrap(), &&attach_x_ct); + assert_eq!(by_aid.get(&("2222222222222222", "bbbb2222")).unwrap(), &&attach_y_ct); +} +``` + +- [ ] **Step 2: Run — should pass already** + +Run: `cargo test -p relicario-core --test backup populated_vault_round_trip` +Expected: PASS (the implementation from Task 2 already covers this). + +If it fails, the implementation is wrong — debug before continuing. + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-core/tests/backup.rs +git commit -m "test(core): populated-vault round-trip for backup" +``` + +--- + +## Task 4: Round-trip the reference image + +**Files:** +- Modify: `crates/relicario-core/tests/backup.rs` + +- [ ] **Step 1: Write the failing test** + +Append to `crates/relicario-core/tests/backup.rs`: + +```rust +#[test] +fn round_trip_with_reference_image() { + let jpg_bytes: Vec = (0u8..=255).cycle().take(1024 * 64).collect(); // 64 KiB + let mut input = empty_input(); + input.reference_jpg = Some(&jpg_bytes); + + let out = pack_backup(input, "p").unwrap(); + let unpacked = unpack_backup(&out, "p").unwrap(); + + assert_eq!(unpacked.reference_jpg.as_deref(), Some(jpg_bytes.as_slice())); + assert!(unpacked.git_archive.is_none()); +} +``` + +- [ ] **Step 2: Run — should pass already** + +Run: `cargo test -p relicario-core --test backup round_trip_with_reference_image` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-core/tests/backup.rs +git commit -m "test(core): backup round-trips reference image bytes" +``` + +--- + +## Task 5: Round-trip the git archive (opaque bytes) + +**Files:** +- Modify: `crates/relicario-core/tests/backup.rs` + +Core only sees the tarball as opaque bytes; CLI does the actual tarring of `.git/`. This test pins that opacity. + +- [ ] **Step 1: Write the failing test** + +Append to `crates/relicario-core/tests/backup.rs`: + +```rust +#[test] +fn round_trip_with_git_archive() { + let tar_bytes: Vec = b"FAKE TAR BYTES; core treats opaquely".repeat(50); + let mut input = empty_input(); + input.git_archive = Some(&tar_bytes); + + let out = pack_backup(input, "p").unwrap(); + let unpacked = unpack_backup(&out, "p").unwrap(); + + assert_eq!(unpacked.git_archive.as_deref(), Some(tar_bytes.as_slice())); +} + +#[test] +fn no_history_produces_strict_subset() { + let mut a = empty_input(); + a.git_archive = Some(b"some-tar-bytes"); + let with = pack_backup(a, "p").unwrap(); + + let without = pack_backup(empty_input(), "p").unwrap(); + + // The "without" file is strictly smaller (one fewer base64-encoded blob in JSON). + assert!(without.len() < with.len(), + "no-history backup should be smaller: with={}, without={}", + with.len(), without.len() + ); +} +``` + +- [ ] **Step 2: Run — should pass** + +Run: `cargo test -p relicario-core --test backup` +Expected: all four tests PASS. + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-core/tests/backup.rs +git commit -m "test(core): backup round-trips git archive + size check" +``` + +--- + +## Task 6: Error paths — bad magic, wrong key, version, schema + +**Files:** +- Modify: `crates/relicario-core/tests/backup.rs` + +- [ ] **Step 1: Write the failing tests** + +Append to `crates/relicario-core/tests/backup.rs`: + +```rust +use relicario_core::RelicarioError; + +#[test] +fn bad_magic_rejected() { + let mut bytes = pack_backup(empty_input(), "p").unwrap(); + bytes[0] = b'X'; + match unpack_backup(&bytes, "p") { + Err(RelicarioError::BackupBadMagic) => {} + other => panic!("expected BackupBadMagic, got {other:?}"), + } +} + +#[test] +fn unsupported_version_rejected() { + let mut bytes = pack_backup(empty_input(), "p").unwrap(); + bytes[4] = 0xFF; + match unpack_backup(&bytes, "p") { + Err(RelicarioError::BackupUnsupportedVersion { found, expected }) => { + assert_eq!(found, 0xFF); + assert_eq!(expected, 0x01); + } + other => panic!("expected BackupUnsupportedVersion, got {other:?}"), + } +} + +#[test] +fn wrong_passphrase_rejected_as_decrypt_error() { + let bytes = pack_backup(empty_input(), "right-passphrase").unwrap(); + match unpack_backup(&bytes, "wrong-passphrase") { + Err(RelicarioError::Decrypt) => {} + other => panic!("expected Decrypt (opaque), got {other:?}"), + } +} + +#[test] +fn truncated_file_rejected() { + let bytes = pack_backup(empty_input(), "p").unwrap(); + let truncated = &bytes[..bytes.len().min(60)]; // shorter than HEADER_LEN + TAG_LEN + match unpack_backup(truncated, "p") { + Err(RelicarioError::Format(_)) => {} + other => panic!("expected Format(truncated), got {other:?}"), + } +} + +#[test] +fn tampered_ciphertext_rejected_as_decrypt_error() { + let mut bytes = pack_backup(empty_input(), "p").unwrap(); + let last = bytes.len() - 1; + bytes[last] ^= 0xFF; // flip a byte in the auth-tag region + match unpack_backup(&bytes, "p") { + Err(RelicarioError::Decrypt) => {} + other => panic!("expected Decrypt for tampered tag, got {other:?}"), + } +} +``` + +- [ ] **Step 2: Run — should pass** + +Run: `cargo test -p relicario-core --test backup` +Expected: all tests PASS (9 total). + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-core/tests/backup.rs +git commit -m "test(core): backup error paths + +Covers bad magic, unsupported version, wrong passphrase, truncation, +and tampered ciphertext. The wrong-passphrase / tampered-tag pair both +collapse to RelicarioError::Decrypt — same opaque-failure contract as +the live vault." +``` + +--- + +## Task 7: CLI — clap surface for export / restore + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` + +Add the two new subcommands and wire them into the dispatcher. Handlers come in Tasks 8 and 9. + +- [ ] **Step 1: Add the variants** + +Open `crates/relicario-cli/src/main.rs`. Find the `enum Commands { … }` block (starts around line 24) and add these two variants after `Sync`: + +```rust + /// Pack the local vault into a single encrypted `.relbak` file. + /// Backup passphrase is independent of the vault passphrase. + Export { + /// Output `.relbak` path. + out: PathBuf, + /// Bundle the reference JPEG into the encrypted envelope. + #[arg(long)] + include_image: bool, + /// Override the reference image path (defaults to the vault's + /// `reference.jpg` or `RELICARIO_IMAGE`). + #[arg(long)] + image: Option, + /// Skip bundling `.git/` history. + #[arg(long)] + no_history: bool, + }, + + /// Unpack a `.relbak` file into a fresh vault directory. + Restore { + /// Input `.relbak` path. + input: PathBuf, + /// Target directory (must NOT already contain `.relicario/`). + /// Defaults to the current directory. + #[arg(default_value = ".")] + target: PathBuf, + }, +``` + +- [ ] **Step 2: Wire the dispatcher** + +In `fn main()` (around line 277), add two arms above the `Lock` arm: + +```rust + Commands::Export { out, include_image, image, no_history } => { + cmd_export(out, include_image, image, no_history) + } + Commands::Restore { input, target } => cmd_restore(input, target), +``` + +- [ ] **Step 3: Add stub handlers** + +Insert these stubs near the end of `main.rs`, just before the `Lock` no-op section: + +```rust +fn cmd_export( + _out: PathBuf, + _include_image: bool, + _image: Option, + _no_history: bool, +) -> Result<()> { + anyhow::bail!("cmd_export not yet implemented") +} + +fn cmd_restore(_input: PathBuf, _target: PathBuf) -> Result<()> { + anyhow::bail!("cmd_restore not yet implemented") +} +``` + +- [ ] **Step 4: Verify compile + clap surface** + +Run: `cargo build -p relicario-cli` +Expected: PASS. + +Run: `cargo run -p relicario-cli -- export --help` +Expected: shows the `--include-image`, `--image`, `--no-history` flags and the positional `out` arg. + +Run: `cargo run -p relicario-cli -- restore --help` +Expected: shows the positional `input` and `target` (default `.`). + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-cli/src/main.rs +git commit -m "feat(cli): clap surface for export/restore (handlers stubbed)" +``` + +--- + +## Task 8: CLI — `cmd_export` implementation + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` + +Reads vault disk layout, prompts for backup passphrase (with confirm + zxcvbn gate), packs, writes via `atomic_write`, leaves a `.relicario/last_backup` marker for `cmd_status`. + +- [ ] **Step 1: Replace the `cmd_export` stub** + +In `crates/relicario-cli/src/main.rs`, replace the stub with this implementation: + +```rust +fn cmd_export( + out: PathBuf, + include_image: bool, + image: Option, + no_history: bool, +) -> Result<()> { + use std::fs; + use relicario_core::{backup, validate_passphrase_strength}; + use zeroize::Zeroizing; + + let root = crate::helpers::vault_dir()?; + + // Backup passphrase — prompt twice, gate on zxcvbn (audit H3). + let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE") { + Zeroizing::new(p) + } else { + Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) + }; + let confirm = if std::env::var_os("RELICARIO_TEST_BACKUP_PASSPHRASE").is_some() { + passphrase.clone() + } else { + Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?) + }; + if passphrase.as_str() != confirm.as_str() { + anyhow::bail!("passphrases do not match"); + } + if let Err(e) = validate_passphrase_strength(&passphrase) { + anyhow::bail!("backup {}. Choose a longer or more entropic phrase.", e); + } + + // Read everything from disk that the envelope needs. + let salt = fs::read(root.join(".relicario").join("salt")) + .with_context(|| "failed to read .relicario/salt")?; + let params_json = fs::read_to_string(root.join(".relicario").join("params.json")) + .with_context(|| "failed to read .relicario/params.json")?; + let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json")) + .with_context(|| "failed to read .relicario/devices.json")?; + let manifest_enc = fs::read(root.join("manifest.enc")) + .with_context(|| "failed to read manifest.enc")?; + let settings_enc = fs::read(root.join("settings.enc")) + .with_context(|| "failed to read settings.enc")?; + + // Items. + let mut item_files = Vec::new(); + let items_dir = root.join("items"); + if items_dir.is_dir() { + for entry in fs::read_dir(&items_dir)? { + let p = entry?.path(); + if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; } + let id = p.file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("bad item filename: {}", p.display()))? + .to_string(); + let bytes = fs::read(&p)?; + item_files.push((id, bytes)); + } + } + + // Attachments. Layout: attachments//.enc + let mut attach_files = Vec::new(); + let attach_dir = root.join("attachments"); + if attach_dir.is_dir() { + for entry in fs::read_dir(&attach_dir)? { + let item_dir = entry?.path(); + if !item_dir.is_dir() { continue; } + let item_id = item_dir.file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("bad attachment dir: {}", item_dir.display()))? + .to_string(); + for sub in fs::read_dir(&item_dir)? { + let p = sub?.path(); + if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; } + let aid = p.file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("bad attachment filename: {}", p.display()))? + .to_string(); + let bytes = fs::read(&p)?; + attach_files.push((item_id.clone(), aid, bytes)); + } + } + } + + // Optional reference image. + let image_bytes = if include_image { + let path = match image { + Some(p) => p, + None => crate::session::get_image_path()?, + }; + Some(fs::read(&path) + .with_context(|| format!("failed to read reference image {}", path.display()))?) + } else { + None + }; + + // Optional .git/ tar. + let git_archive = if no_history { None } else { Some(tar_directory(&root.join(".git"))?) }; + + let items_refs: Vec = item_files.iter() + .map(|(id, bytes)| backup::BackupItem { id: id.clone(), ciphertext: bytes }) + .collect(); + let attach_refs: Vec = attach_files.iter() + .map(|(iid, aid, bytes)| backup::BackupAttachment { + item_id: iid.clone(), + attachment_id: aid.clone(), + ciphertext: bytes, + }) + .collect(); + + let input = backup::BackupInput { + salt: &salt, + params_json: ¶ms_json, + devices_json: &devices_json, + manifest_enc: &manifest_enc, + settings_enc: &settings_enc, + items: items_refs, + attachments: attach_refs, + reference_jpg: image_bytes.as_deref(), + git_archive: git_archive.as_deref(), + }; + + let bytes = backup::pack_backup(input, &passphrase)?; + + // atomic_write via the existing pattern: write `.tmp`, rename. + let tmp = { + let mut t = out.as_os_str().to_owned(); + t.push(".tmp"); + PathBuf::from(t) + }; + fs::write(&tmp, &bytes) + .with_context(|| format!("failed to write {}", tmp.display()))?; + fs::rename(&tmp, &out) + .with_context(|| format!("failed to rename {}", out.display()))?; + + // Marker file for `cmd_status`. Format: ISO-8601 UTC line. + let now_iso = crate::helpers::iso8601(relicario_core::now_unix()); + fs::write(root.join(".relicario").join("last_backup"), format!("{now_iso}\n"))?; + + let mib = (bytes.len() as f64) / (1024.0 * 1024.0); + eprintln!( + "Wrote {} ({:.2} MiB). Delete after restore is verified.", + out.display(), mib + ); + Ok(()) +} + +/// Tar a directory into an in-memory `Vec`. Used for `.git/` bundling. +fn tar_directory(dir: &std::path::Path) -> Result> { + use std::io::Write as _; + let mut buf = Vec::new(); + { + let mut builder = tar::Builder::new(&mut buf); + builder.append_dir_all(".", dir) + .with_context(|| format!("failed to tar {}", dir.display()))?; + builder.finish()?; + } + Ok(buf) +} +``` + +The `tar` crate isn't in the CLI's `Cargo.toml` yet — we add it now. + +- [ ] **Step 2: Add the CLI dep** + +Open `crates/relicario-cli/Cargo.toml`. Under `[dependencies]`, append: + +```toml +tar = { version = "0.4", default-features = false } +``` + +- [ ] **Step 3: Verify compile** + +Run: `cargo build -p relicario-cli` +Expected: PASS. + +- [ ] **Step 4: Smoke** + +```bash +cd $(mktemp -d) +RELICARIO_TEST_PASSPHRASE='strong-test-pass-2026' cargo run -p relicario-cli -- init --image <(echo not-a-real-jpeg) --output reference.jpg 2>/dev/null || true +``` + +The init will fail because the carrier isn't a real JPEG; that's fine. Manual verification of the CLI surface: + +```bash +cargo run -p relicario-cli -- export --help +``` + +Expected: shows the four flags / positional we defined. + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-cli/src/main.rs crates/relicario-cli/Cargo.toml Cargo.lock +git commit -m "feat(cli): cmd_export — pack vault into .relbak + +Reads the vault layout from disk, prompts for backup passphrase +(zxcvbn-gated, independent of the live vault key), tars .git/ +unless --no-history, optionally bundles the reference JPEG, and +atomic-writes the .relbak. Leaves .relicario/last_backup marker +for cmd_status." +``` + +--- + +## Task 9: CLI — `cmd_restore` implementation + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` + +Reverses Task 8: refuses non-empty target; reads `.relbak`; prompts for backup passphrase; unpacks; writes the vault layout; untars `.git/` if present; otherwise `git init` + initial commit. + +- [ ] **Step 1: Replace the stub** + +In `crates/relicario-cli/src/main.rs`, replace the `cmd_restore` stub with: + +```rust +fn cmd_restore(input: PathBuf, target: PathBuf) -> Result<()> { + use std::fs; + use relicario_core::backup; + use zeroize::Zeroizing; + + let target = if target.is_absolute() { + target + } else { + std::env::current_dir()?.join(&target) + }; + + if target.join(".relicario").exists() { + anyhow::bail!( + "target dir already contains a relicario vault; restore refuses to overwrite — use an empty directory: {}", + target.display() + ); + } + fs::create_dir_all(&target) + .with_context(|| format!("failed to create target {}", target.display()))?; + + // Read input file. + let bytes = fs::read(&input) + .with_context(|| format!("failed to read backup file {}", input.display()))?; + + // Backup passphrase prompt. + let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE") { + Zeroizing::new(p) + } else { + Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) + }; + + let unpacked = backup::unpack_backup(&bytes, &passphrase) + .map_err(|e| match e { + relicario_core::RelicarioError::Decrypt => + anyhow::anyhow!("wrong backup passphrase, or the file is corrupt"), + other => anyhow::anyhow!(other), + })?; + + // Write vault layout. + let relicario_dir = target.join(".relicario"); + fs::create_dir_all(&relicario_dir)?; + fs::create_dir_all(target.join("items"))?; + fs::create_dir_all(target.join("attachments"))?; + + fs::write(relicario_dir.join("salt"), unpacked.salt)?; + fs::write(relicario_dir.join("params.json"), &unpacked.params_json)?; + fs::write(relicario_dir.join("devices.json"), &unpacked.devices_json)?; + fs::write(target.join("manifest.enc"), &unpacked.manifest_enc)?; + fs::write(target.join("settings.enc"), &unpacked.settings_enc)?; + + for item in &unpacked.items { + fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?; + } + for a in &unpacked.attachments { + let dir = target.join("attachments").join(&a.item_id); + fs::create_dir_all(&dir)?; + fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?; + } + + // Reference image (if present). + if let Some(jpg) = &unpacked.reference_jpg { + let path = target.join("reference.jpg"); + fs::write(&path, jpg) + .with_context(|| format!("failed to write reference image {}", path.display()))?; + } + + // .git/ history. + if let Some(tar_bytes) = &unpacked.git_archive { + let mut archive = tar::Archive::new(tar_bytes.as_slice()); + archive.unpack(target.join(".git")) + .with_context(|| "failed to untar .git/")?; + } else { + // No history bundled — start a fresh git repo. + let status = crate::helpers::git_command(&target, &["init"]).status()?; + if !status.success() { anyhow::bail!("git init failed"); } + + // .gitignore — exclude reference image if present. + if target.join("reference.jpg").exists() { + fs::write(target.join(".gitignore"), "reference.jpg\n")?; + } + + let _ = crate::helpers::git_command(&target, &["add", "."]).status()?; + let now_iso = crate::helpers::iso8601(relicario_core::now_unix()); + let msg = format!("restore from backup {now_iso}"); + let _ = crate::helpers::git_command(&target, &["commit", "-m", &msg]).status()?; + } + + eprintln!( + "Restored vault to {}. Unlock with your passphrase + reference image.", + target.display() + ); + Ok(()) +} +``` + +- [ ] **Step 2: Verify compile** + +Run: `cargo build -p relicario-cli` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-cli/src/main.rs +git commit -m "feat(cli): cmd_restore — unpack .relbak into target dir + +Refuses non-empty target, prompts for backup passphrase, writes the +full vault layout, untars .git/ when bundled or git-inits a fresh +'restore from backup ' commit otherwise." +``` + +--- + +## Task 10: CLI — integration tests for export / restore + +**Files:** +- Create: `crates/relicario-cli/tests/backup.rs` +- Modify: `crates/relicario-cli/tests/common/mod.rs` (add helper for backup-pass env var) + +End-to-end via the existing `TestVault` harness. + +- [ ] **Step 1: Extend the test harness** + +Open `crates/relicario-cli/tests/common/mod.rs`. Add a method on `TestVault`: + +```rust +impl TestVault { + pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &str) -> std::process::Output { + let mut cmd = Command::cargo_bin("relicario").unwrap(); + cmd.current_dir(self.dir.path()) + .env("RELICARIO_IMAGE", &self.reference_image) + .env("RELICARIO_TEST_PASSPHRASE", &self.passphrase) + .env("RELICARIO_TEST_BACKUP_PASSPHRASE", backup_pass) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + cmd.output().unwrap() + } +} +``` + +- [ ] **Step 2: Write the failing tests** + +Create `crates/relicario-cli/tests/backup.rs`: + +```rust +mod common; +use common::TestVault; +use std::process::Command; +use assert_cmd::cargo::CommandCargoExt; + +const BACKUP_PASS: &str = "strong-backup-pass-test-2026"; + +#[test] +fn export_then_restore_round_trip() { + let v = TestVault::init(); + + v.run(&["add", "login", "--title", "GitHub", "--username", "alice", "--password", "p"]); + v.run(&["add", "login", "--title", "Email", "--username", "bob", "--password", "q"]); + + let backup_path = v.path().join("vault.relbak"); + let out = v.run_with_backup_pass( + &["export", backup_path.to_str().unwrap()], + BACKUP_PASS, + ); + assert!(out.status.success(), "export failed: {:?}", String::from_utf8_lossy(&out.stderr)); + assert!(backup_path.exists()); + assert!(v.path().join(".relicario/last_backup").exists()); + + // Restore into a fresh dir. + let restore_dir = tempfile::TempDir::new().unwrap(); + let out = Command::cargo_bin("relicario") + .unwrap() + .current_dir(restore_dir.path()) + .env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS) + .args(["restore", backup_path.to_str().unwrap(), "."]) + .output() + .unwrap(); + assert!(out.status.success(), "restore failed: {:?}", String::from_utf8_lossy(&out.stderr)); + + // Vault should be unlockable in the restore dir using the same passphrase + image. + // Since the original vault didn't include the image, we copy it in manually + // (the standard restore-without-image flow expects the user to keep their + // reference image separately). + std::fs::copy(&v.reference_image, restore_dir.path().join("reference.jpg")).unwrap(); + + let out = Command::cargo_bin("relicario") + .unwrap() + .current_dir(restore_dir.path()) + .env("RELICARIO_TEST_PASSPHRASE", &v.passphrase) + .env("RELICARIO_IMAGE", restore_dir.path().join("reference.jpg")) + .args(["list"]) + .output() + .unwrap(); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(stdout.contains("GitHub")); + assert!(stdout.contains("Email")); +} + +#[test] +fn restore_refuses_non_empty_target() { + let v = TestVault::init(); + let backup_path = v.path().join("vault.relbak"); + v.run_with_backup_pass(&["export", backup_path.to_str().unwrap()], BACKUP_PASS); + + let out = Command::cargo_bin("relicario") + .unwrap() + .current_dir(v.path()) // already has a .relicario/ + .env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS) + .args(["restore", backup_path.to_str().unwrap(), "."]) + .output() + .unwrap(); + assert!(!out.status.success()); + let err = String::from_utf8(out.stderr).unwrap(); + assert!(err.contains("already contains a relicario vault"), "stderr: {err}"); +} + +#[test] +fn export_with_include_image_round_trips_the_image() { + let v = TestVault::init(); + let backup_path = v.path().join("vault.relbak"); + v.run_with_backup_pass( + &["export", backup_path.to_str().unwrap(), "--include-image"], + BACKUP_PASS, + ); + + let restore_dir = tempfile::TempDir::new().unwrap(); + let out = Command::cargo_bin("relicario") + .unwrap() + .current_dir(restore_dir.path()) + .env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS) + .args(["restore", backup_path.to_str().unwrap(), "."]) + .output() + .unwrap(); + assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr)); + assert!(restore_dir.path().join("reference.jpg").exists(), + "image should be restored when --include-image was used"); +} + +#[test] +fn export_with_no_history_skips_git_dir() { + let v = TestVault::init(); + let backup_path = v.path().join("vault.relbak"); + v.run_with_backup_pass( + &["export", backup_path.to_str().unwrap(), "--no-history"], + BACKUP_PASS, + ); + + let restore_dir = tempfile::TempDir::new().unwrap(); + let out = Command::cargo_bin("relicario") + .unwrap() + .current_dir(restore_dir.path()) + .env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS) + .args(["restore", backup_path.to_str().unwrap(), "."]) + .output() + .unwrap(); + assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr)); + + // .git/ should exist but contain only the "restore from backup ..." commit. + assert!(restore_dir.path().join(".git").is_dir()); + let out = std::process::Command::new("git") + .current_dir(restore_dir.path()) + .args(["log", "--oneline"]) + .output() + .unwrap(); + let log = String::from_utf8(out.stdout).unwrap(); + assert_eq!(log.lines().count(), 1, "expected one commit, got: {log}"); + assert!(log.contains("restore from backup")); +} + +#[test] +fn wrong_backup_passphrase_fails() { + let v = TestVault::init(); + let backup_path = v.path().join("vault.relbak"); + v.run_with_backup_pass(&["export", backup_path.to_str().unwrap()], BACKUP_PASS); + + let restore_dir = tempfile::TempDir::new().unwrap(); + let out = Command::cargo_bin("relicario") + .unwrap() + .current_dir(restore_dir.path()) + .env("RELICARIO_TEST_BACKUP_PASSPHRASE", "definitely-wrong") + .args(["restore", backup_path.to_str().unwrap(), "."]) + .output() + .unwrap(); + assert!(!out.status.success()); + let err = String::from_utf8(out.stderr).unwrap(); + assert!(err.contains("wrong backup passphrase"), "stderr: {err}"); +} +``` + +- [ ] **Step 3: Run the tests** + +Run: `cargo test -p relicario-cli --test backup` +Expected: all five tests PASS. + +If `restore_refuses_non_empty_target` fails because the message is slightly different, update the assertion to match what `cmd_restore` actually printed. + +- [ ] **Step 4: Commit** + +```bash +git add crates/relicario-cli/tests/backup.rs crates/relicario-cli/tests/common/mod.rs +git commit -m "test(cli): export/restore round-trip + error paths" +``` + +--- + +## Task 11: CLI — `status` shows last backup age + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` +- Modify: `crates/relicario-cli/tests/settings.rs` + +Tiny add: `cmd_status` reads `.relicario/last_backup` (written by `cmd_export`) and prints "last `relicario export`: 4 days ago" or "never". + +- [ ] **Step 1: Find `cmd_status` and inspect** + +Open `crates/relicario-cli/src/main.rs`. Locate `fn cmd_status()` (around line 1592). It already prints root path, item counts, attachment counts, devices, last commit. We add one more line. + +- [ ] **Step 2: Add the last-backup-age helper** + +Add this helper somewhere near `iso8601` in `crates/relicario-cli/src/helpers.rs`: + +```rust +/// Format a duration (in seconds) as a coarse human-readable string: +/// "just now" / "5 minutes ago" / "4 days ago" / "3 months ago". +pub fn humanize_age(seconds: i64) -> String { + if seconds < 60 { return "just now".to_string(); } + if seconds < 3600 { return format!("{} minute{} ago", seconds / 60, plural(seconds / 60)); } + if seconds < 86_400 { return format!("{} hour{} ago", seconds / 3600, plural(seconds / 3600)); } + if seconds < 86_400 * 30 { + let d = seconds / 86_400; + return format!("{d} day{} ago", plural(d)); + } + if seconds < 86_400 * 365 { + let m = seconds / (86_400 * 30); + return format!("{m} month{} ago", plural(m)); + } + let y = seconds / (86_400 * 365); + format!("{y} year{} ago", plural(y)) +} + +fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } } +``` + +- [ ] **Step 3: Wire it into `cmd_status`** + +Open `crates/relicario-cli/src/main.rs`. Inside `fn cmd_status()`, add this block after the existing prints (just before the function returns): + +```rust + // Last backup age (read from marker written by cmd_export). + let last_backup_path = vault.root().join(".relicario").join("last_backup"); + let last_backup_str = if last_backup_path.exists() { + let line = std::fs::read_to_string(&last_backup_path) + .unwrap_or_default() + .trim() + .to_string(); + // Parse the ISO-8601 we wrote in cmd_export. + match chrono::DateTime::parse_from_rfc3339(&line) { + Ok(then) => { + let now = relicario_core::now_unix(); + let age = now - then.timestamp(); + crate::helpers::humanize_age(age.max(0)) + } + Err(_) => "unknown".to_string(), + } + } else { + "never".to_string() + }; + println!("last export: {last_backup_str}"); +``` + +You'll need `use chrono::DateTime;` near the top of the file if it isn't already imported. Verify with `grep "use chrono" crates/relicario-cli/src/main.rs`. If missing, add it under the other `use` declarations near the top. + +- [ ] **Step 4: Update the existing `cmd_status` test** + +Open `crates/relicario-cli/tests/settings.rs`. Find the `cmd_status` smoke test. Add an assertion that the new line is present: + +```rust +#[test] +fn status_shows_last_backup_line() { + let v = TestVault::init(); + let out = v.run(&["status"]); + assert!(out.status.success()); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(stdout.contains("last export:"), "missing last export line: {stdout}"); + assert!(stdout.contains("never"), "fresh vault should report 'never': {stdout}"); +} + +#[test] +fn status_shows_recent_backup_after_export() { + let v = TestVault::init(); + let backup_path = v.path().join("v.relbak"); + v.run_with_backup_pass( + &["export", backup_path.to_str().unwrap()], + "test-backup-pass-2026", + ); + let out = v.run(&["status"]); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(stdout.contains("last export:"), "{stdout}"); + assert!(!stdout.contains("never"), "should NOT say 'never' after export: {stdout}"); +} +``` + +- [ ] **Step 5: Run tests** + +Run: `cargo test -p relicario-cli --test settings status` +Expected: both new tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/relicario-cli/src/main.rs crates/relicario-cli/src/helpers.rs crates/relicario-cli/tests/settings.rs +git commit -m "feat(cli): status shows last export age + +Reads .relicario/last_backup (written by cmd_export). Format: +'never' for fresh vaults, '4 days ago' otherwise. Closes the +'is my backup stale?' question without leaving the terminal." +``` + +--- + +## Task 12: WASM — pack/unpack bindings + +**Files:** +- Modify: `crates/relicario-wasm/src/lib.rs` +- Modify: `crates/relicario-wasm/Cargo.toml` (only if `serde_json` isn't already enough — it already is, no edit needed unless step 1 says otherwise) + +JSON bridge between TS and core. Input/output via JSON because the SW will marshal complex `BackupInput` shapes from a JS object easily. + +- [ ] **Step 1: Add the bindings** + +Open `crates/relicario-wasm/src/lib.rs`. Append at the bottom (before the `#[cfg(test)]` block): + +```rust +// ── Backup container bridge ───────────────────────────────────────────────── + +use relicario_core::backup::{ + pack_backup as core_pack_backup, + unpack_backup as core_unpack_backup, + BackupInput, BackupItem, BackupAttachment, +}; +use base64::Engine as _; + +/// Pack a vault into a `.relbak` byte vector. +/// +/// `input_json` shape: +/// ```json +/// { +/// "salt": "", +/// "params_json": "...", +/// "devices_json": "...", +/// "manifest_enc": "", +/// "settings_enc": "", +/// "items": [{"id": "", "ciphertext": ""}, ...], +/// "attachments": [{"item_id": "", "attachment_id": "", "ciphertext": ""}, ...], +/// "reference_jpg": "" | null, +/// "git_archive": "" | null +/// } +/// ``` +#[wasm_bindgen] +pub fn pack_backup_json(input_json: &str, passphrase: &str) -> Result, JsError> { + #[derive(serde::Deserialize)] + struct InJson { + salt: String, + params_json: String, + devices_json: String, + manifest_enc: String, + settings_enc: String, + items: Vec, + attachments: Vec, + reference_jpg: Option, + git_archive: Option, + } + #[derive(serde::Deserialize)] + struct InItem { id: String, ciphertext: String } + #[derive(serde::Deserialize)] + struct InAttachment { item_id: String, attachment_id: String, ciphertext: String } + + let parsed: InJson = serde_json::from_str(input_json) + .map_err(|e| JsError::new(&format!("backup input: {e}")))?; + + let b64 = base64::engine::general_purpose::STANDARD; + let salt = b64.decode(&parsed.salt).map_err(|e| JsError::new(&e.to_string()))?; + let manifest = b64.decode(&parsed.manifest_enc).map_err(|e| JsError::new(&e.to_string()))?; + let settings = b64.decode(&parsed.settings_enc).map_err(|e| JsError::new(&e.to_string()))?; + let items_bytes: Vec<(String, Vec)> = parsed.items.iter() + .map(|i| { + let ct = b64.decode(&i.ciphertext).map_err(|e| JsError::new(&e.to_string()))?; + Ok((i.id.clone(), ct)) + }) + .collect::, JsError>>()?; + let attach_bytes: Vec<(String, String, Vec)> = parsed.attachments.iter() + .map(|a| { + let ct = b64.decode(&a.ciphertext).map_err(|e| JsError::new(&e.to_string()))?; + Ok((a.item_id.clone(), a.attachment_id.clone(), ct)) + }) + .collect::, JsError>>()?; + + let ref_bytes = parsed.reference_jpg.as_deref() + .map(|s| b64.decode(s)) + .transpose() + .map_err(|e| JsError::new(&e.to_string()))?; + let git_bytes = parsed.git_archive.as_deref() + .map(|s| b64.decode(s)) + .transpose() + .map_err(|e| JsError::new(&e.to_string()))?; + + let items_refs: Vec = items_bytes.iter() + .map(|(id, ct)| BackupItem { id: id.clone(), ciphertext: ct }) + .collect(); + let attach_refs: Vec = attach_bytes.iter() + .map(|(iid, aid, ct)| BackupAttachment { + item_id: iid.clone(), + attachment_id: aid.clone(), + ciphertext: ct, + }) + .collect(); + + let input = BackupInput { + salt: &salt, + params_json: &parsed.params_json, + devices_json: &parsed.devices_json, + manifest_enc: &manifest, + settings_enc: &settings, + items: items_refs, + attachments: attach_refs, + reference_jpg: ref_bytes.as_deref(), + git_archive: git_bytes.as_deref(), + }; + core_pack_backup(input, passphrase).map_err(|e| JsError::new(&e.to_string())) +} + +/// Unpack `.relbak` bytes; returns the JSON shape that mirrors `BackupOutput`, +/// with binary fields base64-encoded. +#[wasm_bindgen] +pub fn unpack_backup_json(bytes: &[u8], passphrase: &str) -> Result { + let out = core_unpack_backup(bytes, passphrase) + .map_err(|e| JsError::new(&e.to_string()))?; + + let b64 = base64::engine::general_purpose::STANDARD; + let json = serde_json::json!({ + "salt": b64.encode(out.salt), + "params_json": out.params_json, + "devices_json": out.devices_json, + "manifest_enc": b64.encode(&out.manifest_enc), + "settings_enc": b64.encode(&out.settings_enc), + "items": out.items.iter().map(|i| serde_json::json!({ + "id": i.id, + "ciphertext": b64.encode(&i.ciphertext), + })).collect::>(), + "attachments": out.attachments.iter().map(|a| serde_json::json!({ + "item_id": a.item_id, + "attachment_id": a.attachment_id, + "ciphertext": b64.encode(&a.ciphertext), + })).collect::>(), + "reference_jpg": out.reference_jpg.as_ref().map(|b| b64.encode(b)), + "git_archive": out.git_archive.as_ref().map(|b| b64.encode(b)), + "created_at": out.created_at, + }); + Ok(json.to_string()) +} +``` + +- [ ] **Step 2: Verify native build** + +Run: `cargo build -p relicario-wasm` +Expected: PASS. + +- [ ] **Step 3: Verify WASM build** + +Run: `cargo build -p relicario-wasm --target wasm32-unknown-unknown` +Expected: PASS. + +- [ ] **Step 4: Rebuild WASM artifacts for the extension** + +Run: `cd extension && pnpm run build:wasm` (or whatever script wraps `wasm-pack build ../crates/relicario-wasm`). + +If a script doesn't exist, do it manually: + +```bash +cd crates/relicario-wasm +wasm-pack build --target web --out-dir ../../extension/wasm +cd - +``` + +Verify `extension/wasm/relicario_wasm.d.ts` now exports `pack_backup_json` and `unpack_backup_json`. + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-wasm/src/lib.rs extension/wasm/ +git commit -m "feat(wasm): pack_backup_json / unpack_backup_json + +JSON bridge for the SW. Binary fields are base64 in the JSON wrapper; +core gets borrowed byte slices." +``` + +--- + +## Task 13: SW — message types + handler stubs + +**Files:** +- Modify: `extension/src/shared/messages.ts` +- Modify: `extension/src/service-worker/router/popup-only.ts` + +Adds the `export_backup` and `restore_backup` request types, plumbs them through the router with stub responses. + +- [ ] **Step 1: Add request types** + +Open `extension/src/shared/messages.ts`. In the `PopupMessage` union, append before the closing `;`: + +```typescript + | { type: 'export_backup'; passphrase: string; includeImage: boolean } + | { + type: 'restore_backup'; + bytes: ArrayBuffer; + passphrase: string; + newRemote: { hostType: 'gitea' | 'github'; hostUrl: string; repoPath: string; apiToken: string }; + } +``` + +In `POPUP_ONLY_TYPES`, add the two new strings: + +```typescript +'export_backup', 'restore_backup', +``` + +Append a typed-response helper: + +```typescript +export interface ExportBackupResponse extends Extract { + data: { bytes: ArrayBuffer }; +} + +export interface RestoreBackupResponse extends Extract { + data: { + summary: { itemCount: number; attachmentCount: number; hasImage: boolean }; + }; +} +``` + +- [ ] **Step 2: Add stub handlers** + +Open `extension/src/service-worker/router/popup-only.ts`. Inside the `switch (msg.type)` block, add two new arms before the closing brace: + +```typescript + case 'export_backup': + return { ok: false, error: 'export_backup not yet implemented' }; + + case 'restore_backup': + return { ok: false, error: 'restore_backup not yet implemented' }; +``` + +- [ ] **Step 3: Verify TS compile** + +Run: `cd extension && pnpm run typecheck` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/shared/messages.ts extension/src/service-worker/router/popup-only.ts +git commit -m "feat(ext/sw): export_backup / restore_backup message types" +``` + +--- + +## Task 14: SW — `export_backup` handler + +**Files:** +- Modify: `extension/src/service-worker/router/popup-only.ts` +- Modify: `extension/src/service-worker/vault.ts` (add a helper) + +Reads `.relicario/salt`, `params.json`, `devices.json`, `manifest.enc`, `settings.enc`, every `items/.enc`, every `attachments//.enc`, plus the on-disk reference image from `chrome.storage.local.imageBase64`. Calls WASM `pack_backup_json`. Returns the bytes back to the panel for `chrome.downloads.download`. + +- [ ] **Step 1: Add the gitHost helper** + +Open `extension/src/service-worker/vault.ts`. Add a function near the existing `fetchAndDecryptManifest`. The extension already has the binary-reading primitive: `gitHost.readFile(path) → Uint8Array` and `gitHost.getBlob(path) → Uint8Array`. `gitHost.listDir(path) → string[]` returns just file names, not full paths. The extension stores attachments flat at `attachments/.bin`; we translate to the canonical `/` envelope key via the decrypted manifest. + +```typescript +import { uint8ArrayToBase64 } from './git-host'; + +/** + * 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, + }; +} +``` + +`uint8ArrayToBase64` already lives in `git-host.ts:52-58`; import it (the existing `vault.ts` already imports from `./git-host`, so add `uint8ArrayToBase64` to that import list). `Manifest` and `GitHost` are already imported in `vault.ts`; the parameter signature additions don't need new imports. + +- [ ] **Step 2: Implement the handler** + +In `extension/src/service-worker/router/popup-only.ts`, replace the `export_backup` stub: + +```typescript + 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 }; + } + } +``` + +- [ ] **Step 3: Verify TS compile** + +Run: `cd extension && pnpm run typecheck` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/service-worker/router/popup-only.ts extension/src/service-worker/vault.ts extension/src/service-worker/git-host.ts extension/src/service-worker/gitea.ts extension/src/service-worker/github.ts +git commit -m "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)." +``` + +--- + +## Task 15: SW — `restore_backup` handler + +**Files:** +- Modify: `extension/src/service-worker/router/popup-only.ts` + +Decrypts the `.relbak`, writes every artifact to a fresh remote via `writeFileCreateOnly` (refuses to clobber), updates `chrome.storage.local.{vaultConfig, imageBase64}` so the next unlock works. + +- [ ] **Step 1: Implement the handler** + +Replace the `restore_backup` stub in `extension/src/service-worker/router/popup-only.ts`: + +```typescript + case 'restore_backup': { + try { + const bytes = new Uint8Array(msg.bytes); + const outJson: string = state.wasm.unpack_backup_json(bytes, msg.passphrase); + const out = JSON.parse(outJson) as { + salt: string; + params_json: string; + devices_json: string; + manifest_enc: string; + settings_enc: string; + items: Array<{ id: string; ciphertext: string }>; + attachments: Array<{ item_id: string; attachment_id: string; ciphertext: string }>; + reference_jpg: string | null; + }; + + // Build a GitHost for the new remote. + const newHost = createGitHost( + msg.newRemote.hostType, + msg.newRemote.hostUrl, + msg.newRemote.repoPath, + msg.newRemote.apiToken, + ); + + // Refuse if the remote already has a vault. + try { + const meta = await vault.fetchVaultMeta(newHost); + if (meta.salt && meta.paramsJson) { + return { ok: false, error: 'remote already contains a relicario vault' }; + } + } catch { + // No vault present — expected for a fresh remote. + } + + // Write the layout via writeFileCreateOnly. Refuses to clobber. + const b64 = (s: string) => Uint8Array.from(atob(s), c => c.charCodeAt(0)); + await newHost.writeFileCreateOnly('.relicario/salt', b64(out.salt), 'restore: salt'); + await newHost.writeFileCreateOnly('.relicario/params.json', new TextEncoder().encode(out.params_json), 'restore: params.json'); + await newHost.writeFileCreateOnly('.relicario/devices.json', new TextEncoder().encode(out.devices_json), 'restore: devices.json'); + await newHost.writeFileCreateOnly('manifest.enc', b64(out.manifest_enc), 'restore: manifest.enc'); + await newHost.writeFileCreateOnly('settings.enc', b64(out.settings_enc), 'restore: settings.enc'); + + for (const it of out.items) { + await newHost.writeFileCreateOnly( + `items/${it.id}.enc`, b64(it.ciphertext), `restore: item ${it.id}`, + ); + } + // Translate canonical envelope keys (/) back to the + // extension's flat layout (attachments/.bin). The aid is + // already content-addressed and globally unique; the item_id segment + // is recorded only in the manifest's attachment_summaries. + for (const a of out.attachments) { + await newHost.writeFileCreateOnly( + `attachments/${a.attachment_id}.bin`, + b64(a.ciphertext), + `restore: attachment ${a.attachment_id}`, + ); + } + + // Update local config so subsequent unlocks work. + const cfg = { + hostType: msg.newRemote.hostType, + hostUrl: msg.newRemote.hostUrl, + repoPath: msg.newRemote.repoPath, + apiToken: msg.newRemote.apiToken, + }; + await chrome.storage.local.set({ vaultConfig: cfg }); + if (out.reference_jpg) { + await chrome.storage.local.set({ imageBase64: out.reference_jpg }); + } + + // Make sure the SW's gitHost cache picks up the new config. + state.gitHost = newHost; + state.manifest = null; // user must unlock to populate + + return { + ok: true, + data: { + summary: { + itemCount: out.items.length, + attachmentCount: out.attachments.length, + hasImage: out.reference_jpg != null, + }, + }, + }; + } catch (e) { + return { ok: false, error: (e as Error).message }; + } + } +``` + +- [ ] **Step 2: Verify TS compile** + +Run: `cd extension && pnpm run typecheck` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add extension/src/service-worker/router/popup-only.ts +git commit -m "feat(ext/sw): restore_backup handler + +Unpacks .relbak via WASM, writes every vault artifact to the +user-specified fresh remote via writeFileCreateOnly (refuses to +clobber), and updates chrome.storage.local so subsequent unlocks +hit the restored vault. The reference image — when bundled — is +restored to imageBase64; otherwise the user keeps using their +existing reference.jpg." +``` + +--- + +## Task 16: SW — router test for new messages + +**Files:** +- Modify: `extension/src/service-worker/router/__tests__/router.test.ts` + +Sender check: `export_backup` and `restore_backup` are popup-only; vault tab is allowed; setup tab + content frames are rejected. + +- [ ] **Step 1: Find an existing sender-matrix test** + +Skim `router.test.ts` for the pattern `'list_devices'` (or any other popup-only string). Copy its sender-matrix shape. + +- [ ] **Step 2: Add a test block** + +Append to `extension/src/service-worker/router/__tests__/router.test.ts`: + +```typescript +describe('export_backup / restore_backup sender check', () => { + it('accepts vault tab', async () => { + const result = await dispatch( + { type: 'export_backup', passphrase: 'p', includeImage: false }, + { url: `chrome-extension://${EXT_ID}/vault.html`, id: EXT_ID, frameId: 0 }, + ); + expect(result.ok).toBeDefined(); // either ok:true or ok:false from handler — point is router didn't reject + }); + + it('accepts popup', async () => { + const result = await dispatch( + { type: 'export_backup', passphrase: 'p', includeImage: false }, + { url: `chrome-extension://${EXT_ID}/popup.html`, id: EXT_ID, frameId: 0 }, + ); + expect(result.ok).toBeDefined(); + }); + + it('rejects setup tab', async () => { + const result = await dispatch( + { type: 'export_backup', passphrase: 'p', includeImage: false }, + { url: `chrome-extension://${EXT_ID}/setup.html`, id: EXT_ID, frameId: 0 }, + ); + expect(result).toEqual({ ok: false, error: expect.stringMatching(/unauthorized/) }); + }); + + it('rejects content top frame', async () => { + const result = await dispatch( + { type: 'restore_backup', bytes: new ArrayBuffer(8), passphrase: 'p', + newRemote: { hostType: 'gitea', hostUrl: 'https://x', repoPath: 'a/b', apiToken: 't' } }, + { url: 'https://example.com', tab: { id: 1, url: 'https://example.com' }, frameId: 0, id: EXT_ID }, + ); + expect(result).toEqual({ ok: false, error: expect.stringMatching(/unauthorized/) }); + }); +}); +``` + +The exact spelling of `dispatch` and the `EXT_ID` constant comes from the existing tests in this file — use whatever they use. Don't invent imports. + +- [ ] **Step 3: Run the tests** + +Run: `cd extension && pnpm vitest run src/service-worker/router/__tests__/router.test.ts` +Expected: all four new tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/service-worker/router/__tests__/router.test.ts +git commit -m "test(ext/sw): router accepts/rejects backup messages per sender" +``` + +--- + +## Task 17: Vault tab — Backup & Restore panel UI + +**Files:** +- Create: `extension/src/vault/components/backup-panel.ts` +- Modify: `extension/src/vault/vault.ts` +- Modify: `extension/src/popup/components/settings-vault.ts` + +Renders two cards: Export (passphrase modal + include-image checkbox + Download), Restore (file picker + passphrase modal + new-remote form). + +- [ ] **Step 1: Implement the panel** + +Create `extension/src/vault/components/backup-panel.ts`: + +```typescript +import { getStateHost } from '../../shared/state'; + +type ViewMode = 'idle' | 'exporting' | 'restoring'; + +let mode: ViewMode = 'idle'; + +export function renderBackupPanel(app: HTMLElement): void { + app.innerHTML = ` +
+

Backup & restore

+ +
+

Export

+

Pack this vault into a single encrypted .relbak file. + The backup passphrase is independent of your vault passphrase.

+ +

Git history is not bundled from the extension. Use the CLI if you want a full audit-log backup.

+ + +
+ +
+

Restore

+

Decrypt a .relbak file and push it to a fresh + remote. The remote must be empty.

+ + + +
+
+ `; + + wireExport(app); + wireRestore(app); +} + +function wireExport(scope: HTMLElement): void { + const btn = scope.querySelector('#export-btn') as HTMLButtonElement; + const includeImage = scope.querySelector('#include-image') as HTMLInputElement; + const status = scope.querySelector('#export-status') as HTMLElement; + + btn.addEventListener('click', async () => { + if (mode !== 'idle') return; + const passphrase = window.prompt('Backup passphrase (≥ zxcvbn score 3):'); + if (!passphrase) return; + + mode = 'exporting'; + btn.disabled = true; + showStatus(status, 'Exporting…'); + + try { + const host = getStateHost(); + const resp = await host.sendMessage({ + type: 'export_backup', + passphrase, + includeImage: includeImage.checked, + }); + if (!resp.ok) throw new Error(resp.error); + + const data = (resp as { ok: true; data: { bytes: ArrayBuffer } }).data; + const blob = new Blob([data.bytes], { type: 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const a = document.createElement('a'); + a.href = url; + a.download = `relicario-${ts}.relbak`; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + + showStatus(status, `Exported (${(data.bytes.byteLength / 1024 / 1024).toFixed(2)} MiB).`); + } catch (e) { + showStatus(status, `Failed: ${(e as Error).message}`); + } finally { + mode = 'idle'; + btn.disabled = false; + } + }); +} + +function wireRestore(scope: HTMLElement): void { + const file = scope.querySelector('#restore-file') as HTMLInputElement; + const remoteFs = scope.querySelector('#restore-remote') as HTMLElement; + const btn = scope.querySelector('#restore-btn') as HTMLButtonElement; + const status = scope.querySelector('#restore-status') as HTMLElement; + + file.addEventListener('change', () => { + remoteFs.classList.toggle('hidden', file.files == null || file.files.length === 0); + }); + + btn.addEventListener('click', async () => { + if (mode !== 'idle') return; + const f = file.files?.[0]; + if (!f) return; + + const passphrase = window.prompt('Backup passphrase:'); + if (!passphrase) return; + + const hostType = (scope.querySelector('#rt-host-type') as HTMLSelectElement).value as 'gitea' | 'github'; + const hostUrl = (scope.querySelector('#rt-host-url') as HTMLInputElement).value.trim(); + const repoPath = (scope.querySelector('#rt-repo') as HTMLInputElement).value.trim(); + const apiToken = (scope.querySelector('#rt-token') as HTMLInputElement).value.trim(); + if (!hostUrl || !repoPath || !apiToken) { + showStatus(status, 'fill in host URL, repo path, and API token'); + return; + } + + mode = 'restoring'; + btn.disabled = true; + showStatus(status, 'Restoring…'); + + try { + const bytes = await f.arrayBuffer(); + const host = getStateHost(); + const resp = await host.sendMessage({ + type: 'restore_backup', + bytes, + passphrase, + newRemote: { hostType, hostUrl, repoPath, apiToken }, + }); + if (!resp.ok) throw new Error(resp.error); + + const data = (resp as { ok: true; data: { summary: { itemCount: number; attachmentCount: number; hasImage: boolean } } }).data; + const summary = data.summary; + showStatus(status, `Restored: ${summary.itemCount} items, ${summary.attachmentCount} attachments${summary.hasImage ? ', image included' : ''}. Reload the extension and unlock.`); + } catch (e) { + showStatus(status, `Failed: ${(e as Error).message}`); + } finally { + mode = 'idle'; + btn.disabled = false; + } + }); +} + +function showStatus(el: HTMLElement, text: string): void { + el.textContent = text; + el.classList.remove('hidden'); +} + +export function teardown(): void { + // No timers / object URLs to clean up — Blob URL revocation is per-export. + mode = 'idle'; +} +``` + +- [ ] **Step 2: Wire it into the vault router** + +Open `extension/src/vault/vault.ts`. Find the hash-route dispatch (where `'#trash'`, `'#devices'`, etc. are handled). Add a `'#backup'` case: + +```typescript +import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel'; + +// inside the dispatcher: + case '#backup': + teardownPrev(); + renderBackupPanel(app); + currentTeardown = teardownBackup; + break; +``` + +Adjust to match the existing dispatcher pattern — copy verbatim from how `#trash` is wired. + +- [ ] **Step 3: Add deep-link from popup** + +Open `extension/src/popup/components/settings-vault.ts`. Find the existing list of vault settings sections. Add a row near the bottom: + +```typescript + +``` + +And in the section that wires button handlers, add: + +```typescript +scope.querySelector('#open-backup')?.addEventListener('click', () => { + getStateHost().openVaultTab('#backup'); +}); +``` + +Use the actual host method name from `shared/state.ts`. If `openVaultTab` takes only `()` and not a hash, look at how the existing "Open in vault tab" link is handled and follow the same pattern. + +- [ ] **Step 4: Verify TS compile** + +Run: `cd extension && pnpm run typecheck` +Expected: PASS. + +- [ ] **Step 5: Build extension** + +Run: `cd extension && pnpm run build` (or whatever produces `dist/`). +Expected: PASS, with both `dist/vault.html` and the bundled JS unchanged in name. + +- [ ] **Step 6: Manual smoke** + +Load the unpacked extension in Chrome. Open the vault tab → settings-vault → Backup & restore. Click Export, enter a strong passphrase, see a download appear. Try Restore with a `.relbak` from CLI. + +- [ ] **Step 7: Commit** + +```bash +git add extension/src/vault/components/backup-panel.ts extension/src/vault/vault.ts extension/src/popup/components/settings-vault.ts +git commit -m "feat(ext): vault-tab Backup & Restore panel + +Two cards — Export (passphrase + include-image checkbox → download) +and Restore (file picker + passphrase + new-remote form). Deep-linked +from settings-vault > 'Backup & restore →'." +``` + +--- + +## Task 18: Vault tab — vitest for the panel + +**Files:** +- Create: `extension/src/vault/components/__tests__/backup-panel.test.ts` + +Mocks `getStateHost` so we can assert button-click → SW message shape. + +- [ ] **Step 1: Write the test** + +Create `extension/src/vault/components/__tests__/backup-panel.test.ts`: + +```typescript +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderBackupPanel } from '../backup-panel'; + +const sendMessage = vi.fn(); +const openVaultTab = vi.fn(); + +vi.mock('../../../shared/state', () => ({ + getStateHost: () => ({ + sendMessage: (msg: unknown) => sendMessage(msg), + openVaultTab, + getState: () => ({}), + setState: () => {}, + navigate: () => {}, + escapeHtml: (s: string) => s, + popOutToTab: () => {}, + isInTab: () => true, + }), +})); + +describe('backup panel — export', () => { + let app: HTMLElement; + + beforeEach(() => { + sendMessage.mockReset(); + document.body.innerHTML = '
'; + app = document.getElementById('app')!; + }); + + it('clicking export with no passphrase is a no-op', async () => { + renderBackupPanel(app); + vi.spyOn(window, 'prompt').mockReturnValue(null); + + (app.querySelector('#export-btn') as HTMLButtonElement).click(); + await new Promise((r) => setTimeout(r, 0)); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it('clicking export with a passphrase fires export_backup', async () => { + renderBackupPanel(app); + vi.spyOn(window, 'prompt').mockReturnValue('test-backup-pass'); + sendMessage.mockResolvedValue({ ok: true, data: { bytes: new ArrayBuffer(123) } }); + + (app.querySelector('#include-image') as HTMLInputElement).checked = true; + (app.querySelector('#export-btn') as HTMLButtonElement).click(); + await new Promise((r) => setTimeout(r, 50)); + + expect(sendMessage).toHaveBeenCalledWith({ + type: 'export_backup', + passphrase: 'test-backup-pass', + includeImage: true, + }); + }); + + it('export error surfaces in the status pre', async () => { + renderBackupPanel(app); + vi.spyOn(window, 'prompt').mockReturnValue('p'); + sendMessage.mockResolvedValue({ ok: false, error: 'no reference image stored locally' }); + + (app.querySelector('#export-btn') as HTMLButtonElement).click(); + await new Promise((r) => setTimeout(r, 50)); + + const status = app.querySelector('#export-status') as HTMLElement; + expect(status.textContent).toContain('Failed'); + expect(status.textContent).toContain('no reference image'); + }); +}); + +describe('backup panel — restore', () => { + let app: HTMLElement; + + beforeEach(() => { + sendMessage.mockReset(); + document.body.innerHTML = '
'; + app = document.getElementById('app')!; + }); + + it('clicking restore without filling new-remote shows an error', async () => { + renderBackupPanel(app); + vi.spyOn(window, 'prompt').mockReturnValue('p'); + + // Simulate file picked. + const fakeFile = new File([new Uint8Array([0x52, 0x42, 0x41, 0x4B, 0x01])], 'v.relbak'); + const input = app.querySelector('#restore-file') as HTMLInputElement; + Object.defineProperty(input, 'files', { value: [fakeFile] }); + input.dispatchEvent(new Event('change')); + + (app.querySelector('#restore-btn') as HTMLButtonElement).click(); + await new Promise((r) => setTimeout(r, 50)); + + const status = app.querySelector('#restore-status') as HTMLElement; + expect(status.textContent).toContain('fill in'); + expect(sendMessage).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run the tests** + +Run: `cd extension && pnpm vitest run src/vault/components/__tests__/backup-panel.test.ts` +Expected: all PASS. If `getStateHost`'s shape doesn't match, tweak the mock to align with the real interface from `shared/state.ts`. + +- [ ] **Step 3: Commit** + +```bash +git add extension/src/vault/components/__tests__/backup-panel.test.ts +git commit -m "test(ext): vault-tab Backup & Restore panel" +``` + +--- + +## Task 19: SW — handler unit tests + +**Files:** +- Create: `extension/src/service-worker/__tests__/backup.test.ts` + +Mock WASM + GitHost so we can drive `export_backup` and `restore_backup` end-to-end without real crypto. + +- [ ] **Step 1: Write the tests** + +Create `extension/src/service-worker/__tests__/backup.test.ts`: + +```typescript +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Top-level mocks: hoisted before the SUT import. +const fakeNewHost = { + readFile: vi.fn().mockRejectedValue(new Error('not-found')), + writeFile: vi.fn().mockResolvedValue(undefined), + writeFileCreateOnly: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn(), + listDir: vi.fn().mockResolvedValue([]), + lastCommit: vi.fn().mockResolvedValue(null), + putBlob: vi.fn(), + getBlob: vi.fn(), + deleteBlob: vi.fn(), +}; + +vi.mock('../git-host', async () => { + const actual = await vi.importActual('../git-host'); + return { + ...actual, + createGitHost: () => fakeNewHost, + }; +}); + +vi.mock('../vault', async () => { + const actual = await vi.importActual('../vault'); + return { + ...actual, + fetchVaultStateForBackup: vi.fn().mockResolvedValue({ + salt_b64: 'AAAA', params_json: '{}', devices_json: '[]', + manifest_enc_b64: 'bWZzdA==', settings_enc_b64: 'c3RuZw==', + items: [{ id: 'aaa1', ciphertext_b64: 'aXRlbS1jdA==' }], + attachments: [], + }), + fetchVaultMeta: vi.fn().mockRejectedValue(new Error('no vault')), + }; +}); + +import { handle, type PopupState } from '../router/popup-only'; +import type { Manifest } from '../../shared/types'; + +const FAKE_SENDER = { + url: 'chrome-extension://x/vault.html', + id: 'x', + frameId: 0, +} as unknown as chrome.runtime.MessageSender; + +const EMPTY_MANIFEST: Manifest = { schema_version: 2, items: {} } as Manifest; + +function fakeWasm() { + return { + pack_backup_json: vi.fn().mockReturnValue(new Uint8Array([0x52, 0x42, 0x41, 0x4B, 0x01])), + unpack_backup_json: vi.fn().mockReturnValue(JSON.stringify({ + salt: btoa(String.fromCharCode(...new Uint8Array(32))), + params_json: '{}', + devices_json: '[]', + manifest_enc: btoa('mfst'), + settings_enc: btoa('stng'), + items: [{ id: 'aaa1', ciphertext: btoa('item-ct') }], + attachments: [{ item_id: 'aaa1', attachment_id: 'bbbb', ciphertext: btoa('att-ct') }], + reference_jpg: null, + })), + }; +} + +describe('export_backup handler', () => { + beforeEach(() => { + (globalThis as { chrome?: unknown }).chrome = { + storage: { local: { get: vi.fn().mockResolvedValue({}), set: vi.fn() } }, + }; + }); + + it('returns ArrayBuffer of pack output on success', async () => { + const state: PopupState = { + manifest: EMPTY_MANIFEST, + gitHost: fakeNewHost as never, + wasm: fakeWasm(), + }; + + const result = await handle( + { type: 'export_backup', passphrase: 'p', includeImage: false }, + state, + FAKE_SENDER, + ); + expect(result.ok).toBe(true); + if (result.ok) { + const data = result.data as { bytes: ArrayBuffer }; + expect(data.bytes.byteLength).toBe(5); + } + }); + + it('rejects when manifest is missing (vault_locked)', async () => { + const state: PopupState = { + manifest: null, + gitHost: fakeNewHost as never, + wasm: fakeWasm(), + }; + const result = await handle( + { type: 'export_backup', passphrase: 'p', includeImage: false }, + state, + FAKE_SENDER, + ); + expect(result).toEqual({ ok: false, error: 'vault_locked' }); + }); +}); + +describe('restore_backup handler', () => { + beforeEach(() => { + fakeNewHost.writeFileCreateOnly.mockClear(); + (globalThis as { chrome?: unknown }).chrome = { + storage: { + local: { + get: vi.fn().mockResolvedValue({}), + set: vi.fn().mockResolvedValue(undefined), + }, + }, + }; + }); + + it('writes vault layout via writeFileCreateOnly', async () => { + const state: PopupState = { + manifest: null, + gitHost: null as never, + wasm: fakeWasm(), + }; + const result = await handle( + { + type: 'restore_backup', + bytes: new ArrayBuffer(5), + passphrase: 'p', + newRemote: { hostType: 'gitea', hostUrl: 'https://x', repoPath: 'a/b', apiToken: 't' }, + }, + state, + FAKE_SENDER, + ); + expect(result.ok).toBe(true); + // 5 baseline files (salt, params, devices, manifest, settings) + + // 1 item + 1 attachment = 7 writes. + expect(fakeNewHost.writeFileCreateOnly).toHaveBeenCalledTimes(7); + // Confirm flat-layout attachment path (not /). + const attachCall = fakeNewHost.writeFileCreateOnly.mock.calls.find( + (c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).startsWith('attachments/'), + ); + expect(attachCall![0]).toBe('attachments/bbbb.bin'); + }); +}); +``` + +- [ ] **Step 2: Run** + +Run: `cd extension && pnpm vitest run src/service-worker/__tests__/backup.test.ts` +Expected: PASS. The exact mock shape for `createGitHost` may need adjustment — if a `vi.doMock` inside the test doesn't take, hoist it to a top-level `vi.mock(...)` call. + +- [ ] **Step 3: Commit** + +```bash +git add extension/src/service-worker/__tests__/backup.test.ts +git commit -m "test(ext/sw): export/restore handler unit tests" +``` + +--- + +## Task 20: CHANGELOG + version bumps + +**Files:** +- Modify: `CHANGELOG.md` +- (Versions stay at 0.2.x until both Plan 3A AND Plan 3B are merged, then bump to 0.3.0 in a final commit on the v0.3.0 train.) + +- [ ] **Step 1: Update CHANGELOG** + +Open `CHANGELOG.md`. Under the existing `## Unreleased` section's `### Added`, append: + +```markdown +- **Backup & restore.** New `relicario export ` and + `relicario restore []` commands. The `.relbak` + format is a single encrypted file: Argon2id-derived key from a + user-chosen backup passphrase (independent of the vault factor), + XChaCha20-Poly1305 ciphertext, zstd-compressed JSON envelope. + Reference image and `.git/` history are opt-in inclusions + (`--include-image`, `--no-history`). +- **Vault-tab Backup & Restore panel.** Export downloads the + `.relbak` via `chrome.downloads`. Restore takes a file + backup + passphrase + new-remote config and writes the vault into a fresh + empty repo (refuses to clobber existing). Git history is never + bundled from the extension — CLI is the source of full backups. +- **`relicario status` shows last export age.** Added as `last + export: ` reading `.relicario/last_backup` (a + marker file `cmd_export` writes on success). +``` + +- [ ] **Step 2: Verify the CHANGELOG renders correctly** + +```bash +grep -A 3 "Backup & restore" CHANGELOG.md +``` + +Eyeball it. + +- [ ] **Step 3: Final test sweep** + +```bash +cargo test +cd extension && pnpm test && pnpm run typecheck +``` + +Both must be green. If anything fails, that's an integration bug to fix before declaring this plan complete. + +- [ ] **Step 4: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs(changelog): backup & restore (Plan 3A)" +``` + +--- + +## Done conditions for Plan 3A + +All of the following must hold before this plan is considered shipped: + +- `cargo test` green (all five core backup tests, all five CLI integration tests, status tests). +- `pnpm test` green in `extension/` (router test, panel test, SW handler test). +- `cargo build -p relicario-wasm --target wasm32-unknown-unknown` green. +- Manual smoke (CLI): `init` → `add` two items → `export --include-image` → restore into a fresh dir → `unlock` works → both items present. +- Manual smoke (extension): vault tab Backup & Restore panel → Export downloads a `.relbak`. Restore that `.relbak` into a freshly-created empty git repo via the panel's new-remote form → the extension's next unlock against the new remote shows the same items. +- CHANGELOG updated. +- `relicario status` prints `last export:` line — both "never" and " minute(s) ago" forms verified. + +When all six conditions hold, Plan 3A is done. Plan 3B (LastPass importer) picks up next; v0.3.0 is tagged once both are merged. + +--- + +## Risks / open follow-ups + +- **`writeFileCreateOnly` is N round-trips.** Restoring a vault with 1000 items takes 1000 sequential Contents-API writes — slow against a remote but correct. Future: batch via Git Data API tree updates. Out of scope for v0.3.0. +- **Reference image base64 in `chrome.storage.local`.** Restoring with `--include-image` overwrites the user's existing `imageBase64`. If the user is doing a "restore to recover from a corrupted vault on the same device," this is the right behavior; if they're doing "test-restore on a side install," they should restore into a separate Chrome profile. Documented in the panel UI text (line: "The remote must be empty"). +- **No vault GC after restore.** If a future audit reveals orphan files in the source vault (items on disk without a manifest entry), they'd round-trip through backup. A `relicario gc` sweep is queued for a later release, not v0.3.0. +- **Backup passphrase in test env vars.** `RELICARIO_TEST_BACKUP_PASSPHRASE` is documented in the test harness; production binaries don't fall back to it (env var must be set explicitly). Same shape as `RELICARIO_TEST_PASSPHRASE`. diff --git a/docs/test-checklists/2026-04-27-pre-v0.3.0-audit.md b/docs/test-checklists/2026-04-27-pre-v0.3.0-audit.md new file mode 100644 index 0000000..5d417c8 --- /dev/null +++ b/docs/test-checklists/2026-04-27-pre-v0.3.0-audit.md @@ -0,0 +1,124 @@ +# Pre-v0.3.0 manual test checklist + +Date: 2026-04-27 +Scope: every change in `CHANGELOG.md`'s `Unreleased` section since `v0.2.0` (commits `a7dbf35`, `f79a67b`, `3f0f5b1`, `b951741`, `c66fd52`). + +Purpose: smoke-walk the audit pass before drawing the line and tagging +v0.3.0. Treat as a logic-spot-check, not a regression suite — the +automated tests (`cargo test`, the extension's vitest suite) cover +everything covered by tests already; this list is the things that need +human eyeballs. + +## CLI — new commands (commit `3f0f5b1`) + +- [ ] `relicario status` inside an active vault — shows root path, item + counts (active / trashed), attachment count + total bytes, device + count, `git log -1` last-commit line. +- [ ] `relicario status` with at least one trashed item — trashed count + is non-zero; active count excludes it. +- [ ] `relicario history ` — masked by default (passwords show as + `••••`). +- [ ] `relicario history --show` — values revealed in the clear. +- [ ] `relicario history --field login_password` — filter works. + Also try the raw form (`--field core:login_password`) — both + should match. +- [ ] `relicario history ` on an item with no captured history — + prints "no history captured". +- [ ] `relicario detach ` — removes the attachment ref, + deletes the encrypted blob on disk, commits `detach: …`. +- [ ] `relicario detach ` — refuses with "use + `purge` instead". +- [ ] `relicario edit ` — rotate issuer, label, then secret; + verify a `core:totp_secret` history entry is captured (visible via + `relicario history`). +- [ ] `relicario settings generator-defaults` (no flags) — prints + current defaults. +- [ ] `relicario settings generator-defaults --random --length 32` — + flips mode + length, persists across runs. +- [ ] `relicario settings generator-defaults --bip39 --words 7 + --separator -` — mode flip persists. +- [ ] `relicario generate` inside vault — uses the stored defaults. +- [ ] `relicario generate --length 8` inside vault — explicit flag + overrides the stored default. +- [ ] `relicario generate` outside any vault — still works at hardcoded + defaults (length 20, BIP39 5 words). No unlock prompt. + +## Extension — popup (commit `a7dbf35`) + +- [ ] Settings view → "Sync now" — refresh succeeds with "synced ✓"; + force a sync with a bad token to confirm the error string + surfaces. +- [ ] Item-list toolbar sync button — same coverage. +- [ ] Devices view on a fresh install whose `device_name` isn't on the + remote — banner appears. +- [ ] Click "Register this device" → enter a name → confirm → device + appears in the list, banner disappears. +- [ ] Verify keypair persists across SW restart (re-open popup; banner + should NOT return). + +## Extension — vault tab parity (commit `a7dbf35`) + +- [ ] Open `vault.html` (Ctrl+Shift+L or popup pop-out). All views + render: list, detail, add, edit, settings, settings-vault, trash, + devices, field-history. +- [ ] `register_this_device` works from the vault tab the same way as + the popup. +- [ ] Inactivity timer still fires when only the vault tab is open (no + popup activity). +- [ ] Wrong-extension sender check — install a second extension, send + a message; should be rejected. (Covered by `router.test.ts:373-384` + but worth one manual sanity run if time permits.) + +## Setup wizard (commit `f79a67b` — pure-helper extraction) + +- [ ] First-run new-vault path: zxcvbn meter still updates within ~150 + ms of typing; strength label changes through the five tiers as + the passphrase strengthens. +- [ ] First-run attach path: passphrase / image rejection produces the + exact "Could not decrypt vault — wrong passphrase or reference + image." string (no oracle leak). +- [ ] Step 5 device registration completes without manual fallback when + the extension is reachable. + +## Refactor — cmd_add / cmd_edit per-type helpers (commit `3f0f5b1`) + +For each `ItemCore` variant: spin up the form, save, re-open, edit, +save, verify the on-disk item stays valid. Drives both `build_*_item` +and `edit_*`. + +- [ ] Login (with embedded TOTP sub-config) +- [ ] SecureNote +- [ ] Identity +- [ ] Card +- [ ] Key +- [ ] Document (add via `attach`; `edit` should print the "use `attach` + / `extract`" message) +- [ ] Standalone Totp + +## Build / test gates + +- [ ] `cargo test` — all green. +- [ ] `cargo test -p relicario-cli --test basic_flows` (and the other + named integration tests) — green individually. +- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` — + succeeds. +- [ ] Extension Chrome build (`webpack`) — produces a loadable + extension. +- [ ] Extension Firefox build (`webpack.firefox.config.js`) — produces + a loadable extension. +- [ ] Load in Chrome, load in Firefox, smoke-unlock an existing vault. + +## Architecture-docs sanity (commit `c66fd52`) + +- [ ] Spot-check three line-number citations from each ARCHITECTURE.md + against live code (drift is the silent killer — line-numbered + docs rot fastest). Suggested: + - `service-worker/index.ts:20` (lazy WASM init) + - `crypto.rs:59` (`VERSION_BYTE = 0x02`) + - `helpers.rs:48-52` (hardened-`git` `-c` flags) + +## Sign-off + +When every box above is checked, the audit pass is good to tag as +v0.3.0. Anything that fails goes back into `Unreleased` as a fix +commit before the tag.