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>
This commit is contained in:
539
crates/relicario-cli/ARCHITECTURE.md
Normal file
539
crates/relicario-cli/ARCHITECTURE.md
Normal file
@@ -0,0 +1,539 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user