From 6eb12757101f733b226fdf0a5a086f8fae7b3236 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 17:51:15 -0400 Subject: [PATCH] feat(cli): --*-stdin secret flags for personal add (non-interactive secrets) --- crates/relicario-cli/ARCHITECTURE.md | 62 +++++++++++++++-------- crates/relicario-cli/src/commands/add.rs | 30 +++++------ crates/relicario-cli/src/main.rs | 15 +++++- crates/relicario-cli/tests/basic_flows.rs | 17 +++++++ 4 files changed, 88 insertions(+), 36 deletions(-) diff --git a/crates/relicario-cli/ARCHITECTURE.md b/crates/relicario-cli/ARCHITECTURE.md index 76f73ca..1259b00 100644 --- a/crates/relicario-cli/ARCHITECTURE.md +++ b/crates/relicario-cli/ARCHITECTURE.md @@ -37,15 +37,28 @@ under `src/commands/`. Each source file has one job. `cmd_history`), `edit`, `trash` (rm / restore / purge / trash empty), `backup` (export / restore), `import` (lastpass), `attach` (attach / attachments / extract / detach), `generate`, `settings`, `sync`, `status`, - `rate`, `device`, `recovery_qr`. `add` and `edit` each fan out internally to - per-`ItemCore` helpers (`build__item`, `edit_`) so each - builder/editor reads top-to-bottom and can be tested through the same - integration paths. + `rate`, `device`, `recovery_qr`. `add` and `edit` resolve their non-secret + fields then delegate to the shared `item_build` module's per-`ItemCore` + `build_*` / `edit_*` helpers (see the next bullet), so each builder/editor + 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: - `prompt`, `prompt_optional`, `prompt_keep`, `prompt_keep_opt`, - `prompt_yesno`, `prompt_secret`. `prompt_secret` honours - `RELICARIO_TEST_ITEM_SECRET` before falling back to `rpassword`. + `prompt_keep`, `prompt_keep_opt`, `prompt_yesno`, `prompt_secret`, and the + flag-or-prompt pair `prompt_or_flag` / `prompt_or_flag_optional`. + `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET` before falling back to + `rpassword`. - **`src/parse.rs`** — pure parsers for CLI-typed inputs (e.g. MonthYear expiries, TOTP `otpauth://` URIs, comma-separated tag lists). No I/O. @@ -167,7 +180,7 @@ in code; cite the line if you change it. works without any setup. - **Item IDs are minted by core.** The CLI never constructs an `ItemId` - directly; `Item::new` (called inside every `build_*_item`) does it via + directly; `Item::new` (called inside every `item_build::build_*`) does it via `relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex. - **Manifest is always saved last.** Within a single command, the order is: @@ -237,15 +250,23 @@ in code; cite the line if you change it. ### Item add (`cmd_add`, `main.rs:419-456`) 1. Unlock the vault and load the manifest. -2. Match on the `AddKind` variant and dispatch to the matching - `build__item` helper (`main.rs:423-438`). Seven variants → seven - builders; only `build_document_item` takes `&UnlockedVault` because it - needs `attachment_caps` and writes the encrypted blob alongside the item. -3. The builder returns a fully-populated `Item` (with title, group, tags, +2. Match on the `AddKind` variant: resolve `title` and non-secret fields + (username, URL, holder, expiry, etc.) via `prompt_or_flag` / + `prompt_or_flag_optional`, then delegate to the matching `build_*` builder + in `commands/item_build.rs`. Seven variants → seven builders; only + `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). -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)`. -5. Build the path list — `items/.enc`, `manifest.enc`, plus one +6. Build the path list — `items/.enc`, `manifest.enc`, plus one `attachments//.enc` per attachment — and call `commit_paths` with message `add: (<id>)` (`main.rs:444-452`). @@ -578,11 +599,12 @@ applies to `relicario-core` unit tests, not these CLI integration tests. instead. Non-primary attachments on a Document (e.g., a scanned contract with an addendum) detach normally. -- **Per-type `build_*_item` / `edit_*` helpers exist by design after the - `3f0f5b1` refactor.** Before the refactor, `cmd_add` and `cmd_edit` - carried 217-line `match` arms. The split-out functions are easier to - read, easier to test individually (the existing integration tests still - drive them through the same paths), and easier to grow when a new +- **Per-type `build_*` / `edit_*` helpers exist by design** (extracted in the + `3f0f5b1` refactor, then centralized in `item_build.rs` for v0.8.1 so the + personal and org surfaces share one set). Before the extraction, `cmd_add` + and `cmd_edit` carried 217-line `match` arms. The split-out functions are + easier to read, easier to test individually (the existing integration tests + still drive them through the same paths), and easier to grow when a new `ItemCore` variant lands. Keep this shape — don't fold them back. - **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three diff --git a/crates/relicario-cli/src/commands/add.rs b/crates/relicario-cli/src/commands/add.rs index 45739c1..3b841e5 100644 --- a/crates/relicario-cli/src/commands/add.rs +++ b/crates/relicario-cli/src/commands/add.rs @@ -15,22 +15,22 @@ pub fn cmd_add(kind: AddKind) -> Result<()> { let mut manifest = vault.load_manifest()?; 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 } => { 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 mut item = ib::build_login(title, username, url, password, /*password_stdin*/ false, password_prompt, totp_qr)?; + let mut item = ib::build_login(title, username, url, password, password_stdin, password_prompt, totp_qr)?; item.group = group; item.tags = tags; item.favorite = favorite; item } - AddKind::SecureNote { title, body_prompt: _, group, tags } => { - // NOTE: per the v0.8.1 spec's unified secret model, a note body is a - // multiline secret that always reads stdin to EOF; the legacy single-line - // `prompt("Body")` path is retired. `false` here means "print the Ctrl-D - // hint" (interactive default). A4 replaces `--body-prompt` with `--body-stdin` - // and threads the real flag. `body_prompt` is intentionally ignored this task. + AddKind::SecureNote { title, body_stdin, group, tags } => { + // Per the v0.8.1 spec's unified secret model, a note body is a + // multiline secret that always reads stdin to EOF. `body_stdin=false` + // means "print the Ctrl-D hint" (interactive default); `true` suppresses + // the hint for non-interactive use. + // 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*/ false)?; + let mut item = ib::build_secure_note(title, None, body_stdin)?; item.group = group; item.tags = tags; item } @@ -40,18 +40,18 @@ pub fn cmd_add(kind: AddKind) -> Result<()> { item.group = group; item.tags = tags; item } - AddKind::Card { title, holder, expiry, kind, group, tags } => { + 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, false, false, false)?; + 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, group, tags } => { + 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, false)?; + let mut item = ib::build_key(title, label, algorithm, None, material_stdin)?; item.group = group; item.tags = tags; item } @@ -65,9 +65,9 @@ pub fn cmd_add(kind: AddKind) -> Result<()> { std::fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?; item } - AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } => { + 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, false, period, digits, &algorithm)?; + let mut item = ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm)?; item.group = group; item.tags = tags; item } diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index e9eb826..e3f586a 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -227,6 +227,8 @@ pub(crate) enum AddKind { /// Prompt for password (vs reading from stdin or --password). #[arg(long)] password_prompt: bool, #[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, value_delimiter = ',')] tags: Vec<String>, #[arg(long)] favorite: bool, @@ -235,7 +237,8 @@ pub(crate) enum AddKind { }, SecureNote { #[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, value_delimiter = ',')] tags: Vec<String>, }, @@ -253,6 +256,12 @@ pub(crate) enum AddKind { #[arg(long)] holder: Option<String>, #[arg(long)] expiry: Option<String>, // MM/YYYY #[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, value_delimiter = ',')] tags: Vec<String>, }, @@ -260,6 +269,8 @@ pub(crate) enum AddKind { #[arg(long)] title: Option<String>, #[arg(long)] label: 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, value_delimiter = ',')] tags: Vec<String>, }, @@ -274,6 +285,8 @@ pub(crate) enum AddKind { #[arg(long)] issuer: Option<String>, #[arg(long)] label: Option<String>, #[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 = "6")] digits: u8, #[arg(long, default_value = "sha1")] algorithm: String, diff --git a/crates/relicario-cli/tests/basic_flows.rs b/crates/relicario-cli/tests/basic_flows.rs index 5d73077..a6eaa96 100644 --- a/crates/relicario-cli/tests/basic_flows.rs +++ b/crates/relicario-cli/tests/basic_flows.rs @@ -201,3 +201,20 @@ fn generate_random_and_bip39() { let phrase = String::from_utf8(out.stdout).unwrap(); 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}"); +}