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 b8eb2d0..3b841e5 100644 --- a/crates/relicario-cli/src/commands/add.rs +++ b/crates/relicario-cli/src/commands/add.rs @@ -1,37 +1,76 @@ //! `relicario add <kind>` — create a new item of the given type. //! -//! `cmd_add` does the common save / manifest upsert / commit dance. The seven -//! per-type `build_*_item` helpers each return a fully-populated `Item`. The -//! `Document` builder is the only one that needs the unlocked vault (for the -//! attachment-cap settings + writing the encrypted blob alongside the item). +//! `cmd_add` resolves `title` / non-secret prompts, then delegates to the +//! shared builders in `commands/item_build.rs`. Group / tags / favorite are +//! set AFTER the build so the builders stay portable to the org vault. -use std::path::PathBuf; - -use anyhow::{Context, Result}; +use anyhow::Result; use crate::AddKind; -use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year}; -use crate::prompt::{prompt, prompt_optional, prompt_or_flag, prompt_or_flag_optional, prompt_secret}; +use crate::commands::item_build as ib; +use crate::prompt::{prompt_or_flag, prompt_or_flag_optional}; pub fn cmd_add(kind: AddKind) -> Result<()> { let vault = crate::session::UnlockedVault::unlock_interactive()?; let mut manifest = vault.load_manifest()?; let item = match kind { - AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } => - build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?, - AddKind::SecureNote { title, body_prompt, group, tags } => - build_secure_note_item(title, body_prompt, group, tags)?, - AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } => - build_identity_item(title, full_name, email, phone, date_of_birth, group, tags)?, - AddKind::Card { title, holder, expiry, kind, group, tags } => - build_card_item(title, holder, expiry, kind, group, tags)?, - AddKind::Key { title, label, algorithm, group, tags } => - build_key_item(title, label, algorithm, group, tags)?, - AddKind::Document { title, file, group, tags } => - build_document_item(&vault, title, file, group, tags)?, - AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } => - build_totp_item(title, issuer, label, secret, period, digits, algorithm, group, tags)?, + 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, password_prompt, totp_qr)?; + item.group = group; item.tags = tags; item.favorite = favorite; + item + } + 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)?; + 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)?; @@ -51,263 +90,3 @@ pub fn cmd_add(kind: AddKind) -> Result<()> { eprintln!("Added: {} (id={})", item.title, item.id.as_str()); 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) -} diff --git a/crates/relicario-cli/src/commands/edit.rs b/crates/relicario-cli/src/commands/edit.rs index 798aa52..14897cd 100644 --- a/crates/relicario-cli/src/commands/edit.rs +++ b/crates/relicario-cli/src/commands/edit.rs @@ -2,10 +2,9 @@ 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, prompt_secret, prompt_yesno}; +use crate::prompt::{prompt_keep, prompt_keep_opt}; pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> { 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; match &mut item.core { - ItemCore::Login(l) => edit_login(l, history, totp_qr)?, - ItemCore::SecureNote(n) => edit_secure_note(n, history)?, - ItemCore::Identity(i) => edit_identity(i)?, - ItemCore::Card(c) => edit_card(c, history)?, - ItemCore::Key(k) => edit_key(k, history)?, - ItemCore::Document(_) => edit_document_message(), - ItemCore::Totp(t) => edit_totp(t, history)?, + ItemCore::Login(l) => crate::commands::item_build::edit_login(l, history, totp_qr)?, + ItemCore::SecureNote(n) => crate::commands::item_build::edit_secure_note(n, history)?, + ItemCore::Identity(i) => crate::commands::item_build::edit_identity(i)?, + ItemCore::Card(c) => crate::commands::item_build::edit_card(c, history)?, + ItemCore::Key(k) => crate::commands::item_build::edit_key(k, history)?, + ItemCore::Document(_) => crate::commands::item_build::edit_document_message(), + ItemCore::Totp(t) => crate::commands::item_build::edit_totp(t, history)?, } 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()); 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(), - }); -} diff --git a/crates/relicario-cli/src/commands/item_build.rs b/crates/relicario-cli/src/commands/item_build.rs new file mode 100644 index 0000000..a19661f --- /dev/null +++ b/crates/relicario-cli/src/commands/item_build.rs @@ -0,0 +1,318 @@ +//! 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::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, + }))) +} + +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::{encrypt_attachment, AttachmentRef}; + let bytes = std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?; + let enc = encrypt_attachment(&bytes, key, 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 = crate::parse::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.attachments.push(AttachmentRef { + id: primary_attachment, filename, mime_type, size: bytes.len() as u64, 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()); + } +} diff --git a/crates/relicario-cli/src/commands/mod.rs b/crates/relicario-cli/src/commands/mod.rs index 077e366..0c83da0 100644 --- a/crates/relicario-cli/src/commands/mod.rs +++ b/crates/relicario-cli/src/commands/mod.rs @@ -14,6 +14,7 @@ pub mod edit; pub mod generate; pub mod get; pub mod import; +pub mod item_build; pub mod org; pub mod init; pub mod list; 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/src/prompt.rs b/crates/relicario-cli/src/prompt.rs index 6f3e52e..87c2d3a 100644 --- a/crates/relicario-cli/src/prompt.rs +++ b/crates/relicario-cli/src/prompt.rs @@ -1,13 +1,12 @@ //! Interactive prompt helpers for the CLI. //! -//! The `prompt`/`prompt_optional`/`prompt_secret` family reads from stdin / -//! the TTY; the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are +//! `prompt_secret` reads a masked secret from the TTY (honouring +//! `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 -//! at a blank prompt. `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET` -//! so integration tests (which don't have a TTY) can inject secrets. -//! `prompt_or_flag` and `prompt_or_flag_optional` thread a CLI-flag value -//! 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. +//! at a blank prompt. `prompt_or_flag` and `prompt_or_flag_optional` thread a +//! CLI-flag value through the same path so command handlers can use one call +//! site whether the value came from the command line or an interactive prompt. use anyhow::Result; 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) }) } -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>> { eprint!("{label} [{current}]: "); std::io::Write::flush(&mut std::io::stderr())?; 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}"); +}