Compare commits
31 Commits
feature/v0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fa4d6824c | ||
|
|
783e3493f0 | ||
|
|
4cca9b465c | ||
|
|
5be3043ab5 | ||
|
|
cf89bf8ca4 | ||
|
|
a91ceea0ed | ||
|
|
415d8ed9ef | ||
|
|
b54aaea239 | ||
|
|
4c0a289acb | ||
|
|
03559f81ea | ||
|
|
fe8eeb97c9 | ||
|
|
8ec616be5d | ||
|
|
bd323d8b1b | ||
|
|
db0ab1d82e | ||
|
|
68c6da4d67 | ||
|
|
bccd113f55 | ||
|
|
6e73c5e6a1 | ||
|
|
c5b1917eb0 | ||
|
|
e76d7167d6 | ||
|
|
04ad98973a | ||
|
|
290bc4e2d0 | ||
|
|
82feb49ab4 | ||
|
|
07862b8d44 | ||
|
|
b09e0ce036 | ||
|
|
d8b23d421e | ||
|
|
6eb1275710 | ||
|
|
751e4e9bb1 | ||
|
|
db4e05a193 | ||
|
|
65e23cfddc | ||
|
|
b83643ee0a | ||
|
|
154b984725 |
51
CHANGELOG.md
51
CHANGELOG.md
@@ -1,5 +1,56 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.8.1 — 2026-06-20 — org item-type parity + collection-scoped attachments
|
||||||
|
|
||||||
|
Brings `relicario org add` / `relicario org edit` to **full item-type parity** with the
|
||||||
|
personal vault: the org surface now supports **all 7 item types** (previously Login /
|
||||||
|
SecureNote / Identity only), adds collection-scoped attachment storage for Document
|
||||||
|
items, and grant-scopes attachment write paths in the pre-receive hook — closing a latent
|
||||||
|
authorization gap. Secrets are entered via interactive prompts by default, with `--*-stdin`
|
||||||
|
escape hatches for non-interactive scripting. Tracked under
|
||||||
|
`docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md`.
|
||||||
|
|
||||||
|
> **⚠️ Coordinated server redeploy required.** The `relicario-server` pre-receive hook
|
||||||
|
> (now `0.1.1`) must be rebuilt and redeployed for attachment writes to be grant-scoped in
|
||||||
|
> production. Until the updated hook is installed, `attachments/…` pushes remain
|
||||||
|
> `Unrestricted` (gated only by the per-commit member-signature check).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Shared `item_build` CLI module** (`crates/relicario-cli/src/commands/item_build.rs`):
|
||||||
|
centralizes per-type secret resolution, item builders (`build_*`), and interactive edit
|
||||||
|
helpers (`edit_*`) consumed by **both** the personal and org command surfaces, eliminating
|
||||||
|
the prior personal↔org builder duplication.
|
||||||
|
- **Org `add` / `edit` parity for Card, Key, TOTP, and Document** — `relicario org add` now
|
||||||
|
creates all 7 item types; `relicario org edit` is interactive per-type ("blank to keep",
|
||||||
|
field-history capture) instead of flat flags.
|
||||||
|
- **`--*-stdin` secret flags** on personal and org `add` for non-interactive entry of
|
||||||
|
passwords, card number/CVV/PIN, key material, TOTP secrets, and note bodies.
|
||||||
|
- **Collection-scoped org attachment storage** (`crates/relicario-cli/src/org_session.rs`):
|
||||||
|
attachments stored at `attachments/<slug>/<item-id>/<att-id>.enc` with a default
|
||||||
|
per-attachment cap (10 MiB, mirroring the personal default at
|
||||||
|
`crates/relicario-core/src/settings.rs`). `org add document --file`, `org edit --file`
|
||||||
|
(replace), and `org purge` (removes the item's attachment directory) round-trip with
|
||||||
|
git-status-clean staging.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **Grant-scoped attachment writes** (`relicario-server` `0.1.1`): `classify_path` now
|
||||||
|
recognizes `attachments/<slug>/<item-id>/<att-id>.enc` (exactly 3 path segments, `.`-free
|
||||||
|
slug guard) as `Item { collection }`, bringing attachment writes under the same grant +
|
||||||
|
slug-existence check as `items/` blobs. Previously such paths fell through to
|
||||||
|
`Unrestricted`. The Document source plaintext is read into a `Zeroizing` buffer and wiped
|
||||||
|
after encryption. See `docs/SECURITY.md`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Personal `add secure-note` `--body-prompt` flag renamed to `--body-stdin` (unified
|
||||||
|
multiline-secret model).
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- Updated cli `ARCHITECTURE.md`, `docs/FORMATS.md` (org attachment layout + cap citation),
|
||||||
|
`docs/SECURITY.md`, `STATUS.md`, and `ROADMAP.md`. New
|
||||||
|
`docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md` is the forward
|
||||||
|
plan for extension↔CLI parity (org read/write plus a cluster of personal-side extension
|
||||||
|
gaps). End-user `user_docs/` guide lands as a fast-follow.
|
||||||
|
|
||||||
## v0.8.0 — 2026-06-20 — enterprise org vault
|
## v0.8.0 — 2026-06-20 — enterprise org vault
|
||||||
|
|
||||||
Git-native multi-user **org vaults**: a separate org git repository alongside each
|
Git-native multi-user **org vaults**: a separate org git repository alongside each
|
||||||
|
|||||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -2156,7 +2156,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "relicario-cli"
|
name = "relicario-cli"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arboard",
|
"arboard",
|
||||||
@@ -2188,7 +2188,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "relicario-core"
|
name = "relicario-core"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -2235,7 +2235,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "relicario-wasm"
|
name = "relicario-wasm"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
|
|
||||||
| Version | Highlights |
|
| Version | Highlights |
|
||||||
|---|---|
|
|---|---|
|
||||||
| *(untagged, 2026-06-20)* | **Enterprise org vault — backend complete** (`7392795`): relicario-core `org` module (ECIES X25519 key wrap/unwrap, `OrgRole`/`OrgMember`/`OrgManifest` types, `filter_for_member`, `schema_version: 1`); relicario-server org hook (`verify-org-commit`: signature verification, path-scoped authz, `enforce_owner_only_elevation` on parent role, `enforce_schema_monotonicity`, `generate-org-hook`, new `[lib]` target); relicario-cli — all 19 `relicario org` subcommands: init, add-member/remove-member/set-role, create-collection/grant/revoke, rotate-key (re-encrypts all blobs), transfer-ownership, delete-org, status, audit, and item CRUD (add/get/list/edit/rm/restore/purge). **Not yet shipped:** `org add`/`edit` for Card/SshKey/Document/Totp; extension org parity (Dev-D); phase 2 (SSO/LDAP, read audit, per-collection subkeys, HTTP plane). |
|
| **v0.8.1** *(2026-06-20, tag pending PM)* | **Org item-type parity + collection-scoped attachments + grant-scoped hook** (`4c0a289`, four parallel streams): `relicario org add`/`edit` now cover **all 7 item types** — Card/Key/Totp (Dev-B `6e73c5e`) and Document (Dev-C `4c0a289`) on the shared `item_build` foundation (Dev-A `b09e0ce`); org attachments stored collection-scoped at `attachments/<slug>/<item-id>/<att-id>.enc` with a default cap (Dev-C); `relicario-server` `classify_path` grant-scopes those attachment writes (Dev-D `db4e05a`, server `0.1.1` — **requires pre-receive hook redeploy**). **Still deferred:** extension org read/write (forward plan: `docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md`); org phase 2. |
|
||||||
|
| v0.8.0 *(2026-06-20)* | **Enterprise org vault — backend complete** (`7392795`): relicario-core `org` module (ECIES X25519 key wrap/unwrap, `OrgRole`/`OrgMember`/`OrgManifest` types, `filter_for_member`, `schema_version: 1`); relicario-server org hook (`verify-org-commit`: signature verification, path-scoped authz, `enforce_owner_only_elevation` on parent role, `enforce_schema_monotonicity`, `generate-org-hook`, new `[lib]` target); relicario-cli — all 19 `relicario org` subcommands: init, add-member/remove-member/set-role, create-collection/grant/revoke, rotate-key (re-encrypts all blobs), transfer-ownership, delete-org, status, audit, and item CRUD (add/get/list/edit/rm/restore/purge). Org item-type parity for Card/Key/Document/Totp shipped subsequently in v0.8.1; extension org parity + phase 2 (SSO/LDAP, read audit, per-collection subkeys, HTTP plane) remain deferred. |
|
||||||
| v0.7.0 *(2026-06-01)* | Extension restructure (Plan C) complete — Phases 3/4/6 merged via 3 parallel worktree streams under PM coordination: setup wizard crypto migrated into the SW (`create_vault`/`attach_vault`; `setup.ts` 1230→58 LOC + step registry); `vault.ts` split 1037→194 LOC into 5 focused + 2 support modules; `vault_locked` intercept lifted into `shared/state.ts`; `get_vault_status` SW message + sidebar status indicator closing the last `relicario status` CLI/extension parity gap |
|
| v0.7.0 *(2026-06-01)* | Extension restructure (Plan C) complete — Phases 3/4/6 merged via 3 parallel worktree streams under PM coordination: setup wizard crypto migrated into the SW (`create_vault`/`attach_vault`; `setup.ts` 1230→58 LOC + step registry); `vault.ts` split 1037→194 LOC into 5 focused + 2 support modules; `vault_locked` intercept lifted into `shared/state.ts`; `get_vault_status` SW message + sidebar status indicator closing the last `relicario status` CLI/extension parity gap |
|
||||||
| v0.6.0 *(2026-05-30)* | Security audit fixes; device authentication; backup/restore + LastPass import; fullscreen UX Phases 1+2A+2B; v0.5.1 Streams A/B/C (3-column vault layout + bottom-sheet picker + toast system; left-nav settings; Recovery QR end-to-end + setup wizard Style C); 1C-γ (attachments + Document type + device registration + trash + field history); Plan B multi-stream refactor (commands/ split, prompt_or_flag, core/WASM seam); vault-tab management surfaces revamp (settings synced/local split, devices fingerprint, trash purge countdown, field-history polish, item-history-index, `#history/<id>` routing); doc-structure redesign (rename to DESIGN/CRYPTO/docs/FORMATS, scope headers + Next: footers); GPL-3.0-or-later license |
|
| v0.6.0 *(2026-05-30)* | Security audit fixes; device authentication; backup/restore + LastPass import; fullscreen UX Phases 1+2A+2B; v0.5.1 Streams A/B/C (3-column vault layout + bottom-sheet picker + toast system; left-nav settings; Recovery QR end-to-end + setup wizard Style C); 1C-γ (attachments + Document type + device registration + trash + field history); Plan B multi-stream refactor (commands/ split, prompt_or_flag, core/WASM seam); vault-tab management surfaces revamp (settings synced/local split, devices fingerprint, trash purge countdown, field-history polish, item-history-index, `#history/<id>` routing); doc-structure redesign (rename to DESIGN/CRYPTO/docs/FORMATS, scope headers + Next: footers); GPL-3.0-or-later license |
|
||||||
| v0.2.0 | Typed-item rewrite (Plans 1A/1B/1C-α/β₁/β₂) |
|
| v0.2.0 | Typed-item rewrite (Plans 1A/1B/1C-α/β₁/β₂) |
|
||||||
@@ -16,11 +17,11 @@ See `CHANGELOG.md` for tagged-release detail and `STATUS.md` for the per-train c
|
|||||||
|
|
||||||
## Up next
|
## Up next
|
||||||
|
|
||||||
All three 2026-05-04 architecture-review specs are shipped; enterprise org vault backend is shipped (2026-06-20). Pending items in rough priority order:
|
All three 2026-05-04 architecture-review specs are shipped; the enterprise org vault backend (v0.8.0) and org item-type parity + collection-scoped attachments (v0.8.1) are shipped. Forward plan for extension parity: `docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md`. Pending items in rough priority order:
|
||||||
|
|
||||||
- **Org-vault item-type parity** — `org add`/`edit` support for Card, SshKey, Document, Totp (Login/SecureNote/Identity ship today)
|
|
||||||
- **Extension org parity — read** — org switch + collection-filtered browse in the popup/vault tab (Dev-D, deferred)
|
- **Extension org parity — read** — org switch + collection-filtered browse in the popup/vault tab (Dev-D, deferred)
|
||||||
- **Extension org parity — write** — `org add`/`edit`/`rm` from the extension
|
- **Extension org parity — write** — `org add`/`edit`/`rm` from the extension (Plan B-2; the CLI side reached all-7-type org write in v0.8.1, so this is unblocked CLI-side)
|
||||||
|
- **Personal-side extension gaps** — favorites UI, group/tag/filter editing across all type forms, attachment-remove router wire + per-item purge UI, autofill registrable-domain matching (per the parity gap analysis)
|
||||||
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
|
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
|
||||||
|
|
||||||
## Medium-term
|
## Medium-term
|
||||||
|
|||||||
33
STATUS.md
33
STATUS.md
@@ -5,10 +5,23 @@
|
|||||||
## Version
|
## Version
|
||||||
|
|
||||||
**Last release tagged:** v0.6.0 — rolled up Phase 2B, v0.5.1 Streams A/B/C, 1C-γ, Plan B refactor (Cycles 1+2), management-surfaces revamp, and the doc-structure redesign into one tag.
|
**Last release tagged:** v0.6.0 — rolled up Phase 2B, v0.5.1 Streams A/B/C, 1C-γ, Plan B refactor (Cycles 1+2), management-surfaces revamp, and the doc-structure redesign into one tag.
|
||||||
**Active track:** **extension restructure (Plan C) — COMPLETE.** All six phases merged. Phases 1, 2, 5 merged 2026-05-30; Phases 3, 4, 6 merged 2026-05-31/06-01 via three parallel worktree streams (Dev-A/B/C under PM coordination). Versions bumped to v0.7.0; tag pending.
|
**Active track:** **v0.8.1 — org item-type parity — COMPLETE (on `main` `4c0a289`; tag pending PM).** All four parallel streams merged: shared item-build foundation + personal add/edit refactor (Dev-A, `b09e0ce`); org add/edit parity for Card/Key/Totp (Dev-B, `6e73c5e`); org Document + collection-scoped attachment storage (Dev-C, `4c0a289`); grant-scoped attachment write-path hook (Dev-D, `db4e05a`). See the v0.8.1 landing section below.
|
||||||
|
|
||||||
## What landed on main since the v0.5.0 version bump
|
## What landed on main since the v0.5.0 version bump
|
||||||
|
|
||||||
|
### v0.8.1 — org item-type parity + collection-scoped attachments + grant-scoped hook (merged 2026-06-20, `4c0a289`)
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md`; plan: `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md`. Four parallel streams under PM coordination (relay-bus):
|
||||||
|
|
||||||
|
- **Dev-A — shared item-build foundation** (merge `b09e0ce`): `commands/item_build.rs` (shared secret-resolution, type parsers, per-type `build_*`/`edit_*` helpers, `push_history`); personal `add`/`edit` refactored onto it; personal `--*-stdin` flags for non-interactive scripting/tests.
|
||||||
|
- **Dev-B — org Card/Key/Totp parity** (merge `6e73c5e`): `OrgAddKind` gains Card/Key/Totp; `org edit` becomes per-type interactive dispatch (the old "login/secure-note/identity only" bail is gone).
|
||||||
|
- **Dev-C — org Document + collection-scoped attachments** (merge `4c0a289`): `OrgAddKind::Document`; `org_session.rs` attachment storage (`attachment_path`/`save_attachment`/`load_attachment`/`remove_item_attachments`) writing `attachments/<slug>/<item-id>/<att-id>.enc`; default org attachment cap; `org add document --file` + `org edit --file`; purge removes the item's attachment dir.
|
||||||
|
- **Dev-D — grant-scoped attachment hook** (merge `db4e05a`): `relicario-server` `classify_path` recognizes `attachments/<slug>/<item-id>/<att-id>.enc` (3 segments, slug-only `.`-free guard) as `Item { collection }`, converting attachment writes from `Unrestricted` to grant-scoped — closing a latent authz gap. Bumped `relicario-server` to 0.1.1; `docs/SECURITY.md` documents the required pre-receive hook redeploy.
|
||||||
|
|
||||||
|
Result: `relicario org add`/`edit` now reach **all 7 item types** (Login, Secure Note, Identity, Card, Key, TOTP, Document); org attachments are collection-scoped on disk and grant-enforced at the hook. The C↔D path contract held in the merge — Dev-C's `save_attachment` emitter (`attachments/{slug}/{item}/{att}.enc`) exactly matches Dev-D's `classify_path` authorization. **Deploy note:** the pre-receive hook must be rebuilt on the server for attachment writes to be grant-scoped in production.
|
||||||
|
|
||||||
|
**Still deferred — forward plan in `docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md`:** extension org **read** (Dev-D) and **write** (Plan B-2) — the extension has no org concept yet; org phase-2 (SSO/LDAP, read audit, per-collection subkeys, HTTP plane). That parity gap analysis is the authoritative forward plan for extension↔CLI parity (org read/write plus a cluster of personal-side extension gaps: favorites UI, group/tag/filter editing, attachment-remove router wire, per-item purge).
|
||||||
|
|
||||||
### Phase 2B — polish foundation + form layout (merged 2026-05-02, `5da1e52`)
|
### Phase 2B — polish foundation + form layout (merged 2026-05-02, `5da1e52`)
|
||||||
|
|
||||||
Spec: `docs/superpowers/specs/2026-05-02-phase-2b-form-layout-design.md`
|
Spec: `docs/superpowers/specs/2026-05-02-phase-2b-form-layout-design.md`
|
||||||
@@ -114,10 +127,10 @@ Item CRUD commands (B9–B14): `org add` (`OrgAddKind`: Login/SecureNote/Identit
|
|||||||
|
|
||||||
**A5 doc-fix** (`enforce_owner_only_elevation` parent-role close, `519e503`) and this living-docs sweep also landed.
|
**A5 doc-fix** (`enforce_owner_only_elevation` parent-role close, `519e503`) and this living-docs sweep also landed.
|
||||||
|
|
||||||
**Tracked follow-ups (deferred, not shipped):**
|
**Tracked follow-ups:**
|
||||||
- `org add` / `org edit` parity for Card, SshKey, Document, Totp item types (Login/SecureNote/Identity only today; `get`/`list` can display all types if present)
|
- `org add` / `org edit` parity for Card, Key, Document, Totp — ✅ **SHIPPED v0.8.1** (`4c0a289`; all 7 item types now supported)
|
||||||
- Extension org-vault switch + read parity (Dev-D deferred)
|
- Extension org-vault switch + read parity (Dev-D) — still deferred; forward plan in the parity gap analysis
|
||||||
- Extension org write operations
|
- Extension org write operations — still deferred (Plan B-2)
|
||||||
- Phase 2: SSO/LDAP federation, read audit log, per-collection subkeys (true cryptographic scope separation), HTTP management plane
|
- Phase 2: SSO/LDAP federation, read audit log, per-collection subkeys (true cryptographic scope separation), HTTP management plane
|
||||||
|
|
||||||
**Known limitations (by design in phase 1):** shared org master key — reads are not cryptographically scoped per collection (hook scopes writes; client filters manifest); no read audit (git records writes only); `delete-org` is a local tombstone only (hook rejects protected-file deletion on push).
|
**Known limitations (by design in phase 1):** shared org master key — reads are not cryptographically scoped per collection (hook scopes writes; client filters manifest); no read audit (git records writes only); `delete-org` is a local tombstone only (hook rejects protected-file deletion on push).
|
||||||
@@ -169,10 +182,10 @@ Per the 2026-05-30 post-v0.6.0 audit of the three 2026-05-04 architecture-review
|
|||||||
|
|
||||||
**Enterprise org vault** — ✅ **COMPLETE (backend)** — all 19 CLI subcommands + core + server hook merged `7392795` 2026-06-20. Deferred follow-ups tracked in the landing section above.
|
**Enterprise org vault** — ✅ **COMPLETE (backend)** — all 19 CLI subcommands + core + server hook merged `7392795` 2026-06-20. Deferred follow-ups tracked in the landing section above.
|
||||||
|
|
||||||
Pending org-vault follow-ups (in rough priority order):
|
Pending follow-ups (in rough priority order; **forward plan:** `docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md`):
|
||||||
- `org add`/`edit` parity for Card, SshKey, Document, Totp
|
- **Extension org parity — read** (Dev-D): org context switch + collection-filtered browse in the popup/vault tab
|
||||||
- Extension org switch + read parity (Dev-D)
|
- **Extension org parity — write** (Plan B-2): `org add`/`edit`/`rm` from the extension — blocked behind extension org-read landing (and now unblocked on the CLI side, which reached all-7-type org write in v0.8.1)
|
||||||
- Extension org write operations
|
- **Personal-side extension gaps** (from the parity analysis): favorites UI, group/tag editing on all type forms, popup type/tag filters, attachment-remove router wire + per-item purge UI, autofill registrable-domain matching
|
||||||
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
|
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
|
||||||
|
|
||||||
Long-term: relay server, mobile. See `ROADMAP.md` for the longer arc and `CHANGELOG.md` for tagged-release history (current head: `v0.6.0`; the `v0.7.0` entry covers extension-restructure completion).
|
Long-term: relay server, mobile. See `ROADMAP.md` for the longer arc and `CHANGELOG.md` for tagged-release history (the `v0.8.1` CHANGELOG entry + version bump are owned by the PM in this lift).
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ under `src/commands/`. Each source file has one job.
|
|||||||
- **`src/main.rs`** (`main.rs:1-492`) — clap surface and the flat dispatcher.
|
- **`src/main.rs`** (`main.rs:1-492`) — clap surface and the flat dispatcher.
|
||||||
Owns the top-level `Cli` / `Commands` enum and every subcommand enum
|
Owns the top-level `Cli` / `Commands` enum and every subcommand enum
|
||||||
(`AddKind`, `TrashAction`, `SettingsAction`, `BackupAction`, `ImportAction`,
|
(`AddKind`, `TrashAction`, `SettingsAction`, `BackupAction`, `ImportAction`,
|
||||||
`DeviceAction`, `RecoveryQrCmd`). `main()` is a single `match` that
|
`DeviceAction`, `RecoveryQrCmd`), plus the org clap surface `OrgCommands`
|
||||||
|
(`main.rs:448`) and `OrgAddKind` (`main.rs:556`) — the latter's Card / Key /
|
||||||
|
Document / Totp variants carry `--collection` and the `--*-stdin` secret flags.
|
||||||
|
`main()` is a single `match` that
|
||||||
delegates each variant to `commands::<verb>::cmd_<verb>(...)`. Also owns the
|
delegates each variant to `commands::<verb>::cmd_<verb>(...)`. Also owns the
|
||||||
three test-only env-var hooks (`test_passphrase_override`,
|
three test-only env-var hooks (`test_passphrase_override`,
|
||||||
`test_item_secret_override`, `test_backup_passphrase_override`) — each is
|
`test_item_secret_override`, `test_backup_passphrase_override`) — each is
|
||||||
@@ -37,15 +40,28 @@ under `src/commands/`. Each source file has one job.
|
|||||||
`cmd_history`), `edit`, `trash` (rm / restore / purge / trash empty),
|
`cmd_history`), `edit`, `trash` (rm / restore / purge / trash empty),
|
||||||
`backup` (export / restore), `import` (lastpass), `attach` (attach /
|
`backup` (export / restore), `import` (lastpass), `attach` (attach /
|
||||||
attachments / extract / detach), `generate`, `settings`, `sync`, `status`,
|
attachments / extract / detach), `generate`, `settings`, `sync`, `status`,
|
||||||
`rate`, `device`, `recovery_qr`. `add` and `edit` each fan out internally to
|
`rate`, `device`, `recovery_qr`. `add` and `edit` resolve their non-secret
|
||||||
per-`ItemCore` helpers (`build_<type>_item`, `edit_<type>`) so each
|
fields then delegate to the shared `item_build` module's per-`ItemCore`
|
||||||
builder/editor reads top-to-bottom and can be tested through the same
|
`build_*` / `edit_*` helpers (see the next bullet), so each builder/editor
|
||||||
integration paths.
|
reads top-to-bottom and can be tested through the same integration paths.
|
||||||
|
|
||||||
|
- **`src/commands/item_build.rs`** — shared per-type item construction and
|
||||||
|
interactive editing used by BOTH personal (`add.rs`, `edit.rs`) and org
|
||||||
|
(`org.rs`) handlers, so the two surfaces cannot drift. Contains: secret
|
||||||
|
resolution (`resolve_secret_line` — reads one line from stdin or falls back
|
||||||
|
to an interactive masked prompt; `resolve_secret_multiline` — reads stdin to
|
||||||
|
EOF, printing an optional hint in the interactive case); type parsers
|
||||||
|
(`parse_card_kind`, `parse_totp_algorithm`); the seven `build_*` builders
|
||||||
|
(`build_login`, `build_secure_note`, `build_identity`, `build_card`,
|
||||||
|
`build_key`, `build_document`, `build_totp`); per-type `edit_*` helpers
|
||||||
|
(`edit_login`, `edit_secure_note`, `edit_card`, `edit_key`, `edit_totp`,
|
||||||
|
`edit_identity`, `edit_document_message`); and `push_history`.
|
||||||
|
|
||||||
- **`src/prompt.rs`** — interactive prompt primitives shared across commands:
|
- **`src/prompt.rs`** — interactive prompt primitives shared across commands:
|
||||||
`prompt`, `prompt_optional`, `prompt_keep`, `prompt_keep_opt`,
|
`prompt_keep`, `prompt_keep_opt`, `prompt_yesno`, `prompt_secret`, and the
|
||||||
`prompt_yesno`, `prompt_secret`. `prompt_secret` honours
|
flag-or-prompt pair `prompt_or_flag` / `prompt_or_flag_optional`.
|
||||||
`RELICARIO_TEST_ITEM_SECRET` before falling back to `rpassword`.
|
`prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET` before falling back to
|
||||||
|
`rpassword`.
|
||||||
|
|
||||||
- **`src/parse.rs`** — pure parsers for CLI-typed inputs (e.g. MonthYear
|
- **`src/parse.rs`** — pure parsers for CLI-typed inputs (e.g. MonthYear
|
||||||
expiries, TOTP `otpauth://` URIs, comma-separated tag lists). No I/O.
|
expiries, TOTP `otpauth://` URIs, comma-separated tag lists). No I/O.
|
||||||
@@ -81,7 +97,14 @@ under `src/commands/`. Each source file has one job.
|
|||||||
(`items/<collection-slug>/<id>.enc` — the leading slug is what the pre-receive
|
(`items/<collection-slug>/<id>.enc` — the leading slug is what the pre-receive
|
||||||
hook authorizes against, never decrypting), fingerprint-based member matching
|
hook authorizes against, never decrypting), fingerprint-based member matching
|
||||||
(`relicario_core::fingerprint`, tolerant of OpenSSH whitespace/comment
|
(`relicario_core::fingerprint`, tolerant of OpenSSH whitespace/comment
|
||||||
differences), `atomic_write`, and `org_git_run`. Note `org_git_run` runs
|
differences), `atomic_write`, and `org_git_run`. As of v0.8.1 it also owns
|
||||||
|
**collection-scoped attachment storage** — `attachment_path` /
|
||||||
|
`save_attachment` / `load_attachment` / `remove_item_attachments`
|
||||||
|
(`org_session.rs:125-157`) at layout
|
||||||
|
`attachments/<collection-slug>/<item-id>/<att-id>.enc` (the same leading slug
|
||||||
|
the pre-receive hook authorizes against as for `item_path`), capped
|
||||||
|
per-attachment by `DEFAULT_ORG_ATTACHMENT_MAX_BYTES` (10 MiB,
|
||||||
|
`org_session.rs:20`). Note `org_git_run` runs
|
||||||
**bare git** — unlike `helpers::git_run` it does NOT inject
|
**bare git** — unlike `helpers::git_run` it does NOT inject
|
||||||
`commit.gpgsign=false`, because org commits MUST be signed (the hook verifies
|
`commit.gpgsign=false`, because org commits MUST be signed (the hook verifies
|
||||||
every commit's signature); signing config is established by
|
every commit's signature); signing config is established by
|
||||||
@@ -98,19 +121,38 @@ under `src/commands/`. Each source file has one job.
|
|||||||
concurrent-rotation abort), `transfer-ownership`, `delete-org`, `status` /
|
concurrent-rotation abort), `transfer-ownership`, `delete-org`, `status` /
|
||||||
`audit` (verified-signer attribution + `TAMPERED` flag).
|
`audit` (verified-signer attribution + `TAMPERED` flag).
|
||||||
|
|
||||||
*Item CRUD (7):* `org add` creates typed items via `OrgAddKind`
|
*Item CRUD (7):* full item-type parity with the personal vault (v0.8.1).
|
||||||
(`commands/org.rs:749`) — **Login / SecureNote / Identity only**; Card /
|
`org add` creates **all seven types** (Login / SecureNote / Identity / Card /
|
||||||
SshKey / Document / Totp creation is a deferred follow-up. `get` / `list` can
|
Key / Document / Totp) via `OrgAddKind` (`commands/org.rs:751`); each arm
|
||||||
display any item type if present. `org get <query> [--show]` masks secrets
|
delegates to the shared `item_build::build_*` builders through `build_org_item`
|
||||||
unless `--show`; `org list [--trashed]` filters by the caller's collection
|
(`commands/org.rs:799`), and `run_add` (`commands/org.rs:823`) sets tags
|
||||||
grants; `org edit <query>` is flag-driven (blank flags keep current values);
|
post-build. Document is special-cased in `run_add` (`commands/org.rs:839`): its
|
||||||
`org rm` soft-deletes, `org restore` undoes, `org purge` permanently removes
|
builder also yields an `EncryptedAttachment` that is written via
|
||||||
the encrypted blob. All item ops are collection-scoped and grant-enforced. The
|
`save_attachment` and git-staged before the signed commit. Single-line secrets
|
||||||
audit trail emits `item-create` / `item-update` / `item-delete` /
|
(card number/CVV/PIN, TOTP secret, login password) accept a `--*-stdin` flag;
|
||||||
`item-restore` / `item-purge`.
|
multiline secrets (Key material, SecureNote body) read stdin to EOF — the same
|
||||||
|
`resolve_secret_line` / `resolve_secret_multiline` convention as personal `add`
|
||||||
|
(`commands/item_build.rs`).
|
||||||
|
|
||||||
Deferred: Card / SshKey / Document / Totp `org add` / `edit` parity;
|
`org edit <query>` (`run_edit`, `commands/org.rs:1004`) is **interactive
|
||||||
extension org reads and writes (Dev-D).
|
per-type** as of v0.8.1 (it was flag-driven before): it prompts Title, then
|
||||||
|
dispatches on `&mut item.core` to the shared `item_build::edit_*` helpers
|
||||||
|
("blank keeps current", field-history capture via `push_history`), mirroring
|
||||||
|
personal `cmd_edit`. `--totp-qr` sets a Login TOTP from a QR image; `--file`
|
||||||
|
replaces a Document's primary attachment (`commands/org.rs:1039`, rejected for
|
||||||
|
non-Document items at `commands/org.rs:1018`). The edit commit carries
|
||||||
|
`Relicario-Action: item-update`.
|
||||||
|
|
||||||
|
`org get <query> [--show]` masks every secret unless `--show`; `org list
|
||||||
|
[--trashed]` filters by the caller's collection grants; `org rm` soft-deletes,
|
||||||
|
`org restore` undoes, `org purge` (`run_purge`, `commands/org.rs:1164`)
|
||||||
|
permanently removes the encrypted blob **and** the item's attachment directory
|
||||||
|
(`remove_item_attachments`, `commands/org.rs:1173`). All item ops are
|
||||||
|
collection-scoped and grant-enforced (`filter_for_member` over the manifest +
|
||||||
|
`ensure_grant` before any load/mutate). The audit trail emits `item-create` /
|
||||||
|
`item-update` / `item-delete` / `item-restore` / `item-purge`.
|
||||||
|
|
||||||
|
Deferred: extension org reads and writes (Plan B-2 / phase 2).
|
||||||
|
|
||||||
- **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing:
|
- **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing:
|
||||||
`find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories
|
`find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories
|
||||||
@@ -167,7 +209,7 @@ in code; cite the line if you change it.
|
|||||||
works without any setup.
|
works without any setup.
|
||||||
|
|
||||||
- **Item IDs are minted by core.** The CLI never constructs an `ItemId`
|
- **Item IDs are minted by core.** The CLI never constructs an `ItemId`
|
||||||
directly; `Item::new` (called inside every `build_*_item`) does it via
|
directly; `Item::new` (called inside every `item_build::build_*`) does it via
|
||||||
`relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex.
|
`relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex.
|
||||||
|
|
||||||
- **Manifest is always saved last.** Within a single command, the order is:
|
- **Manifest is always saved last.** Within a single command, the order is:
|
||||||
@@ -237,15 +279,23 @@ in code; cite the line if you change it.
|
|||||||
### Item add (`cmd_add`, `main.rs:419-456`)
|
### Item add (`cmd_add`, `main.rs:419-456`)
|
||||||
|
|
||||||
1. Unlock the vault and load the manifest.
|
1. Unlock the vault and load the manifest.
|
||||||
2. Match on the `AddKind` variant and dispatch to the matching
|
2. Match on the `AddKind` variant: resolve `title` and non-secret fields
|
||||||
`build_<type>_item` helper (`main.rs:423-438`). Seven variants → seven
|
(username, URL, holder, expiry, etc.) via `prompt_or_flag` /
|
||||||
builders; only `build_document_item` takes `&UnlockedVault` because it
|
`prompt_or_flag_optional`, then delegate to the matching `build_*` builder
|
||||||
needs `attachment_caps` and writes the encrypted blob alongside the item.
|
in `commands/item_build.rs`. Seven variants → seven builders; only
|
||||||
3. The builder returns a fully-populated `Item` (with title, group, tags,
|
`build_document` takes `&UnlockedVault` because it needs `attachment_caps`
|
||||||
|
and writes the encrypted blob alongside the item.
|
||||||
|
3. Single-line secrets (Login password, Card number/CVV/PIN, TOTP secret)
|
||||||
|
accept a `--*-stdin` flag that reads one line from stdin instead of
|
||||||
|
prompting; multiline secrets (SecureNote body, Key material) always read
|
||||||
|
stdin to EOF — `--body-stdin` / `--material-stdin` suppress the interactive
|
||||||
|
Ctrl-D hint. Secret-resolution rule: `commands/item_build.rs`
|
||||||
|
`resolve_secret_line` / `resolve_secret_multiline`.
|
||||||
|
4. The builder returns a fully-populated `Item` (with title, group, tags,
|
||||||
favorite-flag, primary attachment if any).
|
favorite-flag, primary attachment if any).
|
||||||
4. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`,
|
5. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`,
|
||||||
`vault.save_manifest(&manifest)`.
|
`vault.save_manifest(&manifest)`.
|
||||||
5. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one
|
6. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one
|
||||||
`attachments/<id>/<aid>.enc` per attachment — and call `commit_paths`
|
`attachments/<id>/<aid>.enc` per attachment — and call `commit_paths`
|
||||||
with message `add: <title> (<id>)` (`main.rs:444-452`).
|
with message `add: <title> (<id>)` (`main.rs:444-452`).
|
||||||
|
|
||||||
@@ -578,11 +628,12 @@ applies to `relicario-core` unit tests, not these CLI integration tests.
|
|||||||
instead. Non-primary attachments on a Document (e.g., a scanned
|
instead. Non-primary attachments on a Document (e.g., a scanned
|
||||||
contract with an addendum) detach normally.
|
contract with an addendum) detach normally.
|
||||||
|
|
||||||
- **Per-type `build_*_item` / `edit_*` helpers exist by design after the
|
- **Per-type `build_*` / `edit_*` helpers exist by design** (extracted in the
|
||||||
`3f0f5b1` refactor.** Before the refactor, `cmd_add` and `cmd_edit`
|
`3f0f5b1` refactor, then centralized in `item_build.rs` for v0.8.1 so the
|
||||||
carried 217-line `match` arms. The split-out functions are easier to
|
personal and org surfaces share one set). Before the extraction, `cmd_add`
|
||||||
read, easier to test individually (the existing integration tests still
|
and `cmd_edit` carried 217-line `match` arms. The split-out functions are
|
||||||
drive them through the same paths), and easier to grow when a new
|
easier to read, easier to test individually (the existing integration tests
|
||||||
|
still drive them through the same paths), and easier to grow when a new
|
||||||
`ItemCore` variant lands. Keep this shape — don't fold them back.
|
`ItemCore` variant lands. Keep this shape — don't fold them back.
|
||||||
|
|
||||||
- **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three
|
- **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "relicario-cli"
|
name = "relicario-cli"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "CLI for relicario password manager"
|
description = "CLI for relicario password manager"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|||||||
@@ -1,37 +1,76 @@
|
|||||||
//! `relicario add <kind>` — create a new item of the given type.
|
//! `relicario add <kind>` — create a new item of the given type.
|
||||||
//!
|
//!
|
||||||
//! `cmd_add` does the common save / manifest upsert / commit dance. The seven
|
//! `cmd_add` resolves `title` / non-secret prompts, then delegates to the
|
||||||
//! per-type `build_*_item` helpers each return a fully-populated `Item`. The
|
//! shared builders in `commands/item_build.rs`. Group / tags / favorite are
|
||||||
//! `Document` builder is the only one that needs the unlocked vault (for the
|
//! set AFTER the build so the builders stay portable to the org vault.
|
||||||
//! attachment-cap settings + writing the encrypted blob alongside the item).
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use anyhow::Result;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
|
|
||||||
use crate::AddKind;
|
use crate::AddKind;
|
||||||
use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year};
|
use crate::commands::item_build as ib;
|
||||||
use crate::prompt::{prompt, prompt_optional, prompt_or_flag, prompt_or_flag_optional, prompt_secret};
|
use crate::prompt::{prompt_or_flag, prompt_or_flag_optional};
|
||||||
|
|
||||||
pub fn cmd_add(kind: AddKind) -> Result<()> {
|
pub fn cmd_add(kind: AddKind) -> Result<()> {
|
||||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
let mut manifest = vault.load_manifest()?;
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
|
||||||
let item = match kind {
|
let item = match kind {
|
||||||
AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } =>
|
AddKind::Login { title, username, url, password_prompt, password, password_stdin, group, tags, favorite, totp_qr } => {
|
||||||
build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?,
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
AddKind::SecureNote { title, body_prompt, group, tags } =>
|
let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?;
|
||||||
build_secure_note_item(title, body_prompt, group, tags)?,
|
let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?;
|
||||||
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } =>
|
let mut item = ib::build_login(title, username, url, password, password_stdin, password_prompt, totp_qr)?;
|
||||||
build_identity_item(title, full_name, email, phone, date_of_birth, group, tags)?,
|
item.group = group; item.tags = tags; item.favorite = favorite;
|
||||||
AddKind::Card { title, holder, expiry, kind, group, tags } =>
|
item
|
||||||
build_card_item(title, holder, expiry, kind, group, tags)?,
|
}
|
||||||
AddKind::Key { title, label, algorithm, group, tags } =>
|
AddKind::SecureNote { title, body_stdin, group, tags } => {
|
||||||
build_key_item(title, label, algorithm, group, tags)?,
|
// Per the v0.8.1 spec's unified secret model, a note body is a
|
||||||
AddKind::Document { title, file, group, tags } =>
|
// multiline secret that always reads stdin to EOF. `body_stdin=false`
|
||||||
build_document_item(&vault, title, file, group, tags)?,
|
// means "print the Ctrl-D hint" (interactive default); `true` suppresses
|
||||||
AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } =>
|
// the hint for non-interactive use.
|
||||||
build_totp_item(title, issuer, label, secret, period, digits, algorithm, group, tags)?,
|
// Secret-resolution rule: `commands/item_build.rs` `resolve_secret_multiline`.
|
||||||
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
|
let mut item = ib::build_secure_note(title, None, body_stdin)?;
|
||||||
|
item.group = group; item.tags = tags;
|
||||||
|
item
|
||||||
|
}
|
||||||
|
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } => {
|
||||||
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
|
let mut item = ib::build_identity(title, full_name, email, phone, date_of_birth)?;
|
||||||
|
item.group = group; item.tags = tags;
|
||||||
|
item
|
||||||
|
}
|
||||||
|
AddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin, group, tags } => {
|
||||||
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
|
let mut item = ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin)?;
|
||||||
|
item.group = group; item.tags = tags;
|
||||||
|
item
|
||||||
|
}
|
||||||
|
AddKind::Key { title, label, algorithm, material_stdin, group, tags } => {
|
||||||
|
// public_key is None for the personal vault: the legacy `prompt_optional`
|
||||||
|
// for it was unreachable (stdin already at EOF after the key-material read).
|
||||||
|
// Org `add key` (Dev-B) supplies it via --public-key.
|
||||||
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
|
let mut item = ib::build_key(title, label, algorithm, None, material_stdin)?;
|
||||||
|
item.group = group; item.tags = tags;
|
||||||
|
item
|
||||||
|
}
|
||||||
|
AddKind::Document { title, file, group, tags } => {
|
||||||
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
|
let caps = vault.load_settings()?.attachment_caps;
|
||||||
|
let (mut item, enc) = ib::build_document(title, file, vault.key(), caps.per_attachment_max_bytes)?;
|
||||||
|
item.group = group; item.tags = tags;
|
||||||
|
let att_dir = vault.root().join("attachments").join(item.id.as_str());
|
||||||
|
std::fs::create_dir_all(&att_dir)?;
|
||||||
|
std::fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;
|
||||||
|
item
|
||||||
|
}
|
||||||
|
AddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm, group, tags } => {
|
||||||
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
|
let mut item = ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm)?;
|
||||||
|
item.group = group; item.tags = tags;
|
||||||
|
item
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
vault.save_item(&item)?;
|
vault.save_item(&item)?;
|
||||||
@@ -51,263 +90,3 @@ pub fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
eprintln!("Added: {} (id={})", item.title, item.id.as_str());
|
eprintln!("Added: {} (id={})", item.title, item.id.as_str());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn build_login_item(
|
|
||||||
title: Option<String>,
|
|
||||||
username: Option<String>,
|
|
||||||
url: Option<String>,
|
|
||||||
password_prompt: bool,
|
|
||||||
password: Option<String>,
|
|
||||||
group: Option<String>,
|
|
||||||
tags: Vec<String>,
|
|
||||||
favorite: bool,
|
|
||||||
totp_qr: Option<PathBuf>,
|
|
||||||
) -> Result<relicario_core::Item> {
|
|
||||||
use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind};
|
|
||||||
use relicario_core::{Item, ItemCore};
|
|
||||||
use zeroize::Zeroizing;
|
|
||||||
|
|
||||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
|
||||||
let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?;
|
|
||||||
let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?;
|
|
||||||
let parsed_url = match url {
|
|
||||||
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
let password = if let Some(p) = password {
|
|
||||||
Some(Zeroizing::new(p))
|
|
||||||
} else if password_prompt {
|
|
||||||
Some(Zeroizing::new(prompt_secret("Password: ")?))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let totp = if let Some(path) = totp_qr {
|
|
||||||
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
|
||||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
|
||||||
Some(TotpConfig {
|
|
||||||
secret: Zeroizing::new(secret_bytes),
|
|
||||||
algorithm: TotpAlgorithm::Sha1,
|
|
||||||
digits: 6,
|
|
||||||
period_seconds: 30,
|
|
||||||
kind: TotpKind::Totp,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let mut item = Item::new(title, ItemCore::Login(LoginCore {
|
|
||||||
username, password, url: parsed_url, totp,
|
|
||||||
}));
|
|
||||||
item.group = group;
|
|
||||||
item.tags = tags;
|
|
||||||
item.favorite = favorite;
|
|
||||||
Ok(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_secure_note_item(
|
|
||||||
title: Option<String>,
|
|
||||||
body_prompt: bool,
|
|
||||||
group: Option<String>,
|
|
||||||
tags: Vec<String>,
|
|
||||||
) -> Result<relicario_core::Item> {
|
|
||||||
use relicario_core::item_types::SecureNoteCore;
|
|
||||||
use relicario_core::{Item, ItemCore};
|
|
||||||
use zeroize::Zeroizing;
|
|
||||||
|
|
||||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
|
||||||
let body = if body_prompt {
|
|
||||||
eprintln!("Enter note body; end with Ctrl-D on a blank line:");
|
|
||||||
let mut s = String::new();
|
|
||||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
|
||||||
s
|
|
||||||
} else {
|
|
||||||
prompt("Body")?
|
|
||||||
};
|
|
||||||
let mut item = Item::new(title, ItemCore::SecureNote(SecureNoteCore {
|
|
||||||
body: Zeroizing::new(body),
|
|
||||||
}));
|
|
||||||
item.group = group;
|
|
||||||
item.tags = tags;
|
|
||||||
Ok(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_identity_item(
|
|
||||||
title: Option<String>,
|
|
||||||
full_name: Option<String>,
|
|
||||||
email: Option<String>,
|
|
||||||
phone: Option<String>,
|
|
||||||
date_of_birth: Option<String>,
|
|
||||||
group: Option<String>,
|
|
||||||
tags: Vec<String>,
|
|
||||||
) -> Result<relicario_core::Item> {
|
|
||||||
use relicario_core::item_types::IdentityCore;
|
|
||||||
use relicario_core::{Item, ItemCore};
|
|
||||||
|
|
||||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
|
||||||
let dob = match date_of_birth {
|
|
||||||
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
|
|
||||||
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
let mut item = Item::new(title, ItemCore::Identity(IdentityCore {
|
|
||||||
full_name, address: None, phone, email, date_of_birth: dob,
|
|
||||||
}));
|
|
||||||
item.group = group;
|
|
||||||
item.tags = tags;
|
|
||||||
Ok(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_card_item(
|
|
||||||
title: Option<String>,
|
|
||||||
holder: Option<String>,
|
|
||||||
expiry: Option<String>,
|
|
||||||
kind: String,
|
|
||||||
group: Option<String>,
|
|
||||||
tags: Vec<String>,
|
|
||||||
) -> Result<relicario_core::Item> {
|
|
||||||
use relicario_core::item_types::{CardCore, CardKind};
|
|
||||||
use relicario_core::{Item, ItemCore};
|
|
||||||
use zeroize::Zeroizing;
|
|
||||||
|
|
||||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
|
||||||
let number = Zeroizing::new(prompt_secret("Card number: ")?);
|
|
||||||
let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
|
|
||||||
let cvv = if cvv.is_empty() { None } else { Some(cvv) };
|
|
||||||
let pin = Zeroizing::new(prompt_secret("PIN (blank to skip): ")?);
|
|
||||||
let pin = if pin.is_empty() { None } else { Some(pin) };
|
|
||||||
|
|
||||||
let parsed_expiry = match expiry {
|
|
||||||
Some(s) => Some(parse_month_year(&s)?),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
let parsed_kind = match kind.as_str() {
|
|
||||||
"credit" => CardKind::Credit,
|
|
||||||
"debit" => CardKind::Debit,
|
|
||||||
"gift" => CardKind::Gift,
|
|
||||||
"loyalty" => CardKind::Loyalty,
|
|
||||||
"other" => CardKind::Other,
|
|
||||||
other => anyhow::bail!("unknown card kind: {other}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut item = Item::new(title, ItemCore::Card(CardCore {
|
|
||||||
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parsed_kind,
|
|
||||||
}));
|
|
||||||
item.group = group;
|
|
||||||
item.tags = tags;
|
|
||||||
Ok(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_key_item(
|
|
||||||
title: Option<String>,
|
|
||||||
label: Option<String>,
|
|
||||||
algorithm: Option<String>,
|
|
||||||
group: Option<String>,
|
|
||||||
tags: Vec<String>,
|
|
||||||
) -> Result<relicario_core::Item> {
|
|
||||||
use relicario_core::item_types::KeyCore;
|
|
||||||
use relicario_core::{Item, ItemCore};
|
|
||||||
use zeroize::Zeroizing;
|
|
||||||
|
|
||||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
|
||||||
eprintln!("Paste key material; end with Ctrl-D on a blank line:");
|
|
||||||
let mut key_material = String::new();
|
|
||||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?;
|
|
||||||
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
|
|
||||||
let public_key = prompt_optional("Public key (blank to skip)")?;
|
|
||||||
|
|
||||||
let mut item = Item::new(title, ItemCore::Key(KeyCore {
|
|
||||||
key_material: Zeroizing::new(key_material),
|
|
||||||
label, public_key, algorithm,
|
|
||||||
}));
|
|
||||||
item.group = group;
|
|
||||||
item.tags = tags;
|
|
||||||
Ok(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_document_item(
|
|
||||||
vault: &crate::session::UnlockedVault,
|
|
||||||
title: Option<String>,
|
|
||||||
file: PathBuf,
|
|
||||||
group: Option<String>,
|
|
||||||
tags: Vec<String>,
|
|
||||||
) -> Result<relicario_core::Item> {
|
|
||||||
use relicario_core::item_types::DocumentCore;
|
|
||||||
use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore};
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
|
||||||
let bytes = fs::read(&file)
|
|
||||||
.with_context(|| format!("failed to read {}", file.display()))?;
|
|
||||||
let caps = vault.load_settings()?.attachment_caps;
|
|
||||||
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
|
|
||||||
|
|
||||||
let filename = file.file_name()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
|
|
||||||
.to_string_lossy()
|
|
||||||
.into_owned();
|
|
||||||
let mime_type = guess_mime(&filename);
|
|
||||||
|
|
||||||
let primary_attachment = enc.id.clone();
|
|
||||||
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
|
|
||||||
filename: filename.clone(),
|
|
||||||
mime_type: mime_type.clone(),
|
|
||||||
primary_attachment: primary_attachment.clone(),
|
|
||||||
}));
|
|
||||||
item.group = group;
|
|
||||||
item.tags = tags;
|
|
||||||
item.attachments.push(AttachmentRef {
|
|
||||||
id: primary_attachment.clone(),
|
|
||||||
filename, mime_type,
|
|
||||||
size: bytes.len() as u64,
|
|
||||||
created: item.created,
|
|
||||||
});
|
|
||||||
|
|
||||||
let att_dir = vault.root().join("attachments").join(item.id.as_str());
|
|
||||||
fs::create_dir_all(&att_dir)?;
|
|
||||||
fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?;
|
|
||||||
Ok(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn build_totp_item(
|
|
||||||
title: Option<String>,
|
|
||||||
issuer: Option<String>,
|
|
||||||
label: Option<String>,
|
|
||||||
secret: Option<String>,
|
|
||||||
period: u32,
|
|
||||||
digits: u8,
|
|
||||||
algorithm: String,
|
|
||||||
group: Option<String>,
|
|
||||||
tags: Vec<String>,
|
|
||||||
) -> Result<relicario_core::Item> {
|
|
||||||
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind};
|
|
||||||
use relicario_core::{Item, ItemCore};
|
|
||||||
use zeroize::Zeroizing;
|
|
||||||
|
|
||||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
|
||||||
let secret_b32 = match secret {
|
|
||||||
Some(s) => s,
|
|
||||||
None => prompt_secret("TOTP secret (base32): ")?,
|
|
||||||
};
|
|
||||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
|
||||||
let algo = match algorithm.to_ascii_lowercase().as_str() {
|
|
||||||
"sha1" => TotpAlgorithm::Sha1,
|
|
||||||
"sha256" => TotpAlgorithm::Sha256,
|
|
||||||
"sha512" => TotpAlgorithm::Sha512,
|
|
||||||
other => anyhow::bail!("unknown algorithm: {other}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut item = Item::new(title, ItemCore::Totp(TotpCore {
|
|
||||||
config: TotpConfig {
|
|
||||||
secret: Zeroizing::new(secret_bytes),
|
|
||||||
algorithm: algo,
|
|
||||||
digits,
|
|
||||||
period_seconds: period,
|
|
||||||
kind: TotpKind::Totp,
|
|
||||||
},
|
|
||||||
issuer, label,
|
|
||||||
}));
|
|
||||||
item.group = group;
|
|
||||||
item.tags = tags;
|
|
||||||
Ok(item)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::Result;
|
||||||
|
|
||||||
use crate::parse::base32_decode_lenient;
|
use crate::prompt::{prompt_keep, prompt_keep_opt};
|
||||||
use crate::prompt::{prompt_keep, prompt_keep_opt, prompt_secret, prompt_yesno};
|
|
||||||
|
|
||||||
pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
||||||
use relicario_core::time::now_unix;
|
use relicario_core::time::now_unix;
|
||||||
@@ -29,13 +28,13 @@ pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
|||||||
|
|
||||||
let history = &mut item.field_history;
|
let history = &mut item.field_history;
|
||||||
match &mut item.core {
|
match &mut item.core {
|
||||||
ItemCore::Login(l) => edit_login(l, history, totp_qr)?,
|
ItemCore::Login(l) => crate::commands::item_build::edit_login(l, history, totp_qr)?,
|
||||||
ItemCore::SecureNote(n) => edit_secure_note(n, history)?,
|
ItemCore::SecureNote(n) => crate::commands::item_build::edit_secure_note(n, history)?,
|
||||||
ItemCore::Identity(i) => edit_identity(i)?,
|
ItemCore::Identity(i) => crate::commands::item_build::edit_identity(i)?,
|
||||||
ItemCore::Card(c) => edit_card(c, history)?,
|
ItemCore::Card(c) => crate::commands::item_build::edit_card(c, history)?,
|
||||||
ItemCore::Key(k) => edit_key(k, history)?,
|
ItemCore::Key(k) => crate::commands::item_build::edit_key(k, history)?,
|
||||||
ItemCore::Document(_) => edit_document_message(),
|
ItemCore::Document(_) => crate::commands::item_build::edit_document_message(),
|
||||||
ItemCore::Totp(t) => edit_totp(t, history)?,
|
ItemCore::Totp(t) => crate::commands::item_build::edit_totp(t, history)?,
|
||||||
}
|
}
|
||||||
|
|
||||||
item.modified = now_unix();
|
item.modified = now_unix();
|
||||||
@@ -47,125 +46,3 @@ pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
|||||||
eprintln!("Updated {}", item.id.as_str());
|
eprintln!("Updated {}", item.id.as_str());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Per-type edit handlers. Each mutates its core slice in place; the ones
|
|
||||||
// that touch history-tracked fields take the item's field_history map so
|
|
||||||
// they can record the prior value alongside the change.
|
|
||||||
|
|
||||||
type FieldHistory = std::collections::HashMap<
|
|
||||||
relicario_core::FieldId,
|
|
||||||
Vec<relicario_core::item::FieldHistoryEntry>,
|
|
||||||
>;
|
|
||||||
|
|
||||||
fn edit_login(
|
|
||||||
l: &mut relicario_core::item_types::LoginCore,
|
|
||||||
history: &mut FieldHistory,
|
|
||||||
totp_qr: Option<PathBuf>,
|
|
||||||
) -> Result<()> {
|
|
||||||
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind};
|
|
||||||
use zeroize::Zeroizing;
|
|
||||||
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
|
|
||||||
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
|
|
||||||
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
|
|
||||||
}
|
|
||||||
if prompt_yesno("Change password?")? {
|
|
||||||
let old = l.password.clone();
|
|
||||||
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
|
|
||||||
if let Some(old_pw) = old {
|
|
||||||
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(path) = totp_qr {
|
|
||||||
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
|
||||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
|
||||||
l.totp = Some(TotpConfig {
|
|
||||||
secret: Zeroizing::new(secret_bytes),
|
|
||||||
algorithm: TotpAlgorithm::Sha1,
|
|
||||||
digits: 6,
|
|
||||||
period_seconds: 30,
|
|
||||||
kind: TotpKind::Totp,
|
|
||||||
});
|
|
||||||
eprintln!("TOTP secret set from QR image.");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
|
|
||||||
use zeroize::Zeroizing;
|
|
||||||
if prompt_yesno("Edit body?")? {
|
|
||||||
let old = n.body.clone();
|
|
||||||
eprintln!("Enter new body; end with Ctrl-D:");
|
|
||||||
let mut s = String::new();
|
|
||||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
|
||||||
n.body = Zeroizing::new(s);
|
|
||||||
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
|
|
||||||
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
|
|
||||||
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
|
|
||||||
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
|
|
||||||
use zeroize::Zeroizing;
|
|
||||||
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
|
|
||||||
if prompt_yesno("Change card number?")? {
|
|
||||||
let old = c.number.clone();
|
|
||||||
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
|
|
||||||
if let Some(o) = old {
|
|
||||||
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
|
|
||||||
use zeroize::Zeroizing;
|
|
||||||
if prompt_yesno("Replace key material?")? {
|
|
||||||
eprintln!("Paste new key material; end with Ctrl-D:");
|
|
||||||
let mut s = String::new();
|
|
||||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
|
||||||
let old = k.key_material.clone();
|
|
||||||
k.key_material = Zeroizing::new(s);
|
|
||||||
push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn edit_document_message() {
|
|
||||||
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
|
|
||||||
use zeroize::Zeroizing;
|
|
||||||
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
|
|
||||||
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
|
|
||||||
if prompt_yesno("Change TOTP secret?")? {
|
|
||||||
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
|
|
||||||
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
|
|
||||||
let new_bytes = base32_decode_lenient(&new_b32)?;
|
|
||||||
t.config.secret = Zeroizing::new(new_bytes);
|
|
||||||
push_history(history, "totp_secret", Zeroizing::new(old_b32));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_history(
|
|
||||||
history: &mut std::collections::HashMap<relicario_core::FieldId, Vec<relicario_core::item::FieldHistoryEntry>>,
|
|
||||||
synthetic_key: &str,
|
|
||||||
old_value: zeroize::Zeroizing<String>,
|
|
||||||
) {
|
|
||||||
use relicario_core::item::FieldHistoryEntry;
|
|
||||||
use relicario_core::time::now_unix;
|
|
||||||
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
|
|
||||||
// custom-field UUIDs can't collide).
|
|
||||||
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
|
|
||||||
history.entry(fid).or_default().push(FieldHistoryEntry {
|
|
||||||
value: old_value,
|
|
||||||
replaced_at: now_unix(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
334
crates/relicario-cli/src/commands/item_build.rs
Normal file
334
crates/relicario-cli/src/commands/item_build.rs
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
//! Shared per-type item construction + interactive editing for both the
|
||||||
|
//! personal vault (`commands/add.rs`, `commands/edit.rs`) and the org vault
|
||||||
|
//! (`commands/org.rs`). Centralizing it keeps the two surfaces from drifting.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use relicario_core::item::FieldHistoryEntry;
|
||||||
|
use relicario_core::item_types::{CardKind, TotpAlgorithm};
|
||||||
|
use relicario_core::time::now_unix;
|
||||||
|
use relicario_core::{EncryptedAttachment, FieldId, Item, ItemCore};
|
||||||
|
|
||||||
|
use crate::parse::base32_decode_lenient;
|
||||||
|
use crate::prompt::{prompt_keep_opt, prompt_secret, prompt_yesno};
|
||||||
|
|
||||||
|
pub(crate) type FieldHistory = HashMap<FieldId, Vec<FieldHistoryEntry>>;
|
||||||
|
|
||||||
|
/// Resolve a single-line secret: from stdin when `from_stdin`, else an
|
||||||
|
/// interactive masked prompt (which honours `RELICARIO_TEST_ITEM_SECRET`).
|
||||||
|
pub(crate) fn resolve_secret_line(from_stdin: bool, label: &str) -> Result<String> {
|
||||||
|
if from_stdin {
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::stdin().read_line(&mut s)?;
|
||||||
|
Ok(s.trim_end_matches(['\n', '\r']).to_string())
|
||||||
|
} else {
|
||||||
|
crate::prompt::prompt_secret(&format!("{label}: "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a multiline secret (key material, note body). Both paths read stdin
|
||||||
|
/// to EOF; the interactive path first prints `hint` to stderr.
|
||||||
|
pub(crate) fn resolve_secret_multiline(from_stdin: bool, hint: &str) -> Result<String> {
|
||||||
|
if !from_stdin {
|
||||||
|
eprintln!("{hint}");
|
||||||
|
}
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_card_kind(s: &str) -> Result<CardKind> {
|
||||||
|
Ok(match s {
|
||||||
|
"credit" => CardKind::Credit,
|
||||||
|
"debit" => CardKind::Debit,
|
||||||
|
"gift" => CardKind::Gift,
|
||||||
|
"loyalty" => CardKind::Loyalty,
|
||||||
|
"other" => CardKind::Other,
|
||||||
|
other => anyhow::bail!("unknown card kind: {other}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_totp_algorithm(s: &str) -> Result<TotpAlgorithm> {
|
||||||
|
Ok(match s.to_ascii_lowercase().as_str() {
|
||||||
|
"sha1" => TotpAlgorithm::Sha1,
|
||||||
|
"sha256" => TotpAlgorithm::Sha256,
|
||||||
|
"sha512" => TotpAlgorithm::Sha512,
|
||||||
|
other => anyhow::bail!("unknown algorithm: {other}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-type interactive edit helpers (moved from commands/edit.rs). Each
|
||||||
|
// mutates its core slice in place; history-tracked variants take the
|
||||||
|
// item's field_history map so they can record the prior value.
|
||||||
|
|
||||||
|
pub(crate) fn edit_login(
|
||||||
|
l: &mut relicario_core::item_types::LoginCore,
|
||||||
|
history: &mut FieldHistory,
|
||||||
|
totp_qr: Option<PathBuf>,
|
||||||
|
) -> Result<()> {
|
||||||
|
use relicario_core::item_types::{TotpConfig, TotpKind};
|
||||||
|
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
|
||||||
|
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
|
||||||
|
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
|
||||||
|
}
|
||||||
|
if prompt_yesno("Change password?")? {
|
||||||
|
let old = l.password.clone();
|
||||||
|
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
|
||||||
|
if let Some(old_pw) = old {
|
||||||
|
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(path) = totp_qr {
|
||||||
|
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||||
|
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||||
|
l.totp = Some(TotpConfig {
|
||||||
|
secret: Zeroizing::new(secret_bytes),
|
||||||
|
algorithm: TotpAlgorithm::Sha1,
|
||||||
|
digits: 6,
|
||||||
|
period_seconds: 30,
|
||||||
|
kind: TotpKind::Totp,
|
||||||
|
});
|
||||||
|
eprintln!("TOTP secret set from QR image.");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
|
||||||
|
if prompt_yesno("Edit body?")? {
|
||||||
|
let old = n.body.clone();
|
||||||
|
let s = resolve_secret_multiline(false, "Enter new body; end with Ctrl-D:")?;
|
||||||
|
n.body = Zeroizing::new(s);
|
||||||
|
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
|
||||||
|
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
|
||||||
|
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
|
||||||
|
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
|
||||||
|
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
|
||||||
|
if prompt_yesno("Change card number?")? {
|
||||||
|
let old = c.number.clone();
|
||||||
|
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
|
||||||
|
if let Some(o) = old {
|
||||||
|
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
|
||||||
|
if prompt_yesno("Replace key material?")? {
|
||||||
|
let s = resolve_secret_multiline(false, "Paste new key material; end with Ctrl-D:")?;
|
||||||
|
let old = k.key_material.clone();
|
||||||
|
k.key_material = Zeroizing::new(s);
|
||||||
|
push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn edit_document_message() {
|
||||||
|
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
|
||||||
|
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
|
||||||
|
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
|
||||||
|
if prompt_yesno("Change TOTP secret?")? {
|
||||||
|
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
|
||||||
|
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
|
||||||
|
let new_bytes = base32_decode_lenient(&new_b32)?;
|
||||||
|
t.config.secret = Zeroizing::new(new_bytes);
|
||||||
|
push_history(history, "totp_secret", Zeroizing::new(old_b32));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_login(
|
||||||
|
title: String, username: Option<String>, url: Option<String>,
|
||||||
|
password: Option<String>, password_stdin: bool, password_prompt: bool,
|
||||||
|
totp_qr: Option<PathBuf>,
|
||||||
|
) -> Result<Item> {
|
||||||
|
use relicario_core::item_types::{LoginCore, TotpConfig, TotpKind};
|
||||||
|
let parsed_url = match url {
|
||||||
|
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let password = if let Some(p) = password {
|
||||||
|
Some(Zeroizing::new(p))
|
||||||
|
} else if password_stdin {
|
||||||
|
Some(Zeroizing::new(resolve_secret_line(password_stdin, "Password")?))
|
||||||
|
} else if password_prompt {
|
||||||
|
Some(Zeroizing::new(crate::prompt::prompt_secret("Password: ")?))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let totp = if let Some(path) = totp_qr {
|
||||||
|
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||||
|
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||||
|
Some(TotpConfig {
|
||||||
|
secret: Zeroizing::new(secret_bytes), algorithm: TotpAlgorithm::Sha1,
|
||||||
|
digits: 6, period_seconds: 30, kind: TotpKind::Totp,
|
||||||
|
})
|
||||||
|
} else { None };
|
||||||
|
Ok(Item::new(title, ItemCore::Login(LoginCore { username, password, url: parsed_url, totp })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_secure_note(title: String, body: Option<String>, body_stdin: bool) -> Result<Item> {
|
||||||
|
use relicario_core::item_types::SecureNoteCore;
|
||||||
|
let body = match body {
|
||||||
|
Some(b) => b,
|
||||||
|
None => resolve_secret_multiline(body_stdin, "Enter note body; end with Ctrl-D on a blank line:")?,
|
||||||
|
};
|
||||||
|
Ok(Item::new(title, ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new(body) })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_identity(
|
||||||
|
title: String, full_name: Option<String>, email: Option<String>,
|
||||||
|
phone: Option<String>, date_of_birth: Option<String>,
|
||||||
|
) -> Result<Item> {
|
||||||
|
use relicario_core::item_types::IdentityCore;
|
||||||
|
let dob = match date_of_birth {
|
||||||
|
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
|
||||||
|
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(Item::new(title, ItemCore::Identity(IdentityCore {
|
||||||
|
full_name, address: None, phone, email, date_of_birth: dob,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_card(
|
||||||
|
title: String, holder: Option<String>, expiry: Option<String>, kind: &str,
|
||||||
|
number_stdin: bool, cvv_stdin: bool, pin_stdin: bool,
|
||||||
|
) -> Result<Item> {
|
||||||
|
use relicario_core::item_types::CardCore;
|
||||||
|
let number = Zeroizing::new(resolve_secret_line(number_stdin, "Card number")?);
|
||||||
|
let cvv = resolve_secret_line(cvv_stdin, "CVV (blank to skip)")?;
|
||||||
|
let cvv = if cvv.is_empty() { None } else { Some(Zeroizing::new(cvv)) };
|
||||||
|
let pin = resolve_secret_line(pin_stdin, "PIN (blank to skip)")?;
|
||||||
|
let pin = if pin.is_empty() { None } else { Some(Zeroizing::new(pin)) };
|
||||||
|
let parsed_expiry = match expiry { Some(s) => Some(crate::parse::parse_month_year(&s)?), None => None };
|
||||||
|
Ok(Item::new(title, ItemCore::Card(CardCore {
|
||||||
|
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parse_card_kind(kind)?,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_key(
|
||||||
|
title: String, label: Option<String>, algorithm: Option<String>,
|
||||||
|
public_key: Option<String>, material_stdin: bool,
|
||||||
|
) -> Result<Item> {
|
||||||
|
use relicario_core::item_types::KeyCore;
|
||||||
|
let key_material = resolve_secret_multiline(material_stdin, "Paste key material; end with Ctrl-D on a blank line:")?;
|
||||||
|
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
|
||||||
|
Ok(Item::new(title, ItemCore::Key(KeyCore {
|
||||||
|
key_material: Zeroizing::new(key_material), label, public_key, algorithm,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) fn build_totp(
|
||||||
|
title: String, issuer: Option<String>, label: Option<String>,
|
||||||
|
secret: Option<String>, secret_stdin: bool, period: u32, digits: u8, algorithm: &str,
|
||||||
|
) -> Result<Item> {
|
||||||
|
use relicario_core::item_types::{TotpConfig, TotpCore, TotpKind};
|
||||||
|
let secret_b32 = match secret {
|
||||||
|
Some(s) => s,
|
||||||
|
None => resolve_secret_line(secret_stdin, "TOTP secret (base32)")?,
|
||||||
|
};
|
||||||
|
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||||
|
Ok(Item::new(title, ItemCore::Totp(TotpCore {
|
||||||
|
config: TotpConfig {
|
||||||
|
secret: Zeroizing::new(secret_bytes), algorithm: parse_totp_algorithm(algorithm)?,
|
||||||
|
digits, period_seconds: period, kind: TotpKind::Totp,
|
||||||
|
},
|
||||||
|
issuer, label,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a file and encrypt it as an attachment under `key`, deriving its display
|
||||||
|
/// metadata. The plaintext is held in a `Zeroizing` buffer so it is wiped after
|
||||||
|
/// encryption. Returns the encrypted blob plus (filename, mime_type, size).
|
||||||
|
pub(crate) fn encrypt_document_file(
|
||||||
|
path: &Path,
|
||||||
|
key: &Zeroizing<[u8; 32]>,
|
||||||
|
max_bytes: u64,
|
||||||
|
) -> Result<(EncryptedAttachment, String, String, u64)> {
|
||||||
|
use relicario_core::encrypt_attachment;
|
||||||
|
let bytes = Zeroizing::new(
|
||||||
|
std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?,
|
||||||
|
);
|
||||||
|
let enc = encrypt_attachment(&bytes, key, max_bytes)?;
|
||||||
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", path.display()))?
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
let mime_type = crate::parse::guess_mime(&filename);
|
||||||
|
Ok((enc, filename, mime_type, bytes.len() as u64))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_document(
|
||||||
|
title: String, file: PathBuf, key: &Zeroizing<[u8; 32]>, max_bytes: u64,
|
||||||
|
) -> Result<(Item, EncryptedAttachment)> {
|
||||||
|
use relicario_core::item_types::DocumentCore;
|
||||||
|
use relicario_core::AttachmentRef;
|
||||||
|
let (enc, filename, mime_type, size) = encrypt_document_file(&file, key, max_bytes)?;
|
||||||
|
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
|
||||||
|
filename: filename.clone(), mime_type: mime_type.clone(), primary_attachment: enc.id.clone(),
|
||||||
|
}));
|
||||||
|
item.attachments.push(AttachmentRef {
|
||||||
|
id: enc.id.clone(), filename, mime_type, size, created: item.created,
|
||||||
|
});
|
||||||
|
Ok((item, enc))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn push_history(
|
||||||
|
history: &mut FieldHistory,
|
||||||
|
synthetic_key: &str,
|
||||||
|
old_value: zeroize::Zeroizing<String>,
|
||||||
|
) {
|
||||||
|
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
|
||||||
|
// custom-field UUIDs can't collide).
|
||||||
|
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
|
||||||
|
history.entry(fid).or_default().push(FieldHistoryEntry {
|
||||||
|
value: old_value,
|
||||||
|
replaced_at: now_unix(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use relicario_core::item_types::{CardKind, TotpAlgorithm};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_kind_parses_known_values() {
|
||||||
|
assert_eq!(parse_card_kind("credit").unwrap(), CardKind::Credit);
|
||||||
|
assert_eq!(parse_card_kind("loyalty").unwrap(), CardKind::Loyalty);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_kind_rejects_unknown() {
|
||||||
|
assert!(parse_card_kind("platinum").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn totp_algorithm_is_case_insensitive() {
|
||||||
|
assert_eq!(parse_totp_algorithm("SHA256").unwrap(), TotpAlgorithm::Sha256);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn totp_algorithm_rejects_unknown() {
|
||||||
|
assert!(parse_totp_algorithm("md5").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ pub mod edit;
|
|||||||
pub mod generate;
|
pub mod generate;
|
||||||
pub mod get;
|
pub mod get;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
|
pub mod item_build;
|
||||||
pub mod org;
|
pub mod org;
|
||||||
pub mod init;
|
pub mod init;
|
||||||
pub mod list;
|
pub mod list;
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ use std::path::Path;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use relicario_core::{
|
use relicario_core::{
|
||||||
generate_org_key, wrap_org_key,
|
generate_org_key, wrap_org_key,
|
||||||
CollectionDef, Item, ItemCore, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta,
|
CollectionDef, Item, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta,
|
||||||
OrgRole, OrgMember,
|
OrgRole, OrgMember,
|
||||||
encrypt_org_manifest,
|
encrypt_org_manifest,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::org_session::atomic_write;
|
use crate::org_session::atomic_write;
|
||||||
|
use crate::commands::item_build as ib;
|
||||||
|
|
||||||
pub fn run_init(dir: &Path, name: &str) -> Result<()> {
|
pub fn run_init(dir: &Path, name: &str) -> Result<()> {
|
||||||
// Create directory structure
|
// Create directory structure
|
||||||
@@ -745,17 +746,20 @@ pub fn run_audit(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Item kinds `org add` supports without interactive prompts.
|
/// Item kinds `org add` supports. Secrets resolve via `--*-stdin` flags or an
|
||||||
|
/// interactive prompt inside the shared `item_build` builders.
|
||||||
pub enum OrgAddKind {
|
pub enum OrgAddKind {
|
||||||
Login {
|
Login {
|
||||||
title: String,
|
title: String,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
url: Option<String>,
|
url: Option<String>,
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
|
password_stdin: bool,
|
||||||
},
|
},
|
||||||
SecureNote {
|
SecureNote {
|
||||||
title: String,
|
title: String,
|
||||||
body: String,
|
body: Option<String>,
|
||||||
|
body_stdin: bool,
|
||||||
},
|
},
|
||||||
Identity {
|
Identity {
|
||||||
title: String,
|
title: String,
|
||||||
@@ -763,43 +767,57 @@ pub enum OrgAddKind {
|
|||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
phone: Option<String>,
|
phone: Option<String>,
|
||||||
},
|
},
|
||||||
|
Card {
|
||||||
|
title: String,
|
||||||
|
holder: Option<String>,
|
||||||
|
expiry: Option<String>,
|
||||||
|
kind: String,
|
||||||
|
number_stdin: bool,
|
||||||
|
cvv_stdin: bool,
|
||||||
|
pin_stdin: bool,
|
||||||
|
},
|
||||||
|
Key {
|
||||||
|
title: String,
|
||||||
|
label: Option<String>,
|
||||||
|
algorithm: Option<String>,
|
||||||
|
public_key: Option<String>,
|
||||||
|
material_stdin: bool,
|
||||||
|
},
|
||||||
|
Totp {
|
||||||
|
title: String,
|
||||||
|
issuer: Option<String>,
|
||||||
|
label: Option<String>,
|
||||||
|
secret: Option<String>,
|
||||||
|
secret_stdin: bool,
|
||||||
|
period: u32,
|
||||||
|
digits: u8,
|
||||||
|
algorithm: String,
|
||||||
|
},
|
||||||
|
Document { title: String, file: std::path::PathBuf },
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_org_item(kind: OrgAddKind, tags: Vec<String>) -> Result<Item> {
|
fn build_org_item(kind: OrgAddKind) -> Result<Item> {
|
||||||
use relicario_core::item_types::{IdentityCore, LoginCore, SecureNoteCore};
|
match kind {
|
||||||
use zeroize::Zeroizing;
|
OrgAddKind::Login { title, username, url, password, password_stdin } => {
|
||||||
|
ib::build_login(title, username, url, password, password_stdin, false, None)
|
||||||
let mut item = match kind {
|
|
||||||
OrgAddKind::Login { title, username, url, password } => {
|
|
||||||
let parsed_url = match url {
|
|
||||||
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
let password = password.map(Zeroizing::new);
|
|
||||||
Item::new(title, ItemCore::Login(LoginCore {
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
url: parsed_url,
|
|
||||||
totp: None,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
OrgAddKind::SecureNote { title, body } => {
|
OrgAddKind::SecureNote { title, body, body_stdin } => {
|
||||||
Item::new(title, ItemCore::SecureNote(SecureNoteCore {
|
ib::build_secure_note(title, body, body_stdin)
|
||||||
body: Zeroizing::new(body),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
OrgAddKind::Identity { title, full_name, email, phone } => {
|
OrgAddKind::Identity { title, full_name, email, phone } => {
|
||||||
Item::new(title, ItemCore::Identity(IdentityCore {
|
ib::build_identity(title, full_name, email, phone, None)
|
||||||
full_name,
|
}
|
||||||
address: None,
|
OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin } => {
|
||||||
phone,
|
ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin)
|
||||||
email,
|
}
|
||||||
date_of_birth: None,
|
OrgAddKind::Key { title, label, algorithm, public_key, material_stdin } => {
|
||||||
}))
|
ib::build_key(title, label, algorithm, public_key, material_stdin)
|
||||||
|
}
|
||||||
|
OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm } => {
|
||||||
|
ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm)
|
||||||
|
}
|
||||||
|
OrgAddKind::Document { .. } => unreachable!("Document handled in run_add before build_org_item"),
|
||||||
}
|
}
|
||||||
};
|
|
||||||
item.tags = tags;
|
|
||||||
Ok(item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
|
pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
|
||||||
@@ -816,7 +834,17 @@ pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>
|
|||||||
// …and the caller must hold a grant for it.
|
// …and the caller must hold a grant for it.
|
||||||
UnlockedOrgVault::ensure_grant(&caller, collection)?;
|
UnlockedOrgVault::ensure_grant(&caller, collection)?;
|
||||||
|
|
||||||
let item = build_org_item(kind, tags)?;
|
// Build the item; Document additionally yields an encrypted attachment to persist.
|
||||||
|
let (mut item, attachment_rel): (relicario_core::Item, Option<String>) = match kind {
|
||||||
|
OrgAddKind::Document { title, file } => {
|
||||||
|
let (item, enc) = ib::build_document(
|
||||||
|
title, file, vault.key(), crate::org_session::DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?;
|
||||||
|
let rel = vault.save_attachment(collection, &item.id, &enc)?;
|
||||||
|
(item, Some(rel))
|
||||||
|
}
|
||||||
|
other => (build_org_item(other)?, None),
|
||||||
|
};
|
||||||
|
item.tags = tags;
|
||||||
let item_rel = vault.save_item(collection, &item)?;
|
let item_rel = vault.save_item(collection, &item)?;
|
||||||
|
|
||||||
// Upsert the manifest entry (collection slug stored plaintext inside the
|
// Upsert the manifest entry (collection slug stored plaintext inside the
|
||||||
@@ -837,11 +865,11 @@ pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>
|
|||||||
collection,
|
collection,
|
||||||
item.id.as_str()
|
item.id.as_str()
|
||||||
);
|
);
|
||||||
crate::org_session::org_git_run(
|
let mut add_args: Vec<&str> = vec!["add", &item_rel, "manifest.enc"];
|
||||||
&vault.root,
|
if let Some(ref rel) = attachment_rel {
|
||||||
&["add", &item_rel, "manifest.enc"],
|
add_args.insert(1, rel);
|
||||||
"org add: git add",
|
}
|
||||||
)?;
|
crate::org_session::org_git_run(&vault.root, &add_args, "org add: git add")?;
|
||||||
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org add: git commit")?;
|
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org add: git commit")?;
|
||||||
|
|
||||||
println!("Added {} ({}) to `{}`", item.title, item.id.as_str(), collection);
|
println!("Added {} ({}) to `{}`", item.title, item.id.as_str(), collection);
|
||||||
@@ -973,21 +1001,9 @@ fn resolve_org_query<'a>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_edit(
|
pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>, file: Option<std::path::PathBuf>) -> Result<()> {
|
||||||
dir: &Path,
|
|
||||||
query: &str,
|
|
||||||
title: Option<String>,
|
|
||||||
username: Option<String>,
|
|
||||||
url: Option<String>,
|
|
||||||
password: Option<String>,
|
|
||||||
body: Option<String>,
|
|
||||||
email: Option<String>,
|
|
||||||
phone: Option<String>,
|
|
||||||
full_name: Option<String>,
|
|
||||||
) -> Result<()> {
|
|
||||||
use relicario_core::time::now_unix;
|
use relicario_core::time::now_unix;
|
||||||
use relicario_core::ItemCore;
|
use relicario_core::ItemCore;
|
||||||
use zeroize::Zeroizing;
|
|
||||||
|
|
||||||
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||||
let caller = vault.current_member()?;
|
let caller = vault.current_member()?;
|
||||||
@@ -999,31 +1015,56 @@ pub fn run_edit(
|
|||||||
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?;
|
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?;
|
||||||
|
|
||||||
let mut item = vault.load_item(&collection, &id)?;
|
let mut item = vault.load_item(&collection, &id)?;
|
||||||
|
if file.is_some() && !matches!(item.core, ItemCore::Document(_)) {
|
||||||
|
anyhow::bail!("--file is only valid when editing a Document item");
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
"Editing: {} ({}) — leave a prompt blank to keep the current value.",
|
||||||
|
item.title,
|
||||||
|
item.id.as_str()
|
||||||
|
);
|
||||||
|
if let Some(v) = crate::prompt::prompt_keep("Title", &item.title)? {
|
||||||
|
item.title = v;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(t) = title { item.title = t; }
|
let history = &mut item.field_history;
|
||||||
|
let mut doc_attachment_rel: Option<String> = None;
|
||||||
|
let mut new_doc_attachments: Option<Vec<relicario_core::AttachmentRef>> = None;
|
||||||
match &mut item.core {
|
match &mut item.core {
|
||||||
ItemCore::Login(l) => {
|
ItemCore::Login(l) => ib::edit_login(l, history, totp_qr)?,
|
||||||
if let Some(u) = username { l.username = Some(u); }
|
ItemCore::SecureNote(n) => ib::edit_secure_note(n, history)?,
|
||||||
if let Some(u) = url {
|
ItemCore::Identity(i) => ib::edit_identity(i)?,
|
||||||
l.url = Some(url::Url::parse(&u).with_context(|| format!("invalid URL: {u}"))?);
|
ItemCore::Card(c) => ib::edit_card(c, history)?,
|
||||||
|
ItemCore::Key(k) => ib::edit_key(k, history)?,
|
||||||
|
ItemCore::Document(d) => {
|
||||||
|
if let Some(path) = &file {
|
||||||
|
let (enc, filename, mime_type, size) = ib::encrypt_document_file(
|
||||||
|
path, vault.key(), crate::org_session::DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?;
|
||||||
|
vault.remove_item_attachments(&collection, &id)?;
|
||||||
|
let rel = vault.save_attachment(&collection, &id, &enc)?;
|
||||||
|
d.filename = filename.clone();
|
||||||
|
d.mime_type = mime_type.clone();
|
||||||
|
d.primary_attachment = enc.id.clone();
|
||||||
|
new_doc_attachments = Some(vec![relicario_core::AttachmentRef {
|
||||||
|
id: enc.id,
|
||||||
|
filename,
|
||||||
|
mime_type,
|
||||||
|
size,
|
||||||
|
created: now_unix(),
|
||||||
|
}]);
|
||||||
|
doc_attachment_rel = Some(rel);
|
||||||
|
} else {
|
||||||
|
ib::edit_document_message();
|
||||||
}
|
}
|
||||||
if let Some(p) = password { l.password = Some(Zeroizing::new(p)); }
|
|
||||||
}
|
}
|
||||||
ItemCore::SecureNote(n) => {
|
ItemCore::Totp(t) => ib::edit_totp(t, history)?,
|
||||||
if let Some(b) = body { n.body = Zeroizing::new(b); }
|
|
||||||
}
|
}
|
||||||
ItemCore::Identity(i) => {
|
if let Some(atts) = new_doc_attachments {
|
||||||
if let Some(v) = full_name { i.full_name = Some(v); }
|
item.attachments = atts;
|
||||||
if let Some(v) = email { i.email = Some(v); }
|
|
||||||
if let Some(v) = phone { i.phone = Some(v); }
|
|
||||||
}
|
|
||||||
_ => anyhow::bail!("org edit currently supports login, secure-note, and identity items"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
item.modified = now_unix();
|
item.modified = now_unix();
|
||||||
let item_rel = vault.save_item(&collection, &item)?;
|
let item_rel = vault.save_item(&collection, &item)?;
|
||||||
|
|
||||||
let mut manifest = vault.load_manifest()?;
|
let mut manifest = vault.load_manifest()?;
|
||||||
upsert_org_entry(&mut manifest, &item, &collection);
|
upsert_org_entry(&mut manifest, &item, &collection);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.save_manifest(&manifest)?;
|
||||||
@@ -1035,12 +1076,20 @@ pub fn run_edit(
|
|||||||
);
|
);
|
||||||
let commit_msg = format!(
|
let commit_msg = format!(
|
||||||
"{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}",
|
"{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}",
|
||||||
caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
|
caller.display_name,
|
||||||
|
caller.member_id.as_str(),
|
||||||
|
collection,
|
||||||
|
item.id.as_str()
|
||||||
);
|
);
|
||||||
crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?;
|
let mut add_args: Vec<&str> = vec!["add", &item_rel, "manifest.enc"];
|
||||||
|
let att_dir_rel;
|
||||||
|
if doc_attachment_rel.is_some() {
|
||||||
|
att_dir_rel = format!("attachments/{}/{}", collection, id.as_str());
|
||||||
|
add_args.push(&att_dir_rel);
|
||||||
|
}
|
||||||
|
crate::org_session::org_git_run(&vault.root, &add_args, "org edit: git add")?;
|
||||||
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?;
|
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?;
|
||||||
|
println!("Updated {} ({}) in `{}`", item.title, item.id.as_str(), collection);
|
||||||
println!("Updated {}", item.id.as_str());
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1114,12 +1163,14 @@ pub fn run_purge(dir: &Path, query: &str) -> Result<()> {
|
|||||||
|
|
||||||
// Remove the blob from disk, drop the manifest entry, stage with git rm.
|
// Remove the blob from disk, drop the manifest entry, stage with git rm.
|
||||||
vault.remove_item(&collection, &id)?;
|
vault.remove_item(&collection, &id)?;
|
||||||
|
vault.remove_item_attachments(&collection, &id)?;
|
||||||
let mut manifest = vault.load_manifest()?;
|
let mut manifest = vault.load_manifest()?;
|
||||||
manifest.entries.retain(|e| e.id != id);
|
manifest.entries.retain(|e| e.id != id);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.save_manifest(&manifest)?;
|
||||||
|
|
||||||
let item_rel = format!("items/{}/{}.enc", collection, id.as_str());
|
let item_rel = format!("items/{}/{}.enc", collection, id.as_str());
|
||||||
crate::helpers::git_rm(&vault.root, &[item_rel], "org purge: git rm")?;
|
let att_dir_rel = format!("attachments/{}/{}", collection, id.as_str());
|
||||||
|
crate::helpers::git_rm(&vault.root, &[item_rel, att_dir_rel], "org purge: git rm")?;
|
||||||
crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?;
|
crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?;
|
||||||
|
|
||||||
let commit_msg = format!(
|
let commit_msg = format!(
|
||||||
|
|||||||
@@ -227,6 +227,8 @@ pub(crate) enum AddKind {
|
|||||||
/// Prompt for password (vs reading from stdin or --password).
|
/// Prompt for password (vs reading from stdin or --password).
|
||||||
#[arg(long)] password_prompt: bool,
|
#[arg(long)] password_prompt: bool,
|
||||||
#[arg(long)] password: Option<String>,
|
#[arg(long)] password: Option<String>,
|
||||||
|
/// Read the password from stdin (one line) instead of prompting.
|
||||||
|
#[arg(long)] password_stdin: bool,
|
||||||
#[arg(long)] group: Option<String>,
|
#[arg(long)] group: Option<String>,
|
||||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
#[arg(long)] favorite: bool,
|
#[arg(long)] favorite: bool,
|
||||||
@@ -235,7 +237,8 @@ pub(crate) enum AddKind {
|
|||||||
},
|
},
|
||||||
SecureNote {
|
SecureNote {
|
||||||
#[arg(long)] title: Option<String>,
|
#[arg(long)] title: Option<String>,
|
||||||
#[arg(long)] body_prompt: bool,
|
/// Read the note body from stdin (to EOF) instead of printing the Ctrl-D hint.
|
||||||
|
#[arg(long)] body_stdin: bool,
|
||||||
#[arg(long)] group: Option<String>,
|
#[arg(long)] group: Option<String>,
|
||||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
},
|
},
|
||||||
@@ -253,6 +256,12 @@ pub(crate) enum AddKind {
|
|||||||
#[arg(long)] holder: Option<String>,
|
#[arg(long)] holder: Option<String>,
|
||||||
#[arg(long)] expiry: Option<String>, // MM/YYYY
|
#[arg(long)] expiry: Option<String>, // MM/YYYY
|
||||||
#[arg(long, default_value = "credit")] kind: String,
|
#[arg(long, default_value = "credit")] kind: String,
|
||||||
|
/// Read the card number from stdin (one line) instead of prompting.
|
||||||
|
#[arg(long)] number_stdin: bool,
|
||||||
|
/// Read the CVV from stdin (one line) instead of prompting.
|
||||||
|
#[arg(long)] cvv_stdin: bool,
|
||||||
|
/// Read the PIN from stdin (one line) instead of prompting.
|
||||||
|
#[arg(long)] pin_stdin: bool,
|
||||||
#[arg(long)] group: Option<String>,
|
#[arg(long)] group: Option<String>,
|
||||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
},
|
},
|
||||||
@@ -260,6 +269,8 @@ pub(crate) enum AddKind {
|
|||||||
#[arg(long)] title: Option<String>,
|
#[arg(long)] title: Option<String>,
|
||||||
#[arg(long)] label: Option<String>,
|
#[arg(long)] label: Option<String>,
|
||||||
#[arg(long)] algorithm: Option<String>,
|
#[arg(long)] algorithm: Option<String>,
|
||||||
|
/// Read the key material from stdin (to EOF) instead of printing the Ctrl-D hint.
|
||||||
|
#[arg(long)] material_stdin: bool,
|
||||||
#[arg(long)] group: Option<String>,
|
#[arg(long)] group: Option<String>,
|
||||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
},
|
},
|
||||||
@@ -274,6 +285,8 @@ pub(crate) enum AddKind {
|
|||||||
#[arg(long)] issuer: Option<String>,
|
#[arg(long)] issuer: Option<String>,
|
||||||
#[arg(long)] label: Option<String>,
|
#[arg(long)] label: Option<String>,
|
||||||
#[arg(long)] secret: Option<String>, // base32
|
#[arg(long)] secret: Option<String>, // base32
|
||||||
|
/// Read the TOTP secret from stdin (one line) instead of prompting.
|
||||||
|
#[arg(long)] secret_stdin: bool,
|
||||||
#[arg(long, default_value = "30")] period: u32,
|
#[arg(long, default_value = "30")] period: u32,
|
||||||
#[arg(long, default_value = "6")] digits: u8,
|
#[arg(long, default_value = "6")] digits: u8,
|
||||||
#[arg(long, default_value = "sha1")] algorithm: String,
|
#[arg(long, default_value = "sha1")] algorithm: String,
|
||||||
@@ -522,18 +535,14 @@ pub(crate) enum OrgCommands {
|
|||||||
List {
|
List {
|
||||||
#[arg(long)] trashed: bool,
|
#[arg(long)] trashed: bool,
|
||||||
},
|
},
|
||||||
/// Edit an org item's fields (flag-driven; blank flags keep current values).
|
/// Edit an org item interactively (per-type prompts; blank keeps current).
|
||||||
Edit {
|
Edit {
|
||||||
/// Item id or case-insensitive title substring.
|
/// Item id or case-insensitive title substring.
|
||||||
query: String,
|
query: String,
|
||||||
#[arg(long)] title: Option<String>,
|
/// Replace the login TOTP secret from a QR image.
|
||||||
#[arg(long)] username: Option<String>,
|
#[arg(long)] totp_qr: Option<std::path::PathBuf>,
|
||||||
#[arg(long)] url: Option<String>,
|
/// Replace a Document item's attachment file.
|
||||||
#[arg(long)] password: Option<String>,
|
#[arg(long)] file: Option<std::path::PathBuf>,
|
||||||
#[arg(long)] body: Option<String>,
|
|
||||||
#[arg(long)] email: Option<String>,
|
|
||||||
#[arg(long)] phone: Option<String>,
|
|
||||||
#[arg(long)] full_name: Option<String>,
|
|
||||||
},
|
},
|
||||||
/// Soft-delete an org item (reversible via `org restore`).
|
/// Soft-delete an org item (reversible via `org restore`).
|
||||||
Rm { query: String },
|
Rm { query: String },
|
||||||
@@ -553,13 +562,15 @@ pub(crate) enum OrgAddKind {
|
|||||||
#[arg(long)] url: Option<String>,
|
#[arg(long)] url: Option<String>,
|
||||||
#[arg(long)] password: Option<String>,
|
#[arg(long)] password: Option<String>,
|
||||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
#[arg(long)] password_stdin: bool,
|
||||||
},
|
},
|
||||||
/// A secure note.
|
/// A secure note.
|
||||||
SecureNote {
|
SecureNote {
|
||||||
#[arg(long)] collection: String,
|
#[arg(long)] collection: String,
|
||||||
#[arg(long)] title: String,
|
#[arg(long)] title: String,
|
||||||
#[arg(long)] body: String,
|
#[arg(long)] body: Option<String>,
|
||||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
#[arg(long)] body_stdin: bool,
|
||||||
},
|
},
|
||||||
/// An identity record.
|
/// An identity record.
|
||||||
Identity {
|
Identity {
|
||||||
@@ -570,6 +581,48 @@ pub(crate) enum OrgAddKind {
|
|||||||
#[arg(long)] phone: Option<String>,
|
#[arg(long)] phone: Option<String>,
|
||||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
},
|
},
|
||||||
|
/// A payment card (number / cvv / pin entered via --*-stdin or prompt).
|
||||||
|
Card {
|
||||||
|
#[arg(long)] collection: String,
|
||||||
|
#[arg(long)] title: String,
|
||||||
|
#[arg(long)] holder: Option<String>,
|
||||||
|
#[arg(long)] expiry: Option<String>,
|
||||||
|
#[arg(long, default_value = "credit")] kind: String,
|
||||||
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
#[arg(long)] number_stdin: bool,
|
||||||
|
#[arg(long)] cvv_stdin: bool,
|
||||||
|
#[arg(long)] pin_stdin: bool,
|
||||||
|
},
|
||||||
|
/// A key / credential blob (material entered via --material-stdin or prompt).
|
||||||
|
Key {
|
||||||
|
#[arg(long)] collection: String,
|
||||||
|
#[arg(long)] title: String,
|
||||||
|
#[arg(long)] label: Option<String>,
|
||||||
|
#[arg(long)] algorithm: Option<String>,
|
||||||
|
#[arg(long)] public_key: Option<String>,
|
||||||
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
#[arg(long)] material_stdin: bool,
|
||||||
|
},
|
||||||
|
/// A TOTP authenticator (base32 secret via --secret or --secret-stdin).
|
||||||
|
Totp {
|
||||||
|
#[arg(long)] collection: String,
|
||||||
|
#[arg(long)] title: String,
|
||||||
|
#[arg(long)] issuer: Option<String>,
|
||||||
|
#[arg(long)] label: Option<String>,
|
||||||
|
#[arg(long)] secret: Option<String>,
|
||||||
|
#[arg(long, default_value_t = 30)] period: u32,
|
||||||
|
#[arg(long, default_value_t = 6)] digits: u8,
|
||||||
|
#[arg(long, default_value = "sha1")] algorithm: String,
|
||||||
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
#[arg(long)] secret_stdin: bool,
|
||||||
|
},
|
||||||
|
/// A document (file payload encrypted into a collection-scoped attachment).
|
||||||
|
Document {
|
||||||
|
#[arg(long)] collection: String,
|
||||||
|
#[arg(long)] title: String,
|
||||||
|
#[arg(long)] file: std::path::PathBuf,
|
||||||
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@@ -663,14 +716,14 @@ fn main() -> Result<()> {
|
|||||||
OrgCommands::Add { kind } => {
|
OrgCommands::Add { kind } => {
|
||||||
let d = crate::org_session::org_dir(dir_path)?;
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
let (collection, add_kind, tags) = match kind {
|
let (collection, add_kind, tags) = match kind {
|
||||||
OrgAddKind::Login { collection, title, username, url, password, tags } => (
|
OrgAddKind::Login { collection, title, username, url, password, tags, password_stdin } => (
|
||||||
collection,
|
collection,
|
||||||
commands::org::OrgAddKind::Login { title, username, url, password },
|
commands::org::OrgAddKind::Login { title, username, url, password, password_stdin },
|
||||||
tags,
|
tags,
|
||||||
),
|
),
|
||||||
OrgAddKind::SecureNote { collection, title, body, tags } => (
|
OrgAddKind::SecureNote { collection, title, body, tags, body_stdin } => (
|
||||||
collection,
|
collection,
|
||||||
commands::org::OrgAddKind::SecureNote { title, body },
|
commands::org::OrgAddKind::SecureNote { title, body, body_stdin },
|
||||||
tags,
|
tags,
|
||||||
),
|
),
|
||||||
OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => (
|
OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => (
|
||||||
@@ -678,6 +731,26 @@ fn main() -> Result<()> {
|
|||||||
commands::org::OrgAddKind::Identity { title, full_name, email, phone },
|
commands::org::OrgAddKind::Identity { title, full_name, email, phone },
|
||||||
tags,
|
tags,
|
||||||
),
|
),
|
||||||
|
OrgAddKind::Card { collection, title, holder, expiry, kind, tags, number_stdin, cvv_stdin, pin_stdin } => (
|
||||||
|
collection,
|
||||||
|
commands::org::OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin },
|
||||||
|
tags,
|
||||||
|
),
|
||||||
|
OrgAddKind::Key { collection, title, label, algorithm, public_key, tags, material_stdin } => (
|
||||||
|
collection,
|
||||||
|
commands::org::OrgAddKind::Key { title, label, algorithm, public_key, material_stdin },
|
||||||
|
tags,
|
||||||
|
),
|
||||||
|
OrgAddKind::Totp { collection, title, issuer, label, secret, period, digits, algorithm, tags, secret_stdin } => (
|
||||||
|
collection,
|
||||||
|
commands::org::OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm },
|
||||||
|
tags,
|
||||||
|
),
|
||||||
|
OrgAddKind::Document { collection, title, file, tags } => (
|
||||||
|
collection,
|
||||||
|
commands::org::OrgAddKind::Document { title, file },
|
||||||
|
tags,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
commands::org::run_add(&d, &collection, add_kind, tags)?;
|
commands::org::run_add(&d, &collection, add_kind, tags)?;
|
||||||
}
|
}
|
||||||
@@ -689,9 +762,9 @@ fn main() -> Result<()> {
|
|||||||
let d = crate::org_session::org_dir(dir_path)?;
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
commands::org::run_list(&d, trashed)?;
|
commands::org::run_list(&d, trashed)?;
|
||||||
}
|
}
|
||||||
OrgCommands::Edit { query, title, username, url, password, body, email, phone, full_name } => {
|
OrgCommands::Edit { query, totp_qr, file } => {
|
||||||
let d = crate::org_session::org_dir(dir_path)?;
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
commands::org::run_edit(&d, &query, title, username, url, password, body, email, phone, full_name)?;
|
commands::org::run_edit(&d, &query, totp_qr, file)?;
|
||||||
}
|
}
|
||||||
OrgCommands::Rm { query } => {
|
OrgCommands::Rm { query } => {
|
||||||
let d = crate::org_session::org_dir(dir_path)?;
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
|||||||
@@ -9,9 +9,16 @@ use zeroize::Zeroizing;
|
|||||||
|
|
||||||
use relicario_core::{
|
use relicario_core::{
|
||||||
decrypt_item, decrypt_org_manifest, encrypt_item, encrypt_org_manifest,
|
decrypt_item, decrypt_org_manifest, encrypt_item, encrypt_org_manifest,
|
||||||
Item, ItemId, MemberId, OrgCollections, OrgManifest, OrgMember, OrgMembers, OrgMeta,
|
AttachmentId, EncryptedAttachment, Item, ItemId, MemberId, OrgCollections, OrgManifest,
|
||||||
|
OrgMember, OrgMembers, OrgMeta,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Default per-attachment cap for org vaults. Org vaults have no settings.enc,
|
||||||
|
/// so this mirrors the personal-vault default
|
||||||
|
/// `AttachmentCaps::per_attachment_max_bytes` at
|
||||||
|
/// crates/relicario-core/src/settings.rs:116.
|
||||||
|
pub const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 10 * 1024 * 1024;
|
||||||
|
|
||||||
pub struct UnlockedOrgVault {
|
pub struct UnlockedOrgVault {
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
pub org_key: Zeroizing<[u8; 32]>,
|
pub org_key: Zeroizing<[u8; 32]>,
|
||||||
@@ -115,6 +122,40 @@ impl UnlockedOrgVault {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn attachment_path(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> PathBuf {
|
||||||
|
self.root.join("attachments").join(collection_slug)
|
||||||
|
.join(item_id.as_str()).join(format!("{}.enc", att_id.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt-already-done blob: persist it and return the repo-relative path for git staging.
|
||||||
|
pub fn save_attachment(&self, collection_slug: &str, item_id: &ItemId, enc: &EncryptedAttachment) -> Result<String> {
|
||||||
|
let path = self.attachment_path(collection_slug, item_id, &enc.id);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
atomic_write(&path, &enc.bytes)?;
|
||||||
|
Ok(format!("attachments/{}/{}/{}.enc", collection_slug, item_id.as_str(), enc.id.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retained for a future `org document read/extract` command (mirrors `org_meta_path` convention).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result<Zeroizing<Vec<u8>>> {
|
||||||
|
let path = self.attachment_path(collection_slug, item_id, att_id);
|
||||||
|
let bytes = fs::read(&path).with_context(|| format!("read attachment {}", path.display()))?;
|
||||||
|
Ok(relicario_core::decrypt_attachment(&bytes, &self.org_key)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an item's whole attachment directory. Missing dir is NOT an error
|
||||||
|
/// (mirrors `remove_item`'s NotFound-tolerant behavior, for partial-write recovery).
|
||||||
|
pub fn remove_item_attachments(&self, collection_slug: &str, item_id: &ItemId) -> Result<()> {
|
||||||
|
let dir = self.root.join("attachments").join(collection_slug).join(item_id.as_str());
|
||||||
|
match fs::remove_dir_all(&dir) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(anyhow::Error::from(e).context(format!("remove {}", dir.display()))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Bail unless `member` has `slug` in their collection grants. The slug
|
/// Bail unless `member` has `slug` in their collection grants. The slug
|
||||||
/// existence check is done separately by the caller against collections.json.
|
/// existence check is done separately by the caller against collections.json.
|
||||||
pub fn ensure_grant(member: &OrgMember, slug: &str) -> Result<()> {
|
pub fn ensure_grant(member: &OrgMember, slug: &str) -> Result<()> {
|
||||||
@@ -292,6 +333,22 @@ mod tests {
|
|||||||
assert_eq!(loaded.entries.len(), 1);
|
assert_eq!(loaded.entries.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_round_trip_collection_scoped() {
|
||||||
|
use relicario_core::encrypt_attachment;
|
||||||
|
let key = Zeroizing::new([7u8; 32]);
|
||||||
|
let (dir, vault) = make_vault(key);
|
||||||
|
let _ = dir; // keep tempdir alive
|
||||||
|
let item_id = ItemId::new();
|
||||||
|
let enc = encrypt_attachment(b"hello world", &vault.org_key, DEFAULT_ORG_ATTACHMENT_MAX_BYTES).unwrap();
|
||||||
|
let rel = vault.save_attachment("eng", &item_id, &enc).unwrap();
|
||||||
|
assert_eq!(rel, format!("attachments/eng/{}/{}.enc", item_id.as_str(), enc.id.as_str()));
|
||||||
|
let got = vault.load_attachment("eng", &item_id, &enc.id).unwrap();
|
||||||
|
assert_eq!(got.as_slice(), b"hello world");
|
||||||
|
vault.remove_item_attachments("eng", &item_id).unwrap();
|
||||||
|
assert!(vault.load_attachment("eng", &item_id, &enc.id).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn save_and_load_members() {
|
fn save_and_load_members() {
|
||||||
let key = Zeroizing::new([0u8; 32]);
|
let key = Zeroizing::new([0u8; 32]);
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
//! Interactive prompt helpers for the CLI.
|
//! Interactive prompt helpers for the CLI.
|
||||||
//!
|
//!
|
||||||
//! The `prompt`/`prompt_optional`/`prompt_secret` family reads from stdin /
|
//! `prompt_secret` reads a masked secret from the TTY (honouring
|
||||||
//! the TTY; the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are
|
//! `RELICARIO_TEST_ITEM_SECRET` so integration tests without a TTY can inject
|
||||||
|
//! secrets); the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are
|
||||||
//! used by the edit handlers to keep current values when the user hits enter
|
//! used by the edit handlers to keep current values when the user hits enter
|
||||||
//! at a blank prompt. `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET`
|
//! at a blank prompt. `prompt_or_flag` and `prompt_or_flag_optional` thread a
|
||||||
//! so integration tests (which don't have a TTY) can inject secrets.
|
//! CLI-flag value through the same path so command handlers can use one call
|
||||||
//! `prompt_or_flag` and `prompt_or_flag_optional` thread a CLI-flag value
|
//! site whether the value came from the command line or an interactive prompt.
|
||||||
//! through the same path so command handlers can use one call site whether
|
|
||||||
//! the value came from the command line or from an interactive prompt.
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::io::BufRead;
|
use std::io::BufRead;
|
||||||
@@ -41,18 +40,6 @@ fn read_optional_line<R: BufRead>(reader: &mut R, label: &str) -> Result<Option<
|
|||||||
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
|
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn prompt(label: &str) -> Result<String> {
|
|
||||||
let stdin = std::io::stdin();
|
|
||||||
let mut reader = std::io::BufReader::new(stdin.lock());
|
|
||||||
read_required_line(&mut reader, label)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn prompt_optional(label: &str) -> Result<Option<String>> {
|
|
||||||
let stdin = std::io::stdin();
|
|
||||||
let mut reader = std::io::BufReader::new(stdin.lock());
|
|
||||||
read_optional_line(&mut reader, label)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
|
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
|
||||||
eprint!("{label} [{current}]: ");
|
eprint!("{label} [{current}]: ");
|
||||||
std::io::Write::flush(&mut std::io::stderr())?;
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
|
|||||||
@@ -201,3 +201,20 @@ fn generate_random_and_bip39() {
|
|||||||
let phrase = String::from_utf8(out.stdout).unwrap();
|
let phrase = String::from_utf8(out.stdout).unwrap();
|
||||||
assert_eq!(phrase.trim().split(' ').count(), 5);
|
assert_eq!(phrase.trim().split(' ').count(), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_card_via_stdin_flags_is_non_interactive() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let out = v.run_with_input(
|
||||||
|
&["add", "card", "--title", "Visa", "--kind", "credit",
|
||||||
|
"--number-stdin", "--cvv-stdin", "--pin-stdin"],
|
||||||
|
&["4111111111111111", "123", "4321"],
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add card via stdin failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = v.run(&["get", "Visa"]);
|
||||||
|
assert!(got.status.success(), "get Visa failed: {}", String::from_utf8_lossy(&got.stderr));
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout);
|
||||||
|
assert!(stdout.contains("********"), "card number should be masked without --show: {stdout}");
|
||||||
|
assert!(!stdout.contains("4111111111111111"), "card number leaked without --show: {stdout}");
|
||||||
|
}
|
||||||
|
|||||||
@@ -152,7 +152,9 @@ fn org_get_edit_rm_restore_purge_reject_ungranted_member() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (label, args) in [
|
for (label, args) in [
|
||||||
("edit", vec!["org", "edit", "GitHub", "--username", "evil"]),
|
// `org edit` is now interactive (no flat flags); the ungranted member is
|
||||||
|
// rejected at manifest lookup, before any prompt is read.
|
||||||
|
("edit", vec!["org", "edit", "GitHub"]),
|
||||||
("rm", vec!["org", "rm", "GitHub"]),
|
("rm", vec!["org", "rm", "GitHub"]),
|
||||||
("restore", vec!["org", "restore", "GitHub"]),
|
("restore", vec!["org", "restore", "GitHub"]),
|
||||||
("purge", vec!["org", "purge", "GitHub"]),
|
("purge", vec!["org", "purge", "GitHub"]),
|
||||||
@@ -170,13 +172,12 @@ fn org_get_edit_rm_restore_purge_reject_ungranted_member() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The item is untouched: the owner can still read the original password and
|
// The item is untouched: the owner can still read the original password and
|
||||||
// the username was NOT changed to the ungranted member's "evil" attempt.
|
// username — the ungranted member's get/edit/rm/restore/purge were all denied.
|
||||||
let owner_get = owner_dev.run(vault, &["org", "get", "GitHub", "--show"]);
|
let owner_get = owner_dev.run(vault, &["org", "get", "GitHub", "--show"]);
|
||||||
let owner_out = String::from_utf8_lossy(&owner_get.stdout).to_string();
|
let owner_out = String::from_utf8_lossy(&owner_get.stdout).to_string();
|
||||||
assert!(owner_get.status.success(), "owner should still read the item");
|
assert!(owner_get.status.success(), "owner should still read the item");
|
||||||
assert!(owner_out.contains("hunter2"), "owner read must still show original password: {owner_out}");
|
assert!(owner_out.contains("hunter2"), "owner read must still show original password: {owner_out}");
|
||||||
assert!(owner_out.contains("alice"), "edit by ungranted member must not have changed username: {owner_out}");
|
assert!(owner_out.contains("alice"), "ungranted member must not have modified the item: {owner_out}");
|
||||||
assert!(!owner_out.contains("evil"), "ungranted edit leaked through: {owner_out}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -67,6 +67,39 @@ impl OrgFixture {
|
|||||||
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
|
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||||
v["members"][0]["member_id"].as_str().unwrap().to_string()
|
v["members"][0]["member_id"].as_str().unwrap().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like `run`, but pipes `stdin_data` into the child's stdin — used to drive
|
||||||
|
/// `--*-stdin` secret flags and the interactive edit prompts. `wait_with_output`
|
||||||
|
/// closes stdin for us, so multiline secrets (read-to-EOF) terminate cleanly.
|
||||||
|
fn run_stdin(&self, args: &[&str], stdin_data: &str) -> std::process::Output {
|
||||||
|
use std::io::Write as _;
|
||||||
|
let mut child = Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.env("XDG_CONFIG_HOME", &self.xdg)
|
||||||
|
.env("RELICARIO_ORG_DIR", self.vault.path())
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
child.stdin.as_mut().unwrap().write_all(stdin_data.as_bytes()).unwrap();
|
||||||
|
child.wait_with_output().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create collection `slug` and grant the owner access to it — the common
|
||||||
|
/// setup the item-type round-trip tests share.
|
||||||
|
fn create_collection_and_grant(&self, slug: &str) {
|
||||||
|
let owner = self.owner_member_id();
|
||||||
|
assert!(
|
||||||
|
self.run(&["org", "create-collection", slug, "--name", slug]).status.success(),
|
||||||
|
"create-collection {slug} failed",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.run(&["org", "grant", &owner, slug]).status.success(),
|
||||||
|
"grant {slug} failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -151,21 +184,17 @@ fn org_add_rejects_unknown_collection() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn org_edit_updates_fields_and_commits_update_trailer() {
|
fn org_edit_updates_fields_and_commits_update_trailer() {
|
||||||
let f = OrgFixture::new();
|
let f = OrgFixture::new();
|
||||||
let owner = f.owner_member_id();
|
f.create_collection_and_grant("prod");
|
||||||
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
|
|
||||||
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
|
|
||||||
assert!(f.run(&[
|
assert!(f.run(&[
|
||||||
"org", "add", "login", "--collection", "prod",
|
"org", "add", "login", "--collection", "prod",
|
||||||
"--title", "Mail", "--username", "old", "--password", "pw",
|
"--title", "Mail", "--username", "old", "--password", "pw",
|
||||||
]).status.success());
|
]).status.success());
|
||||||
|
|
||||||
// Edit the username.
|
// org edit is now interactive per-type: keep title, set username=new-user,
|
||||||
let out = f.run(&[
|
// keep URL, decline password change.
|
||||||
"org", "edit", "Mail", "--username", "new-user",
|
let out = f.run_stdin(&["org", "edit", "Mail"], "\nnew-user\n\nn\n");
|
||||||
]);
|
|
||||||
assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr));
|
assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
// get --show reflects the new username.
|
|
||||||
let out = f.run(&["org", "get", "Mail", "--show"]);
|
let out = f.run(&["org", "get", "Mail", "--show"]);
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||||
assert!(stdout.contains("new-user"), "edit did not take: {stdout}");
|
assert!(stdout.contains("new-user"), "edit did not take: {stdout}");
|
||||||
@@ -215,3 +244,344 @@ fn org_rm_restore_purge_cycle() {
|
|||||||
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
||||||
assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}");
|
assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- v0.8.1 org item-type parity: Card / Key / Totp -------------------------
|
||||||
|
// These drive the new `org add <card|key|totp>` subcommands. Secrets enter via
|
||||||
|
// `--*-stdin` (read from piped stdin) or, for Totp, the `--secret` flag. `org get`
|
||||||
|
// must mask every secret unless `--show` is passed — asserted below.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_card_via_stdin_then_get_masks_secret() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
|
||||||
|
// build_card reads number, then cvv, then pin — one line each, in that order.
|
||||||
|
let out = f.run_stdin(
|
||||||
|
&[
|
||||||
|
"org", "add", "card", "--collection", "eng", "--title", "Corp Visa",
|
||||||
|
"--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin",
|
||||||
|
],
|
||||||
|
"4111111111111111\n123\n4321\n",
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// get masks the card number by default.
|
||||||
|
let got = f.run(&["org", "get", "Corp Visa"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||||
|
assert!(stdout.contains("Corp Visa"), "title missing: {stdout}");
|
||||||
|
assert!(stdout.contains("********"), "card number must be masked without --show: {stdout}");
|
||||||
|
assert!(!stdout.contains("4111111111111111"), "secret leaked without --show: {stdout}");
|
||||||
|
|
||||||
|
// --show reveals it.
|
||||||
|
let shown = f.run(&["org", "get", "Corp Visa", "--show"]);
|
||||||
|
let shown = String::from_utf8_lossy(&shown.stdout).to_string();
|
||||||
|
assert!(shown.contains("4111111111111111"), "number not revealed with --show: {shown}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_key_via_stdin_then_get_masks_material() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
|
||||||
|
// build_key reads key material from stdin to EOF (multiline secret).
|
||||||
|
let out = f.run_stdin(
|
||||||
|
&[
|
||||||
|
"org", "add", "key", "--collection", "eng", "--title", "Deploy Key",
|
||||||
|
"--label", "ci", "--algorithm", "ed25519", "--material-stdin",
|
||||||
|
],
|
||||||
|
"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAsecretmaterial\n-----END OPENSSH PRIVATE KEY-----\n",
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "Deploy Key"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||||
|
assert!(stdout.contains("Label: ci"), "label missing: {stdout}");
|
||||||
|
assert!(stdout.contains("********"), "key material must be masked without --show: {stdout}");
|
||||||
|
assert!(!stdout.contains("secretmaterial"), "key material leaked without --show: {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_totp_with_secret_flag_round_trips() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
|
||||||
|
// Totp accepts the base32 secret via --secret (no stdin needed).
|
||||||
|
let out = f.run(&[
|
||||||
|
"org", "add", "totp", "--collection", "eng", "--title", "AWS root",
|
||||||
|
"--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP",
|
||||||
|
]);
|
||||||
|
assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "AWS root"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||||
|
assert!(stdout.contains("AWS root"), "title missing: {stdout}");
|
||||||
|
assert!(stdout.contains("Issuer: AWS"), "issuer missing: {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_edit_card_interactive_changes_holder() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
let out = f.run_stdin(
|
||||||
|
&[
|
||||||
|
"org", "add", "card", "--collection", "eng", "--title", "Corp Visa",
|
||||||
|
"--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin",
|
||||||
|
],
|
||||||
|
"4111111111111111\n123\n4321\n",
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// Interactive edit: keep title, set holder, decline number change.
|
||||||
|
let out = f.run_stdin(&["org", "edit", "Corp Visa"], "\nJane Q. Public\nn\n");
|
||||||
|
assert!(out.status.success(), "org edit card: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "Corp Visa"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||||
|
assert!(stdout.contains("Holder: Jane Q. Public"), "holder edit did not take: {stdout}");
|
||||||
|
assert!(stdout.contains("********"), "number must stay masked after declining change: {stdout}");
|
||||||
|
assert!(!stdout.contains("4111111111111111"), "number leaked without --show: {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_edit_totp_interactive_changes_issuer() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
assert!(f.run(&[
|
||||||
|
"org", "add", "totp", "--collection", "eng", "--title", "AWS root",
|
||||||
|
"--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP",
|
||||||
|
]).status.success());
|
||||||
|
|
||||||
|
// Interactive edit: keep title, set issuer=GitHub, keep label, decline secret change.
|
||||||
|
let out = f.run_stdin(&["org", "edit", "AWS root"], "\nGitHub\n\nn\n");
|
||||||
|
assert!(out.status.success(), "org edit totp: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "AWS root"]);
|
||||||
|
assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: GitHub"), "issuer edit did not take");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- grant enforcement + remaining --*-stdin paths for the new types ---------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_card_key_totp_reject_ungranted_and_unknown_collection() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
// `secret` exists but is NOT granted to the owner.
|
||||||
|
assert!(f.run(&["org", "create-collection", "secret", "--name", "secret"]).status.success());
|
||||||
|
|
||||||
|
// ensure_grant runs before any secret prompt in run_add, so these need no
|
||||||
|
// stdin — each new type must be rejected for a collection it lacks a grant for.
|
||||||
|
for args in [
|
||||||
|
vec!["org", "add", "card", "--collection", "secret", "--title", "X", "--kind", "credit"],
|
||||||
|
vec!["org", "add", "key", "--collection", "secret", "--title", "X"],
|
||||||
|
vec!["org", "add", "totp", "--collection", "secret", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"],
|
||||||
|
] {
|
||||||
|
let out = f.run(&args);
|
||||||
|
assert!(!out.status.success(), "ungranted add must fail: {args:?}");
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr).to_string();
|
||||||
|
assert!(err.contains("access denied") || err.contains("grant"),
|
||||||
|
"expected grant denial for {args:?}: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// …and rejected for a nonexistent collection.
|
||||||
|
for args in [
|
||||||
|
vec!["org", "add", "card", "--collection", "ghost", "--title", "X", "--kind", "credit"],
|
||||||
|
vec!["org", "add", "key", "--collection", "ghost", "--title", "X"],
|
||||||
|
vec!["org", "add", "totp", "--collection", "ghost", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"],
|
||||||
|
] {
|
||||||
|
let out = f.run(&args);
|
||||||
|
assert!(!out.status.success(), "unknown-collection add must fail: {args:?}");
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr).to_string();
|
||||||
|
assert!(err.contains("does not exist") || err.contains("ghost"),
|
||||||
|
"expected unknown-collection error for {args:?}: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_secure_note_via_body_stdin_masks_body() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
// build_secure_note(body_stdin=true) reads the body from stdin to EOF.
|
||||||
|
let out = f.run_stdin(
|
||||||
|
&["org", "add", "secure-note", "--collection", "eng", "--title", "Runbook", "--body-stdin"],
|
||||||
|
"line one\nsuper-secret-line\n",
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add note: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "Runbook"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||||
|
assert!(stdout.contains("********"), "note body must be masked without --show: {stdout}");
|
||||||
|
assert!(!stdout.contains("super-secret-line"), "note body leaked without --show: {stdout}");
|
||||||
|
|
||||||
|
let shown = f.run(&["org", "get", "Runbook", "--show"]);
|
||||||
|
assert!(String::from_utf8_lossy(&shown.stdout).contains("super-secret-line"), "body not revealed with --show");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_totp_via_secret_stdin_round_trips() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
// build_totp(secret_stdin=true) reads one base32 line from stdin.
|
||||||
|
let out = f.run_stdin(
|
||||||
|
&["org", "add", "totp", "--collection", "eng", "--title", "VPN", "--issuer", "Corp", "--secret-stdin"],
|
||||||
|
"JBSWY3DPEHPK3PXP\n",
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "VPN"]);
|
||||||
|
assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: Corp"), "issuer missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_edit_key_replaces_material_and_reveals_with_show() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
let out = f.run_stdin(
|
||||||
|
&["org", "add", "key", "--collection", "eng", "--title", "Signing Key",
|
||||||
|
"--label", "ci", "--material-stdin"],
|
||||||
|
"OLD-MATERIAL-aaaa\n",
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// Interactive edit: keep title, ACCEPT "Replace key material?" -> new material
|
||||||
|
// read from stdin to EOF (edit_key). Exercises the accept branch + history push.
|
||||||
|
let out = f.run_stdin(&["org", "edit", "Signing Key"], "\ny\nNEW-MATERIAL-bbbb\n");
|
||||||
|
assert!(out.status.success(), "org edit key: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let masked = f.run(&["org", "get", "Signing Key"]);
|
||||||
|
let masked = String::from_utf8_lossy(&masked.stdout).to_string();
|
||||||
|
assert!(masked.contains("********"), "material must be masked without --show: {masked}");
|
||||||
|
assert!(!masked.contains("NEW-MATERIAL"), "material leaked without --show: {masked}");
|
||||||
|
|
||||||
|
let shown = f.run(&["org", "get", "Signing Key", "--show"]);
|
||||||
|
let shown = String::from_utf8_lossy(&shown.stdout).to_string();
|
||||||
|
assert!(shown.contains("NEW-MATERIAL-bbbb"), "replaced material not revealed with --show: {shown}");
|
||||||
|
assert!(!shown.contains("OLD-MATERIAL"), "old material still present after replace: {shown}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- v0.8.1 org Document tests -----------------------------------------------
|
||||||
|
|
||||||
|
/// `git status --porcelain` output for the org repo (trimmed). Empty-of-`attachments/`
|
||||||
|
/// proves every attachment add/remove was staged into the signed commit.
|
||||||
|
fn git_porcelain(repo: &str) -> String {
|
||||||
|
let out = std::process::Command::new("git")
|
||||||
|
.args(["-C", repo, "status", "--porcelain"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_document_stores_collection_scoped_attachment() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
let srcdir = TempDir::new().unwrap();
|
||||||
|
let src = srcdir.path().join("note.txt");
|
||||||
|
std::fs::write(&src, b"secret memo").unwrap();
|
||||||
|
|
||||||
|
let out = f.run(&["org", "add", "document", "--collection", "eng",
|
||||||
|
"--title", "Q3 Memo", "--file", src.to_str().unwrap()]);
|
||||||
|
assert!(out.status.success(), "add doc: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// Encrypted blob at attachments/eng/<item-id>/<att-id>.enc (3 segments).
|
||||||
|
let att_eng = f.vault_path().join("attachments").join("eng");
|
||||||
|
assert!(att_eng.exists(), "attachment dir missing");
|
||||||
|
let item_dirs: Vec<_> = std::fs::read_dir(&att_eng).unwrap().map(|e| e.unwrap().path()).collect();
|
||||||
|
assert_eq!(item_dirs.len(), 1, "expected exactly one item attachment dir");
|
||||||
|
let blobs: Vec<_> = std::fs::read_dir(&item_dirs[0]).unwrap().map(|e| e.unwrap().path()).collect();
|
||||||
|
assert_eq!(blobs.len(), 1, "expected exactly one attachment blob");
|
||||||
|
assert_eq!(blobs[0].extension().and_then(|e| e.to_str()), Some("enc"), "blob must be .enc");
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "Q3 Memo"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout);
|
||||||
|
assert!(stdout.contains("Filename: note.txt"), "get missing filename: {stdout}");
|
||||||
|
// Staging proof: nothing attachment-related left uncommitted.
|
||||||
|
assert!(!git_porcelain(f.vault_str()).contains("attachments/"), "unstaged attachment after add");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_purge_document_removes_attachment_dir() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
let srcdir = TempDir::new().unwrap();
|
||||||
|
let src = srcdir.path().join("d.bin");
|
||||||
|
std::fs::write(&src, b"bytes").unwrap();
|
||||||
|
assert!(f.run(&["org", "add", "document", "--collection", "eng",
|
||||||
|
"--title", "Doc", "--file", src.to_str().unwrap()]).status.success());
|
||||||
|
|
||||||
|
let att_eng = f.vault_path().join("attachments").join("eng");
|
||||||
|
assert!(std::fs::read_dir(&att_eng).unwrap().next().is_some(), "attachment must exist after add");
|
||||||
|
|
||||||
|
assert!(f.run(&["org", "rm", "Doc"]).status.success(), "rm");
|
||||||
|
let out = f.run(&["org", "purge", "Doc"]);
|
||||||
|
assert!(out.status.success(), "purge: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let empty = !att_eng.exists() || std::fs::read_dir(&att_eng).unwrap().next().is_none();
|
||||||
|
assert!(empty, "attachment dir should be gone after purge");
|
||||||
|
assert!(!git_porcelain(f.vault_str()).contains("attachments/"), "unstaged attachment removal after purge");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_edit_document_replaces_attachment_and_stages_cleanly() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
let srcdir = TempDir::new().unwrap();
|
||||||
|
let a = srcdir.path().join("a.txt");
|
||||||
|
std::fs::write(&a, b"version A").unwrap();
|
||||||
|
assert!(f.run(&["org", "add", "document", "--collection", "eng",
|
||||||
|
"--title", "Spec", "--file", a.to_str().unwrap()]).status.success());
|
||||||
|
|
||||||
|
let b = srcdir.path().join("b.md");
|
||||||
|
std::fs::write(&b, b"version B has different content").unwrap();
|
||||||
|
let out = f.run(&["org", "edit", "Spec", "--file", b.to_str().unwrap()]);
|
||||||
|
assert!(out.status.success(), "edit --file: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = String::from_utf8_lossy(&f.run(&["org", "get", "Spec"]).stdout).to_string();
|
||||||
|
assert!(got.contains("Filename: b.md"), "get should show new filename: {got}");
|
||||||
|
assert!(!got.contains("a.txt"), "old filename should be gone: {got}");
|
||||||
|
|
||||||
|
// Old blob replaced, not accumulated: exactly one blob remains.
|
||||||
|
let att_eng = f.vault_path().join("attachments").join("eng");
|
||||||
|
let item_dirs: Vec<_> = std::fs::read_dir(&att_eng).unwrap().map(|e| e.unwrap().path()).collect();
|
||||||
|
assert_eq!(item_dirs.len(), 1, "one item attachment dir");
|
||||||
|
let blobs = std::fs::read_dir(&item_dirs[0]).unwrap().count();
|
||||||
|
assert_eq!(blobs, 1, "old blob must be replaced, not accumulated");
|
||||||
|
|
||||||
|
// The key staging proof: no orphaned old blob / unstaged new blob.
|
||||||
|
assert!(!git_porcelain(f.vault_str()).contains("attachments/"),
|
||||||
|
"edit-replace left attachment changes unstaged (incomplete git add)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_edit_file_on_non_document_is_rejected() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
assert!(f.run(&["org", "add", "login", "--collection", "eng",
|
||||||
|
"--title", "Site", "--password", "p"]).status.success());
|
||||||
|
let srcdir = TempDir::new().unwrap();
|
||||||
|
let x = srcdir.path().join("x.txt");
|
||||||
|
std::fs::write(&x, b"nope").unwrap();
|
||||||
|
|
||||||
|
let out = f.run(&["org", "edit", "Site", "--file", x.to_str().unwrap()]);
|
||||||
|
assert!(!out.status.success(), "--file on a Login must be rejected");
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(stderr.contains("--file is only valid"), "unexpected error: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_document_into_ungranted_collection_is_denied() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
// Collection exists but the owner is NOT granted.
|
||||||
|
assert!(f.run(&["org", "create-collection", "secret", "--name", "Secret"]).status.success(),
|
||||||
|
"create-collection");
|
||||||
|
let srcdir = TempDir::new().unwrap();
|
||||||
|
let src = srcdir.path().join("f.txt");
|
||||||
|
std::fs::write(&src, b"data").unwrap();
|
||||||
|
|
||||||
|
let out = f.run(&["org", "add", "document", "--collection", "secret",
|
||||||
|
"--title", "X", "--file", src.to_str().unwrap()]);
|
||||||
|
assert!(!out.status.success(), "ungranted document add must fail");
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}");
|
||||||
|
// Grant is enforced before any attachment is written.
|
||||||
|
assert!(!f.vault_path().join("attachments").join("secret").exists(),
|
||||||
|
"no attachment dir should exist on a denied add");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "relicario-core"
|
name = "relicario-core"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Core library for relicario password manager"
|
description = "Core library for relicario password manager"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "relicario-wasm"
|
name = "relicario-wasm"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "WASM bindings for relicario password manager"
|
description = "WASM bindings for relicario password manager"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ collections.json # collection definitions
|
|||||||
keys/<member-id>.enc # org master key wrapped to that member's device key
|
keys/<member-id>.enc # org master key wrapped to that member's device key
|
||||||
manifest.enc # OrgManifest (schema_version 1, per-member-filtered)
|
manifest.enc # OrgManifest (schema_version 1, per-member-filtered)
|
||||||
items/<collection-slug>/<item-id>.enc # collection-scoped item blobs
|
items/<collection-slug>/<item-id>.enc # collection-scoped item blobs
|
||||||
|
attachments/<collection-slug>/<item-id>/<att-id>.enc # Document attachment blobs (collection-scoped)
|
||||||
```
|
```
|
||||||
|
|
||||||
### `org.json` — OrgMeta
|
### `org.json` — OrgMeta
|
||||||
@@ -123,7 +124,13 @@ Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org mas
|
|||||||
|
|
||||||
These blobs are written and read by the `relicario org` item commands (`org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge`), all collection-scoped and grant-enforced. `org add` currently creates Login / SecureNote / Identity items; `get` / `list` display any item type present.
|
These blobs are written and read by the `relicario org` item commands (`org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge`), all collection-scoped and grant-enforced. `org add` currently creates Login / SecureNote / Identity items; `get` / `list` display any item type present.
|
||||||
|
|
||||||
**TODO (extension follow-up):** extension UI for browsing and editing org vault items. **Deferred:** `org add` / `edit` parity for Card / Key / Document / Totp item types.
|
### `attachments/<collection-slug>/<item-id>/<att-id>.enc`
|
||||||
|
|
||||||
|
Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org master key — the encrypted file payload of a Document item. As with item blobs, the blob does **not** name its collection; the leading `<collection-slug>` path segment carries it, so the pre-receive hook (`relicario-server`, `classify_path`) authorizes the write by slug without decrypting — reusing the same grant + slug-existence check as the `items/` branch. The path is **exactly three segments** after `attachments/` (`<collection-slug>/<item-id>/<att-id>.enc`); the hook rejects any other shape (segment-count and `.`-free slug guards). `<att-id>` is the content-addressed `AttachmentId` (see **Item IDs and Field IDs** below).
|
||||||
|
|
||||||
|
Per-attachment size is capped at `DEFAULT_ORG_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024` (10 MiB) (`org_session.rs:24`), mirroring the personal-vault default `AttachmentCaps::per_attachment_max_bytes` (`settings.rs:116`). Org vaults have no `settings.enc`, so this cap is a fixed default rather than per-org configurable. Blobs are persisted / read / removed by `UnlockedOrgVault::save_attachment` / `load_attachment` / `remove_item_attachments` (`org_session.rs:137`, `:147`, `:156`). The storage primitives back the org **Document** item type; the `org add document` / Document-edit commands that produce these blobs land in v0.8.1 (see the item-type-parity note below).
|
||||||
|
|
||||||
|
**TODO (extension follow-up):** extension UI for browsing and editing org vault items. **Deferred:** `org add` / `edit` parity for Card / Key / Document / Totp item types (landing in v0.8.1; Document file payloads use the attachment layout above).
|
||||||
|
|
||||||
## Item IDs and Field IDs
|
## Item IDs and Field IDs
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Extension ↔ CLI Parity Gap Analysis
|
||||||
|
|
||||||
|
- **Date:** 2026-06-20
|
||||||
|
- **Author:** Dev-D, reconciled against an independent PM parity sweep
|
||||||
|
- **Status:** Draft for review — **forward-planning**, NOT v0.8.1 scope
|
||||||
|
- **Anchor commit:** `origin/main` `b09e0ce` (v0.8.0 org vault + v0.8.1 Dev-A foundation merged; Dev-B/C/D in flight)
|
||||||
|
- **Scope note:** This plans a *future* milestone. Extension org **writes** remain explicitly out of scope for v0.8.1 per `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` (Plan B-2).
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Survey the gap between the Relicario **CLI** (`relicario`) and the **browser extension**, classify every gap as a *real parity gap*, an *intended CLI-only* capability, or *already-planned-in-a-spec*, and produce a prioritized work list (with rough sizing) to bring the extension up to CLI parity. The driver is the project's **CLI/extension parity philosophy**: features should not ship "CLI-first, extension-later" without an explicit, recorded decision — this doc is that record for the current backlog.
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
Two **independent** surveys were run and then reconciled:
|
||||||
|
|
||||||
|
- **PM sweep** — 3 inventory agents + synthesis.
|
||||||
|
- **Dev-D sweep** — 4 parallel readers (CLI / extension-UI / extension-SW / specs+roadmap) → synthesis → an adversarial completeness/accuracy critic, all reading from a worktree pinned at `b09e0ce`.
|
||||||
|
|
||||||
|
The two sweeps were deliberately blind to each other. All load-bearing claims in this document were **hand-verified against source** (greps + line reads); where the two sweeps disagree, the disagreement is flagged explicitly in §Reconciliation. Line citations are point-in-time against `b09e0ce` and may drift.
|
||||||
|
|
||||||
|
## Executive summary
|
||||||
|
|
||||||
|
Core item-CRUD parity is **excellent**. All 7 item types (Login, Secure Note, Identity, Card, Key, TOTP, Document) and the add / edit / view / list / trash / restore lifecycle are at full parity, and in several places the **extension is the richer surface** (live TOTP codes, custom fields/sections, TOTP-from-QR, password coloring, session auto-lock, autofill/capture). Where a *per-type* gap exists it is most often on the **CLI** side, not the extension's.
|
||||||
|
|
||||||
|
The genuine **extension-side** gaps cluster into three buckets:
|
||||||
|
|
||||||
|
1. **Metadata-management gaps (the headline finding):** editing **groups**, **tags**, and **filtering** is wired into only specific forms/surfaces in the extension, while the CLI offers them uniformly across all types; **favorites** has *zero* extension UI (strictly worse than the CLI). These are real, currently-shipping parity gaps on the *personal* vault.
|
||||||
|
2. **Backend-exists-but-no-wire/UI:** attachment **removal** (`removeAttachmentsFromItem` helper exists, no `remove_attachment` router message), **per-item purge** (`purge_item` handler exists, only a bulk "empty trash" UI), and the `isInTab()` popup-mode gate that hides login/secure-note attachment editing in the popup window.
|
||||||
|
3. **The org (enterprise) vault** — the single largest gap. The entire org feature (shipped CLI-only in v0.8.0) has **no extension presence** (no org routes, no org context). This is fully specced and explicitly deferred (Dev-D org-read / Plan B-2 org-write).
|
||||||
|
|
||||||
|
Plus one quality gap on the personal side surfaced by the PM sweep: **autofill hostname matching** is a naive exact-equality match.
|
||||||
|
|
||||||
|
Intentionally **CLI-only by design** (not gaps): real `git pull`/`push` and `.git`-history backup bundling (the extension writes straight to the host Contents API and keeps no local repo), the `imgsecret embed` recovery subcommand, recovery-QR's deliberate no-file-write contract, org **admin** (members/collections/grants/rotate/audit), and shell completions.
|
||||||
|
|
||||||
|
## Reconciliation with the PM sweep
|
||||||
|
|
||||||
|
The PM sweep concluded: extension at *near-full parity* on the personal surface, ahead in places, with the **org vault as the one material gap** and **autofill hostname matching as the only personal-side quality gap**.
|
||||||
|
|
||||||
|
**Agreements (both sweeps, independently):**
|
||||||
|
- Org vault is the largest gap; it is fully specced and deferred (Dev-D read / Plan B-2 write).
|
||||||
|
- The extension leads on live TOTP, custom fields/sections, password coloring.
|
||||||
|
- The intended-CLI-only set: git sync/push, `.git` backup bundling, device-key deploy-key plumbing, org admin, shell completions.
|
||||||
|
|
||||||
|
**Dev-D refines / partially refutes the PM:** the personal surface is **not** "near-full parity with autofill as the only gap." There is a real cluster of **personal-side extension gaps** the PM sweep understated:
|
||||||
|
- **Favorites — none in the extension** (`favorite` only round-trips through save fns; no toggle, no star in lists, no filter). The CLI is itself only add-only, so the extension is *strictly worse*. The PM hypothesis did not list this.
|
||||||
|
- **Group editing — Login-form only** (`f-group` + `wireGroupAutocomplete` live in `login.ts` only; card/key/identity/totp/document forms pass `group` through without an input).
|
||||||
|
- **Tag editing — Document-form only** (`f-tags` in `document.ts` only; other forms preserve-but-don't-edit).
|
||||||
|
- **Filter — popup has no type filter** (vault-tab only) and **no tag filter** anywhere.
|
||||||
|
- **Per-item purge** and **attachment add/remove** have working backends but no popup-reachable UI / no router wire.
|
||||||
|
|
||||||
|
**PM caught, Dev-D's taxonomy missed:** **autofill hostname matching.** `service-worker/vault.ts` (`findByHostname`, equality at `:344`) matches credentials by exact `icon_hint` equality (`(e.icon_hint ?? '').toLowerCase() === hostname`) — no `www.` strip, no registrable-domain (eTLD+1) match, so `www.example.com` will not match an item stored as `example.com`. Confirmed; folded in as a real LOW-MED personal-side gap. (Dev-D's capability taxonomy centered on item-CRUD/features and under-weighted the content-script autofill path — the PM sweep is the reason it appears here.)
|
||||||
|
|
||||||
|
**Methodology correction (a Dev-D self-sweep error, struck here):** the Dev-D extension-SW inventory referred to a `messages.ts` "that does not exist at that path." **That is false** — the file exists at `extension/src/shared/messages.ts` (227 lines): it holds the `PopupMessage` union (with `delete_item // soft-delete` at line 23), `POPUP_ONLY_TYPES` (line 168), and `CONTENT_CALLABLE_TYPES` (line 224). The inventory had merely dropped the `shared/` directory prefix. The substantive findings it supported (the unwired `searchItems`/`removeAttachmentsFromItem` helpers) are independently verified correct; the "file doesn't exist" caveat is removed from this document.
|
||||||
|
|
||||||
|
## Parity matrix
|
||||||
|
|
||||||
|
Support: **full** / **partial** / **none** / **n/a**. `gap_class`: **at-parity** · **real-gap** (extension work) · **real-gap (CLI-side)** (extension already ahead; CLI backlog) · **cli-only-by-design** · **already-planned**.
|
||||||
|
|
||||||
|
### Item types
|
||||||
|
|
||||||
|
| Capability | CLI | Ext | gap_class | Notes (evidence @ `b09e0ce`) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Login: create/view/edit | full | full | at-parity | CLI `add/get/edit login`; ext form + `add_item`/`update_item`/`get_item`. |
|
||||||
|
| Secure Note: create/view/edit | full | full | at-parity | Both complete. |
|
||||||
|
| Identity: create | full | full | at-parity | Both; ext also exposes `address`. |
|
||||||
|
| Identity: view | full | full | at-parity | Both. |
|
||||||
|
| Identity: edit | partial | full | real-gap (CLI-side) | CLI `edit_identity` omits `date_of_birth` + records no history; ext edits all. CLI backlog. |
|
||||||
|
| Card: create/view | full | full | at-parity | Both. |
|
||||||
|
| Card: edit | partial | full | real-gap (CLI-side) | CLI `edit_card` = holder+number only (no CVV/PIN/expiry/kind); ext edits all. CLI backlog. |
|
||||||
|
| Key: create/view | full | full | at-parity | Both; ext takes `public_key` interactively. |
|
||||||
|
| Key: edit | partial | full | real-gap (CLI-side) | CLI `edit_key` = key-material only (no label/algorithm/public_key); ext edits all. CLI backlog. |
|
||||||
|
| TOTP: create | full | full | at-parity | Both; ext adds Steam Guard kind. |
|
||||||
|
| TOTP: view | partial | full | real-gap (CLI-side) | CLI shows metadata only; ext shows live rotating code. See "TOTP live code". |
|
||||||
|
| TOTP: edit | full | full | at-parity | Both. |
|
||||||
|
| Document: create | full | full | at-parity | CLI encrypts file as attachment; ext `upload_attachment`. |
|
||||||
|
| Document: view | partial | full | real-gap (CLI-side) | CLI metadata + `extract`; ext inline image preview. CLI backlog. |
|
||||||
|
| Document: edit | none | full | real-gap (CLI-side) | CLI `edit` on Document is a no-op redirect to attach/extract; ext changes primary/supplementary files. CLI backlog. |
|
||||||
|
|
||||||
|
### Operations
|
||||||
|
|
||||||
|
| Capability | CLI | Ext | gap_class | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| add / edit / get / list | full | full | at-parity | All 7 types both surfaces. |
|
||||||
|
| rm / soft-delete | full | full | at-parity | CLI `rm`; ext `delete_item` (`messages.ts:23`, handler `popup-only.ts`). |
|
||||||
|
| trash (list) | full | full | at-parity | CLI `trash`; ext trash view. |
|
||||||
|
| restore from trash | full | full | at-parity | CLI `restore`; ext `restore_item`. |
|
||||||
|
| purge (permanent) | full | partial | **real-gap** | Ext UI only bulk "empty trash" (`purge_all_trash`, `popup-only.ts:420`); **no per-item purge UI**, though `purge_item` handler exists (`popup-only.ts:409`). CLI has single + bulk. |
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
| Capability | CLI | Ext | gap_class | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Attachments: add | full | partial | **real-gap** | Login/secure-note attachment editing gated behind `isInTab()` (`login.ts:370,388`; `secure-note.ts:123,140`) — unavailable in popup window; document renders unconditionally (`document.ts`). SW `upload_attachment` is full. |
|
||||||
|
| Attachments: view/download | full | full | at-parity | CLI `extract`; ext download + `download_attachment`. |
|
||||||
|
| Attachments: remove | full | partial | **real-gap** | SW helper `removeAttachmentsFromItem` (`vault.ts:492`) has **no router wire** (`remove_attachment` absent — confirmed). UI removes refs at form-save only, with the same `isInTab()` caveat. CLI `detach` is full. |
|
||||||
|
| TOTP: live code | none | full | real-gap (CLI-side) | CLI reveals raw base32 only; ext computes live codes. Extension leads. No spec mandates CLI OTP. |
|
||||||
|
| Generator: password / passphrase | full | full | at-parity | CLI `generate`; ext generator-panel + `generate_password`. |
|
||||||
|
| Settings: view / edit | full | full | at-parity | CLI `settings`; ext `get/set_vault_settings`. |
|
||||||
|
| Search | partial | partial | at-parity | CLI: title-substring. Ext: client-side over title/group/tags/icon_hint; SW `searchItems` (`vault.ts:316`) exists but **unwired** (no `search_items` message). Neither does field-value full-text. |
|
||||||
|
| Filter | full | partial | **real-gap** | CLI `list` filters type/group/tag/trashed. Ext: type filter is **vault-tab-only**; popup has none; **no tag filter anywhere**. SW `list_items` filters by `group` only. |
|
||||||
|
| Favorites | partial | **none** | **real-gap** | CLI add-only (`--favorite` on Login add, `*` in list; no toggle/filter). Ext: **zero UI** — `favorite` only round-trips. Ext strictly worse; needs a paired CLI+ext design. |
|
||||||
|
| Tags | full | partial | **real-gap** | CLI full create+filter all types. Ext: only Document form edits tags (`f-tags` in `document.ts`); no tag chips in lists; no tag filter. SW round-trips tags. |
|
||||||
|
| Groups/folders | full | partial | **real-gap** | CLI all types `--group`, `list --group`. Ext: only Login form has `f-group`+autocomplete; other forms set no group; vault-tab "group" filter is actually a type filter. SW `list_groups`/group-filter full. |
|
||||||
|
| Field history (view) | full | full | at-parity | CLI `history`; ext `get_field_history`. |
|
||||||
|
| Custom fields / sections | none | full | real-gap (CLI-side) | CLI has no custom-field/section commands (core supports them); ext `renderSectionsEditor` covers all 7 types. CLI backlog. |
|
||||||
|
| Autofill hostname matching | n/a | partial | **real-gap** | Ext-only feature; matcher (`vault.ts` `findByHostname`, `:344`) is exact `icon_hint` equality — no `www.` strip / eTLD+1. `www.x.com` ≠ `x.com`. (PM-surfaced.) |
|
||||||
|
|
||||||
|
### Org (enterprise) vault
|
||||||
|
|
||||||
|
| Capability | CLI | Ext | gap_class | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Org: read items | full | none | already-planned | CLI `org get/list` grant-filtered, all 7 types. Ext has zero org code. Planned Dev-D. Spec: `2026-06-06-relicario-enterprise-org-vault-design.md` § Extension — Org Context; ROADMAP "Extension org parity — read". |
|
||||||
|
| Org: write (add/edit/rm) | partial | none | already-planned | CLI write = Login/SecureNote/Identity only (`OrgAddKind`, `main.rs:560`; Card/Key/Document/Totp absent — v0.8.1 lift in flight). Ext none. Planned Plan B-2. Spec: `2026-06-20-relicario-v0.8.1-parity.md` § Out of scope. |
|
||||||
|
| Org: member/collection mgmt | full | none | cli-only-by-design | CLI full lifecycle (~19 subcommands). Ext none — org **admin** is intended CLI-only (high-trust, low-frequency). |
|
||||||
|
|
||||||
|
### Vault lifecycle / infra
|
||||||
|
|
||||||
|
| Capability | CLI | Ext | gap_class | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Vault init / setup | full | full | at-parity | CLI `init`; ext setup wizard + `create_vault`/`attach_vault`. |
|
||||||
|
| Git sync (pull/push) | full | partial | cli-only-by-design | CLI real `git pull --rebase`/`push`. Ext writes straight to host Contents API; no local graph (`ahead`/`behind` always 0). Functionally syncs; architecturally different by design (`extension/ARCHITECTURE.md`). |
|
||||||
|
| Device management | full | full | at-parity | CLI `device`; ext `renderDevices` + SW device CRUD. (GitHub/GitLab deploy-key API is the deferred edge.) |
|
||||||
|
| Backup / restore | full | full | at-parity | CLI `.relbak` + git-history bundling; ext `export/restore_backup`. `.git` bundling sub-aspect is cli-only-by-design (ext has no local repo). |
|
||||||
|
| Import (LastPass) | partial | partial | at-parity | Both LastPass-CSV only; other importers deferred both surfaces by policy. |
|
||||||
|
| Recovery QR | full | full | at-parity | CLI generate/unwrap; ext `generate/unwrap_recovery_qr`. Webcam scan deferred both. |
|
||||||
|
| Standalone generate (no vault) | full | none | cli-only-by-design (low-confidence) | CLI `generate`/`rate` work outside a vault; ext generator is embedded in login form + settings (needs unlocked vault). A browser extension lacks the "no vault" generator use-case a shell has. No spec; flag if user demand appears. |
|
||||||
|
|
||||||
|
### Intended CLI-only (no taxonomy row; recorded so they are not re-litigated as gaps)
|
||||||
|
|
||||||
|
| Capability | gap_class | Notes / spec |
|
||||||
|
|---|---|---|
|
||||||
|
| Recovery-QR file-write (`--out`) | cli-only-by-design | Negative API contract — no surface writes the payload to disk; absence *is* the security property. `2026-05-01-recovery-qr-design.md`. |
|
||||||
|
| Org delete-org push to remote | cli-only-by-design | Phase-1 delete-org is a local tombstone; pre-receive hook rejects protected-file deletion. Pushable delete-org is phase-2. `2026-06-06-...-design.md`. |
|
||||||
|
| `imgsecret embed` subcommand | cli-only-by-design | CLI disaster-recovery tool; the extension setup wizard's image flow covers the equivalent. |
|
||||||
|
| Password coloring (CLI TTY) | cli-only-by-design (inverted) | Ext shipped it (v0.5.1); CLI TTY parity deferred until demand. `2026-05-01-password-coloring-design.md` § Out of scope. |
|
||||||
|
| Shell completions | cli-only-by-design | No extension analogue. |
|
||||||
|
|
||||||
|
## Gap classification summary
|
||||||
|
|
||||||
|
- **Real extension gaps (extension work closes them):** per-item purge UI; attachment add/remove UI + `remove_attachment` wire + `isInTab()` gate; popup type filter + tag filter; tag editing on all forms; group editing on all forms; favorites UI; autofill registrable-domain matching; **org read** (specced); **org write** (specced, behind CLI type parity).
|
||||||
|
- **CLI-side gaps (extension already ahead — separate CLI backlog, NOT extension work):** Identity/Card/Key edit field coverage; Document view/edit; live TOTP code; custom fields/sections commands.
|
||||||
|
- **Intended CLI-only (not gaps):** git pull/push, `.git` backup bundling, org admin, `imgsecret embed`, recovery-QR file-write, shell completions, standalone generate.
|
||||||
|
- **Already planned / deferred:** org read (Dev-D), org write + org item-type breadth (Plan B-2), org attachments/multi-vault (behind org).
|
||||||
|
|
||||||
|
## Prioritized forward work (extension)
|
||||||
|
|
||||||
|
Only items where **extension work** closes the gap. CLI-side gaps and intended-CLI-only items are excluded.
|
||||||
|
|
||||||
|
| Pri | Item | Size | Why | Depends on |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **P0** | **Attachment remove + un-gate popup:** wire a `remove_attachment` router message to `removeAttachmentsFromItem` (`vault.ts:492`); drop the `isInTab()` gate so login/secure-note attachment **add & remove** work in the popup. Closes *both* the add half (row "Attachments: add") and remove half. | M | Backend exists and is unreachable — highest value-per-effort. The popup-mode gate is a UX cliff (can't manage login attachments without popping out). | — |
|
||||||
|
| **P0** | **Per-item purge UI:** surface the existing `purge_item` handler (`popup-only.ts:409`) as a per-row permanent-delete in the trash view (today only bulk `purge_all_trash`). | S | Pure UI wiring over an existing handler; CLI has single-item purge. | — |
|
||||||
|
| **P1** | **Group editing on all type forms:** add `f-group` + `wireGroupAutocomplete` to card/key/identity/totp/document (Login-only today). | M | SW (`list_groups`, group filter) already full; replicate one existing form pattern across 5 forms. | — |
|
||||||
|
| **P1** | **Tag editing on all type forms + tag chips in lists:** promote Document's `f-tags` to a shared affordance on all 7 forms. | M | SW round-trips tags fully; only Document edits them today. | — |
|
||||||
|
| **P1** | **Filter parity:** add a type filter to the popup (vault-tab has it) and a tag filter to popup + vault tab; optionally push type/tag params into `list_items`. | M | CLI filters type/group/tag; ext type filter is fullscreen-only, tag filter absent. | Tag editing (so tags exist to filter on) |
|
||||||
|
| **P2** | **Favorites (paired CLI + ext):** favorite toggle in detail/edit, favorites filter, star in list rows — and extend the CLI beyond add-only, to reach *true* parity per the parity philosophy. | M | Ext strictly worse than CLI (none vs partial); both surfaces weak. Write a short spec first. | — (spec TBD) |
|
||||||
|
| **P2** | **Autofill registrable-domain matching:** replace exact `icon_hint` equality (`vault.ts` `findByHostname:344`) with `www.`-strip + eTLD+1 matching. | S–M | `www.x.com` ≠ `x.com` today; the one personal-side quality gap. | — |
|
||||||
|
| **P2** | **Search wire-up (hardening):** expose `searchItems` (`vault.ts:316`) via a `search_items` message, or formally adopt client-side filtering and remove the dangling helper. | S | Functionally at-parity, but an unwired helper is dead-code drift. | — |
|
||||||
|
| **P3** | **Org read in extension (Dev-D):** org context switcher + SW org handlers (unwrap org master key into a `Zeroizing` session handle) + grant-filtered manifest browse/read in popup + vault tab. | XL | Largest single gap; entire org feature is CLI-only in the extension. Specced, deferred. | — |
|
||||||
|
| **P3** | **Org offline read-only indicator:** "org offline — writes disabled" banner when the git remote is unreachable in org context. | S | Spec-mandated UX. | Org read |
|
||||||
|
| **P3** | **Org SW acceptance tests:** org context replaces personal manifest cleanly; org master key never in localStorage/IndexedDB; offline mode triggers on network error. | M | Spec-mandated coverage following the feature. | Org read |
|
||||||
|
| **P3** | **Org write in extension (Plan B-2):** org add/edit/rm including Card/Key/Document/Totp. | XL | Closes org write + item breadth. Deferred past v0.8.1. | Org read **and** CLI reaching Card/Key/Document/Totp org-write parity (v0.8.1) |
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
- Line citations are point-in-time against `b09e0ce` and drift with edits.
|
||||||
|
- This is a planning artifact, not a commitment; sizes are rough (S ≈ hours, M ≈ a day, L ≈ days, XL ≈ a multi-stream lift).
|
||||||
|
- Two analytical errors caught during cross-check and corrected here: (1) the struck `messages.ts`-doesn't-exist claim (file exists at `extension/src/shared/messages.ts`); (2) a few inventory line numbers were off by single digits and have been replaced with hand-verified ones.
|
||||||
Reference in New Issue
Block a user