feat(cli): --*-stdin secret flags for personal add (non-interactive secrets)
This commit is contained in:
@@ -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_<type>_item`, `edit_<type>`) 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_<type>_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/<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`
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user