Files
relicario/crates/relicario-cli/ARCHITECTURE.md
adlee-was-taken c66fd520f8 docs(arch): per-codebase ARCHITECTURE.md + cross-codebase overview
Strategic-depth architecture documentation, the kind that's hard to
recover by reading code: invariants, multi-file flows, design rationale,
gotchas. Goal is to cut the token cost for future Claude sessions.

Four new docs (2091 lines total):

- crates/relicario-core/ARCHITECTURE.md (514 lines) — bytes-in/bytes-out
  boundary, 24 verified invariants (VERSION_BYTE=0x02, length-prefixed
  KDF input, NFC normalization, content-addressed AttachmentId, history-
  tracked field kinds, 60% imgsecret confidence floor, MAX_DIMENSION=
  10000, etc.), 7 multi-module flows, 16 non-obvious gotchas (QUANT_STEP=
  50, central-70%-embed, BIP39-128bit-then-truncate, Steam alphabet
  rationale).

- crates/relicario-cli/ARCHITECTURE.md (539 lines) — module map for the
  three source files; the cmd_add/cmd_edit per-type helper pattern (post-
  2026-04-27 refactor); the hardened-git invariant (Command::new("git")
  is gated to helpers.rs:46); the five history synthetic keys; the env-
  var escape-hatch policy; cmd_generate's two-mode design (no-unlock
  outside vault, unlock-and-read-defaults inside).

- extension/ARCHITECTURE.md (831 lines) — five-bundle structure (popup,
  vault, setup, content, service-worker); SW-as-crypto-fortress model;
  capability-set-or-silent-rejection contract; vault-tab-as-popup-class
  router parity (commit a7dbf35); origin TOFU flow; setup state machine;
  test-vs-build gap.

- docs/architecture/overview.md (207 lines) — cross-codebase entry point.
  How the three codebases fit together, the four versioned wire formats
  between them (core→WASM ABI, SW chrome.runtime protocol, vault on-disk
  layout, GitHost API), per-codebase secret residency table, build
  matrix, conventions that span all three.

Specs in docs/superpowers/specs/ remain as historical decision artifacts
("why we chose this") — the new arch docs are the source of truth for
"what is" current invariants and flows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:41:26 -04:00

540 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Architecture: relicario-cli
## What this crate is for
The `relicario` binary is the platform layer for `relicario-core`: it adds
filesystem layout, a hardened `git` shell-out, interactive `rpassword` prompts,
clipboard handoff, and a clap-based command surface. The crate has two design
roles. First, it is the developer / power-user surface that exposes everything
the core can do (every `ItemCore` variant, every `VaultSettings` knob, history
inspection, device key management). Second, it is the only working interface
during disaster recovery — the extension may be uninstalled, the device may be
new — so it intentionally maintains feature parity with the extension's vault
tab. It deliberately shells out to `git` rather than depending on libgit2 /
gitoxide; this keeps the dep tree slim, lets the user override `git` config
locally, and lets recovery debugging happen with familiar tooling.
## Module map
The crate is three files of source and a `tests/` directory. Each source file
has one job.
- **`src/main.rs`** (`main.rs:1-1719`) — clap surface plus every command
handler. Internal structure: a top-level `Cli` / `Commands` enum
(`main.rs:13-275`), a flat dispatcher `match` in `main()`
(`main.rs:277-303`), per-command handlers named `cmd_<verb>`, and a layer of
per-type item helpers (`build_<type>_item` for `cmd_add`, `edit_<type>` for
`cmd_edit`). The per-type split is recent: commit `3f0f5b1` extracted
~217-line `match` arms in `cmd_add` and `cmd_edit` into focused functions,
one per `ItemCore` variant, so each builder/editor reads top-to-bottom and
can be tested through the same integration paths. Owns all clap argument
parsing, all interactive prompts (`prompt`, `prompt_optional`, `prompt_keep`,
`prompt_keep_opt`, `prompt_yesno`, `prompt_secret`), and the shared
`commit_paths` helper that is the single chokepoint for git commits during
vault mutations.
- **`src/session.rs`** (`session.rs:1-152`) — `UnlockedVault` lifecycle. Holds
the derived master key in `Zeroizing<[u8; 32]>` for one CLI invocation; the
key wipes via `Zeroize` on scope exit (`session.rs:22-25`). Owns the
`unlock_interactive` flow (vault root walk → salt read → params read →
reference image extract → passphrase prompt → KDF) at `session.rs:33-59`,
the typed `load_*` / `save_*` accessors for `Item` / `Manifest` /
`VaultSettings`, the `read_salt` / `read_params` helpers, the
`RELICARIO_IMAGE` lookup, and `atomic_write` (`session.rs:144-151`) which
every disk write to a vault file goes through. Owns the env-var escape
hatches `RELICARIO_TEST_PASSPHRASE` (`session.rs:42`) and `RELICARIO_IMAGE`
(`session.rs:125`) that integration tests use to bypass the TTY.
- **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing:
`find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories
looking for a `.relicario/` marker; `vault_dir` and `relicario_dir` wrap it
for `cwd`-rooted callers; `git_command` (`helpers.rs:45-55`) is the
hardened-`git` factory that every git invocation in the crate (production
code, not tests) goes through; `iso8601` (`helpers.rs:60-64`) formats Unix
seconds for human-readable output (audit M11). The hardening is
load-bearing — see Invariants & Gotchas below.
## Invariants & contracts
These are the load-bearing rules the crate relies on. Each has been verified
in code; cite the line if you change it.
- **Every vault-mutating command unlocks via `UnlockedVault`.** The struct
holds the master key in `Zeroizing<[u8; 32]>` and drops via `Zeroize` on
scope exit (`session.rs:22-25`). No command bypasses this except
`cmd_generate` outside a vault dir and `cmd_init` (which derives the key
inline before there is a vault to unlock).
- **Every `git` invocation in production code goes through
`helpers::git_command`.** A grep for `Command::new("git")` outside
`helpers.rs` finds zero hits in `src/`; the only other match is in
`tests/edit_and_history.rs:18`, which is test-side verification of the git
log and is exempt by design. `git_command` injects
`core.hooksPath=/dev/null`, `commit.gpgsign=false`, and `core.editor=true`
via `-c` flags (`helpers.rs:48-52`). Direct `Command::new("git")` would
bypass the hardening — don't.
- **Every file write to a vault file uses `atomic_write`.** `atomic_write`
(`session.rs:144-151`) writes `<path>.tmp` then renames over `<path>`; a
partial write never appears as the live file. All `UnlockedVault::save_*`
helpers route through it. (`cmd_init` writes pre-creation files via
`fs::write` at `main.rs:373-393`; that path doesn't need atomicity because
the vault doesn't exist yet — failure leaves a half-built vault that the
next run rejects via `relicario_dir.exists()` at `main.rs:326`.)
- **Every commit during a mutating command uses `commit_paths`.**
`commit_paths` (`main.rs:767-775`) does `git add <paths> && git commit -m
<msg>` through the hardened wrapper. Commit message convention is
`<verb>: <title> (<id>)``add:`, `edit:`, `trash:`, `restore:`, `purge:`,
`attach:`, `detach:`, `settings: update`, `device: add <name>`, `device:
revoke <name>`, `init: new relicario vault (format v2)`, `trash empty:
purged N item(s)`. `cmd_purge` and `cmd_trash_empty` and `cmd_device` use
`git_command` directly (not `commit_paths`) because they need a slightly
different add/commit pattern; they still go through the hardened wrapper.
- **`cmd_generate` is the only command that runs without unlock — and only
when invoked outside a vault directory.** Inside a vault,
`cmd_generate` unlocks to read `settings.generator_defaults`
(`main.rs:1440-1445`); explicit flags override the stored defaults. This is
why the smoke-test `cargo run -p relicario-cli -- generate --length 32`
works without any setup.
- **Item IDs are minted by core.** The CLI never constructs an `ItemId`
directly; `Item::new` (called inside every `build_*_item`) does it via
`relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex.
- **Manifest is always saved last.** Within a single command, the order is:
write item file → mutate manifest → save manifest → commit. If the process
dies between step 1 and step 3, the next run sees an item file with no
manifest entry; `cmd_status` / `cmd_list` ignore it because they read the
manifest, not the directory. (Recovery would manually re-`add` to surface
it.)
- **Vault root is always discovered, never assumed to be `cwd`.**
`helpers::vault_dir` walks up from `cwd` looking for `.relicario/`, so any
command run from a subdirectory of the vault works (verified by
`vault_detection.rs:23-40`). v1 vaults using `.idfoto/` are naturally
rejected because they don't contain `.relicario/` — no compat shim needed
(`vault_detection.rs:42-59`).
- **`prompt_secret` reads `RELICARIO_TEST_ITEM_SECRET` before falling back to
`rpassword`.** This is the only way integration tests can drive the
per-item secret prompts (Login password, Card number, TOTP secret rotation,
Key material) without a real TTY. The check is at `main.rs:308-313`.
## Key flows
### Vault init (`cmd_init`, `main.rs:315-418`)
1. Refuse if `.relicario/` already exists (`main.rs:326-328`).
2. Read passphrase twice (or once via `RELICARIO_TEST_PASSPHRASE`); confirm
they match; run `validate_passphrase_strength` (zxcvbn-backed) and bail
with audit-H3 message on weak input (`main.rs:331-348`).
3. Generate a 32-byte random `image_secret` via `OsRng`, embed it into the
carrier JPEG via `imgsecret::embed`, write the stego output to `--output`
(`main.rs:351-360`).
4. Generate a 32-byte salt and pin `KdfParams { argon2_m: 65536, argon2_t: 3,
argon2_p: 4 }` (production-grade) at `main.rs:363-365`.
5. `derive_master_key(passphrase, image_secret, salt, params)` →
`Zeroizing<[u8;32]>` (`main.rs:368`).
6. Create `.relicario/`, `items/`, `attachments/` dirs; write
`.relicario/{salt, params.json, devices.json}`; encrypt and write
`manifest.enc` (empty `Manifest::new()`) and `settings.enc`
(`VaultSettings::default()`) (`main.rs:370-393`).
7. Write `.gitignore` listing the reference image filename (so the second
factor never accidentally ends up in git) (`main.rs:396-400`).
8. `git init` then initial commit `init: new relicario vault (format v2)`
via `git_command` (`main.rs:403-412`). Note the initial commit does NOT
go through `commit_paths` — it precedes the existence of an
`UnlockedVault`, so the path list is hand-spelled.
### Vault unlock (`UnlockedVault::unlock_interactive`, `session.rs:33-59`)
1. `vault_dir()` walks up from cwd to find `.relicario/`; bails with the
"run `relicario init` first" message on miss (`helpers.rs:21-26`).
2. `read_salt` reads `.relicario/salt` (32 bytes; rejects any other length).
3. `read_params` deserializes `.relicario/params.json` and extracts the
nested `kdf` sub-object as `KdfParams` (`session.rs:110-121`). The nested
shape exists because `params.json` also stores `format_version`, `aead`,
and `salt_path` for forward-compat probing.
4. `get_image_path` honours `RELICARIO_IMAGE`, then a `<vault>/reference.jpg`
convention, then prompts (`session.rs:124-140`).
5. Read the reference image bytes; `imgsecret::extract` runs the DCT
majority-vote decode to recover the 32-byte image secret
(`session.rs:38-40`).
6. Read the passphrase via `RELICARIO_TEST_PASSPHRASE` or `rpassword`
(`session.rs:42-49`).
7. `derive_master_key` produces the master key; `UnlockedVault { root,
master_key }` is returned and lives until the command function returns.
### Item add (`cmd_add`, `main.rs:419-456`)
1. Unlock the vault and load the manifest.
2. Match on the `AddKind` variant and dispatch to the matching
`build_<type>_item` helper (`main.rs:423-438`). Seven variants → seven
builders; only `build_document_item` takes `&UnlockedVault` because it
needs `attachment_caps` and writes the encrypted blob alongside the item.
3. The builder returns a fully-populated `Item` (with title, group, tags,
favorite-flag, primary attachment if any).
4. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`,
`vault.save_manifest(&manifest)`.
5. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one
`attachments/<id>/<aid>.enc` per attachment — and call `commit_paths`
with message `add: <title> (<id>)` (`main.rs:444-452`).
### Item edit (`cmd_edit`, `main.rs:938-977`)
1. Unlock, load manifest, resolve query → item id, load the item.
2. Universally-editable fields (title, group, tags) are prompted via
`prompt_keep` / `prompt_keep_opt` first; blank input keeps the current
value (`main.rs:952-956`).
3. Borrow `&mut item.field_history` once into a local `history` binding
(`main.rs:958`), then `match` on `&mut item.core` and dispatch to the
per-type `edit_<type>` helper (`main.rs:959-967`). The history-tracking
editors (`edit_login`, `edit_secure_note`, `edit_card`, `edit_key`,
`edit_totp`) take `&mut FieldHistory`; the others (`edit_identity`,
`edit_document_message`) don't.
4. Each editor that mutates a tracked secret calls `push_history(history,
"<key>", old_value)` (`main.rs:1095-1109`) — see the History flow below
for the synthetic-key convention.
5. `item.modified = now_unix()`, save, upsert manifest, commit
`edit: <title> (<id>)`.
`edit_document_message` (`main.rs:1050-1052`) just prints "use `attach` /
`extract` instead" — Document items can't be field-edited; they're
attachment-shaped.
The `FieldHistory` type alias (`main.rs:983-986`) is purely cosmetic; it
exists so the editor signatures don't have to spell out the full
`HashMap<FieldId, Vec<FieldHistoryEntry>>`.
### History capture and view (`push_history` + `cmd_history`)
`push_history` (`main.rs:1095-1109`) records an old value under a synthetic
`FieldId(format!("core:{key}"))`. The `core:` prefix namespaces these keys so
they can never collide with real custom-field UUIDs from the typed-item
custom-fields work. The keys used in the codebase are:
- `core:login_password` (`main.rs:998`)
- `core:secure_note_body` (`main.rs:1012`)
- `core:card_number` (`main.rs:1031`)
- `core:key_material` (`main.rs:1045`)
- `core:totp_secret` (`main.rs:1063`)
`cmd_history` (`main.rs:1111-1159`) reads `item.field_history`, sorts the
keys, strips the `core:` prefix for display, and prints each entry list
masked or revealed depending on `--show`. The `--field <name>` filter
matches against either the stripped name (`login_password`) or the raw key
(`core:login_password`) so both forms work (`main.rs:1126-1129`). The
`relicario history bank --field totp_secret` form is what
`edit_and_history.rs` exercises.
### Trash & purge (`cmd_rm` / `cmd_restore` / `cmd_purge` / `cmd_trash_empty`)
- `cmd_rm` (`main.rs:1161-1176`) calls `Item::soft_delete()` (sets
`trashed_at`), saves, upserts manifest, commits `trash:`.
- `cmd_restore` (`main.rs:1178-1193`) is the inverse: `Item::restore()`,
same wrap-up, commit `restore:`.
- `cmd_purge` (`main.rs:1220-1237`) calls `purge_item` (`main.rs:1197-1218`)
which removes the item file, the attachment dir, the manifest entry, and
`git rm -rf --ignore-unmatch`s the paths. Then a single `git add
manifest.enc` + commit `purge: <title> (<id>)`.
- `cmd_trash_empty` (`main.rs:1246-1282`) is the only multi-item mutating
command. It loads settings once, iterates all items past their
`trash_retention` window, calls `purge_item` for each, then does a single
`git add manifest.enc` + commit `trash empty: purged N item(s)`. The
single-unlock-per-batch shape was the fix in commit `b5015b3` — the
earlier version re-prompted for the passphrase per item.
### Attach / detach / extract
- `cmd_attach` (`main.rs:1283-1339`) loads `attachment_caps` from settings
and rejects if the item has hit `per_item_max_count`. `encrypt_attachment`
enforces `per_attachment_max_bytes`. The encrypted blob lands at
`attachments/<item_id>/<aid>.enc`; the `aid` is content-addressed by core.
Commit message: `attach: <file> → <title> (<id>)`.
- `cmd_detach` (`main.rs:1376-1424`, added in `3f0f5b1`) removes one
attachment from the item, deletes the encrypted blob, rewrites the item.
Refuses if the target `aid` is a `Document` item's `primary_attachment`
(`main.rs:1392-1400`) — that would orphan the item; use `purge` instead.
Commit message: `detach: <filename> from <title> (<id>)`.
- `cmd_extract` (`main.rs:1354-1375`) decrypts the blob and writes the
plaintext to `--out` or to `<filename>` in cwd. Read-only: no commit, no
state mutation.
- `cmd_attachments` (`main.rs:1341-1352`) lists `aid`, size, mime, filename
— read-only.
### Generate (`cmd_generate`, `main.rs:1426-1489`)
Has two distinct modes:
- **Outside a vault** — `vault_dir()` returns `Err`; `vault_defaults` stays
`None`; defaults are hard-coded (`length: 20`, `symbols: SafeOnly`,
`words: 5`, `separator: " "`, `Capitalization::Lower`). No unlock prompt.
- **Inside a vault** — `vault_dir()` succeeds; full unlock; load
`settings.generator_defaults`. Explicit flags override the stored defaults
field-by-field. `--bip39` flips mode; absent that flag, the mode is
whatever the stored default is. Tests:
`settings.rs::generate_uses_vault_default_length` (length-tracking) and
`basic_flows.rs::generate_random_and_bip39` (no-vault smoke).
The two-mode shape is deliberate (see Gotchas) and is why `cmd_generate` is
the only command outside `cmd_init` that touches `helpers::vault_dir()`
directly instead of going through `UnlockedVault::unlock_interactive()`.
### Sync (`cmd_sync`, `main.rs:1582-1590`)
`git pull --rebase` then `git push`, both via the hardened wrapper. No
unlock — sync moves opaque ciphertext, the master key is never needed. This
is the only command that fails on conflict; it doesn't try to resolve.
Resolution happens manually in the user's git tooling.
### Status (`cmd_status`, `main.rs:1592-1631`, added in `3f0f5b1`)
Unlocks; loads manifest; counts items (active vs trashed), attachments
(count + total bytes), devices (parsed from `devices.json`); shells out to
`git log -1 --pretty=format:%h %s` for the last-commit summary line. All
read-only — no commit, no state change.
### Device management (`cmd_device`, `main.rs:1632-1702`)
Add: generate ed25519 keypair via `OsRng`, append `{name, public_key}` to
`.relicario/devices.json`, write the secret signing key to
`<config_dir>/relicario/devices/<name>.key` with `0o600` on Unix, commit
`device: add <name>`. List: print `name pubkey_hex`. Revoke: filter by name,
rewrite `devices.json`, commit `device: revoke <name>`. Note that device
keys are kept entirely separate from the KDF (passphrase × image stays
unchanged across device add/revoke), as per the design spec.
### Backup-passphrase-style commands (none yet)
The import / export / `import-lastpass` commands described in
`docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` are
not yet implemented. When they land they'll fit in the dispatcher
(`main.rs:279-302`) alongside `Sync` and `Status`. Don't add stubs here
until that work begins.
## Cross-cutting concerns
- **Error model.** Every `cmd_*` returns `anyhow::Result<()>`. Core errors
bubble up through `?` from `RelicarioError`. Per-step context is added
via `with_context(|| ...)` chains, e.g. `format!("failed to read {}",
path.display())`. AEAD authentication failures intentionally surface as
the ambiguous "wrong passphrase or corrupt vault" message from core — the
CLI does not differentiate. clap argument errors are produced by clap
(e.g., `--days` and `--forever` together fail at the
`SettingsAction::TrashRetention` arm in `main.rs:1504-1510`).
- **Atomicity.** Every disk write to a vault file goes through
`session.rs::atomic_write` (`session.rs:144-151`): write `<path>.tmp`, then
rename over `<path>`. Manifest is the single source of truth and is
always written *last* in any multi-file operation, so a process kill
between item-write and manifest-write leaves an orphan item file (which
doesn't appear in `list`/`status`) but never a manifest pointing to a
missing file.
- **Git history as audit log.** Per-action commits, never amended, never
squashed. The verb prefix on commit subjects (`add:`, `edit:`, `trash:`,
`restore:`, `purge:`, `attach:`, `detach:`, `settings:`, `device:`,
`init:`) makes `git log --oneline` a literal audit trail. Tests verify
this by greping `git log` directly (e.g., `edit_and_history.rs:18-22`).
- **Where secrets live.**
- Master key — `UnlockedVault.master_key: Zeroizing<[u8; 32]>`
(`session.rs:24`). Wipes on drop.
- Image secret — `Zeroizing<[u8; 32]>`, lives only inside
`unlock_interactive` until the KDF call (`session.rs:40`).
- Passphrase — `Zeroizing<String>` from `rpassword::prompt_password` or
the env var (`session.rs:42-49`, `main.rs:333-342`).
- Item secrets — `Zeroizing<String>` for `Login.password`, `Card.number`,
`Card.cvv`, `Card.pin`, `Key.key_material`, `SecureNote.body`, and
`Zeroizing<Vec<u8>>` for `TotpCore.config.secret` (decoded from
base32). All flow through core types.
- Clipboard copy — `Zeroizing<String>` cloned into the detached 30s
auto-clear thread (`main.rs:873-889`).
- **Test escape hatches.** Three env vars exist for integration tests; all
are read at exactly one site each:
- `RELICARIO_TEST_PASSPHRASE` — `session.rs:42` (unlock) and
`main.rs:333,338` (init).
- `RELICARIO_IMAGE` — `session.rs:125` (image path resolution).
- `RELICARIO_TEST_ITEM_SECRET` — `main.rs:309` (`prompt_secret` only).
None of them have a production fall-through; absent the var, the code
always prompts. They are safe in production binaries because the user
would have to set them explicitly.
- **Generate-without-unlock is intentional.** It is NOT an oversight.
`relicario generate --length 32` is the documented smoke test (see the
repo's CLAUDE.md) and works as a standalone CSPRNG password generator
outside any vault. Inside a vault it does require unlock — see Gotchas.
## Test architecture
All tests are integration tests; there are no `#[cfg(test)]` modules in
`src/main.rs` or `src/session.rs`. `helpers.rs` has four unit tests
(`helpers.rs:67-100`) that exercise vault-dir walking and `iso8601`
formatting in isolation. Everything else is `tests/`.
- **`tests/common/mod.rs`** (`117 lines`) — the harness. `TestVault::init()`
spins up a fresh `TempDir`, generates a 400×300 JPEG via
`make_test_jpeg()` (deterministic noise; no binary fixtures), runs
`relicario init --image carrier.jpg --output reference.jpg` with
`RELICARIO_TEST_PASSPHRASE` set, and stashes the passphrase + reference
image path on the struct. `run` and `run_with_input` are the two ways to
invoke the binary against the test vault: both inherit
`RELICARIO_IMAGE` + `RELICARIO_TEST_PASSPHRASE`; the latter pipes extra
newlines into stdin (used for interactive prompts that aren't
`rpassword`-driven). The note at the top warns Task 23 implementers
about the new-item-password rpassword path; the fix landed as
`RELICARIO_TEST_ITEM_SECRET` in commit `20350d5`.
- **`tests/basic_flows.rs`** (`136 lines`) — covers the init layout
(`.relicario/{salt,params.json,devices.json}`, `manifest.enc`,
`settings.enc`, `reference.jpg`, `.gitignore`, `.git`); the `params.json`
v2 shape; `add login` + `list`; `get` masking semantics (with and
without `--show`); the rm/restore/purge cycle including `list --trashed`;
and the two-mode `generate` smoke (random length + bip39 word count) run
outside a vault.
- **`tests/edit_and_history.rs`** (`191 lines`) — drives `edit` end-to-end
by piping stdin lines (blank to keep, `y` to confirm) plus
`RELICARIO_TEST_ITEM_SECRET` for the rpassword leg. `edit_password_*`
verifies the item file is rewritten and the `edit: bank` commit lands.
The four `history_command_*` tests cover masked listing, `--show`
reveal, "no history captured" output, and per-field filtering. The
`edit_totp_rotates_secret_and_captures_history` test (added 2026-04-27
in commit `3f0f5b1` — fixes a stub at the old `main.rs:925`) drives the
full TOTP edit including issuer / label / secret rotation.
- **`tests/attachments.rs`** (`106 lines`) — `attach`/`attachments`/
`extract` round-trip (verifies the bytes survive the encrypt-decrypt
hop); `detach` removes both the attachment ref and the encrypted blob
on disk; `detach` rejects an unknown `aid`; `attach` rejects payloads
over `per_attachment_max_bytes`. The detach test (`detach_*`) and the
cap test were added in `3f0f5b1` / `20350d5` respectively.
- **`tests/settings.rs`** (`135 lines`) — `settings show` and
`settings trash-retention --days 60` round-trip; the conflicting-flags
rejection (`--days` + `--forever`); the
`generate_uses_vault_default_length` test that verifies (a) default
vault length is 20, (b) updating `settings generator-defaults --length
32` changes the default, (c) explicit `--length 8` overrides the stored
default; the multi-shape `cmd_status` smoke; and the
`generate_works_outside_vault` test that verifies the no-unlock path
works in a bare `TempDir` with no `.relicario/`.
- **`tests/vault_detection.rs`** (`59 lines`) — three tests covering audit
L8: `list` refuses without a marker; `list` from a nested subdirectory
finds the parent `.relicario/`; a v1 `.idfoto/` directory is rejected
with the `.relicario` hint in the error message.
The whole test suite uses `assert_cmd` to spawn the real binary against a
real temp directory, so they exercise actual fs / git / KDF code paths.
The KDF runs with the production-grade `m=64MiB, t=3, p=4` parameters in
the test path (`main.rs:365`), which is why init takes a noticeable beat
in the test runner. The core's "fast Argon2id for tests" CLAUDE.md note
applies to `relicario-core` unit tests, not these CLI integration tests.
## Gotchas & non-obvious decisions
- **`cmd_generate` runs without unlock outside a vault, but with unlock
inside.** This is two ergonomic guarantees in one command. Outside, it's
a fast standalone CSPRNG tool — useful for smoke tests, scripts, and any
user who installed `relicario` just for the generator. Inside, it
consults `settings.generator_defaults` so the user gets the policy they
configured. The branch is the `vault_dir().is_ok()` check at
`main.rs:1440`. Tests pin both behaviours.
- **TOTP edit pushes history under the synthetic key `core:totp_secret`,
not `core:totp` or anything else.** This is what `relicario history
<query> --field totp_secret` matches against. The naming convention
("type underscore field") is shared across all five history-tracked
fields (see Invariants). If you add a new history-tracked field, pick a
matching `<type>_<field>` form so the user-facing `--field` filter
stays predictable.
- **`detach` refuses a Document item's primary attachment.**
(`main.rs:1392-1400`) Document items model "this item *is* a file"; the
primary blob isn't optional. The error directs the user to `purge`
instead. Non-primary attachments on a Document (e.g., a scanned
contract with an addendum) detach normally.
- **Per-type `build_*_item` / `edit_*` helpers exist by design after the
`3f0f5b1` refactor.** Before the refactor, `cmd_add` and `cmd_edit`
carried 217-line `match` arms. The split-out functions are easier to
read, easier to test individually (the existing integration tests still
drive them through the same paths), and easier to grow when a new
`ItemCore` variant lands. Keep this shape — don't fold them back.
- **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three
reasons. (1) Dep tree: pulling in libgit2 doubles compile time and
adds a C dependency. (2) Override surface: users can put any
`~/.gitconfig` they want and it Just Works (subject to the hardening
flags). (3) Recovery: when something is wrong with a vault, the user
can poke around with `git log`, `git show`, `git fsck` directly; the
CLI's git interactions are not opaque.
- **The hardened-`git` injection set is load-bearing.** `git_command`
prepends three `-c` flags before the user-supplied args
(`helpers.rs:48-52`):
- `core.hooksPath=/dev/null` — a malicious or buggy hook in a cloned
vault could otherwise run arbitrary code on every commit. Master key
is in memory at the time of commit; this matters.
- `commit.gpgsign=false` — if the user has global GPG signing on, the
GPG agent prompt would block on `git commit` and hold the master
key alive in memory until the user types the passphrase. Disable it
for relicario commits.
- `core.editor=true` — `true(1)` exits 0 with no output. If `git`
decides to drop into `$EDITOR` (rebase conflict markers, missing
`-m`), this neutralises it without crashing the rebase. We pass
`-m <msg>` ourselves; this flag is the seatbelt.
All three were added together in audit H4. A user can still run
`git` themselves with their own config to inspect or repair the
vault — the hardening only applies to relicario's invocations.
- **`cmd_init` uses production-grade `KdfParams { m: 65536, t: 3, p: 4
}`** (`main.rs:365`), even in tests. `RELICARIO_TEST_PASSPHRASE`
bypasses the prompt but does not lower the KDF cost. This is a
trade-off: integration tests pay the full Argon2id cost (~half a
second per init on a modern machine), but the same code path runs in
production. Don't lower the params here — the core's test-only fast
params are for `relicario-core` unit tests.
- **`params.json` has a nested `kdf` object, not a flat one.**
`read_params` (`session.rs:110-121`) deserializes via a private
`ParamsFile { kdf: KdfParams }` struct. The nesting exists so
`format_version`, `aead`, and `salt_path` can co-exist in the same
file for forward-compat. An earlier version of `read_params` tried
to deserialize the whole file as `KdfParams` and failed silently —
that bug was fixed in commit `b263c27`.
- **`commit_paths` is the convention but not always the call site.**
`cmd_purge`, `cmd_trash_empty`, and `cmd_device` use `git_command`
directly because their add/commit pattern doesn't quite fit
`commit_paths(vault, msg, &[paths...])`. They still use the
hardened wrapper, just at one level lower. If you find yourself
writing a new command with the same shape, prefer `commit_paths`;
reach for `git_command` directly only when you need the slightly
different control flow these three have.
- **Initial commit at `cmd_init` does not use `commit_paths`.**
Reason: `commit_paths` takes `&UnlockedVault`, but `cmd_init` doesn't
construct one — it uses the master key inline before the vault
exists. The init commit goes through `git_command` directly
(`main.rs:403-412`). This is the only production code site outside
`commit_paths` that does so.
- **`Lock` is a no-op (`main.rs:301`).** The CLI doesn't cache a
session — every command re-derives the master key. The command
exists only for UX parity with the extension, where `lock` actually
evicts a cached session. Printed message: `no cached session to
lock`.
- **`resolve_query` accepts an item id or a case-insensitive title
substring** (`main.rs:855-871`). Exact id-match wins; otherwise it
defers to `Manifest::search`. Multi-hit substring matches are
rejected with an "ambiguous" error listing the matched titles. This
is why every `cmd_*` that takes a `query: String` (get, edit,
history, rm, restore, purge, attach, attachments, extract, detach)
works the same way.