From fe017455d3dd607bf515a7580fa5cb487720f74e Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 22:16:51 -0400 Subject: [PATCH] =?UTF-8?q?feat(cli):=20relicario=20add=20=E2=80=94=20rema?= =?UTF-8?q?ining=206=20item=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SecureNote, Identity, Card, Key, Document (with inline attachment), and Totp with base32 secret decoding. Document widens the commit to include the attachment blob path. --- crates/relicario-cli/Cargo.toml | 1 + crates/relicario-cli/src/main.rs | 234 ++++++++++++++++++++++++++++++- 2 files changed, 231 insertions(+), 4 deletions(-) diff --git a/crates/relicario-cli/Cargo.toml b/crates/relicario-cli/Cargo.toml index 5f6a773..76574c5 100644 --- a/crates/relicario-cli/Cargo.toml +++ b/crates/relicario-cli/Cargo.toml @@ -23,6 +23,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" zeroize = "1" url = "2" +data-encoding = "2" [dev-dependencies] assert_cmd = "2" diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 1662f02..dec59b0 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -387,16 +387,202 @@ fn cmd_add(kind: AddKind) -> Result<()> { item.favorite = favorite; item } - // Task 8 fills in the other variants. - _ => anyhow::bail!("item kind not yet implemented"), + AddKind::SecureNote { title, body_prompt, group, tags } => { + 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; + item + } + + AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } => { + 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; + item + } + + AddKind::Card { title, holder, expiry, kind, group, tags } => { + 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(rpassword::prompt_password("Card number: ")?); + let cvv = Zeroizing::new(rpassword::prompt_password("CVV (blank to skip): ")?); + let cvv = if cvv.is_empty() { None } else { Some(cvv) }; + let pin = Zeroizing::new(rpassword::prompt_password("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; + item + } + + AddKind::Key { title, label, algorithm, group, tags } => { + 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; + item + } + + AddKind::Document { title, file, group, tags } => { + 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, + }); + + // Persist the attachment blob before we return from the match arm. + 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)?; + item + } + + AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } => { + 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 => rpassword::prompt_password("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 core = TotpCore { + config: TotpConfig { + secret: Zeroizing::new(secret_bytes), + algorithm: algo, + digits, + period_seconds: period, + kind: TotpKind::Totp, + }, + issuer, + label, + }; + let mut item = Item::new(title, ItemCore::Totp(core)); + item.group = group; + item.tags = tags; + item + } }; vault.save_item(&item)?; manifest.upsert(&item); vault.save_manifest(&manifest)?; - commit_paths(&vault, &format!("add: {} ({})", item.title, item.id.as_str()), - &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; + 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: {} ({})", item.title, item.id.as_str()), &path_refs)?; eprintln!("Added: {} (id={})", item.title, item.id.as_str()); Ok(()) @@ -421,6 +607,46 @@ fn prompt_optional(label: &str) -> Result> { Ok(if trimmed.is_empty() { None } else { Some(trimmed) }) } +fn parse_month_year(s: &str) -> Result { + // Accepts MM/YYYY or MM-YYYY or MM/YY. + let (m_str, y_str) = s.split_once(|c: char| c == '/' || c == '-') + .ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?; + let month: u8 = m_str.parse().context("invalid month")?; + let year: u16 = if y_str.len() == 2 { + 2000 + y_str.parse::().context("invalid 2-digit year")? + } else { + y_str.parse().context("invalid year")? + }; + Ok(relicario_core::MonthYear { month, year }) +} + +fn guess_mime(filename: &str) -> String { + let lower = filename.to_ascii_lowercase(); + match lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("") { + "pdf" => "application/pdf", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "txt" => "text/plain", + "json" => "application/json", + _ => "application/octet-stream", + }.to_string() +} + +fn base32_decode_lenient(s: &str) -> Result> { + let cleaned: String = s.chars() + .filter(|c| !c.is_whitespace()) + .collect::() + .to_ascii_uppercase() + .trim_end_matches('=') + .to_string(); + let padded = { + let rem = cleaned.len() % 8; + if rem == 0 { cleaned } else { format!("{}{}", cleaned, "=".repeat(8 - rem)) } + }; + data_encoding::BASE32.decode(padded.as_bytes()) + .map_err(|e| anyhow::anyhow!("invalid base32: {e}")) +} + fn commit_paths(vault: &crate::session::UnlockedVault, message: &str, paths: &[&str]) -> Result<()> { let mut args: Vec<&str> = vec!["add"]; args.extend_from_slice(paths);