//! `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). use std::path::PathBuf; use anyhow::{Context, Result}; use crate::AddKind; use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year}; use crate::prompt::{prompt, prompt_optional, prompt_secret}; 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)?, }; vault.save_item(&item)?; manifest.upsert(&item); vault.save_manifest(&manifest)?; crate::refresh_groups_cache(vault.root(), &manifest); let mut paths: Vec = vec![ format!("items/{}.enc", item.id.as_str()), "manifest.enc".into(), ]; for att in &item.attachments { paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str())); } let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect(); super::commit_paths(&vault, &format!("add: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), &path_refs)?; 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 = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; let username = username.or_else(|| prompt_optional("Username").ok().flatten()); let url = url.or_else(|| prompt_optional("URL").ok().flatten()); 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 = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; 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 = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; 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 = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; 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 = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; 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 = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; 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 = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; 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) }