Each of the eight tour docs (README, DESIGN, docs/CRYPTO, docs/FORMATS, docs/SECURITY, crates/relicario-core/ARCHITECTURE, crates/relicario-cli/ARCHITECTURE, extension/ARCHITECTURE) now declares its scope in a blockquote under its H1 and ends with a single-line "Next:" pointer to the next doc in the canonical reading order: README → DESIGN → CRYPTO → FORMATS → SECURITY → core → cli → extension. Also trimmed README's mid-section "Architecture" stub to a one- paragraph pointer at DESIGN.md (was duplicating cross-codebase content and referencing a non-existent docs/architecture/ tree). Renamed docs/CRYPTO.md's H1 from "Relicario — Architecture" to "Relicario — Crypto Pipeline" to match the file's renamed scope. Spec: docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md
34 KiB
Architecture: relicario-cli
Audience: contributors editing the CLI. This doc owns the CLI module map, the clap command surface, per-command key flows, session/unlock semantics, and helpers. Does NOT own: crypto, wire formats, or threat model (see ../../docs/CRYPTO.md, ../../docs/FORMATS.md, ../../docs/SECURITY.md).
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
src/main.rs is now a thin clap-surface + dispatcher; per-command logic lives
under src/commands/. Each source file has one job.
-
src/main.rs(main.rs:1-492) — clap surface and the flat dispatcher. Owns the top-levelCli/Commandsenum and every subcommand enum (AddKind,TrashAction,SettingsAction,BackupAction,ImportAction,DeviceAction,RecoveryQrCmd).main()is a singlematchthat delegates each variant tocommands::<verb>::cmd_<verb>(...). Also owns the three test-only env-var hooks (test_passphrase_override,test_item_secret_override,test_backup_passphrase_override) — each is stripped from release builds via#[cfg(debug_assertions)]. -
src/commands/— one module per top-level command.mod.rsre-exports the public surface and hosts the sharedcommit_pathshelper (the single chokepoint for git commits during vault mutations) plus other cross-command glue. Per-command modules:init,add,get,list(also hostscmd_history),edit,trash(rm / restore / purge / trash empty),backup(export / restore),import(lastpass),attach(attach / attachments / extract / detach),generate,settings,sync,status,rate,device,recovery_qr.addandediteach fan out internally to per-ItemCorehelpers (build_<type>_item,edit_<type>) so each builder/editor reads top-to-bottom and can be tested through the same integration paths. -
src/prompt.rs— interactive prompt primitives shared across commands:prompt,prompt_optional,prompt_keep,prompt_keep_opt,prompt_yesno,prompt_secret.prompt_secrethonoursRELICARIO_TEST_ITEM_SECRETbefore falling back torpassword. -
src/parse.rs— pure parsers for CLI-typed inputs (e.g. MonthYear expiries, TOTPotpauth://URIs, comma-separated tag lists). No I/O. -
src/device.rs— device-management plumbing called bycommands::device: ed25519 keypair generation viarelicario-core::device, on-disk layout under<config_dir>/relicario/devices/<name>/, and the read/write of.relicario/devices.json/revoked.json. -
src/gitea.rs— minimal Gitea REST client used bycommands::device add/revoketo register and remove deploy keys. ReadsRELICARIO_GITEA_{URL,TOKEN,OWNER,REPO}env vars (overridable via CLI flags). -
src/session.rs(session.rs:1-152) —UnlockedVaultlifecycle. Holds the derived master key inZeroizing<[u8; 32]>for one CLI invocation; the key wipes viaZeroizeon scope exit (session.rs:22-25). Owns theunlock_interactiveflow (vault root walk → salt read → params read → reference image extract → passphrase prompt → KDF) atsession.rs:33-59, the typedload_*/save_*accessors forItem/Manifest/VaultSettings, theread_salt/read_paramshelpers, theRELICARIO_IMAGElookup, andatomic_write(session.rs:144-151) which every disk write to a vault file goes through. Owns the env-var escape hatchesRELICARIO_TEST_PASSPHRASE(session.rs:42) andRELICARIO_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_dirandrelicario_dirwrap it forcwd-rooted callers;git_command(helpers.rs:45-55) is the hardened-gitfactory 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 inZeroizing<[u8; 32]>and drops viaZeroizeon scope exit (session.rs:22-25). No command bypasses this exceptcmd_generateoutside a vault dir andcmd_init(which derives the key inline before there is a vault to unlock). -
Every
gitinvocation in production code goes throughhelpers::git_command. A grep forCommand::new("git")outsidehelpers.rsfinds zero hits insrc/; the only other match is intests/edit_and_history.rs:18, which is test-side verification of the git log and is exempt by design.git_commandinjectscore.hooksPath=/dev/null,commit.gpgsign=false, andcore.editor=truevia-cflags (helpers.rs:48-52). DirectCommand::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>.tmpthen renames over<path>; a partial write never appears as the live file. AllUnlockedVault::save_*helpers route through it. (cmd_initwrites pre-creation files viafs::writeatmain.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 viarelicario_dir.exists()atmain.rs:326.) -
Every commit during a mutating command uses
commit_paths.commit_paths(main.rs:767-775) doesgit 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_purgeandcmd_trash_emptyandcmd_deviceusegit_commanddirectly (notcommit_paths) because they need a slightly different add/commit pattern; they still go through the hardened wrapper. -
cmd_generateis the only command that runs without unlock — and only when invoked outside a vault directory. Inside a vault,cmd_generateunlocks to readsettings.generator_defaults(main.rs:1440-1445); explicit flags override the stored defaults. This is why the smoke-testcargo run -p relicario-cli -- generate --length 32works without any setup. -
Item IDs are minted by core. The CLI never constructs an
ItemIddirectly;Item::new(called inside everybuild_*_item) does it viarelicario-core::ids::new_item_id.ItemIds 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_listignore it because they read the manifest, not the directory. (Recovery would manually re-addto surface it.) -
Vault root is always discovered, never assumed to be
cwd.helpers::vault_dirwalks up fromcwdlooking for.relicario/, so any command run from a subdirectory of the vault works (verified byvault_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_secretreadsRELICARIO_TEST_ITEM_SECRETbefore falling back torpassword. 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 atmain.rs:308-313.
Key flows
Vault init (cmd_init, main.rs:315-418)
- Refuse if
.relicario/already exists (main.rs:326-328). - Read passphrase twice (or once via
RELICARIO_TEST_PASSPHRASE); confirm they match; runvalidate_passphrase_strength(zxcvbn-backed) and bail with audit-H3 message on weak input (main.rs:331-348). - Generate a 32-byte random
image_secretviaOsRng, embed it into the carrier JPEG viaimgsecret::embed, write the stego output to--output(main.rs:351-360). - Generate a 32-byte salt and pin
KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }(production-grade) atmain.rs:363-365. derive_master_key(passphrase, image_secret, salt, params)→Zeroizing<[u8;32]>(main.rs:368).- Create
.relicario/,items/,attachments/dirs; write.relicario/{salt, params.json, devices.json}; encrypt and writemanifest.enc(emptyManifest::new()) andsettings.enc(VaultSettings::default()) (main.rs:370-393). - Write
.gitignorelisting the reference image filename (so the second factor never accidentally ends up in git) (main.rs:396-400). git initthen initial commitinit: new relicario vault (format v2)viagit_command(main.rs:403-412). Note the initial commit does NOT go throughcommit_paths— it precedes the existence of anUnlockedVault, so the path list is hand-spelled.
Vault unlock (UnlockedVault::unlock_interactive, session.rs:33-59)
vault_dir()walks up from cwd to find.relicario/; bails with the "runrelicario initfirst" message on miss (helpers.rs:21-26).read_saltreads.relicario/salt(32 bytes; rejects any other length).read_paramsdeserializes.relicario/params.jsonand extracts the nestedkdfsub-object asKdfParams(session.rs:110-121). The nested shape exists becauseparams.jsonalso storesformat_version,aead, andsalt_pathfor forward-compat probing.get_image_pathhonoursRELICARIO_IMAGE, then a<vault>/reference.jpgconvention, then prompts (session.rs:124-140).- Read the reference image bytes;
imgsecret::extractruns the DCT majority-vote decode to recover the 32-byte image secret (session.rs:38-40). - Read the passphrase via
RELICARIO_TEST_PASSPHRASEorrpassword(session.rs:42-49). derive_master_keyproduces the master key;UnlockedVault { root, master_key }is returned and lives until the command function returns.
Item add (cmd_add, main.rs:419-456)
- Unlock the vault and load the manifest.
- Match on the
AddKindvariant and dispatch to the matchingbuild_<type>_itemhelper (main.rs:423-438). Seven variants → seven builders; onlybuild_document_itemtakes&UnlockedVaultbecause it needsattachment_capsand writes the encrypted blob alongside the item. - The builder returns a fully-populated
Item(with title, group, tags, favorite-flag, primary attachment if any). - Common wrap-up:
vault.save_item(&item),manifest.upsert(&item),vault.save_manifest(&manifest). - Build the path list —
items/<id>.enc,manifest.enc, plus oneattachments/<id>/<aid>.encper attachment — and callcommit_pathswith messageadd: <title> (<id>)(main.rs:444-452).
Item edit (cmd_edit, main.rs:938-977)
- Unlock, load manifest, resolve query → item id, load the item.
- Universally-editable fields (title, group, tags) are prompted via
prompt_keep/prompt_keep_optfirst; blank input keeps the current value (main.rs:952-956). - Borrow
&mut item.field_historyonce into a localhistorybinding (main.rs:958), thenmatchon&mut item.coreand dispatch to the per-typeedit_<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. - 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. item.modified = now_unix(), save, upsert manifest, commitedit: <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) callsItem::soft_delete()(setstrashed_at), saves, upserts manifest, commitstrash:.cmd_restore(main.rs:1178-1193) is the inverse:Item::restore(), same wrap-up, commitrestore:.cmd_purge(main.rs:1220-1237) callspurge_item(main.rs:1197-1218) which removes the item file, the attachment dir, the manifest entry, andgit rm -rf --ignore-unmatchs the paths. Then a singlegit add manifest.enc+ commitpurge: <title> (<id>).cmd_trash_empty(main.rs:1246-1282) is the only multi-item mutating command. It loads settings once, iterates all items past theirtrash_retentionwindow, callspurge_itemfor each, then does a singlegit add manifest.enc+ committrash empty: purged N item(s). The single-unlock-per-batch shape was the fix in commitb5015b3— the earlier version re-prompted for the passphrase per item.
Attach / detach / extract
cmd_attach(main.rs:1283-1339) loadsattachment_capsfrom settings and rejects if the item has hitper_item_max_count.encrypt_attachmentenforcesper_attachment_max_bytes. The encrypted blob lands atattachments/<item_id>/<aid>.enc; theaidis content-addressed by core. Commit message:attach: <file> → <title> (<id>).cmd_detach(main.rs:1376-1424, added in3f0f5b1) removes one attachment from the item, deletes the encrypted blob, rewrites the item. Refuses if the targetaidis aDocumentitem'sprimary_attachment(main.rs:1392-1400) — that would orphan the item; usepurgeinstead. Commit message:detach: <filename> from <title> (<id>).cmd_extract(main.rs:1354-1375) decrypts the blob and writes the plaintext to--outor to<filename>in cwd. Read-only: no commit, no state mutation.cmd_attachments(main.rs:1341-1352) listsaid, size, mime, filename — read-only.
Generate (cmd_generate, main.rs:1426-1489)
Has two distinct modes:
- Outside a vault —
vault_dir()returnsErr;vault_defaultsstaysNone; defaults are hard-coded (length: 20,symbols: SafeOnly,words: 5,separator: " ",Capitalization::Lower). No unlock prompt. - Inside a vault —
vault_dir()succeeds; full unlock; loadsettings.generator_defaults. Explicit flags override the stored defaults field-by-field.--bip39flips mode; absent that flag, the mode is whatever the stored default is. Tests:settings.rs::generate_uses_vault_default_length(length-tracking) andbasic_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 (commands::backup, commands/backup.rs)
Two subcommands, both keyed by a backup passphrase that is independent of the vault master passphrase.
backup export <out> [--include-image] [--image PATH] [--no-history]— reads the entire on-disk vault layout (.relicario/{salt,params.json, devices.json},manifest.enc,settings.enc, everyitems/*.enc, everyattachments/<iid>/<aid>.enc), optionally bundles the reference JPEG and the.git/directory (as an in-memory tar), and hands the lot torelicario_core::backup::pack_backupwith a zxcvbn-gated backup passphrase prompted twice. The resulting.relbakis written viatmp+ rename. A.relicario/last_backupmarker file (ISO-8601 line) is also written socmd_statuscan show "last backup at …".backup restore <input> [<target>]— refuses to overwrite an existing vault (target/.relicario/must not exist). Unpacks the.relbakviaunpack_backup, then materialises every byte into the target layout. The bundled.git/tar is extracted via the hardenedrelicario_core::safe_unpack_git_archive(path-traversal / symlink / size-cap guards) with a cap ofmin(100 × tar_size, 1 GiB); if no history was bundled, the target gets a freshgit init+ initial commit.
Import (commands::import, commands/import.rs)
import lastpass <csv>— reads the CSV, callsrelicario_core::import_lastpass::parse_lastpass_csv, then unlocks the vault and writes every producedItemthroughvault.save_item+ manifest upsert. Failed rows surface asImportWarnings on stderr and never abort the import; only a missing or malformed header is fatal. Commit message:import: <N> items from LastPass (<csv-filename>). The dispatch shape (ImportActionsubcommand enum) is in place for future importers (Bitwarden, 1Password, etc.) — each would add oneImportActionvariant and one helper.
Rate (commands::rate, commands/rate.rs)
rate <passphrase|-> runs relicario_core::generators::rate_passphrase
(zxcvbn-backed) and prints the 0–4 score, a human-readable label, and the
estimated guess count as ~10^N. Reads one line from stdin when the
argument is -, which keeps the passphrase out of shell history. Purely
informational — does not unlock or mutate anything; the init command
calls validate_passphrase_strength directly and does not consult rate.
RecoveryQr (commands::recovery_qr, commands/recovery_qr.rs)
Two subcommands wrapping relicario_core::recovery_qr::{generate_recovery_qr, unwrap_recovery_qr}.
recovery-qr generate— re-extracts the 32-byte image_secret from the reference JPEG (viaget_image_path+imgsecret::extract), prompts for the recovery passphrase (which may be the same as the vault passphrase or different — domain-separated by core), produces the 109-byte sealed payload, and renders it as a Unicode-block QR (EcLevel::M) directly to stdout. The payload is never written to disk — the user is expected to print or photograph it.recovery-qr unwrap— reads a base64-encoded payload from stdin, prompts for the recovery passphrase, runsunwrap_recovery_qr, and prints the recoveredimage_secretas hex. Useful for recovery dry-runs and for reconstructing a lost reference image.
Cross-cutting concerns
-
Error model. Every
cmd_*returnsanyhow::Result<()>. Core errors bubble up through?fromRelicarioError. Per-step context is added viawith_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.,--daysand--forevertogether fail at theSettingsAction::TrashRetentionarm inmain.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 inlist/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:) makesgit log --onelinea literal audit trail. Tests verify this by grepinggit logdirectly (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 insideunlock_interactiveuntil the KDF call (session.rs:40). - Passphrase —
Zeroizing<String>fromrpassword::prompt_passwordor the env var (session.rs:42-49,main.rs:333-342). - Item secrets —
Zeroizing<String>forLogin.password,Card.number,Card.cvv,Card.pin,Key.key_material,SecureNote.body, andZeroizing<Vec<u8>>forTotpCore.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).
- Master key —
-
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) andmain.rs:333,338(init).RELICARIO_IMAGE—session.rs:125(image path resolution).RELICARIO_TEST_ITEM_SECRET—main.rs:309(prompt_secretonly). 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 32is 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 freshTempDir, generates a 400×300 JPEG viamake_test_jpeg()(deterministic noise; no binary fixtures), runsrelicario init --image carrier.jpg --output reference.jpgwithRELICARIO_TEST_PASSPHRASEset, and stashes the passphrase + reference image path on the struct.runandrun_with_inputare the two ways to invoke the binary against the test vault: both inheritRELICARIO_IMAGE+RELICARIO_TEST_PASSPHRASE; the latter pipes extra newlines into stdin (used for interactive prompts that aren'trpassword-driven). The note at the top warns Task 23 implementers about the new-item-password rpassword path; the fix landed asRELICARIO_TEST_ITEM_SECRETin commit20350d5. -
tests/basic_flows.rs(136 lines) — covers the init layout (.relicario/{salt,params.json,devices.json},manifest.enc,settings.enc,reference.jpg,.gitignore,.git); theparams.jsonv2 shape;add login+list;getmasking semantics (with and without--show); the rm/restore/purge cycle includinglist --trashed; and the two-modegeneratesmoke (random length + bip39 word count) run outside a vault. -
tests/edit_and_history.rs(191 lines) — driveseditend-to-end by piping stdin lines (blank to keep,yto confirm) plusRELICARIO_TEST_ITEM_SECRETfor the rpassword leg.edit_password_*verifies the item file is rewritten and theedit: bankcommit lands. The fourhistory_command_*tests cover masked listing,--showreveal, "no history captured" output, and per-field filtering. Theedit_totp_rotates_secret_and_captures_historytest (added 2026-04-27 in commit3f0f5b1— fixes a stub at the oldmain.rs:925) drives the full TOTP edit including issuer / label / secret rotation. -
tests/attachments.rs(106 lines) —attach/attachments/extractround-trip (verifies the bytes survive the encrypt-decrypt hop);detachremoves both the attachment ref and the encrypted blob on disk;detachrejects an unknownaid;attachrejects payloads overper_attachment_max_bytes. The detach test (detach_*) and the cap test were added in3f0f5b1/20350d5respectively. -
tests/settings.rs(135 lines) —settings showandsettings trash-retention --days 60round-trip; the conflicting-flags rejection (--days+--forever); thegenerate_uses_vault_default_lengthtest that verifies (a) default vault length is 20, (b) updatingsettings generator-defaults --length 32changes the default, (c) explicit--length 8overrides the stored default; the multi-shapecmd_statussmoke; and thegenerate_works_outside_vaulttest that verifies the no-unlock path works in a bareTempDirwith no.relicario/. -
tests/vault_detection.rs(59 lines) — three tests covering audit L8:listrefuses without a marker;listfrom a nested subdirectory finds the parent.relicario/; a v1.idfoto/directory is rejected with the.relicariohint 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_generateruns 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 installedrelicariojust for the generator. Inside, it consultssettings.generator_defaultsso the user gets the policy they configured. The branch is thevault_dir().is_ok()check atmain.rs:1440. Tests pin both behaviours. -
TOTP edit pushes history under the synthetic key
core:totp_secret, notcore:totpor anything else. This is whatrelicario history <query> --field totp_secretmatches 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--fieldfilter stays predictable. -
detachrefuses 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 topurgeinstead. 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 the3f0f5b1refactor. Before the refactor,cmd_addandcmd_editcarried 217-linematcharms. 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 newItemCorevariant 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~/.gitconfigthey want and it Just Works (subject to the hardening flags). (3) Recovery: when something is wrong with a vault, the user can poke around withgit log,git show,git fsckdirectly; the CLI's git interactions are not opaque. -
The hardened-
gitinjection set is load-bearing.git_commandprepends three-cflags 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 ongit commitand 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. Ifgitdecides 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 rungitthemselves with their own config to inspect or repair the vault — the hardening only applies to relicario's invocations.
-
cmd_inituses production-gradeKdfParams { m: 65536, t: 3, p: 4 }(main.rs:365), even in tests.RELICARIO_TEST_PASSPHRASEbypasses 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 forrelicario-coreunit tests. -
params.jsonhas a nestedkdfobject, not a flat one.read_params(session.rs:110-121) deserializes via a privateParamsFile { kdf: KdfParams }struct. The nesting exists soformat_version,aead, andsalt_pathcan co-exist in the same file for forward-compat. An earlier version ofread_paramstried to deserialize the whole file asKdfParamsand failed silently — that bug was fixed in commitb263c27. -
commit_pathsis the convention but not always the call site.cmd_purge,cmd_trash_empty, andcmd_deviceusegit_commanddirectly because their add/commit pattern doesn't quite fitcommit_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, prefercommit_paths; reach forgit_commanddirectly only when you need the slightly different control flow these three have. -
Initial commit at
cmd_initdoes not usecommit_paths. Reason:commit_pathstakes&UnlockedVault, butcmd_initdoesn't construct one — it uses the master key inline before the vault exists. The init commit goes throughgit_commanddirectly (main.rs:403-412). This is the only production code site outsidecommit_pathsthat does so. -
Lockis 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, wherelockactually evicts a cached session. Printed message:no cached session to lock. -
resolve_queryaccepts an item id or a case-insensitive title substring (main.rs:855-871). Exact id-match wins; otherwise it defers toManifest::search. Multi-hit substring matches are rejected with an "ambiguous" error listing the matched titles. This is why everycmd_*that takes aquery: String(get, edit, history, rm, restore, purge, attach, attachments, extract, detach) works the same way.
Next: ../../extension/ARCHITECTURE.md — the browser-side surface.