From 65e23cfddc727349f0920e7410ee21577da75caf Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 17:35:18 -0400 Subject: [PATCH] refactor(cli): personal add delegates to shared item_build builders --- crates/relicario-cli/src/commands/add.rs | 345 ++++-------------- .../relicario-cli/src/commands/item_build.rs | 122 +++++++ 2 files changed, 184 insertions(+), 283 deletions(-) diff --git a/crates/relicario-cli/src/commands/add.rs b/crates/relicario-cli/src/commands/add.rs index b8eb2d0..45739c1 100644 --- a/crates/relicario-cli/src/commands/add.rs +++ b/crates/relicario-cli/src/commands/add.rs @@ -1,37 +1,76 @@ //! `relicario add ` — 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, 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)?; + 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. + let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?; + let mut item = ib::build_secure_note(title, None, /*body_stdin*/ false)?; + 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, 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)?; + item.group = group; item.tags = tags; + item + } + AddKind::Key { title, label, algorithm, 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)?; + 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, 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)?; + 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, - username: Option, - url: Option, - password_prompt: bool, - password: Option, - group: Option, - tags: Vec, - favorite: bool, - totp_qr: Option, -) -> Result { - 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, - body_prompt: bool, - group: Option, - tags: Vec, -) -> Result { - 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, - full_name: Option, - email: Option, - phone: Option, - date_of_birth: Option, - group: Option, - tags: Vec, -) -> Result { - 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, - holder: Option, - expiry: Option, - kind: String, - group: Option, - tags: Vec, -) -> Result { - 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, - label: Option, - algorithm: Option, - group: Option, - tags: Vec, -) -> Result { - 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, - file: PathBuf, - group: Option, - tags: Vec, -) -> Result { - 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, - issuer: Option, - label: Option, - secret: Option, - period: u32, - digits: u8, - algorithm: String, - group: Option, - tags: Vec, -) -> Result { - 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/item_build.rs b/crates/relicario-cli/src/commands/item_build.rs index e80a168..45ed55c 100644 --- a/crates/relicario-cli/src/commands/item_build.rs +++ b/crates/relicario-cli/src/commands/item_build.rs @@ -162,6 +162,128 @@ pub(crate) fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: & Ok(()) } +pub(crate) fn build_login( + title: String, username: Option, url: Option, + password: Option, password_stdin: bool, password_prompt: bool, + totp_qr: Option, +) -> Result { + use relicario_core::item_types::{LoginCore, TotpAlgorithm, 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(true, "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, body_stdin: bool) -> Result { + 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, email: Option, + phone: Option, date_of_birth: Option, +) -> Result { + 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, expiry: Option, kind: &str, + number_stdin: bool, cvv_stdin: bool, pin_stdin: bool, +) -> Result { + 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, algorithm: Option, + public_key: Option, material_stdin: bool, +) -> Result { + 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, + }))) +} + +pub(crate) fn build_totp( + title: String, issuer: Option, label: Option, + secret: Option, secret_stdin: bool, period: u32, digits: u8, algorithm: &str, +) -> Result { + 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,