# Relicario Typed-Item Phase 1B — CLI + WASM Implementation Plan

> **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:** Rewrite the `relicario-cli` binary against the new typed-item core API, add the `relicario-wasm` opaque session-handle bridge, implement all CLI/WASM-side audit fixes (H4, H5, H6, H7, M3, M6, M7, M11, L8), and ship integration tests exercising every CLI command.

**Architecture:**

- CLI remains a single `relicario` binary with clap subcommands. Each mutation that touches vault state follows a uniform flow: locate vault dir (refuse if missing), unlock via `derive_master_key`, do the op, re-serialize, write atomically, commit via a hardened `git_command` helper.
- WASM exposes the typed-item API via opaque `SessionHandle` values. The master key lives entirely in WASM linear memory inside a `Zeroizing<[u8; 32]>` stored in a `HashMap<u32, …>`. `lock(handle)` zeroes and drops the entry. The browser never receives key bytes.
- All randomness goes through `getrandom` (WASM uses the `js` feature). The CLI generator delegates to `relicario-core::generate_password` / `generate_passphrase`, so `rand::distributions::Uniform` + rejection sampling happen once in core (audit H6).

**Tech Stack:** Rust 1.80+, `clap 4` (derive), `anyhow` for CLI error chains, `rpassword 7`, `arboard 3` (clipboard), `zeroize 1`, `chrono 0.4` (ISO-8601 timestamps), `wasm-bindgen 0.2`, `serde_wasm_bindgen 0.6`, `getrandom 0.2` (`js` feature), `assert_cmd 2` + `predicates 3` + `tempfile 3` for CLI integration tests.

---

## Prerequisites (execution setup)

Plan 1B begins with the feature branch `feature/typed-items-1a-rust-core` at tag `plan-1a-rust-core-complete`. That branch predates the `idfoto` → `relicario` rename on `main` (`519a6f0`). Use `superpowers:using-git-worktrees` to create an isolated worktree off `plan-1a-rust-core-complete`. Task 1 then folds `main` into the worktree so the rest of the plan can reference the final `relicario-*` crate names.

**Verification after setup:**

```bash
git worktree list                       # new worktree present
git -C <worktree> log --oneline -1      # points at 49b7820 (plan-1a tag)
ls crates/                              # shows idfoto-core/, idfoto-cli/, idfoto-wasm/
```

---

## Task 1: Reconcile the Plan 1A branch with the `idfoto` → `relicario` rename

**Why first:** every later task names crates, modules, error types, binary names, env vars, and the vault metadata dir with the post-rename values. Doing the reconciliation up front means no task has to dual-reference names.

**Files:**
- Rename: `crates/idfoto-core/` → `crates/relicario-core/`
- Rename: `crates/idfoto-cli/` → `crates/relicario-cli/`
- Rename: `crates/idfoto-wasm/` → `crates/relicario-wasm/`
- Modify: `Cargo.toml` (workspace members)
- Modify: all `Cargo.toml` in the three renamed crates (`name`, `dependencies.*`, `[[bin]]` `name`)
- Modify: every `.rs` file under the renamed crates (identifier + path sweep)
- Merge: `main` into the current branch

- [ ] **Step 1: Worktree + branch**

```bash
cd /home/alee/Sources/relicario
git worktree add .worktrees/typed-items-1b plan-1a-rust-core-complete
cd .worktrees/typed-items-1b
git checkout -b feature/typed-items-1b
```

- [ ] **Step 2: Rename crate directories**

```bash
git mv crates/idfoto-core crates/relicario-core
git mv crates/idfoto-cli crates/relicario-cli
git mv crates/idfoto-wasm crates/relicario-wasm
```

- [ ] **Step 3: Text-sweep identifiers**

Run each of the following sed invocations against every `.rs`, `.toml`, and `.md` file in `crates/` and the workspace root `Cargo.toml`:

```bash
find crates Cargo.toml -type f \( -name '*.rs' -o -name '*.toml' -o -name '*.md' \) -print0 \
  | xargs -0 sed -i \
    -e 's/idfoto_core/relicario_core/g' \
    -e 's/idfoto-core/relicario-core/g' \
    -e 's/idfoto_cli/relicario_cli/g' \
    -e 's/idfoto-cli/relicario-cli/g' \
    -e 's/idfoto_wasm/relicario_wasm/g' \
    -e 's/idfoto-wasm/relicario-wasm/g' \
    -e 's/IdfotoError/RelicarioError/g' \
    -e 's/IDFOTO_IMAGE/RELICARIO_IMAGE/g' \
    -e 's|\.idfoto/|.relicario/|g' \
    -e 's/name = "idfoto"/name = "relicario"/g' \
    -e 's/"idfoto"/"relicario"/g' \
    -e 's/# idfoto/# relicario/g'
```

Manual inspection afterwards: `git diff --stat` and skim any `.md` docstring references. Leave intentional historical references in the `docs/superpowers/` folder untouched — those describe the old name as a historical fact.

- [ ] **Step 4: Verify core builds + tests still pass**

```bash
cargo build -p relicario-core --release
cargo test -p relicario-core
```

Expected: clean build and 110+ unit tests + 18 integration tests pass. If any test fails, the sed missed a reference — find it with `grep -r idfoto crates/`.

- [ ] **Step 5: Merge main**

```bash
git fetch origin
git merge origin/main -m "chore: merge rename commit into Plan 1B branch"
```

Expected: either a fast-forward (likely conflict-free because Step 3 aligned the renamed paths with main's naming) OR small conflicts in `Cargo.toml` workspace members. Resolve by keeping main's member list (it already uses `relicario-*`).

After the merge: re-run `cargo test -p relicario-core` to confirm nothing regressed.

- [ ] **Step 6: Commit reconciliation**

```bash
git add -A
git commit -m "chore: reconcile Plan 1A branch with idfoto→relicario rename

Renames crate directories and sweeps identifiers so Plan 1B can reference
the post-rename names throughout. Merges main afterward to pick up any
additional rename-commit changes (docs, extension, etc.)."
```

- [ ] **Step 7: Re-run core test suite to confirm no regression**

```bash
cargo test -p relicario-core 2>&1 | tail -5
```

Expected: all tests pass. If anything fails, stop and investigate — a missed rename is the most likely cause.

---

## Task 2: Core imgsecret dimension guard (audit M3)

**Files:**
- Modify: `crates/relicario-core/src/imgsecret.rs`
- Test: `crates/relicario-core/src/imgsecret.rs` (inline `#[cfg(test)] mod tests`)

**Why:** audit M3 — an attacker-supplied 32000×32000 JPEG wedges the WASM service worker for tens of seconds during `extract_with_crop_recovery`. Cap the dimension and peek before full decode.

- [ ] **Step 1: Write the failing test**

Add to the existing inline tests block in `imgsecret.rs`:

```rust
#[test]
fn rejects_oversized_image_without_full_decode() {
    // Synthesize a JPEG header claiming 20000x20000 dimensions.
    // The actual pixel data is irrelevant — the dimension peek should bail out
    // before decoding any pixels.
    let jpeg = build_oversized_jpeg_header(20_000, 20_000);
    let result = extract(&jpeg);
    assert!(matches!(result, Err(RelicarioError::ImgSecret(ref msg)) if msg.contains("dimension")));
}

fn build_oversized_jpeg_header(width: u16, height: u16) -> Vec<u8> {
    // SOI + APP0 JFIF + SOF0 declaring width/height + SOS with minimal data + EOI
    let mut v = vec![0xFF, 0xD8];                                   // SOI
    v.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x10]);                 // APP0
    v.extend_from_slice(b"JFIF\0");
    v.extend_from_slice(&[0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00]);
    v.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 0x08]);           // SOF0
    v.extend_from_slice(&height.to_be_bytes());
    v.extend_from_slice(&width.to_be_bytes());
    v.extend_from_slice(&[0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01]);
    v.extend_from_slice(&[0xFF, 0xD9]);                              // EOI
    v
}
```

- [ ] **Step 2: Run the test — expect FAIL**

```bash
cargo test -p relicario-core imgsecret::tests::rejects_oversized_image_without_full_decode
```

Expected: fail, because extract currently tries to full-decode and either panics/slows.

- [ ] **Step 3: Implement the guard**

Near the top of `imgsecret.rs`:

```rust
pub const MAX_DIMENSION: u32 = 10_000;

fn peek_jpeg_dimensions(jpeg: &[u8]) -> Result<(u32, u32)> {
    // Walk JPEG markers until we hit an SOF (start-of-frame) marker, which
    // carries the image dimensions in bytes 5..=8 of its segment.
    let mut i = 0;
    while i + 1 < jpeg.len() {
        if jpeg[i] != 0xFF { i += 1; continue; }
        let marker = jpeg[i + 1];
        match marker {
            0xD8 | 0xD9 => { i += 2; continue; }          // SOI / EOI
            0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => {
                // SOFn — height in [i+5..i+7], width in [i+7..i+9]. Last byte
                // read is jpeg[i+8], so the guard is `i + 8 >= len`.
                if i + 8 >= jpeg.len() {
                    return Err(RelicarioError::ImgSecret("truncated SOF marker".into()));
                }
                let height = u16::from_be_bytes([jpeg[i + 5], jpeg[i + 6]]) as u32;
                let width = u16::from_be_bytes([jpeg[i + 7], jpeg[i + 8]]) as u32;
                return Ok((width, height));
            }
            _ => {
                if i + 3 >= jpeg.len() {
                    return Err(RelicarioError::ImgSecret("truncated marker segment".into()));
                }
                let seg_len = u16::from_be_bytes([jpeg[i + 2], jpeg[i + 3]]) as usize;
                i += 2 + seg_len;
            }
        }
    }
    Err(RelicarioError::ImgSecret("no SOF marker found in JPEG".into()))
}

fn enforce_dimension_cap(jpeg: &[u8]) -> Result<()> {
    let (w, h) = peek_jpeg_dimensions(jpeg)?;
    if w > MAX_DIMENSION || h > MAX_DIMENSION {
        return Err(RelicarioError::ImgSecret(format!(
            "image dimensions {w}x{h} exceed {MAX_DIMENSION}x{MAX_DIMENSION} cap"
        )));
    }
    Ok(())
}
```

Add `enforce_dimension_cap(jpeg)?;` as the first line of both `extract(…)` and `embed(…)` in `imgsecret.rs`. (Find them by their `pub fn` signatures.)

- [ ] **Step 4: Run the test again — expect PASS**

```bash
cargo test -p relicario-core imgsecret::tests::rejects_oversized_image_without_full_decode
```

Expected: pass.

- [ ] **Step 5: Re-run all core tests to catch regressions**

```bash
cargo test -p relicario-core
```

Expected: all pass.

- [ ] **Step 6: Commit**

```bash
git add crates/relicario-core/src/imgsecret.rs
git commit -m "fix(core): cap imgsecret MAX_DIMENSION at 10000px (audit M3)"
```

---

## Task 3: CLI Cargo.toml + helpers (rpassword 7, zeroize, chrono; vault_dir/L8, git_command/H4, iso8601/M11)

**Files:**
- Modify: `crates/relicario-cli/Cargo.toml`
- Create: `crates/relicario-cli/src/helpers.rs`

**What this task lands:**
- H7: `rpassword = "7"` (drops `prompt_password_stderr`, uses `prompt_password` + eprintln).
- L8: `vault_dir()` walks up from CWD looking for `.relicario/`, refuses otherwise.
- H4: `git_command()` central helper with hardened env.
- M11: `iso8601(unix_seconds) -> String` using `chrono::DateTime::from_timestamp`.
- Adds `zeroize`, `chrono`, `tempfile` (dev), `assert_cmd` (dev), `predicates` (dev).

- [ ] **Step 1: Update Cargo.toml**

Rewrite `crates/relicario-cli/Cargo.toml`:

```toml
[package]
name = "relicario-cli"
version = "0.1.0"
edition = "2021"
description = "CLI for relicario password manager"

[[bin]]
name = "relicario"
path = "src/main.rs"

[dependencies]
relicario-core = { path = "../relicario-core" }
clap = { version = "4", features = ["derive"] }
anyhow = "1"
rpassword = "7"
arboard = "3"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
dirs = "5"
hex = "0.4"
ed25519-dalek = { version = "2", features = ["rand_core"] }
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
zeroize = "1"

[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"
```

- [ ] **Step 2: Write failing tests for helpers**

Create `crates/relicario-cli/src/helpers.rs`:

```rust
#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn vault_dir_finds_marker_in_cwd() {
        let tmp = TempDir::new().unwrap();
        std::fs::create_dir(tmp.path().join(".relicario")).unwrap();
        let found = find_vault_dir_from(tmp.path()).unwrap();
        assert_eq!(found, tmp.path());
    }

    #[test]
    fn vault_dir_finds_marker_in_parent() {
        let tmp = TempDir::new().unwrap();
        std::fs::create_dir(tmp.path().join(".relicario")).unwrap();
        let subdir = tmp.path().join("sub/nested");
        std::fs::create_dir_all(&subdir).unwrap();
        let found = find_vault_dir_from(&subdir).unwrap();
        assert_eq!(found, tmp.path());
    }

    #[test]
    fn vault_dir_errors_when_missing() {
        let tmp = TempDir::new().unwrap();
        let err = find_vault_dir_from(tmp.path()).unwrap_err();
        assert!(err.to_string().contains(".relicario"));
    }

    #[test]
    fn iso8601_formats_fixed_timestamp() {
        // 2026-04-19T00:00:00Z = 1776556800
        assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z");
    }
}
```

- [ ] **Step 3: Run tests — expect FAIL (undefined)**

```bash
cargo test -p relicario-cli --lib
```

Expected: compile error (`find_vault_dir_from`, `iso8601` not found).

- [ ] **Step 4: Implement the helpers**

Complete `crates/relicario-cli/src/helpers.rs`:

```rust
//! CLI-side helpers: vault dir detection, hardened git shell-out, ISO-8601
//! timestamp formatting. Kept in their own module so every command handler
//! stays terse.

use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{bail, Context, Result};
use chrono::DateTime;

/// Walk up from `start` looking for a directory containing `.relicario/`.
/// Returns the vault root (the directory that contains `.relicario/`).
/// Audit L8: refuses to operate outside an initialized vault.
pub fn find_vault_dir_from(start: &Path) -> Result<PathBuf> {
    let mut cur = start.to_path_buf();
    loop {
        if cur.join(".relicario").is_dir() {
            return Ok(cur);
        }
        if !cur.pop() {
            bail!(
                "no .relicario/ directory found in {} or any parent — \
                 run `relicario init` first",
                start.display()
            );
        }
    }
}

/// Convenience wrapper that starts the search from `std::env::current_dir()`.
pub fn vault_dir() -> Result<PathBuf> {
    let cwd = std::env::current_dir().context("failed to get current directory")?;
    find_vault_dir_from(&cwd)
}

/// Path to the `.relicario/` configuration directory within the vault.
pub fn relicario_dir() -> Result<PathBuf> {
    Ok(vault_dir()?.join(".relicario"))
}

/// Build a hardened `git` command — no hooks, no GPG signing, no editor.
/// Audit H4: prevents vault mutations from running hostile hooks, blocking on
/// GPG passphrase prompts (which would hold the master key alive), or entering
/// $EDITOR during rebase conflict markers.
pub fn git_command(repo: &Path, args: &[&str]) -> Command {
    let mut cmd = Command::new("git");
    cmd.current_dir(repo);
    cmd.args([
        "-c", "core.hooksPath=/dev/null",
        "-c", "commit.gpgsign=false",
        "-c", "core.editor=true",
    ]);
    cmd.args(args);
    cmd
}

/// Format a Unix-seconds timestamp as an ISO-8601 UTC string.
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
/// a numeric string.
pub fn iso8601(unix_seconds: i64) -> String {
    DateTime::from_timestamp(unix_seconds, 0)
        .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
        .unwrap_or_else(|| format!("invalid-timestamp:{unix_seconds}"))
}
```

Wire the module into the binary by adding to `crates/relicario-cli/src/main.rs` (near the top, after the `use` block):

```rust
mod helpers;
```

- [ ] **Step 5: Run tests — expect PASS**

```bash
cargo test -p relicario-cli --lib
```

Expected: 4 passing.

- [ ] **Step 6: Commit**

```bash
git add crates/relicario-cli/Cargo.toml crates/relicario-cli/src/helpers.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli): add helpers module (vault_dir/L8, git_command/H4, iso8601/M11)

Bumps rpassword to 7.x (H7) and adds zeroize/chrono/assert_cmd dev-deps."
```

---

## Task 4: CLI unlock flow — `UnlockedVault` session struct

**Files:**
- Create: `crates/relicario-cli/src/session.rs`
- Modify: `crates/relicario-cli/src/main.rs` (`mod session;`)

**What this task lands:** a single `UnlockedVault` struct holding the derived master key in `Zeroizing<[u8; 32]>`, plus `unlock_interactive()` that prompts for passphrase, loads the reference image, extracts the image secret, derives the key, and returns the struct. Every command handler calls `UnlockedVault::unlock()` once and then goes through its helper methods (`load_manifest`, `save_manifest`, `load_item`, `save_item`, etc.).

- [ ] **Step 1: Define the UnlockedVault skeleton**

Create `crates/relicario-cli/src/session.rs`:

```rust
//! Unlocked-vault session: the shape every vault-mutating command works with.
//!
//! Holds the derived master key in `Zeroizing<[u8; 32]>` for the lifetime of a
//! CLI invocation. Drops it (via Zeroize) when the struct goes out of scope.

use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use zeroize::Zeroizing;

use relicario_core::{
    decrypt_item, decrypt_manifest, decrypt_settings,
    derive_master_key, encrypt_item, encrypt_manifest, encrypt_settings,
    imgsecret, Item, ItemId, KdfParams, Manifest, VaultSettings,
};

use crate::helpers::{relicario_dir, vault_dir};

/// A vault whose master key has been derived and is held in memory.
/// The key is wiped via `Zeroize` when this struct drops.
pub struct UnlockedVault {
    root: PathBuf,
    master_key: Zeroizing<[u8; 32]>,
}

impl UnlockedVault {
    pub fn root(&self) -> &Path { &self.root }
    pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.master_key }

    /// Full interactive unlock flow: locate vault, prompt passphrase, locate
    /// reference image, derive master key.
    pub fn unlock_interactive() -> Result<Self> {
        let root = vault_dir()?;
        let salt = read_salt(&root)?;
        let params = read_params(&root)?;
        let image_path = get_image_path()?;
        let image_bytes = fs::read(&image_path)
            .with_context(|| format!("failed to read reference image {}", image_path.display()))?;
        let image_secret = imgsecret::extract(&image_bytes)?;

        let passphrase = Zeroizing::new(
            rpassword::prompt_password("Passphrase: ")
                .context("failed to read passphrase")?
        );

        let master_key = derive_master_key(
            passphrase.as_bytes(),
            &image_secret,
            &salt,
            &params,
        )?;

        Ok(Self { root, master_key })
    }

    pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") }
    pub fn settings_path(&self) -> PathBuf { self.root.join("settings.enc") }
    pub fn item_path(&self, id: &ItemId) -> PathBuf {
        self.root.join("items").join(format!("{}.enc", id.as_str()))
    }

    pub fn load_manifest(&self) -> Result<Manifest> {
        let bytes = fs::read(self.manifest_path()).context("failed to read manifest.enc")?;
        Ok(decrypt_manifest(&bytes, &self.master_key)?)
    }

    pub fn save_manifest(&self, manifest: &Manifest) -> Result<()> {
        let bytes = encrypt_manifest(manifest, &self.master_key)?;
        atomic_write(&self.manifest_path(), &bytes)
    }

    pub fn load_settings(&self) -> Result<VaultSettings> {
        let bytes = fs::read(self.settings_path()).context("failed to read settings.enc")?;
        Ok(decrypt_settings(&bytes, &self.master_key)?)
    }

    pub fn save_settings(&self, settings: &VaultSettings) -> Result<()> {
        let bytes = encrypt_settings(settings, &self.master_key)?;
        atomic_write(&self.settings_path(), &bytes)
    }

    pub fn load_item(&self, id: &ItemId) -> Result<Item> {
        let bytes = fs::read(self.item_path(id))
            .with_context(|| format!("failed to read item {}", id.as_str()))?;
        Ok(decrypt_item(&bytes, &self.master_key)?)
    }

    pub fn save_item(&self, item: &Item) -> Result<()> {
        let path = self.item_path(&item.id);
        if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; }
        let bytes = encrypt_item(item, &self.master_key)?;
        atomic_write(&path, &bytes)
    }
}

fn read_salt(root: &Path) -> Result<[u8; 32]> {
    let data = fs::read(root.join(".relicario").join("salt"))
        .context("failed to read .relicario/salt")?;
    if data.len() != 32 { bail!("invalid salt length: {}", data.len()); }
    let mut salt = [0u8; 32];
    salt.copy_from_slice(&data);
    Ok(salt)
}

fn read_params(root: &Path) -> Result<KdfParams> {
    let s = fs::read_to_string(root.join(".relicario").join("params.json"))
        .context("failed to read .relicario/params.json")?;
    let params: KdfParams = serde_json::from_str(&s).context("failed to parse params.json")?;
    Ok(params)
}

/// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt.
pub fn get_image_path() -> Result<PathBuf> {
    if let Ok(path) = std::env::var("RELICARIO_IMAGE") {
        return Ok(PathBuf::from(path));
    }
    // Also accept <vault_root>/reference.jpg as a convention.
    if let Ok(root) = vault_dir() {
        let default = root.join("reference.jpg");
        if default.exists() { return Ok(default); }
    }
    eprint!("Reference image path: ");
    std::io::Write::flush(&mut std::io::stderr())?;
    let mut line = String::new();
    std::io::stdin().read_line(&mut line)?;
    let trimmed = line.trim();
    if trimmed.is_empty() { bail!("no reference image path provided"); }
    Ok(PathBuf::from(trimmed))
}

/// Atomic write: write to <path>.tmp, then rename over <path>. Keeps the
/// vault file consistent if we crash mid-write.
fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
    let tmp = path.with_extension("tmp");
    fs::write(&tmp, data).with_context(|| format!("failed to write {}", tmp.display()))?;
    fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?;
    Ok(())
}
```

Add `mod session;` under the existing `mod helpers;` in `crates/relicario-cli/src/main.rs`.

- [ ] **Step 2: Verify it compiles**

```bash
cargo build -p relicario-cli 2>&1 | head -30
```

Expected: compile errors come ONLY from the stale command handlers in `main.rs` (which we'll rewrite in later tasks), not from `session.rs` itself. If `session.rs` itself errors, fix it before continuing.

- [ ] **Step 3: Commit the partial state**

`main.rs` will not fully compile yet — that's expected; the old `Entry`-based handlers still exist and reference the v1 API. Commit the new session module so the task boundary is clean. The build break is a known transient state that Task 5 starts repairing.

```bash
git add crates/relicario-cli/src/session.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli): add UnlockedVault session wrapping master_key in Zeroizing

Provides load/save helpers for Manifest/Settings/Item; atomic_write keeps
vault files consistent across crashes. main.rs is transiently broken
against the old Entry API — Task 5+ rewrites the command handlers."
```

---

## Task 5: Strip the old command handlers; introduce the typed-item CLI skeleton

**Files:**
- Rewrite: `crates/relicario-cli/src/main.rs`

**What lands:** the clap struct covering every Plan 1B subcommand (even ones whose bodies are stubs returning `bail!("not yet implemented")`). This gets the binary compiling again and gives later tasks a stable clap surface to fill in.

- [ ] **Step 1: Write the new main.rs shell**

Overwrite `crates/relicario-cli/src/main.rs`:

```rust
//! relicario CLI — the platform layer for the relicario password manager.
//!
//! See module docs for the unlock flow and vault layout.

mod helpers;
mod session;

use std::path::PathBuf;

use anyhow::{bail, Result};
use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(
    name = "relicario",
    version,
    about = "Git-backed password manager with reference-image two-factor unlock"
)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Initialize a new vault in the current directory.
    Init {
        /// Carrier JPEG to embed the secret into.
        #[arg(long)]
        image: PathBuf,
        /// Output path for the reference image (gitignored).
        #[arg(long, default_value = "reference.jpg")]
        output: PathBuf,
    },

    /// Add a new item. Type-specific flags populate the core; missing fields
    /// are prompted for interactively.
    Add {
        #[command(subcommand)]
        kind: AddKind,
    },

    /// Print an item. Secrets are masked by default; pass --show to reveal.
    Get {
        /// Item id or case-insensitive title substring.
        query: String,
        /// Print secret field values in plaintext.
        #[arg(long)]
        show: bool,
        /// Copy the primary secret (Login.password, Card.number, etc.) to clipboard.
        #[arg(long)]
        copy: bool,
    },

    /// List items.
    List {
        #[arg(long)]
        r#type: Option<String>,
        #[arg(long)]
        group: Option<String>,
        #[arg(long)]
        tag: Option<String>,
        #[arg(long)]
        trashed: bool,
    },

    /// Edit an item interactively.
    Edit { query: String },

    /// Soft-delete an item (moves to trash; reversible via `restore`).
    Rm { query: String },

    /// Restore a soft-deleted item.
    Restore { query: String },

    /// Permanently purge an item (and its attachments).
    Purge { query: String },

    /// Trash operations.
    Trash {
        #[command(subcommand)]
        action: TrashAction,
    },

    /// Attach a file to an item.
    Attach { query: String, file: PathBuf },

    /// List attachments on an item.
    Attachments { query: String },

    /// Extract an attachment to disk.
    Extract {
        query: String,
        aid: String,
        #[arg(long)]
        out: Option<PathBuf>,
    },

    /// Generate a password or passphrase.
    Generate {
        #[arg(long, default_value_t = 20)]
        length: u32,
        #[arg(long)]
        bip39: bool,
        #[arg(long, default_value_t = 5)]
        words: u32,
        #[arg(long, default_value = "safe")]
        symbols: String,
        /// Separator for BIP39 words.
        #[arg(long, default_value = " ")]
        separator: String,
    },

    /// View or change vault settings.
    Settings {
        #[command(subcommand)]
        action: SettingsAction,
    },

    /// Sync with the git remote (pull --rebase + push).
    Sync,

    /// Device management.
    Device {
        #[command(subcommand)]
        action: DeviceAction,
    },

    /// Lock the vault (no-op in CLI; present for UX parity with the extension).
    Lock,
}

#[derive(Subcommand)]
enum AddKind {
    Login {
        #[arg(long)] title: Option<String>,
        #[arg(long)] username: Option<String>,
        #[arg(long)] url: Option<String>,
        /// Prompt for password (vs reading from stdin or --password).
        #[arg(long)] password_prompt: bool,
        #[arg(long)] password: Option<String>,
        #[arg(long)] group: Option<String>,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
        #[arg(long)] favorite: bool,
    },
    SecureNote {
        #[arg(long)] title: Option<String>,
        #[arg(long)] body_prompt: bool,
        #[arg(long)] group: Option<String>,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
    },
    Identity {
        #[arg(long)] title: Option<String>,
        #[arg(long)] full_name: Option<String>,
        #[arg(long)] email: Option<String>,
        #[arg(long)] phone: Option<String>,
        #[arg(long)] date_of_birth: Option<String>,
        #[arg(long)] group: Option<String>,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
    },
    Card {
        #[arg(long)] title: Option<String>,
        #[arg(long)] holder: Option<String>,
        #[arg(long)] expiry: Option<String>, // MM/YYYY
        #[arg(long, default_value = "credit")] kind: String,
        #[arg(long)] group: Option<String>,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
    },
    Key {
        #[arg(long)] title: Option<String>,
        #[arg(long)] label: Option<String>,
        #[arg(long)] algorithm: Option<String>,
        #[arg(long)] group: Option<String>,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
    },
    Document {
        #[arg(long)] title: Option<String>,
        #[arg(long)] file: PathBuf,
        #[arg(long)] group: Option<String>,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
    },
    Totp {
        #[arg(long)] title: Option<String>,
        #[arg(long)] issuer: Option<String>,
        #[arg(long)] label: Option<String>,
        #[arg(long)] secret: Option<String>, // base32
        #[arg(long, default_value = "30")] period: u32,
        #[arg(long, default_value = "6")] digits: u8,
        #[arg(long, default_value = "sha1")] algorithm: String,
        #[arg(long)] group: Option<String>,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
    },
}

#[derive(Subcommand)]
enum TrashAction {
    /// List trashed items.
    List,
    /// Purge every trashed item past its retention window.
    Empty,
}

#[derive(Subcommand)]
enum SettingsAction {
    /// Show current settings as JSON.
    Show,
    /// Set trash retention (e.g., --days 30 or --forever).
    TrashRetention {
        #[arg(long)] days: Option<u32>,
        #[arg(long)] forever: bool,
    },
    /// Set field history retention.
    HistoryRetention {
        #[arg(long)] last_n: Option<u32>,
        #[arg(long)] days: Option<u32>,
        #[arg(long)] forever: bool,
    },
    /// Set per-attachment max size in bytes.
    AttachmentCap {
        #[arg(long)] per_attachment_max_bytes: Option<u64>,
        #[arg(long)] per_item_max_count: Option<u32>,
        #[arg(long)] per_vault_soft_cap_bytes: Option<u64>,
        #[arg(long)] per_vault_hard_cap_bytes: Option<u64>,
    },
}

#[derive(Subcommand)]
enum DeviceAction {
    Add { #[arg(long)] name: String },
    List,
    Revoke { name: String },
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    match cli.command {
        Commands::Init { image, output } => cmd_init(image, output),
        Commands::Add { kind } => cmd_add(kind),
        Commands::Get { query, show, copy } => cmd_get(query, show, copy),
        Commands::List { r#type, group, tag, trashed } => cmd_list(r#type, group, tag, trashed),
        Commands::Edit { query } => cmd_edit(query),
        Commands::Rm { query } => cmd_rm(query),
        Commands::Restore { query } => cmd_restore(query),
        Commands::Purge { query } => cmd_purge(query),
        Commands::Trash { action } => cmd_trash(action),
        Commands::Attach { query, file } => cmd_attach(query, file),
        Commands::Attachments { query } => cmd_attachments(query),
        Commands::Extract { query, aid, out } => cmd_extract(query, aid, out),
        Commands::Generate { length, bip39, words, symbols, separator } => {
            cmd_generate(length, bip39, words, symbols, separator)
        }
        Commands::Settings { action } => cmd_settings(action),
        Commands::Sync => cmd_sync(),
        Commands::Device { action } => cmd_device(action),
        Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) }
    }
}

fn cmd_init(_image: PathBuf, _output: PathBuf) -> Result<()> { bail!("not yet implemented"); }
fn cmd_add(_kind: AddKind) -> Result<()> { bail!("not yet implemented"); }
fn cmd_get(_query: String, _show: bool, _copy: bool) -> Result<()> { bail!("not yet implemented"); }
fn cmd_list(_t: Option<String>, _g: Option<String>, _tag: Option<String>, _trashed: bool) -> Result<()> { bail!("not yet implemented"); }
fn cmd_edit(_query: String) -> Result<()> { bail!("not yet implemented"); }
fn cmd_rm(_query: String) -> Result<()> { bail!("not yet implemented"); }
fn cmd_restore(_query: String) -> Result<()> { bail!("not yet implemented"); }
fn cmd_purge(_query: String) -> Result<()> { bail!("not yet implemented"); }
fn cmd_trash(_action: TrashAction) -> Result<()> { bail!("not yet implemented"); }
fn cmd_attach(_q: String, _file: PathBuf) -> Result<()> { bail!("not yet implemented"); }
fn cmd_attachments(_q: String) -> Result<()> { bail!("not yet implemented"); }
fn cmd_extract(_q: String, _aid: String, _out: Option<PathBuf>) -> Result<()> { bail!("not yet implemented"); }
fn cmd_generate(_l: u32, _b: bool, _w: u32, _s: String, _sep: String) -> Result<()> { bail!("not yet implemented"); }
fn cmd_settings(_a: SettingsAction) -> Result<()> { bail!("not yet implemented"); }
fn cmd_sync() -> Result<()> { bail!("not yet implemented"); }
fn cmd_device(_a: DeviceAction) -> Result<()> { bail!("not yet implemented"); }
```

- [ ] **Step 2: Build**

```bash
cargo build -p relicario-cli
```

Expected: clean build. If not, fix.

- [ ] **Step 3: Smoke-test the clap surface**

```bash
cargo run -p relicario-cli -- --help
cargo run -p relicario-cli -- add login --help
cargo run -p relicario-cli -- settings trash-retention --help
```

Expected: usage help prints for each. No panic.

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-cli/src/main.rs
git commit -m "feat(cli): scaffold clap surface for all typed-item commands

Every subcommand from the Plan 1B CLI spec present; bodies return
'not yet implemented' so subsequent tasks land one command at a time."
```

---

## Task 6: `relicario init` — create a format-v2 vault

**Files:**
- Modify: `crates/relicario-cli/src/main.rs` (`cmd_init`)

**Spec match:** the created vault layout is:

```
<cwd>/
  .relicario/
    salt                32 random bytes
    params.json         { format_version: 2, kdf: { argon2_m, argon2_t, argon2_p }, ... }
    devices.json        []
  items/                (empty)
  attachments/          (empty)
  manifest.enc          empty Manifest (schema_version 2)
  settings.enc          VaultSettings::default()
  reference.jpg         the carrier with the embedded secret
  .gitignore            excludes reference.jpg
```

- [ ] **Step 1: Implement `cmd_init`**

Replace the stub `cmd_init` in `main.rs`:

```rust
fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
    use std::fs;
    use rand::{rngs::OsRng, RngCore};
    use relicario_core::{
        derive_master_key, encrypt_manifest, encrypt_settings, imgsecret,
        validate_passphrase_strength, KdfParams, Manifest, VaultSettings,
    };
    use zeroize::Zeroizing;

    let root = std::env::current_dir()?;
    let relicario_dir = root.join(".relicario");
    if relicario_dir.exists() {
        anyhow::bail!(".relicario/ already exists in {}", root.display());
    }

    // Passphrase with strength gate (audit H3).
    let passphrase = Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?);
    let confirm = 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!("{}. Choose a longer or more entropic phrase.", e);
    }

    // Image secret: 32 random bytes, embedded in the carrier.
    let mut image_secret = [0u8; 32];
    OsRng.fill_bytes(&mut image_secret);
    let carrier = fs::read(&image)
        .with_context(|| format!("failed to read carrier image {}", image.display()))?;
    let stego = imgsecret::embed(&carrier, &image_secret)?;
    fs::write(&output, &stego)
        .with_context(|| format!("failed to write reference image {}", output.display()))?;

    // Vault salt + KDF params.
    let mut salt = [0u8; 32];
    OsRng.fill_bytes(&mut salt);
    let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };

    // Derive master key, then persist an empty Manifest + default VaultSettings.
    let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, &params)?;

    fs::create_dir_all(&relicario_dir)?;
    fs::create_dir_all(root.join("items"))?;
    fs::create_dir_all(root.join("attachments"))?;
    fs::write(relicario_dir.join("salt"), salt)?;
    fs::write(
        relicario_dir.join("params.json"),
        serde_json::to_string_pretty(&ParamsFile {
            format_version: 2,
            kdf: ParamsKdf {
                algorithm: "argon2id-v0x13".into(),
                argon2_m: params.argon2_m,
                argon2_t: params.argon2_t,
                argon2_p: params.argon2_p,
            },
            aead: "xchacha20poly1305".into(),
            salt_path: ".relicario/salt".into(),
        })?,
    )?;
    fs::write(relicario_dir.join("devices.json"), b"[]")?;

    let manifest = Manifest::new();
    fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?;
    let settings = VaultSettings::default();
    fs::write(root.join("settings.enc"), encrypt_settings(&settings, &master_key)?)?;

    // .gitignore excludes the reference image.
    let gitignore = format!("{}\n", output.file_name().unwrap().to_string_lossy());
    fs::write(root.join(".gitignore"), gitignore)?;

    // git init + initial commit via hardened wrapper.
    let status = crate::helpers::git_command(&root, &["init"]).status()?;
    if !status.success() { anyhow::bail!("git init failed"); }
    let _ = crate::helpers::git_command(&root, &[
        "add", ".gitignore", ".relicario/params.json", ".relicario/devices.json",
        "manifest.enc", "settings.enc",
    ]).status()?;
    let status = crate::helpers::git_command(&root, &[
        "commit", "-m", "init: new relicario vault (format v2)",
    ]).status()?;
    if !status.success() { anyhow::bail!("git commit failed"); }

    eprintln!("Vault initialized at {}", root.display());
    eprintln!("Reference image: {}", output.display());
    eprintln!("  → back this file up somewhere safe; it is your second factor.");
    Ok(())
}

#[derive(serde::Serialize)]
struct ParamsFile {
    format_version: u32,
    kdf: ParamsKdf,
    aead: String,
    salt_path: String,
}

#[derive(serde::Serialize)]
#[serde(rename_all = "snake_case")]
struct ParamsKdf {
    algorithm: String,
    argon2_m: u32,
    argon2_t: u32,
    argon2_p: u32,
}
```

Also add `use anyhow::Context;` to the imports at the top of `main.rs`.

- [ ] **Step 2: Build**

```bash
cargo build -p relicario-cli
```

Expected: clean.

- [ ] **Step 3: Manual smoke test**

```bash
# In a throwaway dir:
mkdir -p /tmp/relicario-smoke && cd /tmp/relicario-smoke
# Create a small synthetic carrier (requires ImageMagick `magick`):
magick -size 800x600 gradient:black-red carrier.jpg
cargo run --manifest-path /home/alee/Sources/relicario/.worktrees/typed-items-1b/Cargo.toml \
  -p relicario-cli -- init --image carrier.jpg --output reference.jpg
# Expected: prompts twice for passphrase (use a strong one like "correct horse battery staple"),
# then creates .relicario/, items/, attachments/, manifest.enc, settings.enc, .gitignore, reference.jpg.
ls -la .relicario/
cat .relicario/params.json
```

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-cli/src/main.rs
git commit -m "feat(cli): relicario init creates a format-v2 vault

Prompts for a strong passphrase (zxcvbn gate via core), generates a
32-byte image secret, embeds it in the carrier JPEG, writes the
standard vault skeleton, and makes an initial git commit via the
hardened git_command helper."
```

---

## Task 7: `relicario add login` — dispatcher + Login variant (flag + interactive)

**Files:**
- Modify: `crates/relicario-cli/src/main.rs` (`cmd_add` for `AddKind::Login`)

**Pattern established here is reused by Task 8 for the other six variants.**

- [ ] **Step 1: Implement `cmd_add` dispatcher + Login**

Replace `cmd_add`:

```rust
fn cmd_add(kind: AddKind) -> Result<()> {
    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let mut manifest = vault.load_manifest()?;

    let item = match kind {
        AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite } => {
            use relicario_core::item_types::LoginCore;
            use relicario_core::{Item, ItemCore};
            use zeroize::Zeroizing;

            let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
            let username = username.or_else(|| prompt_optional("Username").ok().flatten());
            let url = url.or_else(|| prompt_optional("URL").ok().flatten());
            let parsed_url = match url {
                Some(s) => Some(url::Url::parse(&s)
                    .with_context(|| format!("invalid URL: {s}"))?),
                None => None,
            };
            let password = if let Some(p) = password {
                Some(Zeroizing::new(p))
            } else if password_prompt {
                Some(Zeroizing::new(rpassword::prompt_password("Password: ")?))
            } else {
                None
            };
            let core = ItemCore::Login(LoginCore {
                username,
                password,
                url: parsed_url,
                totp: None,
            });
            let mut item = Item::new(title, core);
            item.group = group;
            item.tags = tags;
            item.favorite = favorite;
            item
        }
        // Task 8 fills in the other variants.
        _ => anyhow::bail!("item kind not yet implemented"),
    };

    vault.save_item(&item)?;
    manifest.upsert(&item);
    vault.save_manifest(&manifest)?;

    commit_paths(&vault, &format!("add: {} ({})", item.title, item.id.as_str()),
                 &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;

    eprintln!("Added: {} (id={})", item.title, item.id.as_str());
    Ok(())
}

fn prompt(label: &str) -> Result<String> {
    eprint!("{label}: ");
    std::io::Write::flush(&mut std::io::stderr())?;
    let mut s = String::new();
    std::io::stdin().read_line(&mut s)?;
    let trimmed = s.trim().to_string();
    if trimmed.is_empty() { anyhow::bail!("{label} required"); }
    Ok(trimmed)
}

fn prompt_optional(label: &str) -> Result<Option<String>> {
    eprint!("{label} (leave blank to skip): ");
    std::io::Write::flush(&mut std::io::stderr())?;
    let mut s = String::new();
    std::io::stdin().read_line(&mut s)?;
    let trimmed = s.trim().to_string();
    Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}

fn commit_paths(vault: &crate::session::UnlockedVault, message: &str, paths: &[&str]) -> Result<()> {
    let mut args: Vec<&str> = vec!["add"];
    args.extend_from_slice(paths);
    let status = crate::helpers::git_command(vault.root(), &args).status()?;
    if !status.success() { anyhow::bail!("git add failed"); }
    let status = crate::helpers::git_command(vault.root(), &["commit", "-m", message]).status()?;
    if !status.success() { anyhow::bail!("git commit failed"); }
    Ok(())
}
```

- [ ] **Step 2: Build + manual smoke**

```bash
cargo build -p relicario-cli
# In /tmp/relicario-smoke (vault from Task 6):
export RELICARIO_IMAGE=$PWD/reference.jpg
cargo run --manifest-path <worktree>/Cargo.toml -p relicario-cli -- add login \
  --title "GitHub" --username alice --url https://github.com --password-prompt
# Enter passphrase, enter item password.
ls items/
git log --oneline
```

Expected: one `items/<id>.enc`, manifest updated, one git commit titled `add: GitHub (…)`.

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-cli/src/main.rs
git commit -m "feat(cli): relicario add login with flag + interactive prompting

Unlocks vault, builds LoginCore from flags (password via rpassword if
--password-prompt), saves item + manifest, commits via hardened git."
```

---

## Task 8: Remaining `add` variants (SecureNote, Identity, Card, Key, Document, Totp)

**Files:**
- Modify: `crates/relicario-cli/src/main.rs` (extend the match in `cmd_add`)

Follow the Login pattern: build the `*Core` from flags, fall back to interactive prompts for missing required fields, assemble the `Item`, save, commit.

- [ ] **Step 1: Implement SecureNote**

Add under the Login match arm:

```rust
AddKind::SecureNote { title, body_prompt, group, tags } => {
    use relicario_core::item_types::SecureNoteCore;
    use relicario_core::{Item, ItemCore};
    use zeroize::Zeroizing;

    let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
    let body = if body_prompt {
        eprintln!("Enter note body; end with Ctrl-D on a blank line:");
        let mut s = String::new();
        std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
        s
    } else {
        prompt("Body")?
    };
    let mut item = Item::new(title, ItemCore::SecureNote(SecureNoteCore {
        body: Zeroizing::new(body),
    }));
    item.group = group;
    item.tags = tags;
    item
}
```

- [ ] **Step 2: Implement Identity**

```rust
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } => {
    use relicario_core::item_types::IdentityCore;
    use relicario_core::{Item, ItemCore};

    let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
    let dob = match date_of_birth {
        Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
            .with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
        None => None,
    };
    let mut item = Item::new(title, ItemCore::Identity(IdentityCore {
        full_name,
        address: None,
        phone,
        email,
        date_of_birth: dob,
    }));
    item.group = group;
    item.tags = tags;
    item
}
```

- [ ] **Step 3: Implement Card**

```rust
AddKind::Card { title, holder, expiry, kind, group, tags } => {
    use relicario_core::item_types::{CardCore, CardKind};
    use relicario_core::{Item, ItemCore, MonthYear};
    use zeroize::Zeroizing;

    let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
    let number = Zeroizing::new(rpassword::prompt_password("Card number: ")?);
    let cvv = Zeroizing::new(rpassword::prompt_password("CVV (blank to skip): ")?);
    let cvv = if cvv.is_empty() { None } else { Some(cvv) };
    let pin = Zeroizing::new(rpassword::prompt_password("PIN (blank to skip): ")?);
    let pin = if pin.is_empty() { None } else { Some(pin) };

    let parsed_expiry = match expiry {
        Some(s) => Some(parse_month_year(&s)?),
        None => None,
    };
    let parsed_kind = match kind.as_str() {
        "credit" => CardKind::Credit,
        "debit"  => CardKind::Debit,
        "gift"   => CardKind::Gift,
        "loyalty"=> CardKind::Loyalty,
        "other"  => CardKind::Other,
        other => anyhow::bail!("unknown card kind: {other}"),
    };

    let mut item = Item::new(title, ItemCore::Card(CardCore {
        number: Some(number),
        holder,
        expiry: parsed_expiry,
        cvv,
        pin,
        kind: parsed_kind,
    }));
    item.group = group;
    item.tags = tags;
    item
}
```

Add the helper:

```rust
fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
    // Accepts MM/YYYY or MM-YYYY or MM/YY.
    let (m_str, y_str) = s.split_once(|c: char| c == '/' || c == '-')
        .ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
    let month: u8 = m_str.parse().context("invalid month")?;
    let year: i32 = if y_str.len() == 2 {
        2000 + y_str.parse::<i32>().context("invalid 2-digit year")?
    } else {
        y_str.parse().context("invalid year")?
    };
    Ok(relicario_core::MonthYear { month, year })
}
```

- [ ] **Step 4: Implement Key**

```rust
AddKind::Key { title, label, algorithm, group, tags } => {
    use relicario_core::item_types::KeyCore;
    use relicario_core::{Item, ItemCore};
    use zeroize::Zeroizing;

    let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
    eprintln!("Paste key material; end with Ctrl-D on a blank line:");
    let mut key_material = String::new();
    std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?;
    if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
    let public_key = prompt_optional("Public key (blank to skip)")?;

    let mut item = Item::new(title, ItemCore::Key(KeyCore {
        key_material: Zeroizing::new(key_material),
        label,
        public_key,
        algorithm,
    }));
    item.group = group;
    item.tags = tags;
    item
}
```

- [ ] **Step 5: Implement Document**

```rust
AddKind::Document { title, file, group, tags } => {
    use relicario_core::item_types::DocumentCore;
    use relicario_core::{
        encrypt_attachment, AttachmentRef, Item, ItemCore,
    };
    use std::fs;

    let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
    let bytes = fs::read(&file)
        .with_context(|| format!("failed to read {}", file.display()))?;
    let caps = vault.load_settings()?.attachment_caps;
    let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;

    let filename = file.file_name().unwrap().to_string_lossy().into_owned();
    let mime_type = guess_mime(&filename);

    let primary_attachment = enc.id.clone();
    let mut item = Item::new(title, ItemCore::Document(DocumentCore {
        filename: filename.clone(),
        mime_type: mime_type.clone(),
        primary_attachment: primary_attachment.clone(),
    }));
    item.group = group;
    item.tags = tags;
    item.attachments.push(AttachmentRef {
        id: primary_attachment.clone(),
        filename,
        mime_type,
        size: bytes.len() as u64,
        created: item.created,
    });

    // Persist the attachment blob before we return from the match arm so the
    // later save_item + commit_paths step can reference it as a real file.
    let att_dir = vault.root().join("attachments").join(item.id.as_str());
    fs::create_dir_all(&att_dir)?;
    fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?;
    item
}

fn guess_mime(filename: &str) -> String {
    let lower = filename.to_ascii_lowercase();
    match lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("") {
        "pdf"  => "application/pdf",
        "png"  => "image/png",
        "jpg" | "jpeg" => "image/jpeg",
        "txt"  => "text/plain",
        "json" => "application/json",
        _ => "application/octet-stream",
    }.to_string()
}
```

For Document, widen the commit so the attachment file is also staged:

```rust
// Replace the default `commit_paths(...)` at the bottom of cmd_add with the
// attachments-aware version below (so both Document and non-Document paths
// stage everything they wrote):
let mut paths: Vec<String> = vec![
    format!("items/{}.enc", item.id.as_str()),
    "manifest.enc".into(),
];
for att in &item.attachments {
    paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str()));
}
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
commit_paths(&vault, &format!("add: {} ({})", item.title, item.id.as_str()), &path_refs)?;
```

- [ ] **Step 6: Implement Totp**

```rust
AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } => {
    use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind};
    use relicario_core::{Item, ItemCore};
    use zeroize::Zeroizing;

    let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
    let secret_b32 = match secret {
        Some(s) => s,
        None => rpassword::prompt_password("TOTP secret (base32): ")?,
    };
    let secret_bytes = base32_decode_lenient(&secret_b32)?;
    let algo = match algorithm.to_ascii_lowercase().as_str() {
        "sha1" => TotpAlgorithm::Sha1,
        "sha256" => TotpAlgorithm::Sha256,
        "sha512" => TotpAlgorithm::Sha512,
        other => anyhow::bail!("unknown algorithm: {other}"),
    };

    let core = TotpCore {
        config: TotpConfig {
            secret: Zeroizing::new(secret_bytes),
            algorithm: algo,
            digits,
            period_seconds: period,
            kind: TotpKind::Totp,
        },
        issuer,
        label,
    };
    let mut item = Item::new(title, ItemCore::Totp(core));
    item.group = group;
    item.tags = tags;
    item
}

fn base32_decode_lenient(s: &str) -> Result<Vec<u8>> {
    let cleaned: String = s.chars()
        .filter(|c| !c.is_whitespace())
        .collect::<String>()
        .to_ascii_uppercase()
        .trim_end_matches('=')
        .to_string();
    let padded = {
        let rem = cleaned.len() % 8;
        if rem == 0 { cleaned } else { format!("{}{}", cleaned, "=".repeat(8 - rem)) }
    };
    data_encoding::BASE32.decode(padded.as_bytes())
        .map_err(|e| anyhow::anyhow!("invalid base32: {e}"))
}
```

Add `data-encoding = "2"` to `relicario-cli/Cargo.toml` dependencies.

- [ ] **Step 7: Build + smoke each variant**

```bash
cargo build -p relicario-cli
# Exercise each in /tmp/relicario-smoke (one at a time):
cargo run <…> -- add secure-note --title "diary" --body-prompt
cargo run <…> -- add identity --title "me" --full-name "Alice Example" --email a@example.com
cargo run <…> -- add card --title "Amex" --holder "Alice" --expiry 12/2030 --kind credit
cargo run <…> -- add key --title "ssh-prod" --label "prod@example" --algorithm ed25519
echo "hello world" > /tmp/note.txt
cargo run <…> -- add document --title "Note" --file /tmp/note.txt
cargo run <…> -- add totp --title "GitHub 2FA" --secret JBSWY3DPEHPK3PXP --issuer github.com
```

- [ ] **Step 8: Commit**

```bash
git add crates/relicario-cli/src/main.rs crates/relicario-cli/Cargo.toml
git commit -m "feat(cli): relicario add — remaining 6 item types

SecureNote, Identity, Card, Key, Document (with inline attachment),
and Totp with base32 secret decoding. Document widens the commit
to include the attachment blob path."
```

---

## Task 9: `relicario get` — query, mask-by-default, clipboard with Zeroizing (audit M6, M7)

**Files:**
- Modify: `crates/relicario-cli/src/main.rs` (`cmd_get`)

- [ ] **Step 1: Implement `cmd_get`**

```rust
fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
    use relicario_core::ItemCore;
    use zeroize::Zeroizing;

    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let manifest = vault.load_manifest()?;
    let entry = resolve_query(&manifest, &query)?;
    let item = vault.load_item(&entry.id)?;

    println!("ID:       {}", item.id.as_str());
    println!("Title:    {}", item.title);
    println!("Type:     {:?}", item.r#type);
    if let Some(g) = &item.group { println!("Group:    {g}"); }
    if !item.tags.is_empty() { println!("Tags:     {}", item.tags.join(", ")); }
    println!("Created:  {}", crate::helpers::iso8601(item.created));
    println!("Modified: {}", crate::helpers::iso8601(item.modified));
    if let Some(t) = item.trashed_at { println!("Trashed:  {}", crate::helpers::iso8601(t)); }
    println!();

    let primary_secret: Option<Zeroizing<String>> = match &item.core {
        ItemCore::Login(l) => {
            if let Some(u) = &l.username { println!("Username: {u}"); }
            if let Some(u) = &l.url { println!("URL:      {u}"); }
            if let Some(p) = &l.password { Some(p.clone()) } else { None }
        }
        ItemCore::SecureNote(n) => {
            if show { println!("Body:\n{}", n.body.as_str()); }
            else { println!("Body:     ********"); }
            None
        }
        ItemCore::Identity(i) => {
            if let Some(v) = &i.full_name { println!("Name:     {v}"); }
            if let Some(v) = &i.email { println!("Email:    {v}"); }
            if let Some(v) = &i.phone { println!("Phone:    {v}"); }
            if let Some(v) = &i.date_of_birth { println!("DOB:      {v}"); }
            None
        }
        ItemCore::Card(c) => {
            if let Some(h) = &c.holder { println!("Holder:   {h}"); }
            if let Some(e) = &c.expiry { println!("Expiry:   {:02}/{}", e.month, e.year); }
            println!("Kind:     {:?}", c.kind);
            c.number.clone()
        }
        ItemCore::Key(k) => {
            if let Some(l) = &k.label { println!("Label:    {l}"); }
            if let Some(a) = &k.algorithm { println!("Algo:     {a}"); }
            if let Some(pk) = &k.public_key { println!("Pubkey:   {pk}"); }
            Some(k.key_material.clone())
        }
        ItemCore::Document(d) => {
            println!("Filename: {}", d.filename);
            println!("MIME:     {}", d.mime_type);
            None
        }
        ItemCore::Totp(t) => {
            if let Some(i) = &t.issuer { println!("Issuer:   {i}"); }
            if let Some(l) = &t.label { println!("Label:    {l}"); }
            println!("Period:   {}s", t.config.period_seconds);
            println!("Digits:   {}", t.config.digits);
            None
        }
    };

    if let Some(secret) = primary_secret {
        if show {
            println!("Secret:   {}", secret.as_str());
        } else {
            println!("Secret:   ********  (use --show to reveal, --copy to clipboard)");
        }
        if copy {
            copy_to_clipboard_then_clear(&secret)?;
            eprintln!("Copied to clipboard (auto-clears in 30s).");
        }
    }

    Ok(())
}

fn resolve_query<'a>(
    manifest: &'a relicario_core::Manifest,
    query: &str,
) -> Result<&'a relicario_core::ManifestEntry> {
    if let Some(entry) = manifest.items.values().find(|e| e.id.as_str() == query) {
        return Ok(entry);
    }
    let hits: Vec<_> = manifest.search(query);
    match hits.len() {
        0 => anyhow::bail!("no item matches `{query}`"),
        1 => Ok(hits[0]),
        _ => {
            let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect();
            anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", "))
        }
    }
}

fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing<String>) -> Result<()> {
    use arboard::Clipboard;
    let mut cb = Clipboard::new().context("failed to access clipboard")?;
    cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?;
    let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned());
    // Unconditional clear (audit M6): spawn a detached thread that waits 30s
    // and then rewrites the clipboard with empty string. Even if the user
    // copies something else in the interim, we still overwrite once.
    std::thread::spawn(move || {
        std::thread::sleep(std::time::Duration::from_secs(30));
        if let Ok(mut cb) = Clipboard::new() {
            let _ = cb.set_text(String::new());
            drop(cleared_copy); // zeroize the detached copy
        }
    });
    Ok(())
}
```

- [ ] **Step 2: Build + smoke**

```bash
cargo build -p relicario-cli
cd /tmp/relicario-smoke
cargo run <…> -- get GitHub             # expect masked
cargo run <…> -- get GitHub --show      # expect secret printed
cargo run <…> -- get GitHub --copy      # copies, auto-clears in 30s
```

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-cli/src/main.rs
git commit -m "feat(cli): relicario get with masking, --show, and zeroize-clipboard

Secrets masked by default (audit M7). --show reveals plaintext.
--copy writes to clipboard and spawns a detached 30s auto-clear
thread holding a Zeroizing copy that wipes on drop (audit M6)."
```

---

## Task 10: `relicario list` with filters

**Files:**
- Modify: `crates/relicario-cli/src/main.rs` (`cmd_list`)

- [ ] **Step 1: Implement `cmd_list`**

```rust
fn cmd_list(
    type_filter: Option<String>,
    group_filter: Option<String>,
    tag_filter: Option<String>,
    trashed: bool,
) -> Result<()> {
    use relicario_core::ItemType;

    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let manifest = vault.load_manifest()?;

    let parsed_type: Option<ItemType> = match type_filter.as_deref() {
        None => None,
        Some("login") => Some(ItemType::Login),
        Some("secure_note") | Some("note") => Some(ItemType::SecureNote),
        Some("identity") => Some(ItemType::Identity),
        Some("card") => Some(ItemType::Card),
        Some("key") => Some(ItemType::Key),
        Some("document") => Some(ItemType::Document),
        Some("totp") => Some(ItemType::Totp),
        Some(other) => anyhow::bail!("unknown type filter: {other}"),
    };

    let mut entries: Vec<_> = manifest.items.values()
        .filter(|e| {
            if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() }
        })
        .filter(|e| parsed_type.map_or(true, |t| e.r#type == t))
        .filter(|e| group_filter.as_ref().map_or(true, |g| e.group.as_deref() == Some(g.as_str())))
        .filter(|e| tag_filter.as_ref().map_or(true, |t| e.tags.iter().any(|x| x == t)))
        .collect();
    entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));

    if entries.is_empty() {
        eprintln!("(no items match)");
        return Ok(());
    }

    println!("{:<16}  {:<14}  {:<6}  {}", "ID", "TYPE", "FAV", "TITLE");
    for e in entries {
        let fav = if e.favorite { "  *" } else { "" };
        println!("{:<16}  {:<14}  {:<6}  {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
    }
    Ok(())
}
```

- [ ] **Step 2: Build + smoke**

```bash
cargo build -p relicario-cli
cd /tmp/relicario-smoke
cargo run <…> -- list
cargo run <…> -- list --type login
cargo run <…> -- list --tag work
cargo run <…> -- list --trashed
```

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-cli/src/main.rs
git commit -m "feat(cli): relicario list with --type/--group/--tag/--trashed filters"
```

---

## Task 11: `relicario edit` — interactive field updates

**Files:**
- Modify: `crates/relicario-cli/src/main.rs` (`cmd_edit`)

**Scope:** interactive prompt-per-field; only asks about fields the user wants to change; leaves other fields untouched. History is captured automatically by `Item::set_field_value` for Password/Concealed/Totp kinds, but `cmd_edit` also supports editing the typed core's primary fields (Login.password, etc.) — for those, we manually push to `field_history` via a small helper because set_field_value only operates on custom fields.

- [ ] **Step 1: Implement `cmd_edit`**

```rust
fn cmd_edit(query: String) -> Result<()> {
    use relicario_core::item::FieldHistoryEntry;
    use relicario_core::time::now_unix;
    use relicario_core::ItemCore;
    use zeroize::Zeroizing;

    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let mut manifest = vault.load_manifest()?;
    let entry = resolve_query(&manifest, &query)?;
    let id = entry.id.clone();
    drop(entry);
    let mut item = vault.load_item(&id)?;

    eprintln!("Editing: {} ({})  — leave a prompt blank to keep the current value.",
              item.title, item.id.as_str());

    // Title
    if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; }
    // Group
    if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); }
    // Tags (comma-separated)
    if let Some(v) = prompt_keep_opt("Tags (comma-separated)", Some(&item.tags.join(",")))? {
        item.tags = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
    }

    // Core-specific fields. Only Login.password and Card.number/cvv/pin are
    // history-tracked from the core path.
    match &mut item.core {
        ItemCore::Login(l) => {
            if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
            if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
                l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
            }
            if prompt_yesno("Change password?")? {
                let old = l.password.clone();
                let new_pw = Zeroizing::new(rpassword::prompt_password("New password: ")?);
                l.password = Some(new_pw);
                if let Some(old_pw) = old {
                    push_history(&mut item.field_history, /* synthetic field_id */ "login_password",
                                 Zeroizing::new(old_pw.as_str().to_string()));
                }
            }
        }
        ItemCore::SecureNote(n) => {
            if prompt_yesno("Edit body?")? {
                let old = n.body.clone();
                eprintln!("Enter new body; end with Ctrl-D:");
                let mut s = String::new();
                std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
                n.body = Zeroizing::new(s);
                push_history(&mut item.field_history, "secure_note_body",
                             Zeroizing::new(old.as_str().to_string()));
            }
        }
        ItemCore::Identity(i) => {
            if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
            if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
            if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
        }
        ItemCore::Card(c) => {
            if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
            if prompt_yesno("Change card number?")? {
                let old = c.number.clone();
                c.number = Some(Zeroizing::new(rpassword::prompt_password("New number: ")?));
                if let Some(o) = old {
                    push_history(&mut item.field_history, "card_number",
                                 Zeroizing::new(o.as_str().to_string()));
                }
            }
        }
        ItemCore::Key(k) => {
            if prompt_yesno("Replace key material?")? {
                eprintln!("Paste new key material; end with Ctrl-D:");
                let mut s = String::new();
                std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
                let old = k.key_material.clone();
                k.key_material = Zeroizing::new(s);
                push_history(&mut item.field_history, "key_material",
                             Zeroizing::new(old.as_str().to_string()));
            }
        }
        ItemCore::Document(_) => {
            eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
        }
        ItemCore::Totp(_) => {
            eprintln!("TOTP rotation not yet implemented — delete and re-add for now.");
        }
    }

    item.modified = now_unix();
    vault.save_item(&item)?;
    manifest.upsert(&item);
    vault.save_manifest(&manifest)?;
    commit_paths(&vault, &format!("edit: {} ({})", item.title, item.id.as_str()),
                 &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
    eprintln!("Updated {}", item.id.as_str());
    Ok(())
}

fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
    eprint!("{label} [{current}]: ");
    std::io::Write::flush(&mut std::io::stderr())?;
    let mut s = String::new();
    std::io::stdin().read_line(&mut s)?;
    let trimmed = s.trim().to_string();
    Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}

fn prompt_keep_opt(label: &str, current: Option<&str>) -> Result<Option<String>> {
    let display = current.unwrap_or("(none)");
    eprint!("{label} [{display}]: ");
    std::io::Write::flush(&mut std::io::stderr())?;
    let mut s = String::new();
    std::io::stdin().read_line(&mut s)?;
    let trimmed = s.trim().to_string();
    Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}

fn prompt_yesno(label: &str) -> Result<bool> {
    eprint!("{label} [y/N] ");
    std::io::Write::flush(&mut std::io::stderr())?;
    let mut s = String::new();
    std::io::stdin().read_line(&mut s)?;
    Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes"))
}

fn push_history(
    history: &mut std::collections::HashMap<relicario_core::FieldId, Vec<relicario_core::item::FieldHistoryEntry>>,
    synthetic_key: &str,
    old_value: zeroize::Zeroizing<String>,
) {
    use relicario_core::item::FieldHistoryEntry;
    use relicario_core::time::now_unix;
    // Synthetic FieldId for core-level fields — stable per-item (prefixed so
    // custom-field UUIDs can't collide).
    let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
    history.entry(fid).or_default().push(FieldHistoryEntry {
        value: old_value,
        replaced_at: now_unix(),
    });
}
```

- [ ] **Step 2: Build + smoke**

```bash
cargo build -p relicario-cli
cd /tmp/relicario-smoke
cargo run <…> -- edit GitHub
# Press Enter on title/group/url, answer "y" to change password, enter new password.
cargo run <…> -- get GitHub --show
# Confirm new password present; git log shows edit commit.
```

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-cli/src/main.rs
git commit -m "feat(cli): relicario edit — interactive field updates + history

Title/group/tags always optional. Per-type prompts for core secret
fields (Login.password, Card.number, Key.material, SecureNote.body)
push the old value to field_history via a synthetic core:<key>
FieldId so rotation is audit-traceable."
```

---

## Task 12: Trash ops — `rm`, `restore`, `purge`, `trash list`, `trash empty`

**Files:**
- Modify: `crates/relicario-cli/src/main.rs` (`cmd_rm`, `cmd_restore`, `cmd_purge`, `cmd_trash`)

- [ ] **Step 1: Implement the four**

```rust
fn cmd_rm(query: String) -> Result<()> {
    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let mut manifest = vault.load_manifest()?;
    let entry = resolve_query(&manifest, &query)?;
    let id = entry.id.clone();
    drop(entry);
    let mut item = vault.load_item(&id)?;
    item.soft_delete();
    vault.save_item(&item)?;
    manifest.upsert(&item);
    vault.save_manifest(&manifest)?;
    commit_paths(&vault, &format!("trash: {} ({})", item.title, item.id.as_str()),
                 &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
    eprintln!("Moved to trash: {}", item.title);
    Ok(())
}

fn cmd_restore(query: String) -> Result<()> {
    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let mut manifest = vault.load_manifest()?;
    let entry = resolve_query(&manifest, &query)?;
    let id = entry.id.clone();
    drop(entry);
    let mut item = vault.load_item(&id)?;
    item.restore();
    vault.save_item(&item)?;
    manifest.upsert(&item);
    vault.save_manifest(&manifest)?;
    commit_paths(&vault, &format!("restore: {} ({})", item.title, item.id.as_str()),
                 &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
    eprintln!("Restored: {}", item.title);
    Ok(())
}

fn cmd_purge(query: String) -> Result<()> {
    use std::fs;

    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let mut manifest = vault.load_manifest()?;
    let entry = resolve_query(&manifest, &query)?;
    let id = entry.id.clone();
    let title = entry.title.clone();
    drop(entry);

    // Remove the item file, its attachments directory, and drop the manifest entry.
    let item_path = vault.item_path(&id);
    if item_path.exists() { fs::remove_file(&item_path)?; }
    let att_dir = vault.root().join("attachments").join(id.as_str());
    if att_dir.exists() { fs::remove_dir_all(&att_dir)?; }
    manifest.remove(&id);
    vault.save_manifest(&manifest)?;

    let mut paths = vec![format!("items/{}.enc", id.as_str()), "manifest.enc".into()];
    paths.push(format!("attachments/{}", id.as_str()));
    let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
    // `git add -- attachments/<id>` after rmdir stages the deletion.
    let _ = crate::helpers::git_command(vault.root(), &["rm", "-rf", "--ignore-unmatch",
        &format!("items/{}.enc", id.as_str()),
        &format!("attachments/{}", id.as_str()),
    ]).status()?;
    let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?;
    if !status.success() { anyhow::bail!("git add manifest.enc failed"); }
    let status = crate::helpers::git_command(vault.root(),
        &["commit", "-m", &format!("purge: {} ({})", title, id.as_str())]).status()?;
    if !status.success() { anyhow::bail!("git commit failed"); }
    eprintln!("Purged: {title}");
    Ok(())
}

fn cmd_trash(action: TrashAction) -> Result<()> {
    match action {
        TrashAction::List => cmd_list(None, None, None, true),
        TrashAction::Empty => cmd_trash_empty(),
    }
}

fn cmd_trash_empty() -> Result<()> {
    use relicario_core::time::now_unix;
    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let manifest = vault.load_manifest()?;
    let settings = vault.load_settings()?;
    let now = now_unix();

    let purgeable: Vec<_> = manifest.items.values()
        .filter(|e| match e.trashed_at {
            Some(t) => settings.trash_retention.should_purge(t, now),
            None => false,
        })
        .map(|e| (e.id.clone(), e.title.clone()))
        .collect();

    if purgeable.is_empty() {
        eprintln!("nothing past retention window");
        return Ok(());
    }

    for (id, title) in purgeable {
        cmd_purge(id.as_str().to_string())?;
        eprintln!("  purged {title}");
    }
    Ok(())
}
```

- [ ] **Step 2: Build + smoke**

```bash
cargo build -p relicario-cli
cd /tmp/relicario-smoke
cargo run <…> -- rm GitHub
cargo run <…> -- list --trashed          # expect GitHub listed
cargo run <…> -- restore GitHub
cargo run <…> -- purge GitHub
git log --oneline | head -6
```

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-cli/src/main.rs
git commit -m "feat(cli): trash ops — rm / restore / purge / trash empty

Soft-delete sets trashed_at via Item::soft_delete; restore clears it.
Purge deletes item + attachment dir and removes manifest entry.
Trash empty scans for items past settings.trash_retention."
```

---

## Task 13: Attachment ops — `attach`, `attachments`, `extract`

**Files:**
- Modify: `crates/relicario-cli/src/main.rs` (`cmd_attach`, `cmd_attachments`, `cmd_extract`)

- [ ] **Step 1: Implement all three**

```rust
fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
    use std::fs;
    use relicario_core::{encrypt_attachment, AttachmentRef};
    use relicario_core::time::now_unix;

    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let mut manifest = vault.load_manifest()?;
    let entry = resolve_query(&manifest, &query)?;
    let id = entry.id.clone();
    drop(entry);
    let mut item = vault.load_item(&id)?;
    let settings = vault.load_settings()?;
    let caps = settings.attachment_caps;

    if item.attachments.len() as u32 >= caps.per_item_max_count {
        anyhow::bail!("item already has {} attachments (max {})",
                      item.attachments.len(), caps.per_item_max_count);
    }

    let bytes = fs::read(&file)
        .with_context(|| format!("failed to read {}", file.display()))?;
    let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;

    let filename = file.file_name().unwrap().to_string_lossy().into_owned();
    let mime_type = guess_mime(&filename);
    let aref = AttachmentRef {
        id: enc.id.clone(),
        filename,
        mime_type,
        size: bytes.len() as u64,
        created: now_unix(),
    };

    let att_dir = vault.root().join("attachments").join(item.id.as_str());
    fs::create_dir_all(&att_dir)?;
    fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;

    item.attachments.push(aref);
    item.modified = now_unix();
    vault.save_item(&item)?;
    manifest.upsert(&item);
    vault.save_manifest(&manifest)?;

    let paths = [
        format!("items/{}.enc", item.id.as_str()),
        "manifest.enc".into(),
        format!("attachments/{}/{}.enc", item.id.as_str(), enc.id.as_str()),
    ];
    let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
    commit_paths(&vault, &format!("attach: {} → {} ({})",
                                  file.display(), item.title, item.id.as_str()), &path_refs)?;
    eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str());
    Ok(())
}

fn cmd_attachments(query: String) -> Result<()> {
    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let manifest = vault.load_manifest()?;
    let entry = resolve_query(&manifest, &query)?;
    let item = vault.load_item(&entry.id)?;
    if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
    println!("{:<17}  {:>12}  {:<22}  {}", "AID", "SIZE", "MIME", "FILENAME");
    for a in &item.attachments {
        println!("{:<17}  {:>12}  {:<22}  {}", a.id.as_str(), a.size, a.mime_type, a.filename);
    }
    Ok(())
}

fn cmd_extract(query: String, aid: String, out: Option<PathBuf>) -> Result<()> {
    use std::fs;
    use relicario_core::decrypt_attachment;

    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let manifest = vault.load_manifest()?;
    let entry = resolve_query(&manifest, &query)?;
    let item = vault.load_item(&entry.id)?;

    let aref = item.attachments.iter().find(|a| a.id.as_str() == aid)
        .ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
    let path = vault.root().join("attachments").join(item.id.as_str())
        .join(format!("{}.enc", aid));
    let bytes = fs::read(&path)
        .with_context(|| format!("failed to read {}", path.display()))?;
    let plaintext = decrypt_attachment(&bytes, vault.key())?;
    let out_path = out.unwrap_or_else(|| PathBuf::from(&aref.filename));
    fs::write(&out_path, plaintext.as_slice())
        .with_context(|| format!("failed to write {}", out_path.display()))?;
    eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display());
    Ok(())
}
```

- [ ] **Step 2: Build + smoke**

```bash
cargo build -p relicario-cli
echo "attached data" > /tmp/attachdata.txt
cargo run <…> -- add login --title "Example" --username alice --password foo
cargo run <…> -- attach Example /tmp/attachdata.txt
cargo run <…> -- attachments Example
cargo run <…> -- extract Example <aid> --out /tmp/extracted.txt
diff /tmp/attachdata.txt /tmp/extracted.txt
```

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-cli/src/main.rs
git commit -m "feat(cli): attachment ops — attach / attachments / extract

Respects AttachmentCaps from settings.enc; content-addressed aid
comes from core::encrypt_attachment."
```

---

## Task 14: `relicario generate` — delegate to core (audit H6)

**Files:**
- Modify: `crates/relicario-cli/src/main.rs` (`cmd_generate`)

**Why this task also closes audit H6:** it removes the CLI's ad-hoc `next_u32() % CHARSET.len()` generator (from the pre-1A code) and instead calls `relicario_core::generate_password` / `generate_passphrase`, which use `rand::distributions::Uniform` internally.

- [ ] **Step 1: Implement `cmd_generate`**

```rust
fn cmd_generate(length: u32, bip39: bool, words: u32, symbols: String, separator: String) -> Result<()> {
    use relicario_core::{
        generate_passphrase, generate_password, Capitalization, CharClasses,
        GeneratorRequest, SymbolCharset,
    };

    let output = if bip39 {
        generate_passphrase(&GeneratorRequest::Bip39 {
            word_count: words,
            separator,
            capitalization: Capitalization::Lower,
        })?
    } else {
        let symbol_charset = match symbols.as_str() {
            "safe" => SymbolCharset::SafeOnly,
            "extended" => SymbolCharset::Extended,
            other => SymbolCharset::Custom(other.to_string()),
        };
        generate_password(&GeneratorRequest::Random {
            length,
            classes: CharClasses { lower: true, upper: true, digits: true, symbols: true },
            symbol_charset,
        })?
    };

    // Plain println! is fine here — the user explicitly requested generation
    // and this is not a stored secret being revealed.
    println!("{}", output.as_str());
    Ok(())
}
```

- [ ] **Step 2: Build + smoke**

```bash
cargo build -p relicario-cli
cargo run <…> -- generate --length 32
cargo run <…> -- generate --bip39 --words 5
cargo run <…> -- generate --bip39 --words 4 --separator -
cargo run <…> -- generate --length 24 --symbols extended
```

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-cli/src/main.rs
git commit -m "feat(cli): relicario generate delegates to core (audit H6)

CLI no longer has its own charset-sampling path — uses the CSPRNG
generate_password / generate_passphrase in relicario-core, which use
rand::distributions::Uniform internally."
```

---

## Task 15: `relicario settings` subcommands

**Files:**
- Modify: `crates/relicario-cli/src/main.rs` (`cmd_settings`)

- [ ] **Step 1: Implement**

```rust
fn cmd_settings(action: SettingsAction) -> Result<()> {
    use relicario_core::{HistoryRetention, TrashRetention};

    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let mut settings = vault.load_settings()?;

    match action {
        SettingsAction::Show => {
            println!("{}", serde_json::to_string_pretty(&settings)?);
            return Ok(());
        }
        SettingsAction::TrashRetention { days, forever } => {
            settings.trash_retention = match (days, forever) {
                (Some(d), false) => TrashRetention::Days(d),
                (None, true) => TrashRetention::Forever,
                _ => anyhow::bail!("specify exactly one of --days or --forever"),
            };
        }
        SettingsAction::HistoryRetention { last_n, days, forever } => {
            settings.field_history_retention = match (last_n, days, forever) {
                (Some(n), None, false) => HistoryRetention::LastN(n),
                (None, Some(d), false) => HistoryRetention::Days(d),
                (None, None, true) => HistoryRetention::Forever,
                _ => anyhow::bail!("specify exactly one of --last-n / --days / --forever"),
            };
        }
        SettingsAction::AttachmentCap {
            per_attachment_max_bytes, per_item_max_count,
            per_vault_soft_cap_bytes, per_vault_hard_cap_bytes,
        } => {
            if let Some(v) = per_attachment_max_bytes { settings.attachment_caps.per_attachment_max_bytes = v; }
            if let Some(v) = per_item_max_count { settings.attachment_caps.per_item_max_count = v; }
            if let Some(v) = per_vault_soft_cap_bytes { settings.attachment_caps.per_vault_soft_cap_bytes = v; }
            if let Some(v) = per_vault_hard_cap_bytes { settings.attachment_caps.per_vault_hard_cap_bytes = v; }
        }
    }

    vault.save_settings(&settings)?;
    commit_paths(&vault, "settings: update", &["settings.enc"])?;
    eprintln!("Settings updated.");
    Ok(())
}
```

- [ ] **Step 2: Build + smoke**

```bash
cargo build -p relicario-cli
cargo run <…> -- settings show
cargo run <…> -- settings trash-retention --days 60
cargo run <…> -- settings history-retention --last-n 10
cargo run <…> -- settings attachment-cap --per-attachment-max-bytes 5242880
cargo run <…> -- settings show
```

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-cli/src/main.rs
git commit -m "feat(cli): relicario settings show / trash-retention / history-retention / attachment-cap"
```

---

## Task 16: `relicario sync` — hardened rebase + push (audit H4)

**Files:**
- Modify: `crates/relicario-cli/src/main.rs` (`cmd_sync`)

- [ ] **Step 1: Implement**

```rust
fn cmd_sync() -> Result<()> {
    let root = crate::helpers::vault_dir()?;
    let pull = crate::helpers::git_command(&root, &["pull", "--rebase"]).status()?;
    if !pull.success() { anyhow::bail!("git pull --rebase failed"); }
    let push = crate::helpers::git_command(&root, &["push"]).status()?;
    if !push.success() { anyhow::bail!("git push failed"); }
    eprintln!("Sync complete.");
    Ok(())
}
```

- [ ] **Step 2: Build + smoke**

```bash
cargo build -p relicario-cli
# In a vault with a remote set:
cargo run <…> -- sync
```

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-cli/src/main.rs
git commit -m "feat(cli): relicario sync — pull --rebase then push via hardened git"
```

---

## Task 17: `relicario device` — add / list / revoke

**Files:**
- Modify: `crates/relicario-cli/src/main.rs` (`cmd_device`)

Device management is orthogonal to the typed-item rewrite but still needs to move to the new commit flow. Keep the existing ed25519 generation + 0600 file permissions logic.

- [ ] **Step 1: Implement**

```rust
fn cmd_device(action: DeviceAction) -> Result<()> {
    use std::fs;
    use ed25519_dalek::SigningKey;
    use rand::rngs::OsRng;

    let root = crate::helpers::vault_dir()?;
    let devices_path = root.join(".relicario").join("devices.json");

    #[derive(serde::Serialize, serde::Deserialize)]
    struct DeviceEntry { name: String, public_key: String }

    match action {
        DeviceAction::Add { name } => {
            let mut existing: Vec<DeviceEntry> =
                serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default();
            if existing.iter().any(|d| d.name == name) {
                anyhow::bail!("device `{name}` already exists");
            }
            let signing = SigningKey::generate(&mut OsRng);
            let verifying = signing.verifying_key();
            let pubkey_hex = hex::encode(verifying.to_bytes());

            existing.push(DeviceEntry { name: name.clone(), public_key: pubkey_hex.clone() });
            fs::write(&devices_path, serde_json::to_string_pretty(&existing)?)?;

            // Save the private key under ~/.config/relicario/devices/<name>.key (0600).
            let cfg_dir = dirs::config_dir()
                .ok_or_else(|| anyhow::anyhow!("no config dir"))?
                .join("relicario").join("devices");
            fs::create_dir_all(&cfg_dir)?;
            let key_path = cfg_dir.join(format!("{name}.key"));
            fs::write(&key_path, signing.to_bytes())?;
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?;
            }

            let status = crate::helpers::git_command(&root,
                &["add", ".relicario/devices.json"]).status()?;
            if !status.success() { anyhow::bail!("git add failed"); }
            let status = crate::helpers::git_command(&root,
                &["commit", "-m", &format!("device: add {name}")]).status()?;
            if !status.success() { anyhow::bail!("git commit failed"); }
            eprintln!("Added device `{name}` (pubkey: {pubkey_hex})");
        }
        DeviceAction::List => {
            let existing: Vec<DeviceEntry> =
                serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default();
            if existing.is_empty() { eprintln!("(no devices)"); return Ok(()); }
            for d in existing {
                println!("{:<20}  {}", d.name, d.public_key);
            }
        }
        DeviceAction::Revoke { name } => {
            let mut existing: Vec<DeviceEntry> =
                serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default();
            let before = existing.len();
            existing.retain(|d| d.name != name);
            if existing.len() == before { anyhow::bail!("device `{name}` not found"); }
            fs::write(&devices_path, serde_json::to_string_pretty(&existing)?)?;
            let status = crate::helpers::git_command(&root,
                &["add", ".relicario/devices.json"]).status()?;
            if !status.success() { anyhow::bail!("git add failed"); }
            let status = crate::helpers::git_command(&root,
                &["commit", "-m", &format!("device: revoke {name}")]).status()?;
            if !status.success() { anyhow::bail!("git commit failed"); }
            eprintln!("Revoked device `{name}`");
        }
    }
    Ok(())
}
```

- [ ] **Step 2: Build + smoke**

```bash
cargo build -p relicario-cli
cargo run <…> -- device add --name laptop
cargo run <…> -- device list
cargo run <…> -- device revoke laptop
```

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-cli/src/main.rs
git commit -m "feat(cli): device add / list / revoke rewired to hardened git"
```

---

## Task 18: WASM — Cargo.toml, session module, unlock/lock (audit H2, H5)

**Files:**
- Modify: `crates/relicario-wasm/Cargo.toml`
- Rewrite: `crates/relicario-wasm/src/lib.rs`
- Create: `crates/relicario-wasm/src/session.rs`

**What lands:** the opaque `SessionHandle` bridge. Master key lives in `Zeroizing<[u8; 32]>` inside a `HashMap<u32, ...>` kept in a `thread_local!` (service-worker is single-threaded per-agent). JS never sees key bytes.

- [ ] **Step 1: Cargo.toml**

Overwrite `crates/relicario-wasm/Cargo.toml`:

```toml
[package]
name = "relicario-wasm"
version = "0.1.0"
edition = "2021"
description = "WASM bindings for relicario password manager"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
relicario-core = { path = "../relicario-core" }
wasm-bindgen = "0.2"
serde-wasm-bindgen = "0.6"
serde_json = "1"
serde = { version = "1", features = ["derive"] }
zeroize = "1"
getrandom = { version = "0.2", features = ["js"] }

[dev-dependencies]
wasm-bindgen-test = "0.3"
image = { version = "0.25", default-features = false, features = ["jpeg"] }
```

Removed: `js-sys`, `hmac`, `sha1`, `data-encoding`. TOTP now lives in `relicario-core::item_types::totp` and is computed there; the WASM layer only exposes a thin wrapper.

- [ ] **Step 2: session.rs**

Create `crates/relicario-wasm/src/session.rs`:

```rust
//! Opaque session-handle bridge. The master key never leaves WASM linear
//! memory; JS receives only a u32 handle that it passes back on every
//! subsequent call.

use std::cell::RefCell;
use std::collections::HashMap;
use zeroize::Zeroizing;

thread_local! {
    static SESSIONS: RefCell<HashMap<u32, Zeroizing<[u8; 32]>>> = RefCell::new(HashMap::new());
    static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
}

pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
    let handle = NEXT_HANDLE.with(|n| {
        let mut n = n.borrow_mut();
        let h = *n;
        *n = n.wrapping_add(1);
        if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
        h
    });
    SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); });
    handle
}

pub fn with<F, R>(handle: u32, f: F) -> Option<R>
where
    F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
{
    SESSIONS.with(|s| s.borrow().get(&handle).map(f))
}

pub fn remove(handle: u32) -> bool {
    SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some())
}

/// For tests only — empty the table and wipe all sessions.
#[cfg(test)]
pub fn clear() {
    SESSIONS.with(|s| s.borrow_mut().clear());
}
```

- [ ] **Step 3: lib.rs — unlock/lock shell**

Rewrite `crates/relicario-wasm/src/lib.rs` (start — the rest lands in Tasks 19-21):

```rust
//! WASM bindings for relicario.
//!
//! The bridge exposes an opaque `SessionHandle` API: the master key is held
//! entirely in WASM linear memory, wrapped in `Zeroizing<[u8; 32]>`, and
//! looked up per call via a u32 handle. JS cannot read key bytes.

mod session;

use wasm_bindgen::prelude::*;
use zeroize::Zeroizing;

use relicario_core::{derive_master_key, imgsecret, KdfParams};

/// Handle type returned from `unlock`. Backed by a `u32`; opaque to JS
/// (it's just a number there, but using a wrapper struct makes the
/// intent clear in TS bindings).
#[wasm_bindgen]
pub struct SessionHandle(u32);

#[wasm_bindgen]
impl SessionHandle {
    #[wasm_bindgen(getter)]
    pub fn value(&self) -> u32 { self.0 }
}

#[wasm_bindgen]
pub fn unlock(
    passphrase: &str,
    image_bytes: &[u8],
    salt: &[u8],
    params_json: &str,
) -> Result<SessionHandle, JsError> {
    let params: KdfParams = serde_json::from_str(params_json)
        .map_err(|e| JsError::new(&format!("params: {e}")))?;
    let image_secret = imgsecret::extract(image_bytes)
        .map_err(|e| JsError::new(&e.to_string()))?;
    let salt_arr: &[u8; 32] = salt.try_into()
        .map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
    let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, &params)
        .map_err(|e| JsError::new(&e.to_string()))?;
    let handle = session::insert(master_key);
    Ok(SessionHandle(handle))
}

#[wasm_bindgen]
pub fn lock(handle: &SessionHandle) -> bool {
    session::remove(handle.0)
}

// Subsequent wasm_bindgen fns added in Tasks 19-21.
```

- [ ] **Step 4: Build**

```bash
cargo build -p relicario-wasm --target wasm32-unknown-unknown
```

(If `wasm32-unknown-unknown` target isn't installed: `rustup target add wasm32-unknown-unknown`. Also fine to build native — `cargo build -p relicario-wasm` — as a sanity check; `js-sys`-free lib builds on host too.)

- [ ] **Step 5: Unit test (native) for session table**

Add to the bottom of `lib.rs`:

```rust
#[cfg(test)]
mod session_tests {
    use super::*;
    use zeroize::Zeroizing;

    #[test]
    fn insert_then_remove_clears_entry() {
        session::clear();
        let h = session::insert(Zeroizing::new([0x11u8; 32]));
        assert_ne!(h, 0);
        assert!(session::remove(h));
        assert!(!session::remove(h));   // second remove false
    }

    #[test]
    fn with_yields_key_only_while_session_lives() {
        session::clear();
        let h = session::insert(Zeroizing::new([0x22u8; 32]));
        let byte = session::with(h, |k| k[0]);
        assert_eq!(byte, Some(0x22));
        session::remove(h);
        let byte = session::with(h, |k| k[0]);
        assert_eq!(byte, None);
    }
}
```

Run: `cargo test -p relicario-wasm --lib session_tests` → expect 2 passing.

- [ ] **Step 6: Commit**

```bash
git add crates/relicario-wasm/Cargo.toml crates/relicario-wasm/src/session.rs crates/relicario-wasm/src/lib.rs
git commit -m "feat(wasm): opaque SessionHandle bridge with unlock/lock

Master key never leaves WASM linear memory. Held in Zeroizing<[u8;32]>
inside a thread_local HashMap keyed by u32. lock() removes + zeroizes."
```

---

## Task 19: WASM — manifest / item / settings bridges

**Files:**
- Modify: `crates/relicario-wasm/src/lib.rs`

Add six `#[wasm_bindgen]` functions. Each takes a `SessionHandle` by reference, looks up the master key via `session::with`, and does encrypt/decrypt. Decrypt returns `JsValue` via `serde_wasm_bindgen`; encrypt takes JSON strings from JS to avoid serializing the whole type hierarchy into TS.

- [ ] **Step 1: Add the six functions**

Append to `lib.rs`:

```rust
use serde_wasm_bindgen::to_value;
use relicario_core::{
    decrypt_item, decrypt_manifest, decrypt_settings,
    encrypt_item, encrypt_manifest, encrypt_settings,
    Item, Manifest, VaultSettings,
};

fn need_key(handle: &SessionHandle) -> Result<(), JsError> {
    if session::with(handle.0, |_| ()).is_some() { Ok(()) }
    else { Err(JsError::new("invalid or locked session handle")) }
}

#[wasm_bindgen]
pub fn manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
    need_key(handle)?;
    let out = session::with(handle.0, |k| decrypt_manifest(encrypted, k))
        .unwrap()
        .map_err(|e| JsError::new(&e.to_string()))?;
    to_value(&out).map_err(|e| JsError::new(&e.to_string()))
}

#[wasm_bindgen]
pub fn manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result<Vec<u8>, JsError> {
    need_key(handle)?;
    let m: Manifest = serde_json::from_str(manifest_json)
        .map_err(|e| JsError::new(&format!("manifest json: {e}")))?;
    session::with(handle.0, |k| encrypt_manifest(&m, k))
        .unwrap()
        .map_err(|e| JsError::new(&e.to_string()))
}

#[wasm_bindgen]
pub fn item_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
    need_key(handle)?;
    let out = session::with(handle.0, |k| decrypt_item(encrypted, k))
        .unwrap()
        .map_err(|e| JsError::new(&e.to_string()))?;
    to_value(&out).map_err(|e| JsError::new(&e.to_string()))
}

#[wasm_bindgen]
pub fn item_encrypt(handle: &SessionHandle, item_json: &str) -> Result<Vec<u8>, JsError> {
    need_key(handle)?;
    let item: Item = serde_json::from_str(item_json)
        .map_err(|e| JsError::new(&format!("item json: {e}")))?;
    session::with(handle.0, |k| encrypt_item(&item, k))
        .unwrap()
        .map_err(|e| JsError::new(&e.to_string()))
}

#[wasm_bindgen]
pub fn settings_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
    need_key(handle)?;
    let out = session::with(handle.0, |k| decrypt_settings(encrypted, k))
        .unwrap()
        .map_err(|e| JsError::new(&e.to_string()))?;
    to_value(&out).map_err(|e| JsError::new(&e.to_string()))
}

#[wasm_bindgen]
pub fn settings_encrypt(handle: &SessionHandle, settings_json: &str) -> Result<Vec<u8>, JsError> {
    need_key(handle)?;
    let s: VaultSettings = serde_json::from_str(settings_json)
        .map_err(|e| JsError::new(&format!("settings json: {e}")))?;
    session::with(handle.0, |k| encrypt_settings(&s, k))
        .unwrap()
        .map_err(|e| JsError::new(&e.to_string()))
}
```

- [ ] **Step 2: Native unit test for round-trip**

Add to the `session_tests` module:

```rust
#[test]
fn manifest_round_trip_via_handle() {
    use relicario_core::Manifest;
    session::clear();
    let h = session::insert(Zeroizing::new([0x55u8; 32]));
    let handle = SessionHandle(h);
    let empty = Manifest::new();
    let bytes = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
    let js_out = manifest_decrypt(&handle, &bytes).unwrap();
    let parsed: Manifest = serde_wasm_bindgen::from_value(js_out).unwrap();
    assert_eq!(parsed.items.len(), 0);
}
```

Run: `cargo test -p relicario-wasm --lib manifest_round_trip_via_handle` → expect passing.

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-wasm/src/lib.rs
git commit -m "feat(wasm): manifest / item / settings encrypt+decrypt via SessionHandle"
```

---

## Task 20: WASM — attachments, generators, TOTP, imgsecret, IDs

**Files:**
- Modify: `crates/relicario-wasm/src/lib.rs`

- [ ] **Step 1: Add attachment and ID bridges**

Append to `lib.rs`:

```rust
use relicario_core::{decrypt_attachment, encrypt_attachment, FieldId, ItemId};

#[wasm_bindgen]
pub struct EncryptedAttachment {
    aid: String,
    bytes: Vec<u8>,
}

#[wasm_bindgen]
impl EncryptedAttachment {
    #[wasm_bindgen(getter)] pub fn aid(&self) -> String { self.aid.clone() }
    #[wasm_bindgen(getter)] pub fn bytes(&self) -> Vec<u8> { self.bytes.clone() }
}

#[wasm_bindgen]
pub fn attachment_encrypt(
    handle: &SessionHandle,
    plaintext: &[u8],
    max_bytes: u64,
) -> Result<EncryptedAttachment, JsError> {
    need_key(handle)?;
    let enc = session::with(handle.0, |k| encrypt_attachment(plaintext, k, max_bytes))
        .unwrap()
        .map_err(|e| JsError::new(&e.to_string()))?;
    Ok(EncryptedAttachment { aid: enc.id.as_str().to_owned(), bytes: enc.bytes })
}

#[wasm_bindgen]
pub fn attachment_decrypt(
    handle: &SessionHandle,
    encrypted: &[u8],
) -> Result<Vec<u8>, JsError> {
    need_key(handle)?;
    let plain = session::with(handle.0, |k| decrypt_attachment(encrypted, k))
        .unwrap()
        .map_err(|e| JsError::new(&e.to_string()))?;
    Ok(plain.to_vec())
}

#[wasm_bindgen] pub fn new_item_id() -> String { ItemId::new().as_str().to_owned() }
#[wasm_bindgen] pub fn new_field_id() -> String { FieldId::new().as_str().to_owned() }
```

- [ ] **Step 2: Add generators**

```rust
use relicario_core::{
    generate_passphrase as core_generate_passphrase,
    generate_password as core_generate_password,
    rate_passphrase as core_rate_passphrase,
    GeneratorRequest,
};

#[wasm_bindgen]
pub fn generate_password(request_json: &str) -> Result<String, JsError> {
    let req: GeneratorRequest = serde_json::from_str(request_json)
        .map_err(|e| JsError::new(&format!("generator request: {e}")))?;
    let out = core_generate_password(&req).map_err(|e| JsError::new(&e.to_string()))?;
    Ok(out.as_str().to_owned())
}

#[wasm_bindgen]
pub fn generate_passphrase(request_json: &str) -> Result<String, JsError> {
    let req: GeneratorRequest = serde_json::from_str(request_json)
        .map_err(|e| JsError::new(&format!("generator request: {e}")))?;
    let out = core_generate_passphrase(&req).map_err(|e| JsError::new(&e.to_string()))?;
    Ok(out.as_str().to_owned())
}

#[wasm_bindgen]
pub fn rate_passphrase(p: &str) -> Result<JsValue, JsError> {
    let est = core_rate_passphrase(p);
    to_value(&serde_json::json!({
        "score": est.score,
        "guesses_log10": est.guesses_log10,
    })).map_err(|e| JsError::new(&e.to_string()))
}
```

- [ ] **Step 3: Imgsecret wrappers**

```rust
#[wasm_bindgen]
pub fn extract_image_secret(image_bytes: &[u8]) -> Result<Vec<u8>, JsError> {
    let s = imgsecret::extract(image_bytes).map_err(|e| JsError::new(&e.to_string()))?;
    Ok(s.to_vec())
}

#[wasm_bindgen]
pub fn embed_image_secret(carrier: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsError> {
    let s: &[u8; 32] = secret.try_into()
        .map_err(|_| JsError::new("secret must be exactly 32 bytes"))?;
    imgsecret::embed(carrier, s).map_err(|e| JsError::new(&e.to_string()))
}
```

- [ ] **Step 4: TOTP wrapper**

`relicario-core::item_types::totp` exposes `compute_totp_code(&TotpConfig, now_unix_seconds: u64) -> Result<String>`. (If that helper doesn't exist on the tag, this task has to add it to core too — see Step 4a below.)

```rust
use relicario_core::item_types::{TotpConfig, compute_totp_code};

#[wasm_bindgen]
pub struct TotpCode {
    code: String,
    expires_at: u64,
}

#[wasm_bindgen]
impl TotpCode {
    #[wasm_bindgen(getter)] pub fn code(&self) -> String { self.code.clone() }
    #[wasm_bindgen(getter)] pub fn expires_at(&self) -> u64 { self.expires_at }
}

#[wasm_bindgen]
pub fn totp_compute(
    config_json: &str,
    now_unix_seconds: u64,
) -> Result<TotpCode, JsError> {
    let cfg: TotpConfig = serde_json::from_str(config_json)
        .map_err(|e| JsError::new(&format!("totp config: {e}")))?;
    let code = compute_totp_code(&cfg, now_unix_seconds)
        .map_err(|e| JsError::new(&e.to_string()))?;
    let period = cfg.period_seconds as u64;
    let expires_at = ((now_unix_seconds / period) + 1) * period;
    Ok(TotpCode { code, expires_at })
}
```

- [ ] **Step 4a: Verify core has `compute_totp_code`**

```bash
grep -rn "compute_totp_code" crates/relicario-core/src/
```

If missing, add to `crates/relicario-core/src/item_types/totp.rs`:

```rust
use hmac::{Hmac, Mac};
use sha1::Sha1;
use sha2::{Sha256, Sha512};

use crate::error::{RelicarioError, Result};

pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<String> {
    use TotpAlgorithm::*;
    let counter = match config.kind {
        TotpKind::Totp => now_unix_seconds / config.period_seconds as u64,
        TotpKind::Hotp(counter) => counter,
        TotpKind::Steam => now_unix_seconds / config.period_seconds as u64,
    };
    let counter_bytes = counter.to_be_bytes();
    let hmac_out: Vec<u8> = match config.algorithm {
        Sha1 => {
            let mut mac = Hmac::<Sha1>::new_from_slice(&config.secret)
                .map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
            mac.update(&counter_bytes);
            mac.finalize().into_bytes().to_vec()
        }
        Sha256 => {
            let mut mac = Hmac::<Sha256>::new_from_slice(&config.secret)
                .map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
            mac.update(&counter_bytes);
            mac.finalize().into_bytes().to_vec()
        }
        Sha512 => {
            let mut mac = Hmac::<Sha512>::new_from_slice(&config.secret)
                .map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
            mac.update(&counter_bytes);
            mac.finalize().into_bytes().to_vec()
        }
    };
    let offset = (hmac_out[hmac_out.len() - 1] & 0x0F) as usize;
    let truncated = ((hmac_out[offset]     as u32 & 0x7F) << 24)
                  | ((hmac_out[offset + 1] as u32)        << 16)
                  | ((hmac_out[offset + 2] as u32)        << 8)
                  |  (hmac_out[offset + 3] as u32);
    let modulus = 10u32.pow(config.digits as u32);
    Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize))
}

#[cfg(test)]
mod compute_tests {
    use super::*;
    use zeroize::Zeroizing;

    #[test]
    fn rfc6238_sha1_vector_59() {
        let cfg = TotpConfig {
            secret: Zeroizing::new(b"12345678901234567890".to_vec()),
            algorithm: TotpAlgorithm::Sha1,
            digits: 8,
            period_seconds: 30,
            kind: TotpKind::Totp,
        };
        assert_eq!(compute_totp_code(&cfg, 59).unwrap(), "94287082");
    }
}
```

Also add `hmac = "0.12"`, `sha1 = "0.10"`, `sha2 = "0.10"` to `relicario-core/Cargo.toml` if not already present.

- [ ] **Step 5: Build**

```bash
cargo build -p relicario-core
cargo build -p relicario-wasm --target wasm32-unknown-unknown
```

- [ ] **Step 6: Run native WASM tests**

```bash
cargo test -p relicario-wasm --lib
cargo test -p relicario-core item_types::totp
```

Expected: all pass.

- [ ] **Step 7: Commit**

```bash
git add crates/relicario-wasm/src/lib.rs crates/relicario-core
git commit -m "feat(wasm): attachment / generator / totp / imgsecret / id bridges

Also ports TOTP RFC 6238 compute to relicario-core::item_types::totp
so native + CLI + WASM share one implementation (audit H5: CSPRNG
via core's Uniform-sampling generator)."
```

---

## Task 21: WASM — serde_wasm_bindgen configuration + TypeScript type declarations

**Files:**
- Modify: `crates/relicario-wasm/src/lib.rs` (configure serializer)
- Create: `extension/src/wasm.d.ts` (TS declarations — consumed by Plan 1C)

The default `serde_wasm_bindgen::to_value` produces JS `Map`s for Rust `HashMap`. The extension wants plain objects. Configure the serializer accordingly.

- [ ] **Step 1: Plain-object serializer helper**

In `lib.rs`, replace the `to_value` import with a local helper:

```rust
use serde_wasm_bindgen::Serializer;

fn js_value_for<T: serde::Serialize>(v: &T) -> Result<JsValue, JsError> {
    let ser = Serializer::new().serialize_maps_as_objects(true);
    v.serialize(&ser).map_err(|e| JsError::new(&e.to_string()))
}
```

Replace every `to_value(&out)` call with `js_value_for(&out)`, and every `to_value(&serde_json::json!(...))` with `js_value_for(&serde_json::json!(...))`.

- [ ] **Step 2: TS declarations**

Create `extension/src/wasm.d.ts`:

```ts
// Thin TypeScript declarations for the relicario-wasm bindings.
// These are hand-written to mirror the #[wasm_bindgen] signatures in
// crates/relicario-wasm/src/lib.rs; keep them in sync manually.

export class SessionHandle {
  readonly value: number;
  free(): void;
}

export class EncryptedAttachment {
  readonly aid: string;
  readonly bytes: Uint8Array;
  free(): void;
}

export class TotpCode {
  readonly code: string;
  readonly expires_at: number;
  free(): void;
}

export function unlock(
  passphrase: string,
  image_bytes: Uint8Array,
  salt: Uint8Array,
  params_json: string,
): SessionHandle;

export function lock(handle: SessionHandle): boolean;

export function manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array;
export function item_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function item_encrypt(handle: SessionHandle, item_json: string): Uint8Array;
export function settings_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function settings_encrypt(handle: SessionHandle, settings_json: string): Uint8Array;

export function attachment_encrypt(
  handle: SessionHandle,
  plaintext: Uint8Array,
  max_bytes: bigint,
): EncryptedAttachment;
export function attachment_decrypt(handle: SessionHandle, encrypted: Uint8Array): Uint8Array;

export function new_item_id(): string;
export function new_field_id(): string;

export function generate_password(request_json: string): string;
export function generate_passphrase(request_json: string): string;
export function rate_passphrase(p: string): { score: number; guesses_log10: number };

export function extract_image_secret(image_bytes: Uint8Array): Uint8Array;
export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array;

export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode;

// Initializer (wasm-bindgen's default init function).
export default function init(module_or_path?: unknown): Promise<void>;
```

- [ ] **Step 3: Build both**

```bash
cargo build -p relicario-wasm --target wasm32-unknown-unknown
cd extension && bunx tsc --noEmit 2>&1 | head -30 && cd ..
```

Expected: both clean. If the extension has unrelated TS errors (from earlier phases), ignore — Plan 1C fixes them.

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-wasm/src/lib.rs extension/src/wasm.d.ts
git commit -m "feat(wasm): configure serde_wasm_bindgen for plain-object HashMap

Maps serialize as JS objects, not Maps — what the extension popup
expects. Also ships hand-written TS declarations for the bridge
(consumed by Plan 1C)."
```

---

## Task 22: CLI integration test harness — basic flows

**Files:**
- Create: `crates/relicario-cli/tests/common/mod.rs`
- Create: `crates/relicario-cli/tests/basic_flows.rs`

**Framework:** `assert_cmd` (spawn the compiled `relicario` binary), `predicates` (assert stdout/stderr patterns), `tempfile::TempDir` (each test gets its own vault dir), `serde_json` to verify written state.

- [ ] **Step 1: Common test harness**

Create `crates/relicario-cli/tests/common/mod.rs`:

```rust
//! Shared helpers for CLI integration tests.

use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use assert_cmd::cargo::CommandCargoExt;
use tempfile::TempDir;

pub struct TestVault {
    pub dir: TempDir,
    pub reference_image: PathBuf,
    pub passphrase: String,
}

impl TestVault {
    /// Create a vault in a fresh tempdir and initialize it. Uses a strong
    /// enough passphrase to pass the zxcvbn gate.
    pub fn init() -> Self {
        let dir = TempDir::new().expect("tempdir");
        // Synthesize a 400x300 JPEG carrier (small, passes imgsecret minimums).
        let carrier = make_test_jpeg(400, 300);
        let carrier_path = dir.path().join("carrier.jpg");
        std::fs::write(&carrier_path, &carrier).unwrap();

        let passphrase = "correct horse battery staple 2026".to_string();
        let ref_path = dir.path().join("reference.jpg");

        let mut cmd = Command::cargo_bin("relicario").unwrap();
        cmd.current_dir(dir.path())
            .args(["init", "--image", carrier_path.to_str().unwrap(),
                   "--output", ref_path.to_str().unwrap()])
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        let mut child = cmd.spawn().unwrap();
        // init asks for passphrase twice.
        let stdin = child.stdin.as_mut().unwrap();
        writeln!(stdin, "{passphrase}").unwrap();
        writeln!(stdin, "{passphrase}").unwrap();
        let out = child.wait_with_output().unwrap();
        assert!(out.status.success(), "init failed:\nstdout: {}\nstderr: {}",
                String::from_utf8_lossy(&out.stdout),
                String::from_utf8_lossy(&out.stderr));

        Self { dir, reference_image: ref_path, passphrase }
    }

    pub fn path(&self) -> &Path { self.dir.path() }

    /// Run a relicario subcommand against this vault, piping the passphrase
    /// into stdin for unlock.
    pub fn run(&self, args: &[&str]) -> std::process::Output {
        let mut cmd = Command::cargo_bin("relicario").unwrap();
        cmd.current_dir(self.dir.path())
            .env("RELICARIO_IMAGE", &self.reference_image)
            .args(args)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        let mut child = cmd.spawn().unwrap();
        writeln!(child.stdin.as_mut().unwrap(), "{}", self.passphrase).unwrap();
        child.wait_with_output().unwrap()
    }

    /// Same as `run` but also pipes additional stdin lines after the passphrase.
    pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
        let mut cmd = Command::cargo_bin("relicario").unwrap();
        cmd.current_dir(self.dir.path())
            .env("RELICARIO_IMAGE", &self.reference_image)
            .args(args)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        let mut child = cmd.spawn().unwrap();
        {
            let stdin = child.stdin.as_mut().unwrap();
            writeln!(stdin, "{}", self.passphrase).unwrap();
            for line in extra {
                writeln!(stdin, "{line}").unwrap();
            }
        }
        child.wait_with_output().unwrap()
    }
}

/// Synthesize a JPEG whose dimensions + Q92 encoding pass the imgsecret
/// minimum-size check. Copy of the make_test_jpeg helper from core tests.
pub fn make_test_jpeg(w: u32, h: u32) -> Vec<u8> {
    use image::codecs::jpeg::JpegEncoder;
    use image::{ExtendedColorType, ImageBuffer, ImageEncoder, Rgb};

    let img = ImageBuffer::from_fn(w, h, |x, y| {
        Rgb([
            ((x * 7 + y * 13) % 256) as u8,
            ((x * 11 + y * 3) % 256) as u8,
            ((x * 5 + y * 17) % 256) as u8,
        ])
    });
    let mut out = Vec::new();
    JpegEncoder::new_with_quality(&mut out, 92)
        .write_image(img.as_raw(), w, h, ExtendedColorType::Rgb8)
        .unwrap();
    out
}
```

Also add `image = { version = "0.25", default-features = false, features = ["jpeg"] }` to `relicario-cli/Cargo.toml`'s `[dev-dependencies]`.

- [ ] **Step 2: Write basic-flow tests**

Create `crates/relicario-cli/tests/basic_flows.rs`:

```rust
mod common;

use common::TestVault;

#[test]
fn init_creates_expected_layout() {
    let v = TestVault::init();
    assert!(v.path().join(".relicario/salt").exists());
    assert!(v.path().join(".relicario/params.json").exists());
    assert!(v.path().join(".relicario/devices.json").exists());
    assert!(v.path().join("manifest.enc").exists());
    assert!(v.path().join("settings.enc").exists());
    assert!(v.path().join("reference.jpg").exists());
    assert!(v.path().join(".gitignore").exists());
    assert!(v.path().join(".git").is_dir());
}

#[test]
fn init_params_json_is_format_v2() {
    let v = TestVault::init();
    let s = std::fs::read_to_string(v.path().join(".relicario/params.json")).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
    assert_eq!(parsed["format_version"], 2);
    assert_eq!(parsed["kdf"]["algorithm"], "argon2id-v0x13");
    assert_eq!(parsed["aead"], "xchacha20poly1305");
}

#[test]
fn add_login_then_list_shows_it() {
    let v = TestVault::init();
    let out = v.run(&[
        "add", "login", "--title", "GitHub",
        "--username", "alice", "--url", "https://github.com",
        "--password", "hunter2",
    ]);
    assert!(out.status.success(), "add failed: {:?}", out);
    let out = v.run(&["list"]);
    let stdout = String::from_utf8(out.stdout).unwrap();
    assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}");
}

#[test]
fn get_masks_by_default_shows_with_flag() {
    let v = TestVault::init();
    v.run(&["add", "login", "--title", "gmail",
           "--username", "u", "--password", "super-secret"]);

    let masked = v.run(&["get", "gmail"]);
    let stdout = String::from_utf8(masked.stdout).unwrap();
    assert!(stdout.contains("********"), "expected masked: {stdout}");
    assert!(!stdout.contains("super-secret"), "leaked plaintext: {stdout}");

    let shown = v.run(&["get", "gmail", "--show"]);
    let stdout = String::from_utf8(shown.stdout).unwrap();
    assert!(stdout.contains("super-secret"), "expected plaintext: {stdout}");
}

#[test]
fn rm_restore_purge_cycle() {
    let v = TestVault::init();
    v.run(&["add", "login", "--title", "target",
           "--username", "u", "--password", "p"]);

    let rm = v.run(&["rm", "target"]);
    assert!(rm.status.success());

    // List default excludes trashed
    let out = v.run(&["list"]);
    assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));

    // --trashed shows it
    let out = v.run(&["list", "--trashed"]);
    assert!(String::from_utf8(out.stdout).unwrap().contains("target"));

    let restore = v.run(&["restore", "target"]);
    assert!(restore.status.success());
    let out = v.run(&["list"]);
    assert!(String::from_utf8(out.stdout).unwrap().contains("target"));

    let purge = v.run(&["purge", "target"]);
    assert!(purge.status.success());
    let out = v.run(&["list"]);
    assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
}

#[test]
fn generate_random_and_bip39() {
    // generate does not require unlock.
    let dir = tempfile::TempDir::new().unwrap();
    let mut cmd = std::process::Command::cargo_bin("relicario").unwrap();
    use assert_cmd::cargo::CommandCargoExt as _;
    let out = cmd.current_dir(dir.path())
        .args(["generate", "--length", "32"]).output().unwrap();
    assert!(out.status.success());
    assert_eq!(String::from_utf8(out.stdout).unwrap().trim().len(), 32);

    let mut cmd = std::process::Command::cargo_bin("relicario").unwrap();
    let out = cmd.current_dir(dir.path())
        .args(["generate", "--bip39", "--words", "5"]).output().unwrap();
    assert!(out.status.success());
    let phrase = String::from_utf8(out.stdout).unwrap();
    assert_eq!(phrase.trim().split(' ').count(), 5);
}
```

- [ ] **Step 3: Run tests**

```bash
cargo build -p relicario-cli  # build so assert_cmd finds the binary
cargo test -p relicario-cli --test basic_flows
```

Expected: all passing. Slow — Argon2id runs per-test — so expect 10–30s total.

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-cli/Cargo.toml crates/relicario-cli/tests/
git commit -m "test(cli): integration harness + basic flow tests

Uses assert_cmd + tempfile to spin up a fresh vault per test.
Covers init layout, add/list/get mask semantics, rm/restore/purge cycle,
and generate smoke."
```

---

## Task 23: CLI integration tests — edit + history, attachments, settings

**Files:**
- Create: `crates/relicario-cli/tests/edit_and_history.rs`
- Create: `crates/relicario-cli/tests/attachments.rs`
- Create: `crates/relicario-cli/tests/settings.rs`

- [ ] **Step 1: Edit + history test**

Create `crates/relicario-cli/tests/edit_and_history.rs`:

```rust
mod common;

use common::TestVault;

#[test]
fn edit_password_captures_history() {
    let v = TestVault::init();
    v.run(&["add", "login", "--title", "bank",
           "--username", "u", "--password", "first-pw"]);

    // edit: accept defaults on title/group/tags/username/url, then change pw.
    let out = v.run_with_input(
        &["edit", "bank"],
        &["", "", "", "", "", "y", "second-pw"],
    );
    assert!(out.status.success(), "edit failed: {:?}", out);

    // Round-trip: read the item file directly and confirm field_history has
    // one entry on the synthetic core:login_password key.
    let items_dir = v.path().join("items");
    let entries: Vec<_> = std::fs::read_dir(&items_dir).unwrap()
        .map(|e| e.unwrap().path()).collect();
    assert_eq!(entries.len(), 1);
    // We can't decrypt without the key in this test, so instead observe that:
    // - git log has an "edit: bank" commit.
    let log = std::process::Command::new("git")
        .current_dir(v.path()).args(["log", "--oneline"])
        .output().unwrap();
    let log = String::from_utf8(log.stdout).unwrap();
    assert!(log.contains("edit: bank"), "missing edit commit: {log}");
}
```

- [ ] **Step 2: Attachments test**

Create `crates/relicario-cli/tests/attachments.rs`:

```rust
mod common;

use common::TestVault;

#[test]
fn attach_list_extract_round_trip() {
    let v = TestVault::init();
    v.run(&["add", "login", "--title", "thing",
           "--username", "u", "--password", "p"]);

    let payload_path = v.path().join("payload.txt");
    std::fs::write(&payload_path, b"attached-bytes").unwrap();

    let attach = v.run(&["attach", "thing", payload_path.to_str().unwrap()]);
    assert!(attach.status.success(), "attach failed: {:?}", attach);

    let list = v.run(&["attachments", "thing"]);
    let stdout = String::from_utf8(list.stdout).unwrap();
    assert!(stdout.contains("payload.txt"), "missing payload: {stdout}");

    // Parse aid from stdout — first token on the data row after the header.
    let aid = stdout.lines()
        .find(|l| l.contains("payload.txt"))
        .and_then(|l| l.split_whitespace().next())
        .expect("aid token");

    let out_path = v.path().join("extracted.txt");
    let ex = v.run(&["extract", "thing", aid, "--out", out_path.to_str().unwrap()]);
    assert!(ex.status.success(), "extract failed: {:?}", ex);
    assert_eq!(std::fs::read(out_path).unwrap(), b"attached-bytes");
}

#[test]
fn attach_rejects_over_cap() {
    let v = TestVault::init();
    v.run(&["add", "login", "--title", "thing",
           "--username", "u", "--password", "p"]);

    // Drop the per-attachment cap to 10 bytes, then try to attach 100.
    v.run(&["settings", "attachment-cap", "--per-attachment-max-bytes", "10"]);

    let big = v.path().join("big.bin");
    std::fs::write(&big, vec![0u8; 100]).unwrap();
    let out = v.run(&["attach", "thing", big.to_str().unwrap()]);
    assert!(!out.status.success(), "expected failure; got {:?}", out);
    assert!(String::from_utf8(out.stderr).unwrap().to_lowercase().contains("attachment"));
}
```

- [ ] **Step 3: Settings test**

Create `crates/relicario-cli/tests/settings.rs`:

```rust
mod common;

use common::TestVault;

#[test]
fn settings_roundtrip_trash_retention() {
    let v = TestVault::init();
    let out = v.run(&["settings", "show"]);
    assert!(String::from_utf8(out.stdout).unwrap().contains("trash_retention"));

    let out = v.run(&["settings", "trash-retention", "--days", "60"]);
    assert!(out.status.success(), "set failed: {:?}", out);
    let out = v.run(&["settings", "show"]);
    let stdout = String::from_utf8(out.stdout).unwrap();
    assert!(stdout.contains("\"value\": 60"), "expected 60: {stdout}");
}

#[test]
fn settings_rejects_conflicting_retention_flags() {
    let v = TestVault::init();
    let out = v.run(&["settings", "trash-retention", "--days", "30", "--forever"]);
    assert!(!out.status.success());
}
```

- [ ] **Step 4: Run tests**

```bash
cargo test -p relicario-cli --test edit_and_history
cargo test -p relicario-cli --test attachments
cargo test -p relicario-cli --test settings
```

Expected: all pass.

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-cli/tests/
git commit -m "test(cli): integration tests for edit/history, attachments, settings"
```

---

## Task 24: CLI integration tests — vault_dir detection + v1 vault rejection

**Files:**
- Create: `crates/relicario-cli/tests/vault_detection.rs`

Covers audit L8 (refuse outside vault) and confirms the v1 format is rejected as documented.

- [ ] **Step 1: Test refuses to run outside a vault**

Create `crates/relicario-cli/tests/vault_detection.rs`:

```rust
mod common;

use assert_cmd::cargo::CommandCargoExt;
use std::process::Command;
use tempfile::TempDir;

#[test]
fn list_refuses_without_vault_marker() {
    let dir = TempDir::new().unwrap();
    // No .relicario/ in dir — list should bail with a friendly error.
    let mut cmd = Command::cargo_bin("relicario").unwrap();
    let out = cmd.current_dir(dir.path()).arg("list").output().unwrap();
    assert!(!out.status.success());
    let stderr = String::from_utf8(out.stderr).unwrap();
    assert!(stderr.contains(".relicario"), "expected marker hint: {stderr}");
}

#[test]
fn get_finds_vault_in_parent_dir() {
    let v = common::TestVault::init();
    v.run(&["add", "login", "--title", "parent-test",
           "--username", "u", "--password", "p"]);

    // Create a nested subdir and run `list` from inside it.
    let nested = v.path().join("a/b/c");
    std::fs::create_dir_all(&nested).unwrap();

    let mut cmd = Command::cargo_bin("relicario").unwrap();
    use std::io::Write;
    use std::process::Stdio;
    cmd.current_dir(&nested)
        .env("RELICARIO_IMAGE", &v.reference_image)
        .arg("list")
        .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
    let mut child = cmd.spawn().unwrap();
    writeln!(child.stdin.as_mut().unwrap(), "{}", v.passphrase).unwrap();
    let out = child.wait_with_output().unwrap();
    assert!(out.status.success(), "list from nested dir failed: {:?}", out);
    assert!(String::from_utf8(out.stdout).unwrap().contains("parent-test"));
}

#[test]
fn v1_vault_is_rejected_with_clear_error() {
    // Synthesize an on-disk v1 vault: .idfoto/ dir with old params.json.
    // Since vault_dir detection uses .relicario/, the pre-rename dir name is
    // naturally rejected without any compat shim. Confirm that.
    let dir = TempDir::new().unwrap();
    std::fs::create_dir(dir.path().join(".idfoto")).unwrap();

    let mut cmd = Command::cargo_bin("relicario").unwrap();
    let out = cmd.current_dir(dir.path()).arg("list").output().unwrap();
    assert!(!out.status.success());
    let stderr = String::from_utf8(out.stderr).unwrap();
    assert!(stderr.contains(".relicario"), "expected relicario marker demand: {stderr}");
}
```

- [ ] **Step 2: Run**

```bash
cargo test -p relicario-cli --test vault_detection
```

Expected: all pass.

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-cli/tests/vault_detection.rs
git commit -m "test(cli): vault_dir detection (L8) + v1 vault rejection"
```

---

## Task 25: Update CLAUDE.md + final verification

**Files:**
- Modify: `CLAUDE.md`

- [ ] **Step 1: Rewrite the "Project structure" diagram**

Replace the existing diagram in `CLAUDE.md` with a reflection of the typed-item module layout:

````markdown
## Project structure

```
crates/
├── relicario-core/            # Platform-agnostic library (no filesystem, no git, no network)
│   ├── src/
│   │   ├── lib.rs          # Re-exports public API
│   │   ├── error.rs        # RelicarioError enum (thiserror)
│   │   ├── crypto.rs       # Argon2id KDF (length-prefixed, Zeroizing) + XChaCha20-Poly1305
│   │   ├── ids.rs          # ItemId, FieldId, content-addressed AttachmentId
│   │   ├── time.rs         # now_unix, MonthYear
│   │   ├── item_types/     # per-type cores + ItemType/ItemCore enums
│   │   ├── item.rs         # Item envelope, Field, FieldKind, FieldValue, Section
│   │   ├── attachment.rs   # AttachmentRef, EncryptedAttachment, encrypt/decrypt helpers
│   │   ├── manifest.rs     # Browse-without-decrypt index (schema_version 2)
│   │   ├── settings.rs     # VaultSettings: retention, generator defaults, caps
│   │   ├── generators.rs   # CSPRNG password + BIP39 + zxcvbn gate
│   │   ├── vault.rs        # JSON ↔ AEAD wrappers for Item/Manifest/VaultSettings
│   │   └── imgsecret.rs    # DCT steganography (MAX_DIMENSION cap)
│   └── tests/              # integration.rs, attachments.rs, generators.rs, format_v2.rs, field_history.rs
├── relicario-cli/             # `relicario` binary
│   ├── src/main.rs         # clap surface + command handlers
│   ├── src/helpers.rs      # vault_dir, git_command, iso8601
│   ├── src/session.rs      # UnlockedVault (master key in Zeroizing)
│   └── tests/              # basic_flows, edit_and_history, attachments, settings, vault_detection
└── relicario-wasm/            # WASM bindings for the extension
    ├── src/lib.rs          # #[wasm_bindgen] surface
    └── src/session.rs      # opaque SessionHandle → Zeroizing<[u8;32]>
```
````

Also update the "Build and test" section example commands to use `relicario-cli` crate targets, and add a section summarizing the Plan 1B audit fixes.

- [ ] **Step 2: Run the full test suite end-to-end**

```bash
cargo test
cargo build -p relicario-wasm --target wasm32-unknown-unknown
```

Expected: all core tests, all WASM native tests, all CLI integration tests pass. WASM target builds clean.

- [ ] **Step 3: Run `cargo clippy`**

```bash
cargo clippy --workspace --all-targets -- -D warnings
```

Expected: no warnings. Fix anything that surfaces; rebuild.

- [ ] **Step 4: Commit docs + tag**

```bash
git add CLAUDE.md
git commit -m "docs: update CLAUDE.md for the typed-item module layout"
git tag plan-1b-cli-wasm-complete
```

- [ ] **Step 5: Report completion**

Print the commit + tag summary so the next session knows where to pick up:

```bash
git log --oneline plan-1a-rust-core-complete..plan-1b-cli-wasm-complete
```

Expected: ~25 commits covering this plan. Confirm tag exists: `git tag | grep 1b`.

---

## Hand-off to Plan 1C

Plan 1C (extension rewrite) starts from `plan-1b-cli-wasm-complete` and:

- Rewrites `extension/src/service-worker/` around the split message router (popup-only vs content-callable), sender checks, origin-bound autofill.
- Replaces all page-DOM capture/icon rendering with closed Shadow DOM + textContent.
- Updates popup UI for the 7 typed-item forms.
- Removes `setup.html` + WASM artifacts from `web_accessible_resources`.
- Consumes the `extension/src/wasm.d.ts` declarations shipped in Task 21.
- Lands audit items C1–C4, H8, L11 that depend on the extension surface.

---

## Self-Review Checklist

Before handing this plan off for execution:

### Spec coverage

- [x] `relicario init` — Task 6
- [x] `relicario add` (all 7 types) — Tasks 7–8
- [x] `relicario get` with mask + --show + clipboard — Task 9
- [x] `relicario list` with filters — Task 10
- [x] `relicario edit` with history — Task 11
- [x] `relicario rm/restore/purge/trash empty` — Task 12
- [x] `relicario attach/attachments/extract` — Task 13
- [x] `relicario generate` delegating to core — Task 14
- [x] `relicario settings show/trash-retention/history-retention/attachment-cap` — Task 15
- [x] `relicario sync` hardened — Task 16
- [x] `relicario device add/list/revoke` — Task 17
- [x] WASM `unlock/lock` (SessionHandle) — Task 18
- [x] WASM manifest/item/settings — Task 19
- [x] WASM attachment/generator/totp/imgsecret/ID — Task 20
- [x] WASM TS declarations — Task 21

### Audit fixes

- [x] H4 (hardened git shell-out): Task 3 helper, Tasks 6, 7, 12, 15, 16, 17
- [x] H5 (CSPRNG in WASM): Task 18 uses `getrandom` + core's Uniform generator via Task 20's generator bridge
- [x] H6 (CLI Uniform sampling): Task 14 delegates to core, eliminating CLI modulo bias
- [x] H7 (rpassword 7.x): Task 3 Cargo.toml bump, used throughout
- [x] M3 (imgsecret MAX_DIMENSION): Task 2
- [x] M6 (clipboard Zeroize + always-clear): Task 9
- [x] M7 (mask by default, --show to reveal): Task 9
- [x] M11 (ISO-8601): Task 3 helper, used in Task 9 `get` output
- [x] L8 (vault dir detection): Task 3 helper, used by every command; Task 24 test

### Test coverage

- [x] init layout + format_version 2 — Task 22
- [x] add/list/get mask semantics — Task 22
- [x] rm/restore/purge cycle — Task 22
- [x] generate (random + BIP39) — Task 22
- [x] edit history capture — Task 23
- [x] attach/list/extract + cap rejection — Task 23
- [x] settings retention roundtrip — Task 23
- [x] vault_dir detection (L8) + v1 vault rejection — Task 24

### Placeholder scan

- No "TBD", "implement later", or "fill in details".
- Every task either contains the full code block it produces OR defers to an earlier task where the pattern is established (e.g., Task 8 references Task 7's Login dispatcher pattern for Identity/Card/Key/etc. with fresh per-type code inline).
- Every test has its assertion block fully written.

### Type-name consistency

- `relicario_core::…` (crate ident, underscores) everywhere in Rust imports.
- `relicario-core` (crate name, dashes) in `Cargo.toml` dependency lines.
- `RelicarioError` for the error enum.
- `RELICARIO_IMAGE` for the env var.
- `.relicario/` for the vault metadata dir.
- `SessionHandle` in WASM; `UnlockedVault` in CLI — intentionally different per layer.
- `ItemId`/`FieldId`/`AttachmentId` match `relicario-core` API.
