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