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/attach.rs b/crates/relicario-cli/src/commands/attach.rs new file mode 100644 index 0000000..8b99496 --- /dev/null +++ b/crates/relicario-cli/src/commands/attach.rs @@ -0,0 +1,175 @@ +//! `relicario attach` / `attachments` / `extract` / `detach` — per-attachment ops. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +use crate::parse::guess_mime; + +pub fn cmd_attach(query: String, file: PathBuf) -> Result<()> { + use std::fs; + use relicario_core::{encrypt_attachment, AttachmentRef}; + use relicario_core::time::now_unix; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + let entry = super::resolve_query(&manifest, &query)?; + let id = entry.id.clone(); + let _ = entry; + let mut item = vault.load_item(&id)?; + let settings = vault.load_settings()?; + let caps = settings.attachment_caps; + + if item.attachments.len() as u32 >= caps.per_item_max_count { + anyhow::bail!("item already has {} attachments (max {})", + item.attachments.len(), caps.per_item_max_count); + } + + let bytes = fs::read(&file) + .with_context(|| format!("failed to read {}", file.display()))?; + + // Check per-vault total attachment bytes cap (audit I3). + let current_total: u64 = manifest.items.values() + .flat_map(|e| &e.attachment_summaries) + .map(|s| s.size) + .sum(); + let new_size = bytes.len() as u64; + let hard_cap = caps.per_vault_hard_cap_bytes; + let soft_cap = caps.per_vault_soft_cap_bytes; + if current_total + new_size > hard_cap { + anyhow::bail!( + "attachment would exceed vault hard cap ({} + {} > {} bytes)", + current_total, new_size, hard_cap + ); + } + if current_total + new_size > soft_cap { + eprintln!( + "warning: vault attachments will exceed soft cap ({} bytes)", + soft_cap + ); + } + + 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 aref = AttachmentRef { + id: enc.id.clone(), + filename, + mime_type, + size: bytes.len() as u64, + created: now_unix(), + }; + + 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", enc.id.as_str())), &enc.bytes)?; + + item.attachments.push(aref); + item.modified = now_unix(); + vault.save_item(&item)?; + manifest.upsert(&item); + vault.save_manifest(&manifest)?; + + let paths = [ + format!("items/{}.enc", item.id.as_str()), + "manifest.enc".into(), + format!("attachments/{}/{}.enc", item.id.as_str(), enc.id.as_str()), + ]; + let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect(); + super::commit_paths(&vault, &format!("attach: {} → {} ({})", + crate::helpers::sanitize_for_commit(&file.display().to_string()), + crate::helpers::sanitize_for_commit(&item.title), + item.id.as_str()), &path_refs)?; + eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str()); + Ok(()) +} + +pub fn cmd_attachments(query: String) -> Result<()> { + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let manifest = vault.load_manifest()?; + let entry = super::resolve_query(&manifest, &query)?; + let item = vault.load_item(&entry.id)?; + if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); } + println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME"); + for a in &item.attachments { + println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename); + } + Ok(()) +} + +pub fn cmd_extract(query: String, aid: String, out: Option) -> Result<()> { + use std::fs; + use relicario_core::decrypt_attachment; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let manifest = vault.load_manifest()?; + let entry = super::resolve_query(&manifest, &query)?; + let item = vault.load_item(&entry.id)?; + + let aref = item.attachments.iter().find(|a| a.id.as_str() == aid) + .ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?; + let path = vault.root().join("attachments").join(item.id.as_str()) + .join(format!("{}.enc", aid)); + let bytes = fs::read(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let plaintext = decrypt_attachment(&bytes, vault.key())?; + let out_path = out.unwrap_or_else(|| PathBuf::from(&aref.filename)); + fs::write(&out_path, plaintext.as_slice()) + .with_context(|| format!("failed to write {}", out_path.display()))?; + eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display()); + Ok(()) +} + +pub fn cmd_detach(query: String, aid: String) -> Result<()> { + use std::fs; + use relicario_core::ItemCore; + use relicario_core::time::now_unix; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + let entry = super::resolve_query(&manifest, &query)?; + let id = entry.id.clone(); + let _ = entry; + let mut item = vault.load_item(&id)?; + + let pos = item.attachments.iter().position(|a| a.id.as_str() == aid) + .ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?; + + // Document items keep their primary blob in the core; refuse to orphan it. + if let ItemCore::Document(d) = &item.core { + if d.primary_attachment.as_str() == aid { + anyhow::bail!( + "cannot detach the primary attachment of a Document item; \ + use `purge {}` to delete the whole item", + item.title, + ); + } + } + + let removed = item.attachments.remove(pos); + let blob_path = vault.root().join("attachments").join(item.id.as_str()) + .join(format!("{}.enc", removed.id.as_str())); + if blob_path.exists() { + fs::remove_file(&blob_path) + .with_context(|| format!("failed to delete {}", blob_path.display()))?; + } + + item.modified = now_unix(); + vault.save_item(&item)?; + manifest.upsert(&item); + vault.save_manifest(&manifest)?; + + let item_path = format!("items/{}.enc", item.id.as_str()); + let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str()); + super::commit_paths( + &vault, + &format!("detach: {} from {} ({})", crate::helpers::sanitize_for_commit(&removed.filename), crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), + &[&item_path, "manifest.enc", &blob_relpath], + )?; + eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title); + Ok(()) +} diff --git a/crates/relicario-cli/src/commands/backup.rs b/crates/relicario-cli/src/commands/backup.rs new file mode 100644 index 0000000..73e91e4 --- /dev/null +++ b/crates/relicario-cli/src/commands/backup.rs @@ -0,0 +1,303 @@ +//! `relicario backup export` / `relicario backup restore` — pack/unpack the +//! encrypted `.relbak` envelope. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +use crate::BackupAction; + +pub fn cmd_backup(action: BackupAction) -> Result<()> { + match action { + BackupAction::Export { out, include_image, image, no_history } => { + cmd_backup_export(out, include_image, image, no_history) + } + BackupAction::Restore { input, target } => cmd_backup_restore(input, target), + } +} + +pub(super) fn cmd_backup_export( + out: PathBuf, + include_image: bool, + image: Option, + no_history: bool, +) -> Result<()> { + use std::fs; + use relicario_core::{backup, validate_passphrase_strength}; + use zeroize::Zeroizing; + + let root = crate::helpers::vault_dir()?; + + // Backup passphrase — prompt twice, gate on zxcvbn (audit H3). + let passphrase = if let Some(p) = crate::test_backup_passphrase_override() { + Zeroizing::new(p) + } else { + Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) + }; + let confirm = if crate::test_backup_passphrase_override().is_some() { + passphrase.clone() + } else { + Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?) + }; + if passphrase.as_str() != confirm.as_str() { + anyhow::bail!("passphrases do not match"); + } + if let Err(e) = validate_passphrase_strength(&passphrase) { + anyhow::bail!("backup {}. Choose a longer or more entropic phrase.", e); + } + + // Read everything from disk that the envelope needs. + let salt = fs::read(root.join(".relicario").join("salt")) + .with_context(|| "failed to read .relicario/salt")?; + let params_json = fs::read_to_string(root.join(".relicario").join("params.json")) + .with_context(|| "failed to read .relicario/params.json")?; + // devices.json was removed in the B1 security audit fix; fall back to + // an empty array so backups of post-B1 vaults still pack cleanly. + // Task 12 will remove the devices field from the backup format entirely. + let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json")) + .unwrap_or_else(|_| "[]".to_string()); + let manifest_enc = fs::read(root.join("manifest.enc")) + .with_context(|| "failed to read manifest.enc")?; + let settings_enc = fs::read(root.join("settings.enc")) + .with_context(|| "failed to read settings.enc")?; + + // Items. + let mut item_files = Vec::new(); + let items_dir = root.join("items"); + if items_dir.is_dir() { + for entry in fs::read_dir(&items_dir)? { + let p = entry?.path(); + if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; } + let id = p.file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("bad item filename: {}", p.display()))? + .to_string(); + let bytes = fs::read(&p)?; + item_files.push((id, bytes)); + } + } + + // Attachments. Layout: attachments//.enc + let mut attach_files = Vec::new(); + let attach_dir = root.join("attachments"); + if attach_dir.is_dir() { + for entry in fs::read_dir(&attach_dir)? { + let item_dir = entry?.path(); + if !item_dir.is_dir() { continue; } + let item_id = item_dir.file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("bad attachment dir: {}", item_dir.display()))? + .to_string(); + for sub in fs::read_dir(&item_dir)? { + let p = sub?.path(); + if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; } + let aid = p.file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("bad attachment filename: {}", p.display()))? + .to_string(); + let bytes = fs::read(&p)?; + attach_files.push((item_id.clone(), aid, bytes)); + } + } + } + + // Optional reference image. + let image_bytes = if include_image { + let path = match image { + Some(p) => p, + None => crate::session::get_image_path()?, + }; + Some(fs::read(&path) + .with_context(|| format!("failed to read reference image {}", path.display()))?) + } else { + None + }; + + // Optional .git/ tar. + let git_archive = if no_history { None } else { Some(tar_directory(&root.join(".git"))?) }; + + let items_refs: Vec = item_files.iter() + .map(|(id, bytes)| backup::BackupItem { id: id.clone(), ciphertext: bytes }) + .collect(); + let attach_refs: Vec = attach_files.iter() + .map(|(iid, aid, bytes)| backup::BackupAttachment { + item_id: iid.clone(), + attachment_id: aid.clone(), + ciphertext: bytes, + }) + .collect(); + + let input = backup::BackupInput { + salt: &salt, + params_json: ¶ms_json, + devices_json: &devices_json, + manifest_enc: &manifest_enc, + settings_enc: &settings_enc, + items: items_refs, + attachments: attach_refs, + reference_jpg: image_bytes.as_deref(), + git_archive: git_archive.as_deref(), + }; + + let bytes = backup::pack_backup(input, &passphrase)?; + + // atomic_write via the existing pattern: write `.tmp`, rename. + let tmp = { + let mut t = out.as_os_str().to_owned(); + t.push(".tmp"); + PathBuf::from(t) + }; + fs::write(&tmp, &bytes) + .with_context(|| format!("failed to write {}", tmp.display()))?; + fs::rename(&tmp, &out) + .with_context(|| format!("failed to rename {}", out.display()))?; + + // Marker file for `cmd_status`. Format: ISO-8601 UTC line. + let now_iso = crate::helpers::iso8601(relicario_core::now_unix()); + fs::write(root.join(".relicario").join("last_backup"), format!("{now_iso}\n"))?; + + let mib = (bytes.len() as f64) / (1024.0 * 1024.0); + eprintln!( + "Wrote {} ({:.2} MiB). Delete after restore is verified.", + out.display(), mib + ); + Ok(()) +} + +/// Tar a directory into an in-memory `Vec`. Used for `.git/` bundling. +fn tar_directory(dir: &std::path::Path) -> Result> { + let mut buf = Vec::new(); + { + let mut builder = tar::Builder::new(&mut buf); + builder.append_dir_all(".", dir) + .with_context(|| format!("failed to tar {}", dir.display()))?; + builder.finish().with_context(|| "failed to finalize git tar")?; + } + Ok(buf) +} + +pub(super) fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> { + use std::fs; + use relicario_core::backup; + use relicario_core::{ItemId, AttachmentId}; + use zeroize::Zeroizing; + + let target = if target.is_absolute() { + target + } else { + std::env::current_dir()?.join(&target) + }; + + if target.join(".relicario").exists() { + anyhow::bail!( + "target dir already contains a Relicario vault; restore refuses to overwrite — use an empty directory: {}", + target.display() + ); + } + fs::create_dir_all(&target) + .with_context(|| format!("failed to create target {}", target.display()))?; + + // Read input file. + let bytes = fs::read(&input) + .with_context(|| format!("failed to read backup file {}", input.display()))?; + + // Backup passphrase prompt. + let passphrase = if let Some(p) = crate::test_backup_passphrase_override() { + Zeroizing::new(p) + } else { + Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) + }; + + let unpacked = backup::unpack_backup(&bytes, &passphrase) + .map_err(|e| match e { + relicario_core::RelicarioError::Decrypt => + anyhow::anyhow!("wrong backup passphrase, or the file is corrupt"), + other => anyhow::anyhow!(other), + })?; + + // Write vault layout. + let relicario_dir = target.join(".relicario"); + fs::create_dir_all(&relicario_dir)?; + fs::create_dir_all(target.join("items"))?; + fs::create_dir_all(target.join("attachments"))?; + + fs::write(relicario_dir.join("salt"), unpacked.salt)?; + fs::write(relicario_dir.join("params.json"), &unpacked.params_json)?; + fs::write(relicario_dir.join("devices.json"), &unpacked.devices_json)?; + fs::write(target.join("manifest.enc"), &unpacked.manifest_enc)?; + fs::write(target.join("settings.enc"), &unpacked.settings_enc)?; + + for item in &unpacked.items { + let item_id = ItemId(item.id.clone()); + if !item_id.is_valid() { + anyhow::bail!("invalid item ID in backup: {} (path traversal blocked)", item.id); + } + fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?; + } + for a in &unpacked.attachments { + let item_id = ItemId(a.item_id.clone()); + let att_id = AttachmentId(a.attachment_id.clone()); + if !item_id.is_valid() || !att_id.is_valid() { + anyhow::bail!("invalid attachment ID in backup (path traversal blocked)"); + } + let dir = target.join("attachments").join(&a.item_id); + fs::create_dir_all(&dir)?; + fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?; + } + + // Reference image (if present). + if let Some(jpg) = &unpacked.reference_jpg { + let path = target.join("reference.jpg"); + fs::write(&path, jpg) + .with_context(|| format!("failed to write reference image {}", path.display()))?; + } + + // .git/ history. + if let Some(tar_bytes) = &unpacked.git_archive { + // Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower. + let cap = std::cmp::min( + (tar_bytes.len() as u64).saturating_mul(100), + relicario_core::DEFAULT_MAX_UNCOMPRESSED, + ); + let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap) + .with_context(|| "failed to safely unpack .git/ archive")?; + let git_dir = target.join(".git"); + for (rel_path, body) in entries { + let dest = git_dir.join(&rel_path); + // Paranoid OS-level check even after textual validation in core. + if !dest.starts_with(&git_dir) { + anyhow::bail!( + "tar entry {} resolved outside .git/ (path traversal blocked)", + rel_path.display() + ); + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("create parent {}", parent.display()) + })?; + } + fs::write(&dest, &body).with_context(|| { + format!("write {}", dest.display()) + })?; + } + } else { + // No history bundled — start a fresh git repo. + crate::helpers::git_run(&target, &["init"], "backup restore: git init")?; + + // .gitignore — exclude reference image if present. + if target.join("reference.jpg").exists() { + fs::write(target.join(".gitignore"), "reference.jpg\n")?; + } + + let _ = crate::helpers::git_command(&target, &["add", "."]).status()?; + let now_iso = crate::helpers::iso8601(relicario_core::now_unix()); + let msg = format!("restore from backup {now_iso}"); + let _ = crate::helpers::git_command(&target, &["commit", "-m", &msg]).status()?; + } + + eprintln!( + "Restored vault to {}. Unlock with your passphrase + reference image.", + target.display() + ); + Ok(()) +} diff --git a/crates/relicario-cli/src/commands/device.rs b/crates/relicario-cli/src/commands/device.rs new file mode 100644 index 0000000..276fde3 --- /dev/null +++ b/crates/relicario-cli/src/commands/device.rs @@ -0,0 +1,255 @@ +//! `relicario device {add, revoke, list}` — device key management. +//! +//! Note: command bodies live here as `crate::commands::device`. Local key +//! storage and git-signing config live separately in `crate::device`. + +use anyhow::Result; + +use crate::DeviceAction; + +/// Build a `GiteaClient` from flags or environment variables. +fn load_gitea_client( + gitea_url: Option, + gitea_token: Option, + owner: Option, + repo: Option, +) -> Result { + let url = gitea_url + .or_else(|| std::env::var("RELICARIO_GITEA_URL").ok()) + .ok_or_else(|| anyhow::anyhow!( + "Gitea URL required — pass --gitea-url or set RELICARIO_GITEA_URL" + ))?; + let token = gitea_token + .or_else(|| std::env::var("RELICARIO_GITEA_TOKEN").ok()) + .ok_or_else(|| anyhow::anyhow!( + "Gitea token required — pass --gitea-token or set RELICARIO_GITEA_TOKEN" + ))?; + let owner = owner + .or_else(|| std::env::var("RELICARIO_GITEA_OWNER").ok()) + .ok_or_else(|| anyhow::anyhow!( + "Gitea owner required — pass --owner or set RELICARIO_GITEA_OWNER" + ))?; + let repo = repo + .or_else(|| std::env::var("RELICARIO_GITEA_REPO").ok()) + .ok_or_else(|| anyhow::anyhow!( + "Gitea repo required — pass --repo or set RELICARIO_GITEA_REPO" + ))?; + Ok(crate::gitea::GiteaClient::new(&url, &token, &owner, &repo)) +} + +pub fn cmd_device(action: DeviceAction) -> Result<()> { + use std::fs; + use relicario_core::device::{DeviceEntry, RevokedEntry, generate_keypair}; + + let root = crate::helpers::vault_dir()?; + let relicario_dir = root.join(".relicario"); + let devices_path = relicario_dir.join("devices.json"); + + match action { + DeviceAction::Add { name, gitea_url, gitea_token, owner, repo, no_gitea } => { + // Guard: don't overwrite an already-registered device name. + let existing: Vec = fs::read(&devices_path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + if existing.iter().any(|d| d.name == name) { + anyhow::bail!("a device named '{}' is already registered", name); + } + + eprintln!("Generating signing keypair..."); + let (signing_priv, signing_pub) = generate_keypair() + .map_err(|e| anyhow::anyhow!("generate signing keypair: {e}"))?; + + eprintln!("Generating deploy keypair..."); + let (deploy_priv, deploy_pub) = generate_keypair() + .map_err(|e| anyhow::anyhow!("generate deploy keypair: {e}"))?; + + // Optionally register deploy key with Gitea. + let gitea_key_id: u64 = if no_gitea { + eprintln!("Skipping Gitea deploy key registration (--no-gitea)."); + 0 + } else { + let client = load_gitea_client(gitea_url, gitea_token, owner, repo)?; + let key_title = format!("relicario-{}", name); + eprintln!("Registering deploy key '{}' with Gitea...", key_title); + client.create_deploy_key(&key_title, &deploy_pub)? + }; + + // Store keys locally with proper permissions. + crate::device::store_device_keys( + &name, + &signing_priv, + &signing_pub, + &deploy_priv, + &deploy_pub, + gitea_key_id, + )?; + + // Mark as current device. + crate::device::set_current_device(&name)?; + + // Configure git signing + SSH deploy key in the vault repo. + crate::device::configure_git_signing(&root, &name)?; + + // Update devices.json. + let current_name = name.clone(); + let mut devices = existing; + devices.push(DeviceEntry { + name: name.clone(), + public_key: signing_pub.clone(), + added_at: relicario_core::now_unix(), + added_by: current_name, + }); + fs::create_dir_all(&relicario_dir)?; + fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?; + + // Commit the update. + crate::helpers::git_run( + &root, + &["add", ".relicario/devices.json"], + &format!("device register \"{name}\": git add .relicario/devices.json"), + )?; + let msg = format!("device: register {}", name); + crate::helpers::git_run( + &root, + &["commit", "-m", &msg], + &format!("device register \"{name}\": git commit"), + )?; + + eprintln!("Device '{}' registered.", name); + eprintln!("Signing public key:"); + eprintln!(" {}", signing_pub); + if gitea_key_id != 0 { + eprintln!("Gitea deploy key ID: {}", gitea_key_id); + } + Ok(()) + } + + DeviceAction::Revoke { name } => { + // Guard: refuse to revoke the currently active device (would lock + // the user out). They must add another device first. + if let Some(current) = crate::device::current_device()? { + if current == name { + anyhow::bail!( + "cannot revoke the current device '{}' — you would lose \ + push access. Register another device first.", + name + ); + } + } + + // Load devices.json. + let mut devices: Vec = fs::read(&devices_path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + + let device = devices + .iter() + .find(|d| d.name == name) + .ok_or_else(|| anyhow::anyhow!("device '{}' not found", name))? + .clone(); + + // Remove from devices.json. + devices.retain(|d| d.name != name); + fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?; + + // Append to revoked.json. + let revoked_path = relicario_dir.join("revoked.json"); + let mut revoked: Vec = fs::read(&revoked_path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + + let revoked_by = crate::device::current_device()? + .unwrap_or_else(|| "unknown".to_string()); + + revoked.push(RevokedEntry { + name: name.clone(), + public_key: device.public_key.clone(), + revoked_at: relicario_core::now_unix(), + revoked_by, + }); + fs::write(&revoked_path, serde_json::to_string_pretty(&revoked)?)?; + + // Delete deploy key from Gitea (best-effort — don't fail if it + // was already deleted or the config is missing). + if let Ok(key_id) = crate::device::load_gitea_key_id(&name) { + if key_id != 0 { + // Build client from env vars only (no flags in revoke). + match load_gitea_client(None, None, None, None) { + Ok(client) => { + if let Err(e) = client.delete_deploy_key(key_id) { + eprintln!( + "warning: failed to delete Gitea deploy key {}: {}", + key_id, e + ); + } else { + eprintln!("Deleted Gitea deploy key {}.", key_id); + } + } + Err(_) => { + eprintln!( + "warning: Gitea env vars not set — deploy key {} \ + not deleted from Gitea.", + key_id + ); + } + } + } + } + + // Commit devices.json + revoked.json (always both — revoked.json + // was just written above so it is guaranteed to exist). + let add_args = [ + "add", + ".relicario/devices.json", + ".relicario/revoked.json", + ]; + crate::helpers::git_run( + &root, + &add_args, + &format!("device revoke \"{name}\": git add devices.json + revoked.json"), + )?; + let msg = format!("device: revoke {}", name); + crate::helpers::git_run( + &root, + &["commit", "-m", &msg], + &format!("device revoke \"{name}\": git commit"), + )?; + + eprintln!("Device '{}' revoked.", name); + eprintln!("Revoked signing key: {}", device.public_key); + Ok(()) + } + + DeviceAction::List => { + let devices: Vec = fs::read(&devices_path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + + let current = crate::device::current_device()?.unwrap_or_default(); + + if devices.is_empty() { + println!("No registered devices."); + return Ok(()); + } + + println!("{:<20} {:<20} SIGNING KEY (prefix)", "NAME", "ADDED"); + println!("{}", "-".repeat(72)); + for d in &devices { + let marker = if d.name == current { " *" } else { "" }; + let added = crate::helpers::iso8601(d.added_at); + // Show only the first 40 chars of the public key line for readability. + let key_prefix: String = d.public_key.chars().take(40).collect(); + println!("{:<20} {:<20} {}{}", + d.name, added, key_prefix, marker); + } + if !current.is_empty() { + println!("\n* = current device"); + } + Ok(()) + } + } +} diff --git a/crates/relicario-cli/src/commands/edit.rs b/crates/relicario-cli/src/commands/edit.rs new file mode 100644 index 0000000..a480021 --- /dev/null +++ b/crates/relicario-cli/src/commands/edit.rs @@ -0,0 +1,172 @@ +//! `relicario edit ` — interactive per-type field editing with history capture. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +use crate::parse::base32_decode_lenient; +use crate::prompt::{prompt_keep, prompt_keep_opt, prompt_secret, prompt_yesno}; + +pub fn cmd_edit(query: String, totp_qr: Option) -> Result<()> { + use relicario_core::time::now_unix; + use relicario_core::ItemCore; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + let entry = super::resolve_query(&manifest, &query)?; + let id = entry.id.clone(); + let _ = entry; + let mut item = vault.load_item(&id)?; + + eprintln!("Editing: {} ({}) — leave a prompt blank to keep the current value.", + item.title, item.id.as_str()); + + if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; } + if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); } + if let Some(v) = prompt_keep_opt("Tags (comma-separated)", Some(&item.tags.join(",")))? { + item.tags = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); + } + + let history = &mut item.field_history; + match &mut item.core { + ItemCore::Login(l) => edit_login(l, history, totp_qr)?, + ItemCore::SecureNote(n) => edit_secure_note(n, history)?, + ItemCore::Identity(i) => edit_identity(i)?, + ItemCore::Card(c) => edit_card(c, history)?, + ItemCore::Key(k) => edit_key(k, history)?, + ItemCore::Document(_) => edit_document_message(), + ItemCore::Totp(t) => edit_totp(t, history)?, + } + + item.modified = now_unix(); + vault.save_item(&item)?; + manifest.upsert(&item); + vault.save_manifest(&manifest)?; + crate::refresh_groups_cache(vault.root(), &manifest); + super::commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), + &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; + eprintln!("Updated {}", item.id.as_str()); + Ok(()) +} + +// --- Per-type edit handlers. Each mutates its core slice in place; the ones +// that touch history-tracked fields take the item's field_history map so +// they can record the prior value alongside the change. + +type FieldHistory = std::collections::HashMap< + relicario_core::FieldId, + Vec, +>; + +fn edit_login( + l: &mut relicario_core::item_types::LoginCore, + history: &mut FieldHistory, + totp_qr: Option, +) -> Result<()> { + use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind}; + use zeroize::Zeroizing; + 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(()) +} + +fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> { + use zeroize::Zeroizing; + if prompt_yesno("Edit body?")? { + let old = n.body.clone(); + eprintln!("Enter new body; end with Ctrl-D:"); + let mut s = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; + n.body = Zeroizing::new(s); + push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string())); + } + Ok(()) +} + +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(()) +} + +fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> { + use zeroize::Zeroizing; + 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(()) +} + +fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> { + use zeroize::Zeroizing; + if prompt_yesno("Replace key material?")? { + eprintln!("Paste new key material; end with Ctrl-D:"); + let mut s = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; + 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(()) +} + +fn edit_document_message() { + eprintln!("Document items: use `relicario attach` / `relicario extract` instead."); +} + +fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> { + use zeroize::Zeroizing; + 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(()) +} + +fn push_history( + history: &mut std::collections::HashMap>, + synthetic_key: &str, + old_value: zeroize::Zeroizing, +) { + use relicario_core::item::FieldHistoryEntry; + use relicario_core::time::now_unix; + // 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(), + }); +} diff --git a/crates/relicario-cli/src/commands/generate.rs b/crates/relicario-cli/src/commands/generate.rs new file mode 100644 index 0000000..e334338 --- /dev/null +++ b/crates/relicario-cli/src/commands/generate.rs @@ -0,0 +1,68 @@ +//! `relicario generate` — emit a fresh password or BIP39 passphrase. + +use anyhow::Result; + +pub fn cmd_generate( + length: Option, + bip39: bool, + words: Option, + symbols: Option, + separator: Option, +) -> Result<()> { + use relicario_core::{ + generate_passphrase, generate_password, Capitalization, CharClasses, + GeneratorRequest, SymbolCharset, + }; + + // If we're inside a vault, unlock and pull `generator_defaults`. Outside + // a vault, this stays a fast standalone CSPRNG tool (no unlock prompt). + let vault_defaults: Option = if crate::helpers::vault_dir().is_ok() { + let vault = crate::session::UnlockedVault::unlock_interactive()?; + Some(vault.load_settings()?.generator_defaults) + } else { + None + }; + + // `--bip39` flag forces Bip39 mode; otherwise use whatever mode the + // vault default is in (Random when no vault). + let use_bip39 = bip39 || matches!(vault_defaults, Some(GeneratorRequest::Bip39 { .. })); + + let output = if use_bip39 { + let (def_words, def_sep, def_cap) = match &vault_defaults { + Some(GeneratorRequest::Bip39 { word_count, separator, capitalization }) => { + (*word_count, separator.clone(), *capitalization) + } + _ => (5, " ".to_string(), Capitalization::Lower), + }; + generate_passphrase(&GeneratorRequest::Bip39 { + word_count: words.unwrap_or(def_words), + separator: separator.unwrap_or(def_sep), + capitalization: def_cap, + })? + } else { + let (def_length, def_classes, def_charset) = match &vault_defaults { + Some(GeneratorRequest::Random { length, classes, symbol_charset }) => { + (*length, *classes, symbol_charset.clone()) + } + _ => ( + 20, + CharClasses { lower: true, upper: true, digits: true, symbols: true }, + SymbolCharset::SafeOnly, + ), + }; + let symbol_charset = match symbols.as_deref() { + None => def_charset, + Some("safe") => SymbolCharset::SafeOnly, + Some("extended") => SymbolCharset::Extended, + Some(other) => SymbolCharset::Custom(other.to_string()), + }; + generate_password(&GeneratorRequest::Random { + length: length.unwrap_or(def_length), + classes: def_classes, + symbol_charset, + })? + }; + + println!("{}", output.as_str()); + Ok(()) +} diff --git a/crates/relicario-cli/src/commands/get.rs b/crates/relicario-cli/src/commands/get.rs new file mode 100644 index 0000000..4618a3a --- /dev/null +++ b/crates/relicario-cli/src/commands/get.rs @@ -0,0 +1,107 @@ +//! `relicario get` — print a single item, masking secrets unless `--show`. + +use anyhow::{Context, Result}; + +pub fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> { + use relicario_core::ItemCore; + use zeroize::Zeroizing; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let manifest = vault.load_manifest()?; + crate::refresh_groups_cache(vault.root(), &manifest); + let entry = super::resolve_query(&manifest, &query)?; + let item = vault.load_item(&entry.id)?; + + println!("ID: {}", item.id.as_str()); + println!("Title: {}", item.title); + println!("Type: {:?}", item.r#type); + if let Some(g) = &item.group { println!("Group: {g}"); } + if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); } + println!("Created: {}", crate::helpers::iso8601(item.created)); + println!("Modified: {}", crate::helpers::iso8601(item.modified)); + if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); } + println!(); + + let primary_secret: Option> = match &item.core { + ItemCore::Login(l) => { + if let Some(u) = &l.username { println!("Username: {u}"); } + if let Some(u) = &l.url { println!("URL: {u}"); } + if let Some(t) = &l.totp { + if show { + println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret)); + } else { + println!("TOTP: **** (use --show to reveal)"); + } + } + l.password.clone() + } + ItemCore::SecureNote(n) => { + if show { println!("Body:\n{}", n.body.as_str()); } + else { println!("Body: ********"); } + None + } + ItemCore::Identity(i) => { + if let Some(v) = &i.full_name { println!("Name: {v}"); } + if let Some(v) = &i.email { println!("Email: {v}"); } + if let Some(v) = &i.phone { println!("Phone: {v}"); } + if let Some(v) = &i.date_of_birth { println!("DOB: {v}"); } + None + } + ItemCore::Card(c) => { + if let Some(h) = &c.holder { println!("Holder: {h}"); } + if let Some(e) = &c.expiry { println!("Expiry: {:02}/{}", e.month, e.year); } + println!("Kind: {:?}", c.kind); + c.number.clone() + } + ItemCore::Key(k) => { + if let Some(l) = &k.label { println!("Label: {l}"); } + if let Some(a) = &k.algorithm { println!("Algo: {a}"); } + if let Some(pk) = &k.public_key { println!("Pubkey: {pk}"); } + Some(k.key_material.clone()) + } + ItemCore::Document(d) => { + println!("Filename: {}", d.filename); + println!("MIME: {}", d.mime_type); + None + } + ItemCore::Totp(t) => { + if let Some(i) = &t.issuer { println!("Issuer: {i}"); } + if let Some(l) = &t.label { println!("Label: {l}"); } + println!("Period: {}s", t.config.period_seconds); + println!("Digits: {}", t.config.digits); + None + } + }; + + if let Some(secret) = primary_secret { + if show { + println!("Secret: {}", secret.as_str()); + } else { + println!("Secret: ******** (use --show to reveal, --copy to clipboard)"); + } + if copy { + copy_to_clipboard_then_clear(&secret)?; + eprintln!("Copied to clipboard (auto-clears in 30s)."); + } + } + + Ok(()) +} + +fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing) -> Result<()> { + use arboard::Clipboard; + let mut cb = Clipboard::new().context("failed to access clipboard")?; + cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?; + let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned()); + // Unconditional clear (audit M6): spawn a detached thread that waits 30s + // and then rewrites the clipboard with empty string. Even if the user + // copies something else in the interim, we still overwrite once. + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(30)); + if let Ok(mut cb) = Clipboard::new() { + let _ = cb.set_text(String::new()); + drop(cleared_copy); // zeroize the detached copy + } + }); + Ok(()) +} diff --git a/crates/relicario-cli/src/commands/import.rs b/crates/relicario-cli/src/commands/import.rs new file mode 100644 index 0000000..f4b5713 --- /dev/null +++ b/crates/relicario-cli/src/commands/import.rs @@ -0,0 +1,88 @@ +//! `relicario import` — currently only LastPass CSV is supported. + +use std::path::PathBuf; + +use anyhow::{bail, Context, Result}; + +use crate::ImportAction; + +pub fn cmd_import(action: ImportAction) -> Result<()> { + match action { + ImportAction::Lastpass { csv } => cmd_import_lastpass(csv), + } +} + +fn cmd_import_lastpass(csv_path: PathBuf) -> Result<()> { + use std::fs; + use relicario_core::import_lastpass::parse_lastpass_csv; + + let csv_bytes = fs::read(&csv_path) + .with_context(|| format!("failed to read CSV {}", csv_path.display()))?; + + let (items, warnings) = parse_lastpass_csv(&csv_bytes)?; + + if items.is_empty() { + // Print all warnings so the user sees why nothing imported. + for w in &warnings { + print_warning(w); + } + bail!( + "imported 0 items from {} — see warnings above", + csv_path.display() + ); + } + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + + let total = items.len(); + let mut written_paths: Vec = Vec::with_capacity(items.len() + 1); + + for (idx, item) in items.iter().enumerate() { + vault.save_item(item)?; + manifest.upsert(item); + written_paths.push(format!("items/{}.enc", item.id.as_str())); + + let n = idx + 1; + if n % 50 == 0 || n == total { + eprintln!("[{n}/{total}] importing..."); + } + } + + vault.save_manifest(&manifest)?; + written_paths.push("manifest.enc".into()); + + let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect(); + let csv_filename = csv_path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("lastpass.csv"); + super::commit_paths( + &vault, + &format!("import: {} items from LastPass ({})", total, csv_filename), + &path_refs, + )?; + + for w in &warnings { + print_warning(w); + } + // Counts only true skips, not partial imports. Coupled by convention to + // the parser's warning message strings: skip messages end in "— skipped", + // partial-import messages say "imported without TOTP" / "imported without URL". + // If a future warning uses the word "skipped" in any other sense, this filter + // will need to switch to an enum tag (see ImportWarning::message). + eprintln!( + "Imported {}, skipped {} (see warnings above)", + total, + warnings.iter().filter(|w| w.message.contains("skipped")).count() + ); + Ok(()) +} + +fn print_warning(w: &relicario_core::import_lastpass::ImportWarning) { + let prefix = match &w.title { + Some(t) => format!("row {} ({}):", w.row, t), + None => format!("row {}:", w.row), + }; + eprintln!("warning: {prefix} {}", w.message); +} diff --git a/crates/relicario-cli/src/commands/init.rs b/crates/relicario-cli/src/commands/init.rs new file mode 100644 index 0000000..c5aedc1 --- /dev/null +++ b/crates/relicario-cli/src/commands/init.rs @@ -0,0 +1,125 @@ +//! `relicario init` — bootstrap a fresh vault in the current directory. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { + use std::fs; + use rand::{rngs::OsRng, RngCore}; + use relicario_core::{ + derive_master_key, encrypt_manifest, encrypt_settings, imgsecret, + validate_passphrase_strength, KdfParams, Manifest, VaultSettings, + }; + use zeroize::Zeroizing; + + let root = std::env::current_dir()?; + let relicario_dir = root.join(".relicario"); + if relicario_dir.exists() { + anyhow::bail!(".relicario/ already exists in {}", root.display()); + } + + // Passphrase with strength gate (audit H3). + // RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the + // TTY prompt so integration tests can run without a real TTY. + let passphrase = if let Some(p) = crate::test_passphrase_override() { + Zeroizing::new(p) + } else { + Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?) + }; + let confirm = if crate::test_passphrase_override().is_some() { + passphrase.clone() + } else { + Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?) + }; + if passphrase.as_str() != confirm.as_str() { + anyhow::bail!("passphrases do not match"); + } + if let Err(e) = validate_passphrase_strength(&passphrase) { + anyhow::bail!("{}. Choose a longer or more entropic phrase.", e); + } + + // Image secret: 32 random bytes, embedded in the carrier. + let image_secret = { + let mut buf = Zeroizing::new([0u8; 32]); + OsRng.fill_bytes(buf.as_mut_slice()); + buf + }; + let carrier = fs::read(&image) + .with_context(|| format!("failed to read carrier image {}", image.display()))?; + let stego = imgsecret::embed(&carrier, &image_secret)?; + fs::write(&output, &stego) + .with_context(|| format!("failed to write reference image {}", output.display()))?; + + // Vault salt + KDF params. + let mut salt = [0u8; 32]; + OsRng.fill_bytes(&mut salt); + let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }; + + // Derive master key, then persist an empty Manifest + default VaultSettings. + let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)?; + + fs::create_dir_all(&relicario_dir)?; + fs::create_dir_all(root.join("items"))?; + fs::create_dir_all(root.join("attachments"))?; + fs::write(relicario_dir.join("salt"), salt)?; + fs::write( + relicario_dir.join("params.json"), + serde_json::to_string_pretty(&ParamsFile { + format_version: 2, + kdf: ParamsKdf { + algorithm: "argon2id-v0x13".into(), + argon2_m: params.argon2_m, + argon2_t: params.argon2_t, + argon2_p: params.argon2_p, + }, + aead: "xchacha20poly1305".into(), + salt_path: ".relicario/salt".into(), + })?, + )?; + let manifest = Manifest::new(); + fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?; + let settings = VaultSettings::default(); + fs::write(root.join("settings.enc"), encrypt_settings(&settings, &master_key)?)?; + + // .gitignore excludes the reference image. + let fname = output.file_name() + .ok_or_else(|| anyhow::anyhow!("output path has no filename: {}", output.display()))? + .to_string_lossy(); + let gitignore = format!("{fname}\n"); + fs::write(root.join(".gitignore"), gitignore)?; + + // git init + initial commit via hardened wrapper. + crate::helpers::git_run(&root, &["init"], "init: git init")?; + let _ = crate::helpers::git_command(&root, &[ + "add", ".gitignore", ".relicario/params.json", + ".relicario/salt", "manifest.enc", "settings.enc", + ]).status()?; + crate::helpers::git_run( + &root, + &["commit", "-m", "init: new Relicario vault (format v2)"], + "init: git commit", + )?; + + eprintln!("Vault initialized at {}", root.display()); + eprintln!("Reference image: {}", output.display()); + eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor."); + Ok(()) +} + +#[derive(serde::Serialize)] +struct ParamsFile { + format_version: u32, + kdf: ParamsKdf, + aead: String, + salt_path: String, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "snake_case")] +struct ParamsKdf { + algorithm: String, + argon2_m: u32, + argon2_t: u32, + argon2_p: u32, +} diff --git a/crates/relicario-cli/src/commands/list.rs b/crates/relicario-cli/src/commands/list.rs new file mode 100644 index 0000000..1c153a5 --- /dev/null +++ b/crates/relicario-cli/src/commands/list.rs @@ -0,0 +1,103 @@ +//! `relicario list` and `relicario history` — both read-only browse paths. + +use anyhow::Result; + +pub fn cmd_list( + type_filter: Option, + group_filter: Option, + tag_filter: Option, + trashed: bool, +) -> Result<()> { + use relicario_core::ItemType; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let manifest = vault.load_manifest()?; + crate::refresh_groups_cache(vault.root(), &manifest); + + let parsed_type: Option = match type_filter.as_deref() { + None => None, + Some("login") => Some(ItemType::Login), + Some("secure_note") | Some("note") => Some(ItemType::SecureNote), + Some("identity") => Some(ItemType::Identity), + Some("card") => Some(ItemType::Card), + Some("key") => Some(ItemType::Key), + Some("document") => Some(ItemType::Document), + Some("totp") => Some(ItemType::Totp), + Some(other) => anyhow::bail!("unknown type filter: {other}"), + }; + + let mut entries: Vec<_> = manifest.items.values() + .filter(|e| { + if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() } + }) + .filter(|e| match parsed_type { + Some(t) => e.r#type == t, + None => true, + }) + .filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str()))) + .filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t))) + .collect(); + entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())); + + if entries.is_empty() { + eprintln!("(no items match)"); + return Ok(()); + } + + println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV"); + for e in entries { + let fav = if e.favorite { " *" } else { "" }; + println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title); + } + Ok(()) +} + +pub fn cmd_history(query: String, show: bool, field: Option) -> Result<()> { + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let manifest = vault.load_manifest()?; + let entry = super::resolve_query(&manifest, &query)?; + let item = vault.load_item(&entry.id)?; + + println!("History for {} ({})", item.title, item.id.as_str()); + println!(); + + // Filter and sort the field-id keys so output is deterministic. + let mut keys: Vec<&relicario_core::FieldId> = item.field_history.keys().collect(); + keys.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut printed_any = false; + for fid in keys { + let display_name = fid.0.strip_prefix("core:").unwrap_or(&fid.0); + if let Some(filter) = &field { + if display_name != filter && fid.0 != *filter { continue; } + } + let entries = &item.field_history[fid]; + if entries.is_empty() { continue; } + printed_any = true; + + println!("{display_name} ({} {})", + entries.len(), + if entries.len() == 1 { "entry" } else { "entries" }); + for (i, e) in entries.iter().enumerate() { + let ts = crate::helpers::iso8601(e.replaced_at); + if show { + println!(" [{i}] {ts} {}", e.value.as_str()); + } else { + println!(" [{i}] {ts} ********"); + } + } + println!(); + } + + if !printed_any { + if field.is_some() { + println!("no history for the requested field"); + } else { + println!("no history captured for this item"); + } + } else if !show { + println!("(use --show to reveal values)"); + } + + Ok(()) +} diff --git a/crates/relicario-cli/src/commands/mod.rs b/crates/relicario-cli/src/commands/mod.rs new file mode 100644 index 0000000..791464f --- /dev/null +++ b/crates/relicario-cli/src/commands/mod.rs @@ -0,0 +1,60 @@ +//! Per-command modules — one file per top-level subcommand. +//! +//! `main.rs` holds the clap surface (argument enums) and the dispatch +//! `match`; the actual command bodies live here. Helpers shared between +//! command modules (e.g. `commit_paths`, `resolve_query`) are defined in +//! 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; +pub mod edit; +pub mod generate; +pub mod get; +pub mod import; +pub mod init; +pub mod list; +pub mod rate; +pub mod recovery_qr; +pub mod settings; +pub mod status; +pub mod sync; +pub mod trash; + +use anyhow::Result; + +pub(crate) fn commit_paths( + vault: &crate::session::UnlockedVault, + message: &str, + paths: &[&str], +) -> Result<()> { + let mut args: Vec<&str> = vec!["add"]; + args.extend_from_slice(paths); + crate::helpers::git_run(vault.root(), &args, &format!("commit \"{message}\": git add"))?; + crate::helpers::git_run( + vault.root(), + &["commit", "-m", message], + &format!("commit \"{message}\": git commit"), + )?; + Ok(()) +} + +pub(crate) fn resolve_query<'a>( + manifest: &'a relicario_core::Manifest, + query: &str, +) -> Result<&'a relicario_core::ManifestEntry> { + if let Some(entry) = manifest.items.values().find(|e| e.id.as_str() == query) { + return Ok(entry); + } + let hits: Vec<_> = manifest.search(query); + match hits.len() { + 0 => anyhow::bail!("no item matches `{query}`"), + 1 => Ok(hits[0]), + _ => { + let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect(); + anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", ")) + } + } +} diff --git a/crates/relicario-cli/src/commands/rate.rs b/crates/relicario-cli/src/commands/rate.rs new file mode 100644 index 0000000..035f556 --- /dev/null +++ b/crates/relicario-cli/src/commands/rate.rs @@ -0,0 +1,28 @@ +//! `relicario rate` — score a passphrase via zxcvbn. + +use anyhow::Result; + +pub fn cmd_rate(passphrase: String) -> Result<()> { + let pw: String = if passphrase == "-" { + use std::io::BufRead; + let stdin = std::io::stdin(); + let mut line = String::new(); + stdin.lock().read_line(&mut line)?; + line.trim_end_matches(&['\r', '\n'][..]).to_string() + } else { + passphrase + }; + let est = relicario_core::generators::rate_passphrase(&pw); + let label = match est.score { + 0 => "very weak", + 1 => "weak", + 2 => "fair", + 3 => "good", + 4 => "strong", + _ => "?", + }; + println!("score: {}/4 ({})", est.score, label); + println!("guesses: ~10^{:.1}", est.guesses_log10); + println!("note: init requires score ≥ 3 (see `relicario init`)"); + Ok(()) +} diff --git a/crates/relicario-cli/src/commands/recovery_qr.rs b/crates/relicario-cli/src/commands/recovery_qr.rs new file mode 100644 index 0000000..7aaf08d --- /dev/null +++ b/crates/relicario-cli/src/commands/recovery_qr.rs @@ -0,0 +1,69 @@ +//! `relicario recovery-qr {generate,unwrap}` — last-resort vault-key escape hatch. + +use anyhow::{Context, Result}; + +use crate::RecoveryQrCmd; + +pub fn cmd_recovery_qr(cmd: RecoveryQrCmd) -> Result<()> { + match cmd { + RecoveryQrCmd::Generate => cmd_recovery_qr_generate(), + RecoveryQrCmd::Unwrap => cmd_recovery_qr_unwrap(), + } +} + +fn cmd_recovery_qr_generate() -> Result<()> { + use relicario_core::{generate_recovery_qr, imgsecret}; + use zeroize::Zeroizing; + + let image_path = crate::session::get_image_path()?; + let image_bytes = std::fs::read(&image_path) + .with_context(|| format!("read reference image {}", image_path.display()))?; + let image_secret = imgsecret::extract(&image_bytes) + .context("extract image secret")?; + + let passphrase = Zeroizing::new( + rpassword::prompt_password("Enter vault passphrase: ") + .context("read passphrase")? + ); + + let payload = generate_recovery_qr(passphrase.as_str(), &image_secret) + .map_err(|e| anyhow::anyhow!("{e}"))?; + + use qrcode::{EcLevel, QrCode, render::unicode}; + let code = QrCode::with_error_correction_level(payload.as_bytes(), EcLevel::M) + .expect("valid payload"); + let image = code + .render::() + .dark_color(unicode::Dense1x2::Dark) + .light_color(unicode::Dense1x2::Light) + .build(); + println!("{image}"); + println!("Recovery QR generated. Print or photograph this code and store it securely."); + println!("The QR has NOT been saved to disk."); + Ok(()) +} + +fn cmd_recovery_qr_unwrap() -> Result<()> { + use relicario_core::unwrap_recovery_qr; + use std::io::BufRead; + use zeroize::Zeroizing; + + println!("Paste the base64 recovery QR payload and press Enter:"); + let stdin = std::io::stdin(); + let payload_b64 = stdin.lock().lines().next() + .context("no input")??; + let payload_b64 = payload_b64.trim().to_owned(); + + let bytes = data_encoding::BASE64.decode(payload_b64.as_bytes()) + .map_err(|e| anyhow::anyhow!("base64 decode: {e}"))?; + + let passphrase = Zeroizing::new( + rpassword::prompt_password("Enter passphrase: ") + .context("read passphrase")? + ); + + let secret = unwrap_recovery_qr(&bytes, passphrase.as_str()) + .map_err(|e| anyhow::anyhow!("{e}"))?; + println!("image_secret: {}", hex::encode(secret.as_ref())); + Ok(()) +} diff --git a/crates/relicario-cli/src/commands/settings.rs b/crates/relicario-cli/src/commands/settings.rs new file mode 100644 index 0000000..226e320 --- /dev/null +++ b/crates/relicario-cli/src/commands/settings.rs @@ -0,0 +1,98 @@ +//! `relicario settings {show, trash-retention, history-retention, attachment-cap, generator-defaults}`. + +use anyhow::Result; + +use crate::SettingsAction; + +pub fn cmd_settings(action: SettingsAction) -> Result<()> { + use relicario_core::{ + Capitalization, CharClasses, GeneratorRequest, HistoryRetention, + SymbolCharset, TrashRetention, + }; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut settings = vault.load_settings()?; + + match action { + SettingsAction::Show => { + println!("{}", serde_json::to_string_pretty(&settings)?); + return Ok(()); + } + SettingsAction::TrashRetention { days, forever } => { + settings.trash_retention = match (days, forever) { + (Some(d), false) => TrashRetention::Days(d), + (None, true) => TrashRetention::Forever, + _ => anyhow::bail!("specify exactly one of --days or --forever"), + }; + } + SettingsAction::HistoryRetention { last_n, days, forever } => { + settings.field_history_retention = match (last_n, days, forever) { + (Some(n), None, false) => HistoryRetention::LastN(n), + (None, Some(d), false) => HistoryRetention::Days(d), + (None, None, true) => HistoryRetention::Forever, + _ => anyhow::bail!("specify exactly one of --last-n / --days / --forever"), + }; + } + SettingsAction::AttachmentCap { + per_attachment_max_bytes, per_item_max_count, + per_vault_soft_cap_bytes, per_vault_hard_cap_bytes, + } => { + if let Some(v) = per_attachment_max_bytes { settings.attachment_caps.per_attachment_max_bytes = v; } + if let Some(v) = per_item_max_count { settings.attachment_caps.per_item_max_count = v; } + if let Some(v) = per_vault_soft_cap_bytes { settings.attachment_caps.per_vault_soft_cap_bytes = v; } + if let Some(v) = per_vault_hard_cap_bytes { settings.attachment_caps.per_vault_hard_cap_bytes = v; } + } + SettingsAction::GeneratorDefaults { + random, bip39, length, words, symbols, separator, + } => { + // Decide target mode: explicit flag wins, else preserve current. + let target_bip39 = if random { false } + else if bip39 { true } + else { matches!(settings.generator_defaults, GeneratorRequest::Bip39 { .. }) }; + + // Pull existing fields where compatible, else seed with sensible + // defaults (kept in sync with `GeneratorRequest::default()`). + let (cur_length, cur_classes, cur_charset) = match &settings.generator_defaults { + GeneratorRequest::Random { length, classes, symbol_charset } => { + (*length, *classes, symbol_charset.clone()) + } + _ => ( + 20, + CharClasses { lower: true, upper: true, digits: true, symbols: true }, + SymbolCharset::SafeOnly, + ), + }; + let (cur_words, cur_sep, cur_cap) = match &settings.generator_defaults { + GeneratorRequest::Bip39 { word_count, separator, capitalization } => { + (*word_count, separator.clone(), *capitalization) + } + _ => (5, " ".to_string(), Capitalization::Lower), + }; + + settings.generator_defaults = if target_bip39 { + GeneratorRequest::Bip39 { + word_count: words.unwrap_or(cur_words), + separator: separator.unwrap_or(cur_sep), + capitalization: cur_cap, + } + } else { + let charset = match symbols.as_deref() { + None => cur_charset, + Some("safe") => SymbolCharset::SafeOnly, + Some("extended") => SymbolCharset::Extended, + Some(other) => SymbolCharset::Custom(other.to_string()), + }; + GeneratorRequest::Random { + length: length.unwrap_or(cur_length), + classes: cur_classes, + symbol_charset: charset, + } + }; + } + } + + vault.save_settings(&settings)?; + super::commit_paths(&vault, "settings: update", &["settings.enc"])?; + eprintln!("Settings updated."); + Ok(()) +} diff --git a/crates/relicario-cli/src/commands/status.rs b/crates/relicario-cli/src/commands/status.rs new file mode 100644 index 0000000..788b062 --- /dev/null +++ b/crates/relicario-cli/src/commands/status.rs @@ -0,0 +1,52 @@ +//! `relicario status` — vault-level summary (counts, last commit, last backup). + +use anyhow::Result; + +pub fn cmd_status() -> Result<()> { + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let root = vault.root().to_path_buf(); + let manifest = vault.load_manifest()?; + + let total_items = manifest.items.len(); + let trashed_items = manifest.items.values().filter(|e| e.trashed_at.is_some()).count(); + let active_items = total_items - trashed_items; + + let (attachment_count, attachment_bytes) = manifest.items.values() + .flat_map(|e| e.attachment_summaries.iter()) + .fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size)); + + let last_commit = crate::helpers::git_command(&root, &[ + "log", "-1", "--pretty=format:%h %s", + ]).output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "(no commits)".into()); + + // Last backup age (read from marker written by cmd_backup_export). + let last_backup_path = vault.root().join(".relicario").join("last_backup"); + let last_backup_str = if last_backup_path.exists() { + let line = std::fs::read_to_string(&last_backup_path) + .unwrap_or_default() + .trim() + .to_string(); + // Parse the ISO-8601 we wrote in cmd_backup_export. + match chrono::DateTime::parse_from_rfc3339(&line) { + Ok(then) => { + let now = relicario_core::now_unix(); + let age = now - then.timestamp(); + crate::helpers::humanize_age(age.max(0)) + } + Err(_) => "unknown".to_string(), + } + } else { + "never".to_string() + }; + + println!("Vault: {}", root.display()); + println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)"); + println!("Attachments: {attachment_count} ({attachment_bytes} bytes)"); + println!("Last commit: {last_commit}"); + println!("Last export: {last_backup_str}"); + Ok(()) +} diff --git a/crates/relicario-cli/src/commands/sync.rs b/crates/relicario-cli/src/commands/sync.rs new file mode 100644 index 0000000..00e3acd --- /dev/null +++ b/crates/relicario-cli/src/commands/sync.rs @@ -0,0 +1,11 @@ +//! `relicario sync` — pull --rebase + push. + +use anyhow::Result; + +pub fn cmd_sync() -> Result<()> { + let root = crate::helpers::vault_dir()?; + crate::helpers::git_run(&root, &["pull", "--rebase"], "sync: git pull --rebase")?; + crate::helpers::git_run(&root, &["push"], "sync: git push")?; + eprintln!("Sync complete."); + Ok(()) +} diff --git a/crates/relicario-cli/src/commands/trash.rs b/crates/relicario-cli/src/commands/trash.rs new file mode 100644 index 0000000..65b41cd --- /dev/null +++ b/crates/relicario-cli/src/commands/trash.rs @@ -0,0 +1,139 @@ +//! Trash umbrella: `rm` (soft-delete), `restore`, `purge` (permanent), +//! `trash list` / `trash empty`. + +use anyhow::Result; + +use crate::TrashAction; + +pub fn cmd_rm(query: String) -> Result<()> { + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + let entry = super::resolve_query(&manifest, &query)?; + let id = entry.id.clone(); + let _ = entry; + let mut item = vault.load_item(&id)?; + item.soft_delete(); + vault.save_item(&item)?; + manifest.upsert(&item); + vault.save_manifest(&manifest)?; + crate::refresh_groups_cache(vault.root(), &manifest); + super::commit_paths(&vault, &format!("trash: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), + &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; + eprintln!("Moved to trash: {}", item.title); + Ok(()) +} + +pub fn cmd_restore(query: String) -> Result<()> { + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + let entry = super::resolve_query(&manifest, &query)?; + let id = entry.id.clone(); + let _ = entry; + let mut item = vault.load_item(&id)?; + item.restore(); + vault.save_item(&item)?; + manifest.upsert(&item); + vault.save_manifest(&manifest)?; + crate::refresh_groups_cache(vault.root(), &manifest); + super::commit_paths(&vault, &format!("restore: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), + &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; + eprintln!("Restored: {}", item.title); + Ok(()) +} + +/// Inner purge: assumes vault is already unlocked and manifest is loaded. +/// Caller is responsible for saving the manifest and committing afterwards. +pub(super) fn purge_item( + vault: &crate::session::UnlockedVault, + manifest: &mut relicario_core::Manifest, + id: &relicario_core::ItemId, + title: &str, +) -> Result<()> { + use std::fs; + + let item_path = vault.item_path(id); + if item_path.exists() { fs::remove_file(&item_path)?; } + let att_dir = vault.root().join("attachments").join(id.as_str()); + if att_dir.exists() { fs::remove_dir_all(&att_dir)?; } + manifest.remove(id); + + let _ = crate::helpers::git_command(vault.root(), &["rm", "-rf", "--ignore-unmatch", + &format!("items/{}.enc", id.as_str()), + &format!("attachments/{}", id.as_str()), + ]).status()?; + // Note: caller adds+commits manifest.enc after processing all purges. + eprintln!("Purged: {title}"); + Ok(()) +} + +pub fn cmd_purge(query: String) -> Result<()> { + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + let entry = super::resolve_query(&manifest, &query)?; + let id = entry.id.clone(); + let title = entry.title.clone(); + let _ = entry; + + purge_item(&vault, &mut manifest, &id, &title)?; + vault.save_manifest(&manifest)?; + crate::refresh_groups_cache(vault.root(), &manifest); + + let purge_ctx = format!("purge \"{}\" ({})", title, id.as_str()); + crate::helpers::git_run(vault.root(), &["add", "manifest.enc"], &format!("{purge_ctx}: git add manifest.enc"))?; + crate::helpers::git_run( + vault.root(), + &["commit", "-m", &format!("purge: {} ({})", title, id.as_str())], + &format!("{purge_ctx}: git commit"), + )?; + Ok(()) +} + +pub fn cmd_trash(action: TrashAction) -> Result<()> { + match action { + TrashAction::List => super::list::cmd_list(None, None, None, true), + TrashAction::Empty => cmd_trash_empty(), + } +} + +pub fn cmd_trash_empty() -> Result<()> { + use relicario_core::time::now_unix; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + let settings = vault.load_settings()?; + let now = now_unix(); + + let purgeable: Vec<_> = manifest.items.values() + .filter(|e| match e.trashed_at { + Some(t) => settings.trash_retention.should_purge(t, now), + None => false, + }) + .map(|e| (e.id.clone(), e.title.clone())) + .collect(); + + if purgeable.is_empty() { + eprintln!("nothing past retention window"); + return Ok(()); + } + + let mut purged_titles = Vec::new(); + for (id, title) in purgeable { + purge_item(&vault, &mut manifest, &id, &title)?; + purged_titles.push(title); + } + + vault.save_manifest(&manifest)?; + crate::helpers::git_run( + vault.root(), + &["add", "manifest.enc"], + "trash empty: git add manifest.enc", + )?; + crate::helpers::git_run( + vault.root(), + &["commit", "-m", &format!("trash empty: purged {} item(s)", purged_titles.len())], + "trash empty: git commit", + )?; + + eprintln!("Emptied trash: {} item(s)", purged_titles.len()); + Ok(()) +} diff --git a/crates/relicario-cli/src/helpers.rs b/crates/relicario-cli/src/helpers.rs index d3d373d..f07b266 100644 --- a/crates/relicario-cli/src/helpers.rs +++ b/crates/relicario-cli/src/helpers.rs @@ -55,6 +55,37 @@ pub fn git_command(repo: &Path, args: &[&str]) -> Command { cmd } +/// Run `git ` in `repo` with the same hardening as `git_command`, +/// capturing stdout/stderr and reproducing them on failure so the caller +/// sees git's exact diagnostic instead of just a verb. +/// +/// `context` should be a short caller-supplied label like `"commit add: "` +/// or `"sync: git push"`; it prefixes the bail message so the failing call is +/// identifiable from the error alone. +/// +/// Trade-off vs. `git_command(...).status()`: this captures the child's stderr +/// (so live progress disappears during long-running fetches/pushes) but the +/// captured chunk is replayed verbatim on failure. The win is that +/// non-interactive callers (tests, hooks, CI, redirected stdout) finally see +/// pre-receive rejections, signing-key prompts, and dirty-tree complaints +/// instead of one-line "git X failed" bails. Use `git_command` directly when +/// live streaming is required. +pub fn git_run(repo: &Path, args: &[&str], context: &str) -> Result<()> { + let output = git_command(repo, args) + .output() + .with_context(|| format!("{context}: failed to spawn git"))?; + if !output.status.success() { + if !output.stdout.is_empty() { + eprint!("{}", String::from_utf8_lossy(&output.stdout)); + } + if !output.stderr.is_empty() { + eprint!("{}", String::from_utf8_lossy(&output.stderr)); + } + bail!("{context}: git failed ({})", output.status); + } + Ok(()) +} + /// Format a Unix-seconds timestamp as an ISO-8601 UTC string. /// Audit M11: replaces the old `now_iso8601` helper that actually returned /// a numeric string. @@ -220,6 +251,24 @@ mod tests { assert_eq!(sanitize_for_commit("emoji \u{1F4AA}"), "emoji \u{1F4AA}"); } + #[test] + fn git_run_bails_with_context_on_failure() { + // Empty tempdir — `git status` will fail with "not a git repository". + let tmp = TempDir::new().unwrap(); + let err = git_run(tmp.path(), &["status"], "test_ctx").unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("test_ctx"), "context not in error: {msg}"); + assert!(msg.contains("git failed"), "missing failure marker: {msg}"); + } + + #[test] + fn git_run_succeeds_for_a_zero_exit_command() { + // `git --version` always succeeds and is independent of cwd. + let tmp = TempDir::new().unwrap(); + git_run(tmp.path(), &["--version"], "version probe") + .expect("git --version should succeed"); + } + #[test] fn humanize_age_buckets() { assert_eq!(humanize_age(0), "just now"); diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 1ebf32f..8a32f5a 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -2,14 +2,17 @@ //! //! See module docs for the unlock flow and vault layout. +mod commands; mod device; mod gitea; mod helpers; +mod parse; +mod prompt; mod session; use std::path::PathBuf; -use anyhow::{bail, Context, Result}; +use anyhow::Result; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Shell}; @@ -205,7 +208,7 @@ enum Commands { } #[derive(Subcommand)] -enum AddKind { +pub(crate) enum AddKind { Login { #[arg(long)] title: Option, #[arg(long)] username: Option, @@ -269,7 +272,7 @@ enum AddKind { } #[derive(Subcommand)] -enum TrashAction { +pub(crate) enum TrashAction { /// List trashed items. List, /// Purge every trashed item past its retention window. @@ -277,7 +280,7 @@ enum TrashAction { } #[derive(Subcommand)] -enum SettingsAction { +pub(crate) enum SettingsAction { /// Show current settings as JSON. Show, /// Set trash retention (e.g., --days 30 or --forever). @@ -321,7 +324,7 @@ enum SettingsAction { } #[derive(Subcommand)] -enum BackupAction { +pub(crate) enum BackupAction { /// Pack the local vault into a single encrypted `.relbak` file. /// Backup passphrase is independent of the vault passphrase. Export { @@ -350,7 +353,7 @@ enum BackupAction { } #[derive(Subcommand)] -enum ImportAction { +pub(crate) enum ImportAction { /// Import a LastPass CSV export into the unlocked vault. /// Each row creates a new item with a freshly-minted ID; title /// collisions are kept (no dedup). Failed rows are skipped and @@ -362,7 +365,7 @@ enum ImportAction { } #[derive(Subcommand)] -enum DeviceAction { +pub(crate) enum DeviceAction { /// Register this machine as a new device. /// /// Generates two ed25519 keypairs: one for signing commits, one for push @@ -410,7 +413,7 @@ enum DeviceAction { } #[derive(clap::Subcommand)] -enum RecoveryQrCmd { +pub(crate) enum RecoveryQrCmd { /// Generate a recovery QR code and display it as ASCII art in the terminal. Generate, /// Unwrap a recovery QR payload (base64) to recover the image_secret as hex. @@ -420,37 +423,37 @@ enum RecoveryQrCmd { fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { - Commands::Init { image, output } => cmd_init(image, output), - Commands::Add { kind } => cmd_add(kind), - Commands::Get { query, show, copy } => cmd_get(query, show, copy), - Commands::List { r#type, group, tag, trashed } => cmd_list(r#type, group, tag, trashed), - Commands::Edit { query, totp_qr } => cmd_edit(query, totp_qr), - Commands::History { query, show, field } => cmd_history(query, show, field), - Commands::Rm { query } => cmd_rm(query), - Commands::Restore { query } => cmd_restore(query), - Commands::Purge { query } => cmd_purge(query), - Commands::Trash { action } => cmd_trash(action), - Commands::Backup { action } => cmd_backup(action), - Commands::Import { action } => cmd_import(action), - Commands::Attach { query, file } => cmd_attach(query, file), - Commands::Attachments { query } => cmd_attachments(query), - Commands::Extract { query, aid, out } => cmd_extract(query, aid, out), - Commands::Detach { query, aid } => cmd_detach(query, aid), + Commands::Init { image, output } => commands::init::cmd_init(image, output), + 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), + Commands::History { query, show, field } => commands::list::cmd_history(query, show, field), + Commands::Rm { query } => commands::trash::cmd_rm(query), + Commands::Restore { query } => commands::trash::cmd_restore(query), + Commands::Purge { query } => commands::trash::cmd_purge(query), + Commands::Trash { action } => commands::trash::cmd_trash(action), + Commands::Backup { action } => commands::backup::cmd_backup(action), + Commands::Import { action } => commands::import::cmd_import(action), + Commands::Attach { query, file } => commands::attach::cmd_attach(query, file), + Commands::Attachments { query } => commands::attach::cmd_attachments(query), + Commands::Extract { query, aid, out } => commands::attach::cmd_extract(query, aid, out), + Commands::Detach { query, aid } => commands::attach::cmd_detach(query, aid), Commands::Generate { length, bip39, words, symbols, separator } => { - cmd_generate(length, bip39, words, symbols, separator) + commands::generate::cmd_generate(length, bip39, words, symbols, separator) } - Commands::Settings { action } => cmd_settings(action), - Commands::Sync => cmd_sync(), - Commands::Status => cmd_status(), + Commands::Settings { action } => commands::settings::cmd_settings(action), + Commands::Sync => commands::sync::cmd_sync(), + Commands::Status => commands::status::cmd_status(), Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) } Commands::Completions { shell } => { let mut cmd = Cli::command(); generate(shell, &mut cmd, "relicario", &mut std::io::stdout()); Ok(()) } - Commands::Rate { passphrase } => cmd_rate(passphrase), - Commands::Device { action } => cmd_device(action), - Commands::RecoveryQr { cmd } => cmd_recovery_qr(cmd), + Commands::Rate { passphrase } => commands::rate::cmd_rate(passphrase), + Commands::Device { action } => commands::device::cmd_device(action), + Commands::RecoveryQr { cmd } => commands::recovery_qr::cmd_recovery_qr(cmd), } } @@ -460,7 +463,7 @@ fn main() -> Result<()> { /// /// Failures are silently swallowed — a missing cache is merely a UX degradation, /// not a correctness problem. -fn refresh_groups_cache(vault_dir: &std::path::Path, manifest: &relicario_core::Manifest) { +pub(crate) fn refresh_groups_cache(vault_dir: &std::path::Path, manifest: &relicario_core::Manifest) { let mut set = std::collections::BTreeSet::::new(); for entry in manifest.items.values() { if let Some(g) = entry.group.as_ref() { @@ -484,2158 +487,23 @@ pub(crate) fn test_passphrase_override() -> Option { /// Check for test item secret override (debug builds only; stripped from release). #[cfg(debug_assertions)] -fn test_item_secret_override() -> Option { +pub(crate) fn test_item_secret_override() -> Option { std::env::var("RELICARIO_TEST_ITEM_SECRET").ok() } #[cfg(not(debug_assertions))] -fn test_item_secret_override() -> Option { +pub(crate) fn test_item_secret_override() -> Option { None } /// Check for test backup passphrase override (debug builds only; stripped from release). #[cfg(debug_assertions)] -fn test_backup_passphrase_override() -> Option { +pub(crate) fn test_backup_passphrase_override() -> Option { std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE").ok() } #[cfg(not(debug_assertions))] -fn test_backup_passphrase_override() -> Option { +pub(crate) fn test_backup_passphrase_override() -> Option { None } -/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET` -/// for integration-test use (rpassword reads /dev/tty by default, which is -/// unavailable in assert_cmd-spawned children). -fn prompt_secret(label: &str) -> Result { - if let Some(s) = test_item_secret_override() { - return Ok(s); - } - rpassword::prompt_password(label).map_err(Into::into) -} -fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { - use std::fs; - use rand::{rngs::OsRng, RngCore}; - use relicario_core::{ - derive_master_key, encrypt_manifest, encrypt_settings, imgsecret, - validate_passphrase_strength, KdfParams, Manifest, VaultSettings, - }; - use zeroize::Zeroizing; - let root = std::env::current_dir()?; - let relicario_dir = root.join(".relicario"); - if relicario_dir.exists() { - anyhow::bail!(".relicario/ already exists in {}", root.display()); - } - - // Passphrase with strength gate (audit H3). - // RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the - // TTY prompt so integration tests can run without a real TTY. - let passphrase = if let Some(p) = test_passphrase_override() { - Zeroizing::new(p) - } else { - Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?) - }; - let confirm = if test_passphrase_override().is_some() { - passphrase.clone() - } else { - Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?) - }; - if passphrase.as_str() != confirm.as_str() { - anyhow::bail!("passphrases do not match"); - } - if let Err(e) = validate_passphrase_strength(&passphrase) { - anyhow::bail!("{}. Choose a longer or more entropic phrase.", e); - } - - // Image secret: 32 random bytes, embedded in the carrier. - let image_secret = { - let mut buf = Zeroizing::new([0u8; 32]); - OsRng.fill_bytes(buf.as_mut_slice()); - buf - }; - let carrier = fs::read(&image) - .with_context(|| format!("failed to read carrier image {}", image.display()))?; - let stego = imgsecret::embed(&carrier, &image_secret)?; - fs::write(&output, &stego) - .with_context(|| format!("failed to write reference image {}", output.display()))?; - - // Vault salt + KDF params. - let mut salt = [0u8; 32]; - OsRng.fill_bytes(&mut salt); - let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }; - - // Derive master key, then persist an empty Manifest + default VaultSettings. - let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)?; - - fs::create_dir_all(&relicario_dir)?; - fs::create_dir_all(root.join("items"))?; - fs::create_dir_all(root.join("attachments"))?; - fs::write(relicario_dir.join("salt"), salt)?; - fs::write( - relicario_dir.join("params.json"), - serde_json::to_string_pretty(&ParamsFile { - format_version: 2, - kdf: ParamsKdf { - algorithm: "argon2id-v0x13".into(), - argon2_m: params.argon2_m, - argon2_t: params.argon2_t, - argon2_p: params.argon2_p, - }, - aead: "xchacha20poly1305".into(), - salt_path: ".relicario/salt".into(), - })?, - )?; - let manifest = Manifest::new(); - fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?; - let settings = VaultSettings::default(); - fs::write(root.join("settings.enc"), encrypt_settings(&settings, &master_key)?)?; - - // .gitignore excludes the reference image. - let fname = output.file_name() - .ok_or_else(|| anyhow::anyhow!("output path has no filename: {}", output.display()))? - .to_string_lossy(); - let gitignore = format!("{fname}\n"); - fs::write(root.join(".gitignore"), gitignore)?; - - // git init + initial commit via hardened wrapper. - let status = crate::helpers::git_command(&root, &["init"]).status()?; - if !status.success() { anyhow::bail!("git init failed"); } - let _ = crate::helpers::git_command(&root, &[ - "add", ".gitignore", ".relicario/params.json", - ".relicario/salt", "manifest.enc", "settings.enc", - ]).status()?; - let status = crate::helpers::git_command(&root, &[ - "commit", "-m", "init: new Relicario vault (format v2)", - ]).status()?; - if !status.success() { anyhow::bail!("git commit failed"); } - - eprintln!("Vault initialized at {}", root.display()); - eprintln!("Reference image: {}", output.display()); - eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor."); - Ok(()) -} -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) -} - -fn prompt(label: &str) -> Result { - eprint!("{label}: "); - std::io::Write::flush(&mut std::io::stderr())?; - let mut s = String::new(); - std::io::stdin().read_line(&mut s)?; - let trimmed = s.trim().to_string(); - if trimmed.is_empty() { anyhow::bail!("{label} required"); } - Ok(trimmed) -} - -fn prompt_optional(label: &str) -> Result> { - eprint!("{label} (leave blank to skip): "); - std::io::Write::flush(&mut std::io::stderr())?; - let mut s = String::new(); - std::io::stdin().read_line(&mut s)?; - let trimmed = s.trim().to_string(); - 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(['/', '-']) - .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); - let status = crate::helpers::git_command(vault.root(), &args).status()?; - if !status.success() { anyhow::bail!("git add failed"); } - let status = crate::helpers::git_command(vault.root(), &["commit", "-m", message]).status()?; - if !status.success() { anyhow::bail!("git commit failed"); } - Ok(()) -} - -fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> { - use relicario_core::ItemCore; - use zeroize::Zeroizing; - - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let manifest = vault.load_manifest()?; - refresh_groups_cache(vault.root(), &manifest); - let entry = resolve_query(&manifest, &query)?; - let item = vault.load_item(&entry.id)?; - - println!("ID: {}", item.id.as_str()); - println!("Title: {}", item.title); - println!("Type: {:?}", item.r#type); - if let Some(g) = &item.group { println!("Group: {g}"); } - if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); } - println!("Created: {}", crate::helpers::iso8601(item.created)); - println!("Modified: {}", crate::helpers::iso8601(item.modified)); - if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); } - println!(); - - let primary_secret: Option> = match &item.core { - ItemCore::Login(l) => { - if let Some(u) = &l.username { println!("Username: {u}"); } - if let Some(u) = &l.url { println!("URL: {u}"); } - if let Some(t) = &l.totp { - if show { - println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret)); - } else { - println!("TOTP: **** (use --show to reveal)"); - } - } - l.password.clone() - } - ItemCore::SecureNote(n) => { - if show { println!("Body:\n{}", n.body.as_str()); } - else { println!("Body: ********"); } - None - } - ItemCore::Identity(i) => { - if let Some(v) = &i.full_name { println!("Name: {v}"); } - if let Some(v) = &i.email { println!("Email: {v}"); } - if let Some(v) = &i.phone { println!("Phone: {v}"); } - if let Some(v) = &i.date_of_birth { println!("DOB: {v}"); } - None - } - ItemCore::Card(c) => { - if let Some(h) = &c.holder { println!("Holder: {h}"); } - if let Some(e) = &c.expiry { println!("Expiry: {:02}/{}", e.month, e.year); } - println!("Kind: {:?}", c.kind); - c.number.clone() - } - ItemCore::Key(k) => { - if let Some(l) = &k.label { println!("Label: {l}"); } - if let Some(a) = &k.algorithm { println!("Algo: {a}"); } - if let Some(pk) = &k.public_key { println!("Pubkey: {pk}"); } - Some(k.key_material.clone()) - } - ItemCore::Document(d) => { - println!("Filename: {}", d.filename); - println!("MIME: {}", d.mime_type); - None - } - ItemCore::Totp(t) => { - if let Some(i) = &t.issuer { println!("Issuer: {i}"); } - if let Some(l) = &t.label { println!("Label: {l}"); } - println!("Period: {}s", t.config.period_seconds); - println!("Digits: {}", t.config.digits); - None - } - }; - - if let Some(secret) = primary_secret { - if show { - println!("Secret: {}", secret.as_str()); - } else { - println!("Secret: ******** (use --show to reveal, --copy to clipboard)"); - } - if copy { - copy_to_clipboard_then_clear(&secret)?; - eprintln!("Copied to clipboard (auto-clears in 30s)."); - } - } - - Ok(()) -} - -fn resolve_query<'a>( - manifest: &'a relicario_core::Manifest, - query: &str, -) -> Result<&'a relicario_core::ManifestEntry> { - if let Some(entry) = manifest.items.values().find(|e| e.id.as_str() == query) { - return Ok(entry); - } - let hits: Vec<_> = manifest.search(query); - match hits.len() { - 0 => anyhow::bail!("no item matches `{query}`"), - 1 => Ok(hits[0]), - _ => { - let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect(); - anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", ")) - } - } -} - -fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing) -> Result<()> { - use arboard::Clipboard; - let mut cb = Clipboard::new().context("failed to access clipboard")?; - cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?; - let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned()); - // Unconditional clear (audit M6): spawn a detached thread that waits 30s - // and then rewrites the clipboard with empty string. Even if the user - // copies something else in the interim, we still overwrite once. - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(30)); - if let Ok(mut cb) = Clipboard::new() { - let _ = cb.set_text(String::new()); - drop(cleared_copy); // zeroize the detached copy - } - }); - Ok(()) -} -fn cmd_list( - type_filter: Option, - group_filter: Option, - tag_filter: Option, - trashed: bool, -) -> Result<()> { - use relicario_core::ItemType; - - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let manifest = vault.load_manifest()?; - refresh_groups_cache(vault.root(), &manifest); - - let parsed_type: Option = match type_filter.as_deref() { - None => None, - Some("login") => Some(ItemType::Login), - Some("secure_note") | Some("note") => Some(ItemType::SecureNote), - Some("identity") => Some(ItemType::Identity), - Some("card") => Some(ItemType::Card), - Some("key") => Some(ItemType::Key), - Some("document") => Some(ItemType::Document), - Some("totp") => Some(ItemType::Totp), - Some(other) => anyhow::bail!("unknown type filter: {other}"), - }; - - let mut entries: Vec<_> = manifest.items.values() - .filter(|e| { - if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() } - }) - .filter(|e| match parsed_type { - Some(t) => e.r#type == t, - None => true, - }) - .filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str()))) - .filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t))) - .collect(); - entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())); - - if entries.is_empty() { - eprintln!("(no items match)"); - return Ok(()); - } - - println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV"); - for e in entries { - let fav = if e.favorite { " *" } else { "" }; - println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title); - } - Ok(()) -} -fn cmd_edit(query: String, totp_qr: Option) -> Result<()> { - use relicario_core::time::now_unix; - use relicario_core::ItemCore; - - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let mut manifest = vault.load_manifest()?; - let entry = resolve_query(&manifest, &query)?; - let id = entry.id.clone(); - let _ = entry; - let mut item = vault.load_item(&id)?; - - eprintln!("Editing: {} ({}) — leave a prompt blank to keep the current value.", - item.title, item.id.as_str()); - - if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; } - if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); } - if let Some(v) = prompt_keep_opt("Tags (comma-separated)", Some(&item.tags.join(",")))? { - item.tags = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); - } - - let history = &mut item.field_history; - match &mut item.core { - ItemCore::Login(l) => edit_login(l, history, totp_qr)?, - ItemCore::SecureNote(n) => edit_secure_note(n, history)?, - ItemCore::Identity(i) => edit_identity(i)?, - ItemCore::Card(c) => edit_card(c, history)?, - ItemCore::Key(k) => edit_key(k, history)?, - ItemCore::Document(_) => edit_document_message(), - ItemCore::Totp(t) => edit_totp(t, history)?, - } - - item.modified = now_unix(); - vault.save_item(&item)?; - manifest.upsert(&item); - vault.save_manifest(&manifest)?; - refresh_groups_cache(vault.root(), &manifest); - commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), - &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; - eprintln!("Updated {}", item.id.as_str()); - Ok(()) -} - -// --- Per-type edit handlers. Each mutates its core slice in place; the ones -// that touch history-tracked fields take the item's field_history map so -// they can record the prior value alongside the change. - -type FieldHistory = std::collections::HashMap< - relicario_core::FieldId, - Vec, ->; - -fn edit_login( - l: &mut relicario_core::item_types::LoginCore, - history: &mut FieldHistory, - totp_qr: Option, -) -> Result<()> { - use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind}; - use zeroize::Zeroizing; - 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(()) -} - -fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> { - use zeroize::Zeroizing; - if prompt_yesno("Edit body?")? { - let old = n.body.clone(); - eprintln!("Enter new body; end with Ctrl-D:"); - let mut s = String::new(); - std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; - n.body = Zeroizing::new(s); - push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string())); - } - Ok(()) -} - -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(()) -} - -fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> { - use zeroize::Zeroizing; - 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(()) -} - -fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> { - use zeroize::Zeroizing; - if prompt_yesno("Replace key material?")? { - eprintln!("Paste new key material; end with Ctrl-D:"); - let mut s = String::new(); - std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; - 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(()) -} - -fn edit_document_message() { - eprintln!("Document items: use `relicario attach` / `relicario extract` instead."); -} - -fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> { - use zeroize::Zeroizing; - 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(()) -} - -fn prompt_keep(label: &str, current: &str) -> Result> { - eprint!("{label} [{current}]: "); - std::io::Write::flush(&mut std::io::stderr())?; - let mut s = String::new(); - std::io::stdin().read_line(&mut s)?; - let trimmed = s.trim().to_string(); - Ok(if trimmed.is_empty() { None } else { Some(trimmed) }) -} - -fn prompt_keep_opt(label: &str, current: Option<&str>) -> Result> { - let display = current.unwrap_or("(none)"); - eprint!("{label} [{display}]: "); - std::io::Write::flush(&mut std::io::stderr())?; - let mut s = String::new(); - std::io::stdin().read_line(&mut s)?; - let trimmed = s.trim().to_string(); - Ok(if trimmed.is_empty() { None } else { Some(trimmed) }) -} - -fn prompt_yesno(label: &str) -> Result { - eprint!("{label} [y/N] "); - std::io::Write::flush(&mut std::io::stderr())?; - let mut s = String::new(); - std::io::stdin().read_line(&mut s)?; - Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes")) -} - -fn push_history( - history: &mut std::collections::HashMap>, - synthetic_key: &str, - old_value: zeroize::Zeroizing, -) { - use relicario_core::item::FieldHistoryEntry; - use relicario_core::time::now_unix; - // 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(), - }); -} - -fn cmd_history(query: String, show: bool, field: Option) -> Result<()> { - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let manifest = vault.load_manifest()?; - let entry = resolve_query(&manifest, &query)?; - let item = vault.load_item(&entry.id)?; - - println!("History for {} ({})", item.title, item.id.as_str()); - println!(); - - // Filter and sort the field-id keys so output is deterministic. - let mut keys: Vec<&relicario_core::FieldId> = item.field_history.keys().collect(); - keys.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut printed_any = false; - for fid in keys { - let display_name = fid.0.strip_prefix("core:").unwrap_or(&fid.0); - if let Some(filter) = &field { - if display_name != filter && fid.0 != *filter { continue; } - } - let entries = &item.field_history[fid]; - if entries.is_empty() { continue; } - printed_any = true; - - println!("{display_name} ({} {})", - entries.len(), - if entries.len() == 1 { "entry" } else { "entries" }); - for (i, e) in entries.iter().enumerate() { - let ts = crate::helpers::iso8601(e.replaced_at); - if show { - println!(" [{i}] {ts} {}", e.value.as_str()); - } else { - println!(" [{i}] {ts} ********"); - } - } - println!(); - } - - if !printed_any { - if field.is_some() { - println!("no history for the requested field"); - } else { - println!("no history captured for this item"); - } - } else if !show { - println!("(use --show to reveal values)"); - } - - Ok(()) -} - -fn cmd_rm(query: String) -> Result<()> { - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let mut manifest = vault.load_manifest()?; - let entry = resolve_query(&manifest, &query)?; - let id = entry.id.clone(); - let _ = entry; - let mut item = vault.load_item(&id)?; - item.soft_delete(); - vault.save_item(&item)?; - manifest.upsert(&item); - vault.save_manifest(&manifest)?; - refresh_groups_cache(vault.root(), &manifest); - commit_paths(&vault, &format!("trash: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), - &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; - eprintln!("Moved to trash: {}", item.title); - Ok(()) -} - -fn cmd_restore(query: String) -> Result<()> { - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let mut manifest = vault.load_manifest()?; - let entry = resolve_query(&manifest, &query)?; - let id = entry.id.clone(); - let _ = entry; - let mut item = vault.load_item(&id)?; - item.restore(); - vault.save_item(&item)?; - manifest.upsert(&item); - vault.save_manifest(&manifest)?; - refresh_groups_cache(vault.root(), &manifest); - commit_paths(&vault, &format!("restore: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), - &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; - eprintln!("Restored: {}", item.title); - Ok(()) -} - -/// Inner purge: assumes vault is already unlocked and manifest is loaded. -/// Caller is responsible for saving the manifest and committing afterwards. -fn purge_item( - vault: &crate::session::UnlockedVault, - manifest: &mut relicario_core::Manifest, - id: &relicario_core::ItemId, - title: &str, -) -> Result<()> { - use std::fs; - - let item_path = vault.item_path(id); - if item_path.exists() { fs::remove_file(&item_path)?; } - let att_dir = vault.root().join("attachments").join(id.as_str()); - if att_dir.exists() { fs::remove_dir_all(&att_dir)?; } - manifest.remove(id); - - let _ = crate::helpers::git_command(vault.root(), &["rm", "-rf", "--ignore-unmatch", - &format!("items/{}.enc", id.as_str()), - &format!("attachments/{}", id.as_str()), - ]).status()?; - // Note: caller adds+commits manifest.enc after processing all purges. - eprintln!("Purged: {title}"); - Ok(()) -} - -fn cmd_purge(query: String) -> Result<()> { - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let mut manifest = vault.load_manifest()?; - let entry = resolve_query(&manifest, &query)?; - let id = entry.id.clone(); - let title = entry.title.clone(); - let _ = entry; - - purge_item(&vault, &mut manifest, &id, &title)?; - vault.save_manifest(&manifest)?; - refresh_groups_cache(vault.root(), &manifest); - - let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?; - if !status.success() { anyhow::bail!("git add manifest.enc failed"); } - let status = crate::helpers::git_command(vault.root(), - &["commit", "-m", &format!("purge: {} ({})", title, id.as_str())]).status()?; - if !status.success() { anyhow::bail!("git commit failed"); } - Ok(()) -} - -fn cmd_trash(action: TrashAction) -> Result<()> { - match action { - TrashAction::List => cmd_list(None, None, None, true), - TrashAction::Empty => cmd_trash_empty(), - } -} - -fn cmd_backup(action: BackupAction) -> Result<()> { - match action { - BackupAction::Export { out, include_image, image, no_history } => { - cmd_backup_export(out, include_image, image, no_history) - } - BackupAction::Restore { input, target } => cmd_backup_restore(input, target), - } -} - -fn cmd_backup_export( - out: PathBuf, - include_image: bool, - image: Option, - no_history: bool, -) -> Result<()> { - use std::fs; - use relicario_core::{backup, validate_passphrase_strength}; - use zeroize::Zeroizing; - - let root = crate::helpers::vault_dir()?; - - // Backup passphrase — prompt twice, gate on zxcvbn (audit H3). - let passphrase = if let Some(p) = test_backup_passphrase_override() { - Zeroizing::new(p) - } else { - Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) - }; - let confirm = if test_backup_passphrase_override().is_some() { - passphrase.clone() - } else { - Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?) - }; - if passphrase.as_str() != confirm.as_str() { - anyhow::bail!("passphrases do not match"); - } - if let Err(e) = validate_passphrase_strength(&passphrase) { - anyhow::bail!("backup {}. Choose a longer or more entropic phrase.", e); - } - - // Read everything from disk that the envelope needs. - let salt = fs::read(root.join(".relicario").join("salt")) - .with_context(|| "failed to read .relicario/salt")?; - let params_json = fs::read_to_string(root.join(".relicario").join("params.json")) - .with_context(|| "failed to read .relicario/params.json")?; - // devices.json was removed in the B1 security audit fix; fall back to - // an empty array so backups of post-B1 vaults still pack cleanly. - // Task 12 will remove the devices field from the backup format entirely. - let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json")) - .unwrap_or_else(|_| "[]".to_string()); - let manifest_enc = fs::read(root.join("manifest.enc")) - .with_context(|| "failed to read manifest.enc")?; - let settings_enc = fs::read(root.join("settings.enc")) - .with_context(|| "failed to read settings.enc")?; - - // Items. - let mut item_files = Vec::new(); - let items_dir = root.join("items"); - if items_dir.is_dir() { - for entry in fs::read_dir(&items_dir)? { - let p = entry?.path(); - if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; } - let id = p.file_stem() - .and_then(|s| s.to_str()) - .ok_or_else(|| anyhow::anyhow!("bad item filename: {}", p.display()))? - .to_string(); - let bytes = fs::read(&p)?; - item_files.push((id, bytes)); - } - } - - // Attachments. Layout: attachments//.enc - let mut attach_files = Vec::new(); - let attach_dir = root.join("attachments"); - if attach_dir.is_dir() { - for entry in fs::read_dir(&attach_dir)? { - let item_dir = entry?.path(); - if !item_dir.is_dir() { continue; } - let item_id = item_dir.file_name() - .and_then(|s| s.to_str()) - .ok_or_else(|| anyhow::anyhow!("bad attachment dir: {}", item_dir.display()))? - .to_string(); - for sub in fs::read_dir(&item_dir)? { - let p = sub?.path(); - if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; } - let aid = p.file_stem() - .and_then(|s| s.to_str()) - .ok_or_else(|| anyhow::anyhow!("bad attachment filename: {}", p.display()))? - .to_string(); - let bytes = fs::read(&p)?; - attach_files.push((item_id.clone(), aid, bytes)); - } - } - } - - // Optional reference image. - let image_bytes = if include_image { - let path = match image { - Some(p) => p, - None => crate::session::get_image_path()?, - }; - Some(fs::read(&path) - .with_context(|| format!("failed to read reference image {}", path.display()))?) - } else { - None - }; - - // Optional .git/ tar. - let git_archive = if no_history { None } else { Some(tar_directory(&root.join(".git"))?) }; - - let items_refs: Vec = item_files.iter() - .map(|(id, bytes)| backup::BackupItem { id: id.clone(), ciphertext: bytes }) - .collect(); - let attach_refs: Vec = attach_files.iter() - .map(|(iid, aid, bytes)| backup::BackupAttachment { - item_id: iid.clone(), - attachment_id: aid.clone(), - ciphertext: bytes, - }) - .collect(); - - let input = backup::BackupInput { - salt: &salt, - params_json: ¶ms_json, - devices_json: &devices_json, - manifest_enc: &manifest_enc, - settings_enc: &settings_enc, - items: items_refs, - attachments: attach_refs, - reference_jpg: image_bytes.as_deref(), - git_archive: git_archive.as_deref(), - }; - - let bytes = backup::pack_backup(input, &passphrase)?; - - // atomic_write via the existing pattern: write `.tmp`, rename. - let tmp = { - let mut t = out.as_os_str().to_owned(); - t.push(".tmp"); - PathBuf::from(t) - }; - fs::write(&tmp, &bytes) - .with_context(|| format!("failed to write {}", tmp.display()))?; - fs::rename(&tmp, &out) - .with_context(|| format!("failed to rename {}", out.display()))?; - - // Marker file for `cmd_status`. Format: ISO-8601 UTC line. - let now_iso = crate::helpers::iso8601(relicario_core::now_unix()); - fs::write(root.join(".relicario").join("last_backup"), format!("{now_iso}\n"))?; - - let mib = (bytes.len() as f64) / (1024.0 * 1024.0); - eprintln!( - "Wrote {} ({:.2} MiB). Delete after restore is verified.", - out.display(), mib - ); - Ok(()) -} - -/// Tar a directory into an in-memory `Vec`. Used for `.git/` bundling. -fn tar_directory(dir: &std::path::Path) -> Result> { - let mut buf = Vec::new(); - { - let mut builder = tar::Builder::new(&mut buf); - builder.append_dir_all(".", dir) - .with_context(|| format!("failed to tar {}", dir.display()))?; - builder.finish().with_context(|| "failed to finalize git tar")?; - } - Ok(buf) -} - -fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> { - use std::fs; - use relicario_core::backup; - use relicario_core::{ItemId, AttachmentId}; - use zeroize::Zeroizing; - - let target = if target.is_absolute() { - target - } else { - std::env::current_dir()?.join(&target) - }; - - if target.join(".relicario").exists() { - anyhow::bail!( - "target dir already contains a Relicario vault; restore refuses to overwrite — use an empty directory: {}", - target.display() - ); - } - fs::create_dir_all(&target) - .with_context(|| format!("failed to create target {}", target.display()))?; - - // Read input file. - let bytes = fs::read(&input) - .with_context(|| format!("failed to read backup file {}", input.display()))?; - - // Backup passphrase prompt. - let passphrase = if let Some(p) = test_backup_passphrase_override() { - Zeroizing::new(p) - } else { - Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) - }; - - let unpacked = backup::unpack_backup(&bytes, &passphrase) - .map_err(|e| match e { - relicario_core::RelicarioError::Decrypt => - anyhow::anyhow!("wrong backup passphrase, or the file is corrupt"), - other => anyhow::anyhow!(other), - })?; - - // Write vault layout. - let relicario_dir = target.join(".relicario"); - fs::create_dir_all(&relicario_dir)?; - fs::create_dir_all(target.join("items"))?; - fs::create_dir_all(target.join("attachments"))?; - - fs::write(relicario_dir.join("salt"), unpacked.salt)?; - fs::write(relicario_dir.join("params.json"), &unpacked.params_json)?; - fs::write(relicario_dir.join("devices.json"), &unpacked.devices_json)?; - fs::write(target.join("manifest.enc"), &unpacked.manifest_enc)?; - fs::write(target.join("settings.enc"), &unpacked.settings_enc)?; - - for item in &unpacked.items { - let item_id = ItemId(item.id.clone()); - if !item_id.is_valid() { - anyhow::bail!("invalid item ID in backup: {} (path traversal blocked)", item.id); - } - fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?; - } - for a in &unpacked.attachments { - let item_id = ItemId(a.item_id.clone()); - let att_id = AttachmentId(a.attachment_id.clone()); - if !item_id.is_valid() || !att_id.is_valid() { - anyhow::bail!("invalid attachment ID in backup (path traversal blocked)"); - } - let dir = target.join("attachments").join(&a.item_id); - fs::create_dir_all(&dir)?; - fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?; - } - - // Reference image (if present). - if let Some(jpg) = &unpacked.reference_jpg { - let path = target.join("reference.jpg"); - fs::write(&path, jpg) - .with_context(|| format!("failed to write reference image {}", path.display()))?; - } - - // .git/ history. - if let Some(tar_bytes) = &unpacked.git_archive { - // Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower. - let cap = std::cmp::min( - (tar_bytes.len() as u64).saturating_mul(100), - relicario_core::DEFAULT_MAX_UNCOMPRESSED, - ); - let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap) - .with_context(|| "failed to safely unpack .git/ archive")?; - let git_dir = target.join(".git"); - for (rel_path, body) in entries { - let dest = git_dir.join(&rel_path); - // Paranoid OS-level check even after textual validation in core. - if !dest.starts_with(&git_dir) { - anyhow::bail!( - "tar entry {} resolved outside .git/ (path traversal blocked)", - rel_path.display() - ); - } - if let Some(parent) = dest.parent() { - fs::create_dir_all(parent).with_context(|| { - format!("create parent {}", parent.display()) - })?; - } - fs::write(&dest, &body).with_context(|| { - format!("write {}", dest.display()) - })?; - } - } else { - // No history bundled — start a fresh git repo. - let status = crate::helpers::git_command(&target, &["init"]).status()?; - if !status.success() { anyhow::bail!("git init failed"); } - - // .gitignore — exclude reference image if present. - if target.join("reference.jpg").exists() { - fs::write(target.join(".gitignore"), "reference.jpg\n")?; - } - - let _ = crate::helpers::git_command(&target, &["add", "."]).status()?; - let now_iso = crate::helpers::iso8601(relicario_core::now_unix()); - let msg = format!("restore from backup {now_iso}"); - let _ = crate::helpers::git_command(&target, &["commit", "-m", &msg]).status()?; - } - - eprintln!( - "Restored vault to {}. Unlock with your passphrase + reference image.", - target.display() - ); - Ok(()) -} - -fn cmd_import(action: ImportAction) -> Result<()> { - match action { - ImportAction::Lastpass { csv } => cmd_import_lastpass(csv), - } -} - -fn cmd_import_lastpass(csv_path: PathBuf) -> Result<()> { - use std::fs; - use relicario_core::import_lastpass::parse_lastpass_csv; - - let csv_bytes = fs::read(&csv_path) - .with_context(|| format!("failed to read CSV {}", csv_path.display()))?; - - let (items, warnings) = parse_lastpass_csv(&csv_bytes)?; - - if items.is_empty() { - // Print all warnings so the user sees why nothing imported. - for w in &warnings { - print_warning(w); - } - bail!( - "imported 0 items from {} — see warnings above", - csv_path.display() - ); - } - - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let mut manifest = vault.load_manifest()?; - - let total = items.len(); - let mut written_paths: Vec = Vec::with_capacity(items.len() + 1); - - for (idx, item) in items.iter().enumerate() { - vault.save_item(item)?; - manifest.upsert(item); - written_paths.push(format!("items/{}.enc", item.id.as_str())); - - let n = idx + 1; - if n % 50 == 0 || n == total { - eprintln!("[{n}/{total}] importing..."); - } - } - - vault.save_manifest(&manifest)?; - written_paths.push("manifest.enc".into()); - - let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect(); - let csv_filename = csv_path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("lastpass.csv"); - commit_paths( - &vault, - &format!("import: {} items from LastPass ({})", total, csv_filename), - &path_refs, - )?; - - for w in &warnings { - print_warning(w); - } - // Counts only true skips, not partial imports. Coupled by convention to - // the parser's warning message strings: skip messages end in "— skipped", - // partial-import messages say "imported without TOTP" / "imported without URL". - // If a future warning uses the word "skipped" in any other sense, this filter - // will need to switch to an enum tag (see ImportWarning::message). - eprintln!( - "Imported {}, skipped {} (see warnings above)", - total, - warnings.iter().filter(|w| w.message.contains("skipped")).count() - ); - Ok(()) -} - -fn print_warning(w: &relicario_core::import_lastpass::ImportWarning) { - let prefix = match &w.title { - Some(t) => format!("row {} ({}):", w.row, t), - None => format!("row {}:", w.row), - }; - eprintln!("warning: {prefix} {}", w.message); -} - -fn cmd_trash_empty() -> Result<()> { - use relicario_core::time::now_unix; - - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let mut manifest = vault.load_manifest()?; - let settings = vault.load_settings()?; - let now = now_unix(); - - let purgeable: Vec<_> = manifest.items.values() - .filter(|e| match e.trashed_at { - Some(t) => settings.trash_retention.should_purge(t, now), - None => false, - }) - .map(|e| (e.id.clone(), e.title.clone())) - .collect(); - - if purgeable.is_empty() { - eprintln!("nothing past retention window"); - return Ok(()); - } - - let mut purged_titles = Vec::new(); - for (id, title) in purgeable { - purge_item(&vault, &mut manifest, &id, &title)?; - purged_titles.push(title); - } - - vault.save_manifest(&manifest)?; - let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?; - if !status.success() { anyhow::bail!("git add manifest.enc failed"); } - let status = crate::helpers::git_command(vault.root(), - &["commit", "-m", &format!("trash empty: purged {} item(s)", purged_titles.len())]).status()?; - if !status.success() { anyhow::bail!("git commit failed"); } - - eprintln!("Emptied trash: {} item(s)", purged_titles.len()); - Ok(()) -} -fn cmd_attach(query: String, file: PathBuf) -> Result<()> { - use std::fs; - use relicario_core::{encrypt_attachment, AttachmentRef}; - use relicario_core::time::now_unix; - - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let mut manifest = vault.load_manifest()?; - let entry = resolve_query(&manifest, &query)?; - let id = entry.id.clone(); - let _ = entry; - let mut item = vault.load_item(&id)?; - let settings = vault.load_settings()?; - let caps = settings.attachment_caps; - - if item.attachments.len() as u32 >= caps.per_item_max_count { - anyhow::bail!("item already has {} attachments (max {})", - item.attachments.len(), caps.per_item_max_count); - } - - let bytes = fs::read(&file) - .with_context(|| format!("failed to read {}", file.display()))?; - - // Check per-vault total attachment bytes cap (audit I3). - let current_total: u64 = manifest.items.values() - .flat_map(|e| &e.attachment_summaries) - .map(|s| s.size) - .sum(); - let new_size = bytes.len() as u64; - let hard_cap = caps.per_vault_hard_cap_bytes; - let soft_cap = caps.per_vault_soft_cap_bytes; - if current_total + new_size > hard_cap { - anyhow::bail!( - "attachment would exceed vault hard cap ({} + {} > {} bytes)", - current_total, new_size, hard_cap - ); - } - if current_total + new_size > soft_cap { - eprintln!( - "warning: vault attachments will exceed soft cap ({} bytes)", - soft_cap - ); - } - - 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 aref = AttachmentRef { - id: enc.id.clone(), - filename, - mime_type, - size: bytes.len() as u64, - created: now_unix(), - }; - - 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", enc.id.as_str())), &enc.bytes)?; - - item.attachments.push(aref); - item.modified = now_unix(); - vault.save_item(&item)?; - manifest.upsert(&item); - vault.save_manifest(&manifest)?; - - let paths = [ - format!("items/{}.enc", item.id.as_str()), - "manifest.enc".into(), - format!("attachments/{}/{}.enc", item.id.as_str(), enc.id.as_str()), - ]; - let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect(); - commit_paths(&vault, &format!("attach: {} → {} ({})", - crate::helpers::sanitize_for_commit(&file.display().to_string()), - crate::helpers::sanitize_for_commit(&item.title), - item.id.as_str()), &path_refs)?; - eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str()); - Ok(()) -} - -fn cmd_attachments(query: String) -> Result<()> { - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let manifest = vault.load_manifest()?; - let entry = resolve_query(&manifest, &query)?; - let item = vault.load_item(&entry.id)?; - if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); } - println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME"); - for a in &item.attachments { - println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename); - } - Ok(()) -} - -fn cmd_extract(query: String, aid: String, out: Option) -> Result<()> { - use std::fs; - use relicario_core::decrypt_attachment; - - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let manifest = vault.load_manifest()?; - let entry = resolve_query(&manifest, &query)?; - let item = vault.load_item(&entry.id)?; - - let aref = item.attachments.iter().find(|a| a.id.as_str() == aid) - .ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?; - let path = vault.root().join("attachments").join(item.id.as_str()) - .join(format!("{}.enc", aid)); - let bytes = fs::read(&path) - .with_context(|| format!("failed to read {}", path.display()))?; - let plaintext = decrypt_attachment(&bytes, vault.key())?; - let out_path = out.unwrap_or_else(|| PathBuf::from(&aref.filename)); - fs::write(&out_path, plaintext.as_slice()) - .with_context(|| format!("failed to write {}", out_path.display()))?; - eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display()); - Ok(()) -} -fn cmd_detach(query: String, aid: String) -> Result<()> { - use std::fs; - use relicario_core::ItemCore; - use relicario_core::time::now_unix; - - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let mut manifest = vault.load_manifest()?; - let entry = resolve_query(&manifest, &query)?; - let id = entry.id.clone(); - let _ = entry; - let mut item = vault.load_item(&id)?; - - let pos = item.attachments.iter().position(|a| a.id.as_str() == aid) - .ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?; - - // Document items keep their primary blob in the core; refuse to orphan it. - if let ItemCore::Document(d) = &item.core { - if d.primary_attachment.as_str() == aid { - anyhow::bail!( - "cannot detach the primary attachment of a Document item; \ - use `purge {}` to delete the whole item", - item.title, - ); - } - } - - let removed = item.attachments.remove(pos); - let blob_path = vault.root().join("attachments").join(item.id.as_str()) - .join(format!("{}.enc", removed.id.as_str())); - if blob_path.exists() { - fs::remove_file(&blob_path) - .with_context(|| format!("failed to delete {}", blob_path.display()))?; - } - - item.modified = now_unix(); - vault.save_item(&item)?; - manifest.upsert(&item); - vault.save_manifest(&manifest)?; - - let item_path = format!("items/{}.enc", item.id.as_str()); - let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str()); - commit_paths( - &vault, - &format!("detach: {} from {} ({})", crate::helpers::sanitize_for_commit(&removed.filename), crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), - &[&item_path, "manifest.enc", &blob_relpath], - )?; - eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title); - Ok(()) -} - -fn cmd_generate( - length: Option, - bip39: bool, - words: Option, - symbols: Option, - separator: Option, -) -> Result<()> { - use relicario_core::{ - generate_passphrase, generate_password, Capitalization, CharClasses, - GeneratorRequest, SymbolCharset, - }; - - // If we're inside a vault, unlock and pull `generator_defaults`. Outside - // a vault, this stays a fast standalone CSPRNG tool (no unlock prompt). - let vault_defaults: Option = if crate::helpers::vault_dir().is_ok() { - let vault = crate::session::UnlockedVault::unlock_interactive()?; - Some(vault.load_settings()?.generator_defaults) - } else { - None - }; - - // `--bip39` flag forces Bip39 mode; otherwise use whatever mode the - // vault default is in (Random when no vault). - let use_bip39 = bip39 || matches!(vault_defaults, Some(GeneratorRequest::Bip39 { .. })); - - let output = if use_bip39 { - let (def_words, def_sep, def_cap) = match &vault_defaults { - Some(GeneratorRequest::Bip39 { word_count, separator, capitalization }) => { - (*word_count, separator.clone(), *capitalization) - } - _ => (5, " ".to_string(), Capitalization::Lower), - }; - generate_passphrase(&GeneratorRequest::Bip39 { - word_count: words.unwrap_or(def_words), - separator: separator.unwrap_or(def_sep), - capitalization: def_cap, - })? - } else { - let (def_length, def_classes, def_charset) = match &vault_defaults { - Some(GeneratorRequest::Random { length, classes, symbol_charset }) => { - (*length, *classes, symbol_charset.clone()) - } - _ => ( - 20, - CharClasses { lower: true, upper: true, digits: true, symbols: true }, - SymbolCharset::SafeOnly, - ), - }; - let symbol_charset = match symbols.as_deref() { - None => def_charset, - Some("safe") => SymbolCharset::SafeOnly, - Some("extended") => SymbolCharset::Extended, - Some(other) => SymbolCharset::Custom(other.to_string()), - }; - generate_password(&GeneratorRequest::Random { - length: length.unwrap_or(def_length), - classes: def_classes, - symbol_charset, - })? - }; - - println!("{}", output.as_str()); - Ok(()) -} -fn cmd_settings(action: SettingsAction) -> Result<()> { - use relicario_core::{ - Capitalization, CharClasses, GeneratorRequest, HistoryRetention, - SymbolCharset, TrashRetention, - }; - - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let mut settings = vault.load_settings()?; - - match action { - SettingsAction::Show => { - println!("{}", serde_json::to_string_pretty(&settings)?); - return Ok(()); - } - SettingsAction::TrashRetention { days, forever } => { - settings.trash_retention = match (days, forever) { - (Some(d), false) => TrashRetention::Days(d), - (None, true) => TrashRetention::Forever, - _ => anyhow::bail!("specify exactly one of --days or --forever"), - }; - } - SettingsAction::HistoryRetention { last_n, days, forever } => { - settings.field_history_retention = match (last_n, days, forever) { - (Some(n), None, false) => HistoryRetention::LastN(n), - (None, Some(d), false) => HistoryRetention::Days(d), - (None, None, true) => HistoryRetention::Forever, - _ => anyhow::bail!("specify exactly one of --last-n / --days / --forever"), - }; - } - SettingsAction::AttachmentCap { - per_attachment_max_bytes, per_item_max_count, - per_vault_soft_cap_bytes, per_vault_hard_cap_bytes, - } => { - if let Some(v) = per_attachment_max_bytes { settings.attachment_caps.per_attachment_max_bytes = v; } - if let Some(v) = per_item_max_count { settings.attachment_caps.per_item_max_count = v; } - if let Some(v) = per_vault_soft_cap_bytes { settings.attachment_caps.per_vault_soft_cap_bytes = v; } - if let Some(v) = per_vault_hard_cap_bytes { settings.attachment_caps.per_vault_hard_cap_bytes = v; } - } - SettingsAction::GeneratorDefaults { - random, bip39, length, words, symbols, separator, - } => { - // Decide target mode: explicit flag wins, else preserve current. - let target_bip39 = if random { false } - else if bip39 { true } - else { matches!(settings.generator_defaults, GeneratorRequest::Bip39 { .. }) }; - - // Pull existing fields where compatible, else seed with sensible - // defaults (kept in sync with `GeneratorRequest::default()`). - let (cur_length, cur_classes, cur_charset) = match &settings.generator_defaults { - GeneratorRequest::Random { length, classes, symbol_charset } => { - (*length, *classes, symbol_charset.clone()) - } - _ => ( - 20, - CharClasses { lower: true, upper: true, digits: true, symbols: true }, - SymbolCharset::SafeOnly, - ), - }; - let (cur_words, cur_sep, cur_cap) = match &settings.generator_defaults { - GeneratorRequest::Bip39 { word_count, separator, capitalization } => { - (*word_count, separator.clone(), *capitalization) - } - _ => (5, " ".to_string(), Capitalization::Lower), - }; - - settings.generator_defaults = if target_bip39 { - GeneratorRequest::Bip39 { - word_count: words.unwrap_or(cur_words), - separator: separator.unwrap_or(cur_sep), - capitalization: cur_cap, - } - } else { - let charset = match symbols.as_deref() { - None => cur_charset, - Some("safe") => SymbolCharset::SafeOnly, - Some("extended") => SymbolCharset::Extended, - Some(other) => SymbolCharset::Custom(other.to_string()), - }; - GeneratorRequest::Random { - length: length.unwrap_or(cur_length), - classes: cur_classes, - symbol_charset: charset, - } - }; - } - } - - vault.save_settings(&settings)?; - commit_paths(&vault, "settings: update", &["settings.enc"])?; - eprintln!("Settings updated."); - Ok(()) -} -fn cmd_sync() -> Result<()> { - let root = crate::helpers::vault_dir()?; - let pull = crate::helpers::git_command(&root, &["pull", "--rebase"]).status()?; - if !pull.success() { anyhow::bail!("git pull --rebase failed"); } - let push = crate::helpers::git_command(&root, &["push"]).status()?; - if !push.success() { anyhow::bail!("git push failed"); } - eprintln!("Sync complete."); - Ok(()) -} - -fn cmd_status() -> Result<()> { - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let root = vault.root().to_path_buf(); - let manifest = vault.load_manifest()?; - - let total_items = manifest.items.len(); - let trashed_items = manifest.items.values().filter(|e| e.trashed_at.is_some()).count(); - let active_items = total_items - trashed_items; - - let (attachment_count, attachment_bytes) = manifest.items.values() - .flat_map(|e| e.attachment_summaries.iter()) - .fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size)); - - let last_commit = crate::helpers::git_command(&root, &[ - "log", "-1", "--pretty=format:%h %s", - ]).output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) - .unwrap_or_else(|| "(no commits)".into()); - - // Last backup age (read from marker written by cmd_backup_export). - let last_backup_path = vault.root().join(".relicario").join("last_backup"); - let last_backup_str = if last_backup_path.exists() { - let line = std::fs::read_to_string(&last_backup_path) - .unwrap_or_default() - .trim() - .to_string(); - // Parse the ISO-8601 we wrote in cmd_backup_export. - match chrono::DateTime::parse_from_rfc3339(&line) { - Ok(then) => { - let now = relicario_core::now_unix(); - let age = now - then.timestamp(); - crate::helpers::humanize_age(age.max(0)) - } - Err(_) => "unknown".to_string(), - } - } else { - "never".to_string() - }; - - println!("Vault: {}", root.display()); - println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)"); - println!("Attachments: {attachment_count} ({attachment_bytes} bytes)"); - println!("Last commit: {last_commit}"); - println!("Last export: {last_backup_str}"); - Ok(()) -} -#[derive(serde::Serialize)] -struct ParamsFile { - format_version: u32, - kdf: ParamsKdf, - aead: String, - salt_path: String, -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "snake_case")] -struct ParamsKdf { - algorithm: String, - argon2_m: u32, - argon2_t: u32, - argon2_p: u32, -} - -fn cmd_rate(passphrase: String) -> Result<()> { - let pw: String = if passphrase == "-" { - use std::io::BufRead; - let stdin = std::io::stdin(); - let mut line = String::new(); - stdin.lock().read_line(&mut line)?; - line.trim_end_matches(&['\r', '\n'][..]).to_string() - } else { - passphrase - }; - let est = relicario_core::generators::rate_passphrase(&pw); - let label = match est.score { - 0 => "very weak", - 1 => "weak", - 2 => "fair", - 3 => "good", - 4 => "strong", - _ => "?", - }; - println!("score: {}/4 ({})", est.score, label); - println!("guesses: ~10^{:.1}", est.guesses_log10); - println!("note: init requires score ≥ 3 (see `relicario init`)"); - Ok(()) -} - -// ── Device management ───────────────────────────────────────────────────────── - -/// Build a `GiteaClient` from flags or environment variables. -fn load_gitea_client( - gitea_url: Option, - gitea_token: Option, - owner: Option, - repo: Option, -) -> Result { - let url = gitea_url - .or_else(|| std::env::var("RELICARIO_GITEA_URL").ok()) - .ok_or_else(|| anyhow::anyhow!( - "Gitea URL required — pass --gitea-url or set RELICARIO_GITEA_URL" - ))?; - let token = gitea_token - .or_else(|| std::env::var("RELICARIO_GITEA_TOKEN").ok()) - .ok_or_else(|| anyhow::anyhow!( - "Gitea token required — pass --gitea-token or set RELICARIO_GITEA_TOKEN" - ))?; - let owner = owner - .or_else(|| std::env::var("RELICARIO_GITEA_OWNER").ok()) - .ok_or_else(|| anyhow::anyhow!( - "Gitea owner required — pass --owner or set RELICARIO_GITEA_OWNER" - ))?; - let repo = repo - .or_else(|| std::env::var("RELICARIO_GITEA_REPO").ok()) - .ok_or_else(|| anyhow::anyhow!( - "Gitea repo required — pass --repo or set RELICARIO_GITEA_REPO" - ))?; - Ok(crate::gitea::GiteaClient::new(&url, &token, &owner, &repo)) -} - -fn cmd_device(action: DeviceAction) -> Result<()> { - use std::fs; - use relicario_core::device::{DeviceEntry, RevokedEntry, generate_keypair}; - - let root = crate::helpers::vault_dir()?; - let relicario_dir = root.join(".relicario"); - let devices_path = relicario_dir.join("devices.json"); - - match action { - DeviceAction::Add { name, gitea_url, gitea_token, owner, repo, no_gitea } => { - // Guard: don't overwrite an already-registered device name. - let existing: Vec = fs::read(&devices_path) - .ok() - .and_then(|b| serde_json::from_slice(&b).ok()) - .unwrap_or_default(); - if existing.iter().any(|d| d.name == name) { - anyhow::bail!("a device named '{}' is already registered", name); - } - - eprintln!("Generating signing keypair..."); - let (signing_priv, signing_pub) = generate_keypair() - .map_err(|e| anyhow::anyhow!("generate signing keypair: {e}"))?; - - eprintln!("Generating deploy keypair..."); - let (deploy_priv, deploy_pub) = generate_keypair() - .map_err(|e| anyhow::anyhow!("generate deploy keypair: {e}"))?; - - // Optionally register deploy key with Gitea. - let gitea_key_id: u64 = if no_gitea { - eprintln!("Skipping Gitea deploy key registration (--no-gitea)."); - 0 - } else { - let client = load_gitea_client(gitea_url, gitea_token, owner, repo)?; - let key_title = format!("relicario-{}", name); - eprintln!("Registering deploy key '{}' with Gitea...", key_title); - client.create_deploy_key(&key_title, &deploy_pub)? - }; - - // Store keys locally with proper permissions. - crate::device::store_device_keys( - &name, - &signing_priv, - &signing_pub, - &deploy_priv, - &deploy_pub, - gitea_key_id, - )?; - - // Mark as current device. - crate::device::set_current_device(&name)?; - - // Configure git signing + SSH deploy key in the vault repo. - crate::device::configure_git_signing(&root, &name)?; - - // Update devices.json. - let current_name = name.clone(); - let mut devices = existing; - devices.push(DeviceEntry { - name: name.clone(), - public_key: signing_pub.clone(), - added_at: relicario_core::now_unix(), - added_by: current_name, - }); - fs::create_dir_all(&relicario_dir)?; - fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?; - - // Commit the update. - let status = crate::helpers::git_command( - &root, - &["add", ".relicario/devices.json"], - ) - .status()?; - if !status.success() { - anyhow::bail!("git add .relicario/devices.json failed"); - } - let msg = format!("device: register {}", name); - let status = crate::helpers::git_command(&root, &["commit", "-m", &msg]) - .status()?; - if !status.success() { - anyhow::bail!("git commit failed"); - } - - eprintln!("Device '{}' registered.", name); - eprintln!("Signing public key:"); - eprintln!(" {}", signing_pub); - if gitea_key_id != 0 { - eprintln!("Gitea deploy key ID: {}", gitea_key_id); - } - Ok(()) - } - - DeviceAction::Revoke { name } => { - // Guard: refuse to revoke the currently active device (would lock - // the user out). They must add another device first. - if let Some(current) = crate::device::current_device()? { - if current == name { - anyhow::bail!( - "cannot revoke the current device '{}' — you would lose \ - push access. Register another device first.", - name - ); - } - } - - // Load devices.json. - let mut devices: Vec = fs::read(&devices_path) - .ok() - .and_then(|b| serde_json::from_slice(&b).ok()) - .unwrap_or_default(); - - let device = devices - .iter() - .find(|d| d.name == name) - .ok_or_else(|| anyhow::anyhow!("device '{}' not found", name))? - .clone(); - - // Remove from devices.json. - devices.retain(|d| d.name != name); - fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?; - - // Append to revoked.json. - let revoked_path = relicario_dir.join("revoked.json"); - let mut revoked: Vec = fs::read(&revoked_path) - .ok() - .and_then(|b| serde_json::from_slice(&b).ok()) - .unwrap_or_default(); - - let revoked_by = crate::device::current_device()? - .unwrap_or_else(|| "unknown".to_string()); - - revoked.push(RevokedEntry { - name: name.clone(), - public_key: device.public_key.clone(), - revoked_at: relicario_core::now_unix(), - revoked_by, - }); - fs::write(&revoked_path, serde_json::to_string_pretty(&revoked)?)?; - - // Delete deploy key from Gitea (best-effort — don't fail if it - // was already deleted or the config is missing). - if let Ok(key_id) = crate::device::load_gitea_key_id(&name) { - if key_id != 0 { - // Build client from env vars only (no flags in revoke). - match load_gitea_client(None, None, None, None) { - Ok(client) => { - if let Err(e) = client.delete_deploy_key(key_id) { - eprintln!( - "warning: failed to delete Gitea deploy key {}: {}", - key_id, e - ); - } else { - eprintln!("Deleted Gitea deploy key {}.", key_id); - } - } - Err(_) => { - eprintln!( - "warning: Gitea env vars not set — deploy key {} \ - not deleted from Gitea.", - key_id - ); - } - } - } - } - - // Commit devices.json + revoked.json (always both — revoked.json - // was just written above so it is guaranteed to exist). - let add_args = [ - "add", - ".relicario/devices.json", - ".relicario/revoked.json", - ]; - let status = crate::helpers::git_command(&root, &add_args).status()?; - if !status.success() { - anyhow::bail!("git add failed"); - } - let msg = format!("device: revoke {}", name); - let status = crate::helpers::git_command(&root, &["commit", "-m", &msg]) - .status()?; - if !status.success() { - anyhow::bail!("git commit failed"); - } - - eprintln!("Device '{}' revoked.", name); - eprintln!("Revoked signing key: {}", device.public_key); - Ok(()) - } - - DeviceAction::List => { - let devices: Vec = fs::read(&devices_path) - .ok() - .and_then(|b| serde_json::from_slice(&b).ok()) - .unwrap_or_default(); - - let current = crate::device::current_device()?.unwrap_or_default(); - - if devices.is_empty() { - println!("No registered devices."); - return Ok(()); - } - - println!("{:<20} {:<20} SIGNING KEY (prefix)", "NAME", "ADDED"); - println!("{}", "-".repeat(72)); - for d in &devices { - let marker = if d.name == current { " *" } else { "" }; - let added = crate::helpers::iso8601(d.added_at); - // Show only the first 40 chars of the public key line for readability. - let key_prefix: String = d.public_key.chars().take(40).collect(); - println!("{:<20} {:<20} {}{}", - d.name, added, key_prefix, marker); - } - if !current.is_empty() { - println!("\n* = current device"); - } - Ok(()) - } - } -} - -fn cmd_recovery_qr(cmd: RecoveryQrCmd) -> Result<()> { - match cmd { - RecoveryQrCmd::Generate => cmd_recovery_qr_generate(), - RecoveryQrCmd::Unwrap => cmd_recovery_qr_unwrap(), - } -} - -fn cmd_recovery_qr_generate() -> Result<()> { - use relicario_core::{generate_recovery_qr, imgsecret}; - use zeroize::Zeroizing; - - let image_path = crate::session::get_image_path()?; - let image_bytes = std::fs::read(&image_path) - .with_context(|| format!("read reference image {}", image_path.display()))?; - let image_secret = imgsecret::extract(&image_bytes) - .context("extract image secret")?; - - let passphrase = Zeroizing::new( - rpassword::prompt_password("Enter vault passphrase: ") - .context("read passphrase")? - ); - - let payload = generate_recovery_qr(passphrase.as_str(), &image_secret) - .map_err(|e| anyhow::anyhow!("{e}"))?; - - use qrcode::{EcLevel, QrCode, render::unicode}; - let code = QrCode::with_error_correction_level(payload.as_bytes(), EcLevel::M) - .expect("valid payload"); - let image = code - .render::() - .dark_color(unicode::Dense1x2::Dark) - .light_color(unicode::Dense1x2::Light) - .build(); - println!("{image}"); - println!("Recovery QR generated. Print or photograph this code and store it securely."); - println!("The QR has NOT been saved to disk."); - Ok(()) -} - -fn cmd_recovery_qr_unwrap() -> Result<()> { - use relicario_core::unwrap_recovery_qr; - use std::io::BufRead; - use zeroize::Zeroizing; - - println!("Paste the base64 recovery QR payload and press Enter:"); - let stdin = std::io::stdin(); - let payload_b64 = stdin.lock().lines().next() - .context("no input")??; - let payload_b64 = payload_b64.trim().to_owned(); - - let bytes = data_encoding::BASE64.decode(payload_b64.as_bytes()) - .map_err(|e| anyhow::anyhow!("base64 decode: {e}"))?; - - let passphrase = Zeroizing::new( - rpassword::prompt_password("Enter passphrase: ") - .context("read passphrase")? - ); - - let secret = unwrap_recovery_qr(&bytes, passphrase.as_str()) - .map_err(|e| anyhow::anyhow!("{e}"))?; - println!("image_secret: {}", hex::encode(secret.as_ref())); - Ok(()) -} diff --git a/crates/relicario-cli/src/parse.rs b/crates/relicario-cli/src/parse.rs new file mode 100644 index 0000000..cbf8084 --- /dev/null +++ b/crates/relicario-cli/src/parse.rs @@ -0,0 +1,47 @@ +//! Small parsers used by the CLI (`MM/YY[YY]`, lenient base32, MIME guess). +//! +//! Phase 7 of the CLI restructure migrates these to `relicario-core` and +//! turns this file into a thin re-export shim. They live here for now so +//! the Phase 1 relocation stays mechanical. + +use anyhow::{Context, Result}; + +pub(crate) fn parse_month_year(s: &str) -> Result { + // Accepts MM/YYYY or MM-YYYY or MM/YY. + let (m_str, y_str) = s.split_once(['/', '-']) + .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 }) +} + +pub(crate) 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() +} + +pub(crate) 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}")) +} diff --git a/crates/relicario-cli/src/prompt.rs b/crates/relicario-cli/src/prompt.rs new file mode 100644 index 0000000..8e21f90 --- /dev/null +++ b/crates/relicario-cli/src/prompt.rs @@ -0,0 +1,65 @@ +//! Interactive prompt helpers for the CLI. +//! +//! The `prompt`/`prompt_optional`/`prompt_secret` family reads from stdin / +//! the TTY; the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are +//! used by the edit handlers to keep current values when the user hits enter +//! at a blank prompt. `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET` +//! so integration tests (which don't have a TTY) can inject secrets. + +use anyhow::Result; + +/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET` +/// for integration-test use (rpassword reads /dev/tty by default, which is +/// unavailable in assert_cmd-spawned children). +pub(crate) fn prompt_secret(label: &str) -> Result { + if let Some(s) = crate::test_item_secret_override() { + return Ok(s); + } + rpassword::prompt_password(label).map_err(Into::into) +} + +pub(crate) fn prompt(label: &str) -> Result { + eprint!("{label}: "); + std::io::Write::flush(&mut std::io::stderr())?; + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + let trimmed = s.trim().to_string(); + if trimmed.is_empty() { anyhow::bail!("{label} required"); } + Ok(trimmed) +} + +pub(crate) fn prompt_optional(label: &str) -> Result> { + eprint!("{label} (leave blank to skip): "); + std::io::Write::flush(&mut std::io::stderr())?; + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + let trimmed = s.trim().to_string(); + Ok(if trimmed.is_empty() { None } else { Some(trimmed) }) +} + +pub(crate) fn prompt_keep(label: &str, current: &str) -> Result> { + eprint!("{label} [{current}]: "); + std::io::Write::flush(&mut std::io::stderr())?; + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + let trimmed = s.trim().to_string(); + Ok(if trimmed.is_empty() { None } else { Some(trimmed) }) +} + +pub(crate) fn prompt_keep_opt(label: &str, current: Option<&str>) -> Result> { + let display = current.unwrap_or("(none)"); + eprint!("{label} [{display}]: "); + std::io::Write::flush(&mut std::io::stderr())?; + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + let trimmed = s.trim().to_string(); + Ok(if trimmed.is_empty() { None } else { Some(trimmed) }) +} + +pub(crate) fn prompt_yesno(label: &str) -> Result { + eprint!("{label} [y/N] "); + std::io::Write::flush(&mut std::io::stderr())?; + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes")) +}