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>
33 KiB
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_unwrapdoes not check for empty input before base64 decode (main.rs:2625-2630)cmd_recovery_qr_generatemixes the QR ASCII and a "NOT been saved to disk" message both on stdout — pipe-unfriendly (main.rs:2612-2614)Locksubcommand 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-76has 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_exportstill readsdevices.json(with[]fallback) per a "Task 12 will remove" TODO at:1535-1537. If Task 12 has shipped, this code can simplifyformat!("{:?}", e.r#type)for the TYPE column atmain.rs:1158— Debug-format for user-facing output. AddDisplaytoItemTypein corehelpers.rs:37 #[allow(dead_code)] pub fn relicario_dir()— helper has no callers but severalvault_dir.join(".relicario")sites in main.rs should be using itdevice.rs:94, 103, 120andgitea.rs:24, 26, 47, 77, 94, 101have#[allow(dead_code)]markers without explanation. Either wire up or add// TODO(<plan>):so a newcomer knows whether they're scaffolding or scar tissuegitea.rsconstructs a freshreqwest::blocking::Client::new()per method call (3 sites) — make it a struct fieldtests/edit_and_history.rswrites 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— useLazyLockoronce_cell::sync::Lazy; theexpect("static regex")comment hints the author knew - The malformed-devices.json path at
:46returns ananyhowchain on stderr without the consistentREJECT: ...prefix the rest of the file uses — ops parsing logs forREJECT:will miss it - No
--versionflag exposed in clap (small ops courtesy) generate-hookdoesn't tell users tochmod +xthe 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. Thewasm.d.tsmirrors 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:50comment "Subsequent wasm_bindgen fns added in Tasks 19-21" is stale historical scaffolding; removedevice.rs:18 #[allow(dead_code)] deploy_private— wire it or remove itsession.rs:24 if *n == 0 { *n = 1; }runs on every insert; logically only matters after wraparound — move into thewrapping_addreturned-zero branchsession_testsmod insidelib.rs:522-591covers session + lastpass; rename or splitSessionHandledoc-comment says "opaque to JS" butvalue()getter (:21-22) makes the u32 visible — align the comment with the APIpack_backup_jsondoes sixb64.decode().map_err(...)blocks (lib.rs:387-410) — a smallb64_decode(s) -> Result<Vec<u8>, JsError>helper would compress ~20 linesget_field_historywalks sections and constructsserde_json::json!manually rather than serializing a typed struct (lib.rs:262-295); the_ => String::new()fallback at:277silently swallows futureFieldValuevariants. Use exhaustive match
Cross-cutting (all three crates)
-
Pure parsers / formatters belong in core.
parse_month_year,base32_decode_lenient,guess_mime(CLI), andget_field_history's walk (WASM) are all examples of logic that today lives in a consumer crate but logically belongs inrelicario-coreso 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. -
Error formatting is inconsistent. CLI uses
anyhow::bail!with a mix of sentence-case ("Settings updated.") and lowercase fragments ("git commit failed"). Server is uniformlyeprintln!("REJECT: ...")except for the malformed-devices.json path that surfaces an anyhow chain. WASM usesJsError::new(&e.to_string())mostly, but the messages range from "salt must be exactly 32 bytes" to whateverRelicarioError::Displayproduces. A short style note ("user-facing errors lead with sentence-case context, internal errors use lowercase") plus an audit pass would unify these. -
#[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>):. -
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!] -
Workspace
Cargo.tomlworking-tree changes are cosmetic (version bumps0.2.0 → 0.5.0on cli/core/wasm). No structural concern. Worth committing or reverting before the next merge to keepgit statusquiet.
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,reqwestblocking + 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(withcore.hooksPath=/dev/null,commit.gpgsign=false,core.editor=truehardening — 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 testscoveringhumanize_age,sanitize_for_commit,iso8601.find_vault_dir_fromwalks parents. Plaintextgroups.cacheis a deliberate trade-off and the doc-comment at:90-93is admirably explicit.src/session.rs— short and clear (~151 lines).UnlockedVault { root, master_key: Zeroizing<[u8; 32]> }plusload_*/save_*for Item, Manifest, VaultSettings.unlock_interactive()does passphrase prompt → image extract → KDF.key()accessor returns&Zeroizing<...>used at 4 call sites; consider passing throughencrypt_attachment/decrypt_attachmentmethods so the key never leaves this module.read_paramsdefines an innerParamsFilestruct that mismatches the writer inmain.rs:2287— see P2.src/device.rs— ~169 lines. ed25519 keypair storage under~/.config/relicario/devices/<name>/.configure_git_signingruns fourgit configcalls. 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_keyparses the JSON response intoDeployKey { id, title, key }(latter two#[allow(dead_code)]).delete_deploy_keytreats 404 as success — sensible. Each method allocates a freshreqwest::blocking::Client::new().tests/basic_flows.rs— 137 lines. Tests at the right level: spawn binary viaassert_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, interactiveeditflow.run_edit_with_pw_changeandrun_edit_totpwrite 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_capexercises 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,statuscommand coverage includinglast_backupround-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,ratecommand (strong/weak/-stdin),--totp-qrvia 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.TestVaultharness;init()creates aTempDir, generates synthetic JPEG viamake_test_jpeg, runs binary withRELICARIO_TEST_PASSPHRASEset.run,run_with_input,run_with_backup_passvariants. No shared global state — parallel-test safe.
relicario-server
src/main.rs— 189 lines, very legible. Two clap subcommands cleanly mapped.verify_commitreads devices.json + revoked.json from the commit's tree (git show), spawnsgit verify-commit --rawwith a dynamically-built allowed-signers file injected viaGIT_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 useeprintln!("REJECT: ...")with actionable context (commit SHA, fingerprint, reason) and exit 1 — visible to the pushing client viagit pushstderr.generate_hookemits 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 viarelicario_core::device::generate_keypair, signs a commit with explicit committer date, and shells out to the cargo-built binary viaassert_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.imageis dev-only — good.relicario-core/Cargo.tomldoes NOT enable thejsgetrandom 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 inthread_local! { RefCell<HashMap<u32, _>> }, monotonicNEXT_HANDLEu32 (skips 0 on wraparound).insert,with,with_image_secret,remove,clear(test-only). Missingimpl Drop for SessionHandle— see P1.src/device.rs— 71 lines. Clean.Zeroizing<String>for private keys (correct —String::zeroizewipes the heap allocation). UsesLazy<Mutex<DeviceState>>(different pattern fromsession.rs— see P2).
Build / clippy
cargo check -p relicario-cli— cleancargo check -p relicario-server— cleancargo check -p relicario-wasm— cleancargo build -p relicario-wasm --target wasm32-unknown-unknown— clean, finishes in ~6s, zero warningscargo 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:
- CRITICAL — every
.free()callsite on aSessionHandle. wasm-bindgen's auto-generated.free()does NOT remove the entry from the Rust-sideSESSIONSregistry today, because there is noimpl Drop for SessionHandle(P1 above). Until that lands, every.free()callsite in TypeScript that does not first callwasm.lock(handle)is a master-key residency window in WASM linear memory of unbounded duration. Auditextension/srcfor every.free()on aSessionHandle. The Rust-side fix is preferred (defense in depth); DEV-C should also confirm that every TS-side lock path callswasm.lock(handle)before.free()regardless. - Verify every
.free()callsite, full stop. Same principle applies toEncryptedAttachment.free()andTotpCode.free(). wasm-bindgen will not callfreeautomatically 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. unlock()failure semantics. When unlock throws (bad passphrase, bad params_json, salt wrong length, image_secret extract failure), noSessionHandleis created. TS callers should not wrap intry { ... } finally { handle.free() }because the handle var will be undefined.manifest_decrypt/item_decrypt/settings_decryptreturnJsValuetyped asunknownin TS. Rust usesserde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true)(lib.rs:65). Verify TS doesn't assumeMapsemantics anywhere — should be plain JS objects. Binary fields decode toUint8Array; confirm TS doesn't try toJSON.stringifya decrypted item containing binary.*_encryptfunctions take*_json: string. TS mustJSON.stringifybefore calling. wasm-bindgen TS bindings will catch this at compile time, but verify noas anycasts bypass.totp_compute(_, now_unix_seconds: bigint)andattachment_encrypt(_, _, max_bytes: bigint)— TS must useBigInt(...), notnumber. wasm-bindgen throws at runtime on mismatch.Uint8Arrayarguments are copied into WASM linear memory. TS doesn't need defensive copies. But aUint8Arrayview onto aSharedArrayBuffermay behave differently — verify TS isn't passing those.EncryptedAttachment.aidand.bytesclone on every read (P2). TS code that doesenc.bytestwice does double work + double copy. Cache locally.SessionHandle.valuegetter 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.get_field_historyreturns 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.register_device/sign_for_git/get_device_info/clear_deviceare global, not per-session — backed bystatic 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.- Naming inconsistency: only
wasm_generate_recovery_qrandwasm_unwrap_recovery_qrcarry thewasm_prefix. If DEV-C maintains a name-mapping table or auto-generated wrappers, flag for a rename pass. parse_lastpass_csv_jsonreturnsstring(JSON-encoded), not aJsValue. TS mustJSON.parsethe result — different shape frommanifest_decryptwhich returns already-deserializedunknown. Verifyimport_lastpass.tsdoesJSON.parse(...)on the result.- 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.rsexplaining 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.rsreads top-to-bottom okay; the doc-comment onSessionHandleis clear about the opaque-handle contract; the section dividers help. The cliff is the missingDropimpl: a beginner reasonably assumes wasm-bindgen handles registry cleanup automatically (it does not). A short comment insession.rssaying "Drop the SessionHandle ≠ remove from registry; you must calllock()" would prevent that mistake — but better to fix the missingDropso the comment isn't needed. -
CLI has one big roadblock and a lot of small ones.
helpers.rs,session.rs,device.rs,gitea.rsall read like real code: small modules, focused responsibilities, doc-comment headers, sensible names.helpers.rs's tests double as documentation. The clap surface inmain.rs:1-455is itself a product tour. Past line 456,main.rsbecomes a 2200-line flat file with no submodule boundaries. A newcomer searching "where does add work?" findscmd_addcalling 7 differentbuild_*_itemfunctions (each ~50-60 lines) plus 6 prompt helpers, all peers. Plus the Rust-specific tripwires — thelet _ = entry;pattern repeated 6×, theZeroizingnewtype, theOption<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)
- Was
impl Drop for SessionHandledeliberately 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. - CLI
parse_month_year,base32_decode_lenient,guess_mime— should they migrate torelicario-corefor CLI/extension parity? - 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? - Is the
Lockno-op CLI subcommand worth keeping visible in--helpfor parity with the extension, or hide behind#[command(hide = true)]? cmd_backup_exportstill readsdevices.jsonper a "Task 12 will remove" TODO. Is Task 12 landed, deferred, or abandoned?