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),
|
`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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user