Files
relicario/docs/superpowers/reviews/2026-05-04-dev-b-notes.md
adlee-was-taken 061facd5a9 docs(reviews): whole-codebase architecture audit 2026-05-04
Three-reviewer architecture audit (DEV-A: core, DEV-B: cli/server/wasm,
DEV-C: extension/relay) plus PM synthesis. Lens: make the codebase
readable for a smart developer who doesn't know Rust but wants to learn
by tinkering.

Top synthesis findings (P1):
- SessionHandle has no impl Drop; .free() is a cleanup no-op (cross-cutting Rust+JS)
- cli/main.rs is a 2641-line monolith with no submodule boundaries
- setup.ts (1220 LOC) bypasses the SW and orchestrates WASM directly
- vault.ts (1027 LOC) owns shell + sidebar + list + drawer + routing
- shared/state.ts is fully any-typed
- recovery_qr.rs is undocumented vs. rest of crypto-adjacent core
- duplicated SW router helpers (storage + itemToManifestEntry)
- pure parsers (parse_month_year, base32_decode_lenient) belong in core
- 16x duplicated git invocation boilerplate with one-line errors

CLI/extension parity: 22/23 capabilities ✓; only true gap is `relicario
status` (no get_vault_status); `detach` is partial via update_item.

Also fixes tools/relay/queue.test.ts:54 to match the dev-c role
expansion already in queue.ts (was failing 1/4; now 5/5 pass).

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

33 KiB
Raw Blame History

DEV-B Architecture Review Notes — Rust Consumers (CLI, Server, WASM)

Date: 2026-05-04 Scope: crates/relicario-cli/, crates/relicario-server/, crates/relicario-wasm/ Out of scope: relicario-core internals (DEV-A), extension/ and tools/relay/ (DEV-C) Method: read-only walk of every src + test file; cargo check and cargo clippy per crate; cargo build --target wasm32-unknown-unknown for the WASM crate.

Summary

The consumer layer is in good shape conceptually but uneven in execution. relicario-server is the highlight: 189 lines, one obvious responsibility, every dependency on relicario-core is plaintext device metadata only — the "server only ever sees ciphertext" invariant is structurally enforced by the import surface, not just by convention. relicario-wasm is small and clean but has one real Rust-side defect: SessionHandle lacks an impl Drop, so when wasm-bindgen's auto-generated .free() runs, the master key stays in WASM linear memory until lock() is also called explicitly — defense-in-depth that is currently missing. relicario-cli does its job correctly but is hard to read: src/main.rs is a 2641-line file with no submodule boundaries between the clap surface, item builders, edit handlers, prompt helpers, and parsers — the single biggest readability blocker in the consumer layer. Across all three crates, naming and module structure are good; what hurts is duplicated boilerplate and the absence of a few obvious helpers.


Findings

relicario-cli

P1 — main.rs is a 2641-line monolith with no submodule boundaries

File(s): crates/relicario-cli/src/main.rs:1-2641 Observation: every subcommand handler, every per-type item builder, every per-type edit handler, prompt helpers, parsing helpers, the ParamsFile shape, the clap surface, and 24+ git shell-outs all live in one file. The clap surface (lines 1-455) reads as a tour of the product and is excellent; lines 456-2641 are a flat sequence of cmd_*, build_*_item, edit_*, prompt helpers, and parse helpers, all peers. A newcomer searching "where does add work?" finds cmd_add calling 7 different build_*_item functions (each ~50-60 lines) with no module boundaries. Why it matters: this is the first file a newcomer opens. Today they have to scroll, not navigate. Suggested direction: keep main.rs as clap definitions + match dispatcher only (~470 lines). Split into commands/{add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr}.rs, plus prompt.rs (the six prompt_* helpers + prompt_secret) and parse.rs (parse_month_year, base32_decode_lenient, guess_mime). Same LOC, completely different reading experience.

P1 — Git invocation boilerplate duplicated ~16× with one-line errors

File(s): crates/relicario-cli/src/main.rs:601, 602, 610, 986, 988, 1477, 1480, 1767, 1897, 1900, 2432, 2438, 2533, 2540 (and others) Observation: every git_command invocation that bails uses the same shape: git_command(repo).args([...]).status()? + if !status.success() { bail!("git foo failed") }. Child stderr is inherited to the parent tty (which helps interactively) but in test runs and noninteractive tooling it is lost, and the bail message is just the verb ("git commit failed"). When this fires in the wild — pre-receive reject, missing remote, dirty tree, signing-key prompt — the user sees one line of "$verb failed" and nothing else. Why it matters: this is the entire error UX for the git side of the CLI. Failure modes are common; the diagnostic is actively unhelpful. Suggested direction: add helpers::git_run(repo, args, context) that uses .output() (capturing stderr), prints captured stderr unmodified on failure, and includes the human-readable context ("commit add: GitHub", "register device", "purge trashed item"). Replaces 16 copies with one-liners.

P2 — build_*_item functions mix prompting, parsing, and core construction

File(s): crates/relicario-cli/src/main.rs:664-921 Each build_* does its own prompt-or-flag fallback (title.map(Ok).unwrap_or_else(|| prompt("Title"))?), parses domain values (URL, MonthYear, base32 TOTP), then constructs an ItemCore. Adding a new item type is currently 50-80 lines of mechanical code. A prompt_or_flag<T> helper plus per-type builders that take already-validated values would compress this materially.

P2 — Pure parsers belong in core, not the CLI

File(s): crates/relicario-cli/src/main.rs:942-980 base32_decode_lenient and parse_month_year are pure parsing producing typed core values. Per the user's CLI/extension parity philosophy, these need to be reachable from WASM too — the extension will want them when it gets QR-import / month-year smart input. MonthYear::parse and an ItemCore::parse_totp_seed (or Totp::parse_secret) on the core side would avoid future duplication.

P2 — refresh_groups_cache invocation discipline is manual at 7 sites

File(s): crates/relicario-cli/src/main.rs:641, 998, 1123, 1197, 1414, 1432, 1474 (and helpers.rs:90-93 for the doc comment) The plaintext groups.cache is updated by-hand after every manifest mutation. The "failures are silently swallowed" rationale is documented at main.rs:462, but the rule "every mutating handler must call this" is not enforced — easy to forget on a new command. Either invoke from Vault::save_manifest (couples session to cache layout — maybe wrong) or wrap in Vault::after_manifest_change(&self, manifest: &Manifest).

P2 — ParamsFile is defined twice with mismatching shapes

File(s): crates/relicario-cli/src/main.rs:2287 (write) vs crates/relicario-cli/src/session.rs:114 (read) The write side has aead, salt_path, format_version; the read side takes only kdf. Two definitions of the on-disk params.json shape, in different files, with overlapping but non-equal fields. A single Params struct in relicario-core (or in session.rs) used by both readers and writers would eliminate the drift risk.

P2 — cmd_purge and cmd_trash_empty duplicate the manifest-add-and-commit dance

File(s): crates/relicario-cli/src/main.rs:1476-1480, 1896-1900 Same three lines, same error strings, same git rm + git add + git commit per item. cmd_trash_empty invokes purge_item per item, each of which is its own three-git-invocation loop. Batching the staging would reduce a 50-item purge from 150 git invocations to 3.

P3 — Selection of the let _ = entry; pattern, repeated 6×

File(s): crates/relicario-cli/src/main.rs:1170, 1407, 1426, 1469, 1913, 2030 Drop-the-borrow-before-reborrow-mutably is a Rust newcomer's worst surprise. A NLL-friendly refactor (clone id and title eagerly, let the borrow end naturally) would make these lines disappear.

P3 — Other nits

  • cmd_recovery_qr_unwrap does not check for empty input before base64 decode (main.rs:2625-2630)
  • cmd_recovery_qr_generate mixes the QR ASCII and a "NOT been saved to disk" message both on stdout — pipe-unfriendly (main.rs:2612-2614)
  • Lock subcommand is a no-op but visible in --help; either #[command(hide = true)] or accept the parity-with-extension argument and document why (main.rs:445, doc-comment at :166)
  • tests/attachments.rs:69-76 has a dead variable (blob_path) kept "to avoid an unused warning" — delete
  • Three test-only env vars (RELICARIO_TEST_PASSPHRASE, RELICARIO_TEST_ITEM_SECRET, RELICARIO_TEST_BACKUP_PASSPHRASE) each duplicated under #[cfg(debug_assertions)]/#[cfg(not(debug_assertions))] — one macro would flatten ~30 lines
  • cmd_backup_export still reads devices.json (with [] fallback) per a "Task 12 will remove" TODO at :1535-1537. If Task 12 has shipped, this code can simplify
  • format!("{:?}", e.r#type) for the TYPE column at main.rs:1158 — Debug-format for user-facing output. Add Display to ItemType in core
  • helpers.rs:37 #[allow(dead_code)] pub fn relicario_dir() — helper has no callers but several vault_dir.join(".relicario") sites in main.rs should be using it
  • device.rs:94, 103, 120 and gitea.rs:24, 26, 47, 77, 94, 101 have #[allow(dead_code)] markers without explanation. Either wire up or add // TODO(<plan>): so a newcomer knows whether they're scaffolding or scar tissue
  • gitea.rs constructs a fresh reqwest::blocking::Client::new() per method call (3 sites) — make it a struct field
  • tests/edit_and_history.rs writes hardcoded prompt sequences (["", "", "", "", "", "y"]) blindly; if main.rs reorders prompts, tests hang silently. A scripted-test layer (expect_prompt("Title"); respond("");) would survive refactors

relicario-server

Trust-model assessment (the headline question)

The server respects the "ciphertext only" invariant. Confirmed. The crate's entire relicario-core import surface is DeviceEntry, RevokedEntry, fingerprint (and generate_keypair in tests) — every one of those is plaintext-only device metadata. There is no import of vault, crypto, imgsecret, item, manifest, or settings. A grep over crates/relicario-server/ for decrypt|wrapped|encrypted|vault:: returns nothing. The Cargo.toml dep surface (anyhow, clap, serde_json, tempfile, regex) confirms it: there is no AEAD or KDF crate present. The server cannot decrypt the vault even in principle — it never reads the passphrase, the reference image, the salt, or the params.

The two on-disk files the server reads from the commit tree are .relicario/devices.json and .relicario/revoked.json (main.rs:38, 48), both plaintext metadata. All other operations are git subprocesses (git show, git verify-commit --raw, git show -s --format=%ct) plus local fingerprint computation. generate-hook emits a pure shell script that re-invokes relicario-server verify-commit per commit; it embeds no secret material. No P1 findings.

P2 — generate-hook assumes $PATH

File(s): crates/relicario-server/src/main.rs:170 The emitted script calls relicario-server verify-commit "$commit" with no path. Most Gitea deployments do not put /usr/local/bin on the hook process's $PATH, so this will fail with "command not found" or silently no-op. Either embed std::env::current_exe() at hook-generation time, or document an explicit PATH= line in the emitted script. There is no test that the generated hook actually executes.

P2 — Bootstrap branch is permissive

File(s): crates/relicario-server/src/main.rs:38-44, 54-57 A missing .relicario/devices.json at newrev is treated as bootstrap and accepted. Combined with "devices empty AND revoked empty → OK", an attacker who pushes a brand-new branch with no .relicario/ directory could push arbitrary unsigned commits. There's no test for "second push to an established repo where devices.json was stripped." Worth either documenting the rule (first push wins; once devices.json exists in history, it can never be removed) or enforcing it: oldrev != 0 should imply devices.json exists in newrev.

P2 — stdin-parsing lives in the shell hook only; binary alone is not hook-shaped

File(s): crates/relicario-server/src/main.rs:130-189 (generate_hook) The Rust binary's verify-commit takes a single SHA argument; the per-line <old> <new> <ref> parsing is delegated to bash in generate_hook. Defensible split, but means anyone wiring up a hook by hand cannot use the binary alone. A verify-commit --from-stdin mode (or at least a doc comment on VerifyCommit calling this out) would help.

P2 — Test coverage gaps vs. trust-model

File(s): crates/relicario-server/tests/verify_commit.rs Tests cover: accepted, unregistered → reject, revoked-after → reject, revoked-before → accept. Missing: unsigned commit (no signature at all), malformed devices.json (parse error path on :46), bootstrap-empty-devices acceptance (:54), and one of the two stripped-.relicario/ cases. Each is one extra #[test].

P3 — Server nits

  • Regex compiled per call at main.rs:85 — use LazyLock or once_cell::sync::Lazy; the expect("static regex") comment hints the author knew
  • The malformed-devices.json path at :46 returns an anyhow chain on stderr without the consistent REJECT: ... prefix the rest of the file uses — ops parsing logs for REJECT: will miss it
  • No --version flag exposed in clap (small ops courtesy)
  • generate-hook doesn't tell users to chmod +x the result (one-line comment header in the emitted script would help)

relicario-wasm

P1 — SessionHandle has no impl Drop; master key leaks on JS GC / .free()

File(s): crates/relicario-wasm/src/lib.rs:15-23 and crates/relicario-wasm/src/session.rs:1-58 Observation: SessionHandle is pub struct SessionHandle(u32) with no Drop impl. wasm-bindgen auto-generates a JS-side .free() that, on the Rust side, drops the SessionHandle wrapper — i.e. drops a u32, a no-op. The SESSIONS HashMap entry stays alive with the master key + image_secret still in WASM linear memory until JS calls the explicit lock(handle) function (which calls session::remove). I confirmed via grep -n "impl Drop" crates/relicario-wasm/src/*.rs — empty. Why it matters: every .free() callsite that does not also call lock() first is a key-residency window of unbounded duration. wasm-bindgen does not auto-call free() on JS GC, but JS code that does call .free() reasonably expects the Rust side to clean up. The current contract requires JS to call lock() and then free(), which is asymmetric and easy to get wrong on the JS side (see boundary notes for DEV-C). Suggested direction: add

impl Drop for SessionHandle {
    fn drop(&mut self) { session::remove(self.0); }
}

to lib.rs. lock() becomes the explicit "I am done now" call; .free() (auto or manual on the JS side) is the safety net. Defense in depth — the cost is one impl block. Worth a wasm-bindgen-test covering construct → drop → confirm registry empty.

P2 — Redundant need_key + with(...).unwrap() double-lookup

File(s): crates/relicario-wasm/src/lib.rs:73, 84, 92, 103, 111, 122, 160, 172 Every per-call op does need_key(handle)? and then session::with(handle.0, |k| ...).unwrap(). Two HashMap lookups per call, with the second .unwrap() justified only because the first proved the key existed. Single-threaded WASM makes this safe today, but if anyone ever introduces a reentrant path (Serializer callback that calls back into WASM), the assumption breaks. Refactor to a single session::with(...).ok_or_else(|| JsError::new("invalid or locked session handle"))? helper.

P2 — Vec<u8> getters clone on every read

File(s): crates/relicario-wasm/src/lib.rs:141-150 (EncryptedAttachment::aid and bytes) Each call clones the whole field. JS can call enc.bytes repeatedly without realising. For attachment payloads (potentially MB-sized), that's a real cost. Either consume by value or document "call once, cache locally."

P2 — Naming: wasm_*_recovery_qr prefix is inconsistent with everything else

File(s): crates/relicario-wasm/src/lib.rs:497, 510 wasm_generate_recovery_qr and wasm_unwrap_recovery_qr are the only exports with the wasm_ prefix. Rename to generate_recovery_qr_svg and unwrap_recovery_qr (and update extension/src/wasm.d.ts accordingly). Trivial breaking rename, do it before any new caller appears.

P2 — device.rs and session.rs use different concurrency primitives

File(s): crates/relicario-wasm/src/device.rs (Lazy<Mutex<...>>) vs crates/relicario-wasm/src/session.rs:14-17 (thread_local! { RefCell<HashMap<...>> }) Both work in single-threaded WASM. The inconsistency hurts readability — pick one pattern. thread_local! + RefCell is fine and avoids Mutex overhead; Mutex over Lazy is closer to typical Rust idioms. Either is defensible; consistency is the win.

P3 — WASM nits

  • All #[wasm_bindgen] exports use snake_case (manifest_encrypt, parse_lastpass_csv_json); JS idiom is camelCase. The wasm.d.ts mirrors snake_case verbatim, so it's consistent — but if DEV-C ever wants idiomatic JS naming, #[wasm_bindgen(js_name = "manifestEncrypt")] is the path. Decide once, cite the decision in the module doc
  • lib.rs:50 comment "Subsequent wasm_bindgen fns added in Tasks 19-21" is stale historical scaffolding; remove
  • device.rs:18 #[allow(dead_code)] deploy_private — wire it or remove it
  • session.rs:24 if *n == 0 { *n = 1; } runs on every insert; logically only matters after wraparound — move into the wrapping_add returned-zero branch
  • session_tests mod inside lib.rs:522-591 covers session + lastpass; rename or split
  • SessionHandle doc-comment says "opaque to JS" but value() getter (:21-22) makes the u32 visible — align the comment with the API
  • pack_backup_json does six b64.decode().map_err(...) blocks (lib.rs:387-410) — a small b64_decode(s) -> Result<Vec<u8>, JsError> helper would compress ~20 lines
  • get_field_history walks sections and constructs serde_json::json! manually rather than serializing a typed struct (lib.rs:262-295); the _ => String::new() fallback at :277 silently swallows future FieldValue variants. Use exhaustive match

Cross-cutting (all three crates)

  1. Pure parsers / formatters belong in core. parse_month_year, base32_decode_lenient, guess_mime (CLI), and get_field_history's walk (WASM) are all examples of logic that today lives in a consumer crate but logically belongs in relicario-core so all consumers share it. The CLI/extension parity philosophy makes this concrete: anything the CLI does in pure logic, the extension will eventually need too.

  2. Error formatting is inconsistent. CLI uses anyhow::bail! with a mix of sentence-case ("Settings updated.") and lowercase fragments ("git commit failed"). Server is uniformly eprintln!("REJECT: ...") except for the malformed-devices.json path that surfaces an anyhow chain. WASM uses JsError::new(&e.to_string()) mostly, but the messages range from "salt must be exactly 32 bytes" to whatever RelicarioError::Display produces. A short style note ("user-facing errors lead with sentence-case context, internal errors use lowercase") plus an audit pass would unify these.

  3. #[allow(dead_code)] without explanation appears in cli/device.rs, cli/gitea.rs, and wasm/device.rs. Each one is either "API completeness for a feature that hasn't shipped" or "scar tissue from a refactor." A newcomer cannot tell which. Either delete or annotate with // TODO(<plan>):.

  4. Layering is correct. None of the three consumer crates reaches past relicario-core's public API. The CLI doesn't import from server or wasm; server doesn't import from CLI or wasm; wasm doesn't import from CLI or server. The only shared concept (device entries, fingerprints) is correctly a core export. ¡Chido! [cool!]

  5. Workspace Cargo.toml working-tree changes are cosmetic (version bumps 0.2.0 → 0.5.0 on cli/core/wasm). No structural concern. Worth committing or reverting before the next merge to keep git status quiet.


File-by-file walk

relicario-cli

  • Cargo.toml — 18 runtime + 4 dev deps, all reasonable. arboard (clipboard), rqrr (QR decode), qrcode (QR encode), image, rpassword, dirs, reqwest blocking + JSON for Gitea, data-encoding, tar. Platform concerns ferried correctly away from core.
  • src/main.rs — entrypoint, dispatcher, and every command handler, item builder, edit handler, prompt helper, parse helper. 2641 lines, 71 functions. Clap surface (lines 1-455) is itself a tour of the product and is excellent. Past line 456 the file becomes flat; see P1 #1.
  • src/helpers.rs — the cleanest file. ~239 lines: vault_dir, git_command (with core.hooksPath=/dev/null, commit.gpgsign=false, core.editor=true hardening — newcomers should read the comment at :42-45), iso8601, humanize_age, groups_cache_path, sanitize_for_commit, decode_totp_qr. Has its own #[cfg(test)] mod tests covering humanize_age, sanitize_for_commit, iso8601. find_vault_dir_from walks parents. Plaintext groups.cache is a deliberate trade-off and the doc-comment at :90-93 is admirably explicit.
  • src/session.rs — short and clear (~151 lines). UnlockedVault { root, master_key: Zeroizing<[u8; 32]> } plus load_*/save_* for Item, Manifest, VaultSettings. unlock_interactive() does passphrase prompt → image extract → KDF. key() accessor returns &Zeroizing<...> used at 4 call sites; consider passing through encrypt_attachment/decrypt_attachment methods so the key never leaves this module. read_params defines an inner ParamsFile struct that mismatches the writer in main.rs:2287 — see P2.
  • src/device.rs — ~169 lines. ed25519 keypair storage under ~/.config/relicario/devices/<name>/. configure_git_signing runs four git config calls. Three #[allow(dead_code)] items (load_signing_key, load_deploy_key, delete_device_keys) — API completeness without callers.
  • src/gitea.rs — ~117 lines. Plain blocking reqwest client. create_deploy_key parses the JSON response into DeployKey { id, title, key } (latter two #[allow(dead_code)]). delete_deploy_key treats 404 as success — sensible. Each method allocates a fresh reqwest::blocking::Client::new().
  • tests/basic_flows.rs — 137 lines. Tests at the right level: spawn binary via assert_cmd, assert on stdout/stderr/exit. init_creates_expected_layout, add_login_then_list_shows_it, get_masks_by_default_shows_with_flag, rm_restore_purge_cycle, generate_random_and_bip39. Solid CLI-surface coverage.
  • tests/edit_and_history.rs — 191 lines. Most subtle test, interactive edit flow. run_edit_with_pw_change and run_edit_totp write hardcoded prompt sequences (["", "", "", "", "", "y"]) to stdin — fragile to prompt reordering. See P3.
  • tests/attachments.rs — 106 lines. Round-trip attach → extract → detach. Has a dead variable kept "to avoid an unused warning" (P3). attach_rejects_over_cap exercises only the per-attachment cap, not per-item or per-vault — coverage gap.
  • tests/settings.rs — 158 lines. settings_roundtrip_trash_retention, conflicting-flags rejection, generator-defaults end-to-end, status command coverage including last_backup round-trip. Solid.
  • tests/backup.rs — 142 lines. Export/restore round-trip, --include-image, --no-history, refusal of non-empty target, wrong-passphrase failure. Excellent coverage.
  • tests/import_lastpass.rs — 127 lines. Importer integration: success, single-commit guarantee, zero-items rejection, header validation, duplicate-import-IDs-are-unique invariant.
  • tests/smart_inputs.rs — 210 lines. Completion-script smoke tests, groups-cache write/suppress, rate command (strong/weak/- stdin), --totp-qr via in-process synthesized QR PNG (make_test_qr) — adheres to "synthetic fixtures, no binary blobs."
  • tests/vault_detection.rs — 59 lines. list_refuses_without_vault_marker, get_finds_vault_in_parent_dir, v1_vault_is_rejected_with_clear_error (.idfoto/ ignored because lookup is for .relicario/).
  • tests/common/mod.rs — 132 lines. TestVault harness; init() creates a TempDir, generates synthetic JPEG via make_test_jpeg, runs binary with RELICARIO_TEST_PASSPHRASE set. run, run_with_input, run_with_backup_pass variants. No shared global state — parallel-test safe.

relicario-server

  • src/main.rs — 189 lines, very legible. Two clap subcommands cleanly mapped. verify_commit reads devices.json + revoked.json from the commit's tree (git show), spawns git verify-commit --raw with a dynamically-built allowed-signers file injected via GIT_CONFIG_* env vars (a clever touch — chido [cool] — avoids touching user gitconfig), parses the SHA-256 fingerprint from stderr, then enforces revocation-first then registration logic. Uses committer timestamp (not author) for revocation cutoff (:115-123) — correct for non-rebased histories. All rejection paths use eprintln!("REJECT: ...") with actionable context (commit SHA, fingerprint, reason) and exit 1 — visible to the pushing client via git push stderr. generate_hook emits a clean bash script handling branch creation (oldrev = 000...) and branch deletion (newrev = 000...).
  • tests/verify_commit.rs — 230 lines, four named scenarios mapping to audit S1. Each test stands up a tempdir git repo, generates real ed25519 keypairs via relicario_core::device::generate_keypair, signs a commit with explicit committer date, and shells out to the cargo-built binary via assert_cmd. Coverage gaps noted in P2.
  • Cargo.toml — minimal, no surprises. Importantly: no AEAD or KDF crate, which structurally guarantees the server cannot decrypt.

relicario-wasm

  • Cargo.toml — 27 lines. Right deps. getrandom = { features = ["js"] } correctly enabled for browser entropy routing. image is dev-only — good. relicario-core/Cargo.toml does NOT enable the js getrandom feature (correct: core stays platform-agnostic), and the wasm crate "lifts" the feature flag for the dep graph.
  • src/lib.rs — 591 lines, the bulk of the surface. Module doc-comment is concise. Imports are sprinkled mid-file (:52, :138, :180, :310, :340, :469, :491) instead of clustered at the top — historical from incremental authoring per Task 19/20/21 markers. Consolidating saves scroll. Sections are demarcated by // ── ... ── dividers which help.
  • src/session.rs — 57 lines. SessionData { master_key, image_secret } stored in thread_local! { RefCell<HashMap<u32, _>> }, monotonic NEXT_HANDLE u32 (skips 0 on wraparound). insert, with, with_image_secret, remove, clear (test-only). Missing impl Drop for SessionHandle — see P1.
  • src/device.rs — 71 lines. Clean. Zeroizing<String> for private keys (correct — String::zeroize wipes the heap allocation). Uses Lazy<Mutex<DeviceState>> (different pattern from session.rs — see P2).

Build / clippy

  • cargo check -p relicario-cli — clean
  • cargo check -p relicario-server — clean
  • cargo check -p relicario-wasm — clean
  • cargo build -p relicario-wasm --target wasm32-unknown-unknown — clean, finishes in ~6s, zero warnings
  • cargo clippy --workspace — silent (per subagent reports)

Boundary notes for DEV-C

These are the items that look fine from the Rust side but DEV-C must verify on the TypeScript side:

  1. CRITICAL — every .free() callsite on a SessionHandle. wasm-bindgen's auto-generated .free() does NOT remove the entry from the Rust-side SESSIONS registry today, because there is no impl Drop for SessionHandle (P1 above). Until that lands, every .free() callsite in TypeScript that does not first call wasm.lock(handle) is a master-key residency window in WASM linear memory of unbounded duration. Audit extension/src for every .free() on a SessionHandle. The Rust-side fix is preferred (defense in depth); DEV-C should also confirm that every TS-side lock path calls wasm.lock(handle) before .free() regardless.
  2. Verify every .free() callsite, full stop. Same principle applies to EncryptedAttachment.free() and TotpCode.free(). wasm-bindgen will not call free automatically when the JS object is GC'd — JS GC does not trigger Rust drop. Any handle that goes out of scope without explicit .free() leaks in WASM linear memory.
  3. unlock() failure semantics. When unlock throws (bad passphrase, bad params_json, salt wrong length, image_secret extract failure), no SessionHandle is created. TS callers should not wrap in try { ... } finally { handle.free() } because the handle var will be undefined.
  4. manifest_decrypt/item_decrypt/settings_decrypt return JsValue typed as unknown in TS. Rust uses serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true) (lib.rs:65). Verify TS doesn't assume Map semantics anywhere — should be plain JS objects. Binary fields decode to Uint8Array; confirm TS doesn't try to JSON.stringify a decrypted item containing binary.
  5. *_encrypt functions take *_json: string. TS must JSON.stringify before calling. wasm-bindgen TS bindings will catch this at compile time, but verify no as any casts bypass.
  6. totp_compute(_, now_unix_seconds: bigint) and attachment_encrypt(_, _, max_bytes: bigint) — TS must use BigInt(...), not number. wasm-bindgen throws at runtime on mismatch.
  7. Uint8Array arguments are copied into WASM linear memory. TS doesn't need defensive copies. But a Uint8Array view onto a SharedArrayBuffer may behave differently — verify TS isn't passing those.
  8. EncryptedAttachment.aid and .bytes clone on every read (P2). TS code that does enc.bytes twice does double work + double copy. Cache locally.
  9. SessionHandle.value getter is exposed. It's a u32 monotonic counter. If TS ever logs it (telemetry/debug), it's a session correlation identifier that survives across handles.
  10. get_field_history returns JS objects with snake_case keys (field_id, field_name, current_value, changed_at). Verify TS components consume these names — easy mismatch with TS-side camelCase conventions.
  11. register_device/sign_for_git/get_device_info/clear_device are global, not per-session — backed by static DEVICE_STATE (Lazy<Mutex<>>). Single SW = single state, fine. If TS ever instantiates multiple WASM modules (e.g. one per offscreen doc), each gets its own state — verify TS uses one shared init path.
  12. Naming inconsistency: only wasm_generate_recovery_qr and wasm_unwrap_recovery_qr carry the wasm_ prefix. If DEV-C maintains a name-mapping table or auto-generated wrappers, flag for a rename pass.
  13. parse_lastpass_csv_json returns string (JSON-encoded), not a JsValue. TS must JSON.parse the result — different shape from manifest_decrypt which returns already-deserialized unknown. Verify import_lastpass.ts does JSON.parse(...) on the result.
  14. All exports are snake_case in JS. If DEV-C ever wants idiomatic camelCase, the mechanism is #[wasm_bindgen(js_name = "...")] per export. Decide once, before the surface grows.

Beginner-friendliness assessment

For a competent dev who's never written Rust:

  • Server is ideal. 189 lines, one trust-model question, every dependency is plaintext metadata. A newcomer can read it end-to-end in under ten minutes and walk away understanding what the server does and does not do. The single change that would help most is a paragraph-length module-level comment near the top of main.rs explaining why the server only verifies signatures (because the vault is encrypted client-side, the server has no key, the hook's only job is to gate writes by device authenticity). That paragraph would make the trust story self-evident on first contact.

  • WASM is approachable but has one cliff. 720 lines across 3 files; lib.rs reads top-to-bottom okay; the doc-comment on SessionHandle is clear about the opaque-handle contract; the section dividers help. The cliff is the missing Drop impl: a beginner reasonably assumes wasm-bindgen handles registry cleanup automatically (it does not). A short comment in session.rs saying "Drop the SessionHandle ≠ remove from registry; you must call lock()" would prevent that mistake — but better to fix the missing Drop so the comment isn't needed.

  • CLI has one big roadblock and a lot of small ones. helpers.rs, session.rs, device.rs, gitea.rs all read like real code: small modules, focused responsibilities, doc-comment headers, sensible names. helpers.rs's tests double as documentation. The clap surface in main.rs:1-455 is itself a product tour. Past line 456, main.rs becomes a 2200-line flat file with no submodule boundaries. A newcomer searching "where does add work?" finds cmd_add calling 7 different build_*_item functions (each ~50-60 lines) plus 6 prompt helpers, all peers. Plus the Rust-specific tripwires — the let _ = entry; pattern repeated 6×, the Zeroizing newtype, the Option<Foo>::map(Ok).unwrap_or_else(...)? chain at every builder — compound the unfamiliarity.

Single biggest change across the consumer layer: split crates/relicario-cli/src/main.rs into a folder. Keep main.rs as clap definitions + dispatcher (~470 lines, very readable). Move every cmd_* to commands/<name>.rs, prompts to prompt.rs, parsers to parse.rs. Same LOC, completely different reading experience — and this is the precondition for fixing the duplicated git boilerplate (CLI P1 #2) and centralizing refresh_groups_cache.


Open questions for DEV-B (escalated to PM separately)

  1. Was impl Drop for SessionHandle deliberately omitted (e.g. to avoid double-free if JS holds two refs to the same handle)? If yes, document it; if no, this is the headline fix.
  2. CLI parse_month_year, base32_decode_lenient, guess_mime — should they migrate to relicario-core for CLI/extension parity?
  3. Is "missing .relicario/devices.json = bootstrap = accept" intended in perpetuity, or should it be tightened once a repo has any non-empty devices.json in history?
  4. Is the Lock no-op CLI subcommand worth keeping visible in --help for parity with the extension, or hide behind #[command(hide = true)]?
  5. cmd_backup_export still reads devices.json per a "Task 12 will remove" TODO. Is Task 12 landed, deferred, or abandoned?