# 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/<item_id>/<aid>.enc` (per-item subdirectory, `.enc` extension).
- Extension: `attachments/<aid>.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** — `<item_id>/<aid>` — regardless of source tool. Each tool translates between its native layout and the canonical form at the export / restore boundary:

- **CLI export** reads `attachments/<item_id>/<aid>.enc` directly → envelope key `<item_id>/<aid>`. 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/<aid>.bin` and emits envelope key `<item_id>/<aid>`.
- **CLI restore** writes envelope key `<item_id>/<aid>` → `attachments/<item_id>/<aid>.enc`. 1:1.
- **Extension restore** writes envelope key `<item_id>/<aid>` → `attachments/<aid>.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<BackupItem<'a>>,
    /// One entry per attachment blob (verbatim ciphertext).
    pub attachments: Vec<BackupAttachment<'a>>,
    /// 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 `<item_id>/<attachment_id>` 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<u8>,
    pub settings_enc: Vec<u8>,
    pub items: Vec<UnpackedItem>,
    pub attachments: Vec<UnpackedAttachment>,
    pub reference_jpg: Option<Vec<u8>>,
    pub git_archive: Option<Vec<u8>>,
    pub created_at: i64,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnpackedItem {
    pub id: String,
    pub ciphertext: Vec<u8>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnpackedAttachment {
    pub item_id: String,
    pub attachment_id: String,
    pub ciphertext: Vec<u8>,
}

#[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<String, String>,
    /// Map of `<item_id>/<attachment_id>` → base64-encoded ciphertext.
    attachments: std::collections::BTreeMap<String, String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    reference_jpg: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    git_archive: Option<String>,
}

/// 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<Vec<u8>> {
    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<BackupOutput> {
    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<Zeroizing<[u8; 32]>> {
    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<Envelope> {
    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<u8> = (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<u8> = 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<PathBuf>,
        /// 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<PathBuf>,
    _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<PathBuf>,
    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/<item_id>/<aid>.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<backup::BackupItem> = item_files.iter()
        .map(|(id, bytes)| backup::BackupItem { id: id.clone(), ciphertext: bytes })
        .collect();
    let attach_refs: Vec<backup::BackupAttachment> = 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: &params_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<u8>`. Used for `.git/` bundling.
fn tar_directory(dir: &std::path::Path) -> Result<Vec<u8>> {
    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 <iso8601>' 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": "<base64>",
///   "params_json": "...",
///   "devices_json": "...",
///   "manifest_enc": "<base64>",
///   "settings_enc": "<base64>",
///   "items": [{"id": "<hex>", "ciphertext": "<base64>"}, ...],
///   "attachments": [{"item_id": "<hex>", "attachment_id": "<hex>", "ciphertext": "<base64>"}, ...],
///   "reference_jpg": "<base64>" | null,
///   "git_archive": "<base64>" | null
/// }
/// ```
#[wasm_bindgen]
pub fn pack_backup_json(input_json: &str, passphrase: &str) -> Result<Vec<u8>, JsError> {
    #[derive(serde::Deserialize)]
    struct InJson {
        salt: String,
        params_json: String,
        devices_json: String,
        manifest_enc: String,
        settings_enc: String,
        items: Vec<InItem>,
        attachments: Vec<InAttachment>,
        reference_jpg: Option<String>,
        git_archive: Option<String>,
    }
    #[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<u8>)> = 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::<Result<Vec<_>, JsError>>()?;
    let attach_bytes: Vec<(String, String, Vec<u8>)> = 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::<Result<Vec<_>, 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<BackupItem> = items_bytes.iter()
        .map(|(id, ct)| BackupItem { id: id.clone(), ciphertext: ct })
        .collect();
    let attach_refs: Vec<BackupAttachment> = 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<String, JsError> {
    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::<Vec<_>>(),
        "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::<Vec<_>>(),
        "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<Response, { ok: true }> {
  data: { bytes: ArrayBuffer };
}

export interface RestoreBackupResponse extends Extract<Response, { ok: true }> {
  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/<id>.enc`, every `attachments/<item>/<aid>.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/<aid>.bin`; we translate to the canonical `<item_id>/<aid>` 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/<aid>.bin` layout to the
 * canonical `<item_id>/<aid>` envelope-key form by walking the decrypted
 * manifest. Attachments referenced by the manifest but missing on-disk
 * are skipped with a console warning (the user already lost them; the
 * backup just records what's there).
 */
export async function fetchVaultStateForBackup(
  gitHost: GitHost,
  manifest: Manifest,
): Promise<{
  salt_b64: string;
  params_json: string;
  devices_json: string;
  manifest_enc_b64: string;
  settings_enc_b64: string;
  items: Array<{ id: string; ciphertext_b64: string }>;
  attachments: Array<{ item_id: string; attachment_id: string; ciphertext_b64: string }>;
}> {
  const meta = await fetchVaultMeta(gitHost);
  const devicesBytes = await gitHost.readFile('.relicario/devices.json');
  const devicesText = new TextDecoder().decode(devicesBytes);
  const manifestEnc = await gitHost.readFile('manifest.enc');
  const settingsEnc = await gitHost.readFile('settings.enc');

  // Items: items/<id>.enc, flat directory.
  const itemNames = await gitHost.listDir('items');
  const items = await Promise.all(itemNames
    .filter((name) => name.endsWith('.enc'))
    .map(async (name) => {
      const id = name.replace(/\.enc$/, '');
      const ct = await gitHost.readFile(`items/${name}`);
      return { id, ciphertext_b64: uint8ArrayToBase64(ct) };
    }));

  // Attachments live at `attachments/<aid>.bin`. Map aid -> item_id via the
  // manifest's attachment_summaries.
  const aidToItem: Record<string, string> = {};
  for (const [itemId, entry] of Object.entries(manifest.items)) {
    for (const summary of entry.attachment_summaries ?? []) {
      aidToItem[summary.id] = itemId;
    }
  }

  let attachments: Array<{ item_id: string; attachment_id: string; ciphertext_b64: string }> = [];
  try {
    const blobNames = await gitHost.listDir('attachments');
    for (const name of blobNames.filter((n) => n.endsWith('.bin'))) {
      const aid = name.replace(/\.bin$/, '');
      const item_id = aidToItem[aid];
      if (!item_id) {
        console.warn('[relicario] backup: attachment', aid, 'is orphan (no manifest entry); skipping');
        continue;
      }
      const ct = await gitHost.getBlob(`attachments/${name}`);
      attachments.push({
        item_id,
        attachment_id: aid,
        ciphertext_b64: uint8ArrayToBase64(ct),
      });
    }
  } catch {
    // attachments/ may not exist yet — fine.
  }

  return {
    salt_b64: uint8ArrayToBase64(meta.salt),
    params_json: meta.paramsJson,
    devices_json: devicesText,
    manifest_enc_b64: uint8ArrayToBase64(manifestEnc),
    settings_enc_b64: uint8ArrayToBase64(settingsEnc),
    items,
    attachments,
  };
}
```

`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 (<item_id>/<aid>) back to the
        // extension's flat layout (attachments/<aid>.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 = `
    <div class="panel">
      <h1>Backup &amp; restore</h1>

      <section class="card" id="export-card">
        <h2>Export</h2>
        <p>Pack this vault into a single encrypted <code>.relbak</code> file.
        The backup passphrase is independent of your vault passphrase.</p>
        <label><input type="checkbox" id="include-image"> Include reference image (single-file recovery)</label>
        <p class="muted">Git history is not bundled from the extension. Use the CLI if you want a full audit-log backup.</p>
        <button id="export-btn">Export backup…</button>
        <pre id="export-status" class="status hidden"></pre>
      </section>

      <section class="card" id="restore-card">
        <h2>Restore</h2>
        <p>Decrypt a <code>.relbak</code> file and push it to a fresh
        remote. The remote must be empty.</p>
        <input type="file" id="restore-file" accept=".relbak">
        <fieldset id="restore-remote" class="hidden">
          <legend>Target remote</legend>
          <label>Host type
            <select id="rt-host-type">
              <option value="gitea">Gitea</option>
              <option value="github">GitHub</option>
            </select>
          </label>
          <label>Host URL <input id="rt-host-url" type="url" placeholder="https://git.example.com"></label>
          <label>Repo path <input id="rt-repo" type="text" placeholder="user/relicario-vault"></label>
          <label>API token <input id="rt-token" type="password"></label>
          <button id="restore-btn">Restore…</button>
        </fieldset>
        <pre id="restore-status" class="status hidden"></pre>
      </section>
    </div>
  `;

  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
<button id="open-backup" class="row">Backup &amp; restore →</button>
```

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 = '<div id="app"></div>';
    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 = '<div id="app"></div>';
    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<typeof import('../git-host')>('../git-host');
  return {
    ...actual,
    createGitHost: () => fakeNewHost,
  };
});

vi.mock('../vault', async () => {
  const actual = await vi.importActual<typeof import('../vault')>('../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 <item_id>/<aid>).
    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 <out.relbak>` and
  `relicario restore <in.relbak> [<dir>]` 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: <human-readable>` 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 "<n> 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`.
