Files
relicario/docs/superpowers/plans/2026-04-27-relicario-backup-restore.md
adlee-was-taken 17ff79d5f6 docs: plan 3A spec + pre-v0.3.0 audit checklist
Plan 3A: backup & restore — drives the feature branch landing in
the next commit (merge of feature/backup-restore).

Pre-v0.3.0 audit checklist: manual smoke-test list for the v0.2.x
audit-pass commits (TOTP edit, history, detach, status, generator
defaults, vault-tab parity, sync button) — to walk through before
the v0.3.0 tag.
2026-04-29 20:29:09 -04:00

100 KiB
Raw Blame History

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 (D1D14 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.rspub mod backup + re-exports.
  • crates/relicario-core/src/error.rs — new variants (BackupBadMagic, BackupUnsupportedVersion, BackupSchemaMismatch).
  • crates/relicario-cli/src/main.rsExport / 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.rspack_backup_json, unpack_backup_json exports.
  • extension/src/shared/messages.tsexport_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.mdUnreleased entry.

Pre-flight check

Run before starting Task 1:

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]:

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):

    /// 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:

    #[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
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:

//! 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):

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:

//! 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
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:

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
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:

#[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
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:

#[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
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:

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
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:

    /// 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:

        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:

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
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:

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:

tar = { version = "0.4", default-features = false }
  • Step 3: Verify compile

Run: cargo build -p relicario-cli Expected: PASS.

  • Step 4: Smoke
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:

cargo run -p relicario-cli -- export --help

Expected: shows the four flags / positional we defined.

  • Step 5: Commit
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:

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
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:

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:

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
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:

/// 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):

    // 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:

#[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
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):

// ── 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:

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
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 ;:

  | { 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:

'export_backup', 'restore_backup',

Append a typed-response helper:

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:

    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
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.

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:

    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
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:

    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
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:

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
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:

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:

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:

<button id="open-backup" class="row">Backup &amp; restore </button>

And in the section that wires button handlers, add:

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
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:

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
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:

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
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:

- **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
grep -A 3 "Backup & restore" CHANGELOG.md

Eyeball it.

  • Step 3: Final test sweep
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
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): initadd two items → export --include-image → restore into a fresh dir → unlock works → both items present.
  • Manual smoke (extension): vault tab Backup & Restore panel → Export downloads a .relbak. Restore that .relbak into a freshly-created empty git repo via the panel's new-remote form → the extension's next unlock against the new remote shows the same items.
  • CHANGELOG updated.
  • relicario status prints last export: line — both "never" and " minute(s) ago" forms verified.

When all six conditions hold, Plan 3A is done. Plan 3B (LastPass importer) picks up next; v0.3.0 is tagged once both are merged.


Risks / open follow-ups

  • writeFileCreateOnly is N round-trips. Restoring a vault with 1000 items takes 1000 sequential Contents-API writes — slow against a remote but correct. Future: batch via Git Data API tree updates. Out of scope for v0.3.0.
  • Reference image base64 in chrome.storage.local. Restoring with --include-image overwrites the user's existing imageBase64. If the user is doing a "restore to recover from a corrupted vault on the same device," this is the right behavior; if they're doing "test-restore on a side install," they should restore into a separate Chrome profile. Documented in the panel UI text (line: "The remote must be empty").
  • No vault GC after restore. If a future audit reveals orphan files in the source vault (items on disk without a manifest entry), they'd round-trip through backup. A relicario gc sweep is queued for a later release, not v0.3.0.
  • Backup passphrase in test env vars. RELICARIO_TEST_BACKUP_PASSPHRASE is documented in the test harness; production binaries don't fall back to it (env var must be set explicitly). Same shape as RELICARIO_TEST_PASSPHRASE.