feat(cli): --*-stdin secret flags for personal add (non-interactive secrets)

This commit is contained in:
adlee-was-taken
2026-06-20 17:51:15 -04:00
parent 751e4e9bb1
commit 6eb1275710
4 changed files with 88 additions and 36 deletions

View File

@@ -37,15 +37,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.
@@ -167,7 +180,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 +250,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 +599,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

View File

@@ -15,22 +15,22 @@ pub fn cmd_add(kind: AddKind) -> Result<()> {
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 } => {
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?; 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 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 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.group = group; item.tags = tags; item.favorite = favorite;
item item
} }
AddKind::SecureNote { title, body_prompt: _, group, tags } => { AddKind::SecureNote { title, body_stdin, group, tags } => {
// NOTE: per the v0.8.1 spec's unified secret model, a note body is a // 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 // multiline secret that always reads stdin to EOF. `body_stdin=false`
// `prompt("Body")` path is retired. `false` here means "print the Ctrl-D // means "print the Ctrl-D hint" (interactive default); `true` suppresses
// hint" (interactive default). A4 replaces `--body-prompt` with `--body-stdin` // the hint for non-interactive use.
// and threads the real flag. `body_prompt` is intentionally ignored this task. // Secret-resolution rule: `commands/item_build.rs` `resolve_secret_multiline`.
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?; 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.group = group; item.tags = tags;
item item
} }
@@ -40,18 +40,18 @@ pub fn cmd_add(kind: AddKind) -> Result<()> {
item.group = group; item.tags = tags; item.group = group; item.tags = tags;
item 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 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.group = group; item.tags = tags;
item 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` // 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). // for it was unreachable (stdin already at EOF after the key-material read).
// Org `add key` (Dev-B) supplies it via --public-key. // Org `add key` (Dev-B) supplies it via --public-key.
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?; 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.group = group; item.tags = tags;
item 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)?; std::fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;
item 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 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.group = group; item.tags = tags;
item item
} }

View File

@@ -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,

View File

@@ -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}");
}