diff --git a/crates/relicario-cli/src/commands/add.rs b/crates/relicario-cli/src/commands/add.rs new file mode 100644 index 0000000..6d58154 --- /dev/null +++ b/crates/relicario-cli/src/commands/add.rs @@ -0,0 +1,314 @@ +//! `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) +} diff --git a/crates/relicario-cli/src/commands/mod.rs b/crates/relicario-cli/src/commands/mod.rs index 57cc995..f1a8798 100644 --- a/crates/relicario-cli/src/commands/mod.rs +++ b/crates/relicario-cli/src/commands/mod.rs @@ -6,6 +6,7 @@ //! this file as `pub(crate)` so siblings can pull them in via //! `use crate::commands::*`. +pub mod add; pub mod attach; pub mod backup; pub mod device; diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 425f1c7..8a32f5a 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -12,14 +12,10 @@ mod session; use std::path::PathBuf; -use anyhow::{Context, Result}; +use anyhow::Result; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Shell}; -use crate::commands::commit_paths; -use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year}; -use crate::prompt::{prompt, prompt_optional, prompt_secret}; - #[derive(Parser)] #[command( name = "relicario", @@ -428,7 +424,7 @@ fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Commands::Init { image, output } => commands::init::cmd_init(image, output), - Commands::Add { kind } => cmd_add(kind), + Commands::Add { kind } => commands::add::cmd_add(kind), Commands::Get { query, show, copy } => commands::get::cmd_get(query, show, copy), Commands::List { r#type, group, tag, trashed } => commands::list::cmd_list(r#type, group, tag, trashed), Commands::Edit { query, totp_qr } => commands::edit::cmd_edit(query, totp_qr), @@ -509,310 +505,5 @@ pub(crate) fn test_backup_passphrase_override() -> Option { None } -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)?; - 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(); - 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(()) -} - -// --- Per-type item builders, one per AddKind variant. Each returns a -// fully-populated Item; cmd_add handles the common save/manifest/commit -// wrap-up. Document is the only builder that needs the unlocked vault -// (for attachment-cap settings + writing the encrypted blob alongside -// the item). - -#[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) -}