//! 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>; /// 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 { 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 { 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 { 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 { 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, ) -> 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, url: Option, password: Option, password_stdin: bool, password_prompt: bool, totp_qr: Option, ) -> Result { 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, 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, }))) } #[allow(clippy::too_many_arguments)] 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, old_value: zeroize::Zeroizing, ) { // 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()); } }