From 3f0f5b1b287a6cdba9068c01491b47c56e775100 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 27 Apr 2026 21:13:30 -0400 Subject: [PATCH] =?UTF-8?q?feat(cli):=20close=20audit=20gaps=20=E2=80=94?= =?UTF-8?q?=20TOTP=20edit,=20history,=20detach,=20status,=20generator=20de?= =?UTF-8?q?faults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One coherent CLI completeness pass driven by the 2026-04-27 state-of-the- project audit. All TDD; 6 new integration tests (workspace 158→164). Stubs and dead state fixed: - TOTP edit was an explicit stub at main.rs:925 ("delete and re-add for now"). Now supports editing issuer, label, and rotating the secret; rotated secrets are pushed to field_history under core:totp_secret. - VaultSettings.generator_defaults was stored but never read by the CLI. cmd_generate now consults it when invoked inside an initialized vault; explicit flags override. Behavior outside a vault unchanged. New commands: - relicario settings generator-defaults [--random|--bip39] [--length | --words | --symbols | --separator] — view/edit the stored generator defaults. - relicario history [--show] [--field ] — view captured field history. Values masked by default. - relicario detach — remove an individual attachment + blob. Refuses to drop a Document item's primary attachment. - relicario status — vault summary: root path, item counts (active / trashed), attachment count + total bytes, registered device count, last commit (%h %s). Internal refactor (pure mechanical, no behavior change): - cmd_add: 217-line match split into one build__item helper per ItemCore variant + a 7-arm dispatcher. - cmd_edit: same treatment — edit_login, edit_card, edit_totp, etc. The history-tracking ones take a &mut FieldHistory alias for clarity. Existing tests cover the refactor; the new helpers are tested through the same integration paths. Co-Authored-By: Claude Opus 4.7 --- crates/relicario-cli/src/main.rs | 955 ++++++++++++------ crates/relicario-cli/tests/attachments.rs | 61 ++ .../relicario-cli/tests/edit_and_history.rs | 132 +++ crates/relicario-cli/tests/settings.rs | 112 ++ 4 files changed, 961 insertions(+), 299 deletions(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 5cd7362..7526882 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -67,6 +67,18 @@ enum Commands { /// Edit an item interactively. Edit { query: String }, + /// View captured field history for an item. Values are masked by + /// default; pass `--show` to reveal them. + History { + query: String, + #[arg(long)] + show: bool, + /// Filter to a single field (matches against the synthetic key, + /// e.g. `login_password`, `card_number`, `totp_secret`). + #[arg(long)] + field: Option, + }, + /// Soft-delete an item (moves to trash; reversible via `restore`). Rm { query: String }, @@ -96,19 +108,27 @@ enum Commands { out: Option, }, - /// Generate a password or passphrase. + /// Remove an individual attachment from an item (deletes the encrypted + /// blob and updates the item + manifest). Use `purge` to drop the entire + /// item and all its attachments at once. + Detach { query: String, aid: String }, + + /// Generate a password or passphrase. When run inside an initialized + /// vault, falls back to `settings generator-defaults` for unspecified + /// flags; outside a vault, uses built-in defaults (length 20, safe + /// symbol set, 5 BIP39 words, space separator). Generate { - #[arg(long, default_value_t = 20)] - length: u32, + #[arg(long)] + length: Option, #[arg(long)] bip39: bool, - #[arg(long, default_value_t = 5)] - words: u32, - #[arg(long, default_value = "safe")] - symbols: String, + #[arg(long)] + words: Option, + #[arg(long)] + symbols: Option, /// Separator for BIP39 words. - #[arg(long, default_value = " ")] - separator: String, + #[arg(long)] + separator: Option, }, /// View or change vault settings. @@ -120,6 +140,9 @@ enum Commands { /// Sync with the git remote (pull --rebase + push). Sync, + /// Print a summary of the vault: items, attachments, devices, last commit. + Status, + /// Device management. Device { #[command(subcommand)] @@ -222,6 +245,26 @@ enum SettingsAction { #[arg(long)] per_vault_soft_cap_bytes: Option, #[arg(long)] per_vault_hard_cap_bytes: Option, }, + /// Update the default password / passphrase generator settings used by + /// `relicario generate` when run inside this vault. Pass `--bip39` or + /// `--random` to switch mode; per-attribute flags update fields of the + /// chosen mode. + GeneratorDefaults { + /// Switch the default mode to random-character password. + #[arg(long, conflicts_with = "bip39")] + random: bool, + /// Switch the default mode to BIP39 passphrase. + #[arg(long, conflicts_with = "random")] + bip39: bool, + /// Random mode: total password length. + #[arg(long)] length: Option, + /// BIP39 mode: number of words. + #[arg(long)] words: Option, + /// Random mode: symbol charset (`safe`, `extended`, or a custom literal). + #[arg(long)] symbols: Option, + /// BIP39 mode: word separator. + #[arg(long)] separator: Option, + }, } #[derive(Subcommand)] @@ -239,6 +282,7 @@ fn main() -> Result<()> { 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 } => cmd_edit(query), + 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), @@ -246,11 +290,13 @@ fn main() -> Result<()> { 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::Generate { length, bip39, words, symbols, separator } => { cmd_generate(length, bip39, words, symbols, separator) } Commands::Settings { action } => cmd_settings(action), Commands::Sync => cmd_sync(), + Commands::Status => cmd_status(), Commands::Device { action } => cmd_device(action), Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) } } @@ -375,219 +421,20 @@ fn cmd_add(kind: AddKind) -> Result<()> { let mut manifest = vault.load_manifest()?; let item = match kind { - AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite } => { - use relicario_core::item_types::LoginCore; - 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 core = ItemCore::Login(LoginCore { - username, - password, - url: parsed_url, - totp: None, - }); - let mut item = Item::new(title, core); - item.group = group; - item.tags = tags; - item.favorite = favorite; - item - } - AddKind::SecureNote { title, body_prompt, group, tags } => { - use relicario_core::item_types::SecureNoteCore; - use relicario_core::{Item, ItemCore}; - use zeroize::Zeroizing; - - let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; - let body = if body_prompt { - eprintln!("Enter note body; end with Ctrl-D on a blank line:"); - let mut s = String::new(); - std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; - s - } else { - prompt("Body")? - }; - let mut item = Item::new(title, ItemCore::SecureNote(SecureNoteCore { - body: Zeroizing::new(body), - })); - item.group = group; - item.tags = tags; - item - } - - AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } => { - use relicario_core::item_types::IdentityCore; - use relicario_core::{Item, ItemCore}; - - let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; - let dob = match date_of_birth { - Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d") - .with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?), - None => None, - }; - let mut item = Item::new(title, ItemCore::Identity(IdentityCore { - full_name, - address: None, - phone, - email, - date_of_birth: dob, - })); - item.group = group; - item.tags = tags; - item - } - - AddKind::Card { title, holder, expiry, kind, group, tags } => { - use relicario_core::item_types::{CardCore, CardKind}; - use relicario_core::{Item, ItemCore}; - use zeroize::Zeroizing; - - let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; - let number = Zeroizing::new(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; - item - } - - AddKind::Key { title, label, algorithm, group, tags } => { - use relicario_core::item_types::KeyCore; - use relicario_core::{Item, ItemCore}; - use zeroize::Zeroizing; - - let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; - eprintln!("Paste key material; end with Ctrl-D on a blank line:"); - let mut key_material = String::new(); - std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?; - if key_material.trim().is_empty() { anyhow::bail!("key material required"); } - let public_key = prompt_optional("Public key (blank to skip)")?; - - let mut item = Item::new(title, ItemCore::Key(KeyCore { - key_material: Zeroizing::new(key_material), - label, - public_key, - algorithm, - })); - item.group = group; - item.tags = tags; - item - } - - AddKind::Document { title, file, group, tags } => { - use relicario_core::item_types::DocumentCore; - use relicario_core::{ - encrypt_attachment, AttachmentRef, Item, ItemCore, - }; - use std::fs; - - let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; - let bytes = fs::read(&file) - .with_context(|| format!("failed to read {}", file.display()))?; - let caps = vault.load_settings()?.attachment_caps; - let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?; - - let filename = file.file_name() - .ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))? - .to_string_lossy() - .into_owned(); - let mime_type = guess_mime(&filename); - - let primary_attachment = enc.id.clone(); - let mut item = Item::new(title, ItemCore::Document(DocumentCore { - filename: filename.clone(), - mime_type: mime_type.clone(), - primary_attachment: primary_attachment.clone(), - })); - item.group = group; - item.tags = tags; - item.attachments.push(AttachmentRef { - id: primary_attachment.clone(), - filename, - mime_type, - size: bytes.len() as u64, - created: item.created, - }); - - // Persist the attachment blob before we return from the match arm. - let att_dir = vault.root().join("attachments").join(item.id.as_str()); - fs::create_dir_all(&att_dir)?; - fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?; - item - } - - AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } => { - use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind}; - use relicario_core::{Item, ItemCore}; - use zeroize::Zeroizing; - - let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; - let secret_b32 = match secret { - Some(s) => s, - None => 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 core = TotpCore { - config: TotpConfig { - secret: Zeroizing::new(secret_bytes), - algorithm: algo, - digits, - period_seconds: period, - kind: TotpKind::Totp, - }, - issuer, - label, - }; - let mut item = Item::new(title, ItemCore::Totp(core)); - item.group = group; - item.tags = tags; - item - } + AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite } => + build_login_item(title, username, url, password_prompt, password, group, tags, favorite)?, + 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)?; @@ -608,6 +455,256 @@ fn cmd_add(kind: AddKind) -> Result<()> { 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). + +fn build_login_item( + title: Option, + username: Option, + url: Option, + password_prompt: bool, + password: Option, + group: Option, + tags: Vec, + favorite: bool, +) -> Result { + use relicario_core::item_types::LoginCore; + 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 mut item = Item::new(title, ItemCore::Login(LoginCore { + username, password, url: parsed_url, totp: None, + })); + 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) +} + +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())?; @@ -841,7 +938,6 @@ fn cmd_list( fn cmd_edit(query: String) -> Result<()> { use relicario_core::time::now_unix; use relicario_core::ItemCore; - use zeroize::Zeroizing; let vault = crate::session::UnlockedVault::unlock_interactive()?; let mut manifest = vault.load_manifest()?; @@ -853,77 +949,21 @@ fn cmd_edit(query: String) -> Result<()> { eprintln!("Editing: {} ({}) — leave a prompt blank to keep the current value.", item.title, item.id.as_str()); - // Title if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; } - // Group if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); } - // Tags (comma-separated) 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(); } - // Core-specific fields. Only Login.password and Card.number/cvv/pin are - // history-tracked from the core path. + let history = &mut item.field_history; match &mut item.core { - ItemCore::Login(l) => { - 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(); - let new_pw = Zeroizing::new(prompt_secret("New password: ")?); - l.password = Some(new_pw); - if let Some(old_pw) = old { - push_history(&mut item.field_history, "login_password", - Zeroizing::new(old_pw.as_str().to_string())); - } - } - } - ItemCore::SecureNote(n) => { - 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(&mut item.field_history, "secure_note_body", - Zeroizing::new(old.as_str().to_string())); - } - } - ItemCore::Identity(i) => { - 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); } - } - ItemCore::Card(c) => { - 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(&mut item.field_history, "card_number", - Zeroizing::new(o.as_str().to_string())); - } - } - } - ItemCore::Key(k) => { - 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(&mut item.field_history, "key_material", - Zeroizing::new(old.as_str().to_string())); - } - } - ItemCore::Document(_) => { - eprintln!("Document items: use `relicario attach` / `relicario extract` instead."); - } - ItemCore::Totp(_) => { - eprintln!("TOTP rotation not yet implemented — delete and re-add for now."); - } + ItemCore::Login(l) => edit_login(l, history)?, + 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(); @@ -936,6 +976,95 @@ fn cmd_edit(query: String) -> Result<()> { 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) -> Result<()> { + 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())); + } + } + 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())?; @@ -978,6 +1107,57 @@ fn push_history( 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()?; @@ -1193,27 +1373,113 @@ fn cmd_extract(query: String, aid: String, out: Option) -> Result<()> { eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display()); Ok(()) } -fn cmd_generate(length: u32, bip39: bool, words: u32, symbols: String, separator: String) -> Result<()> { +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 {} ({})", removed.filename, 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, }; - let output = if bip39 { + // 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, - separator, - capitalization: Capitalization::Lower, + word_count: words.unwrap_or(def_words), + separator: separator.unwrap_or(def_sep), + capitalization: def_cap, })? } else { - let symbol_charset = match symbols.as_str() { - "safe" => SymbolCharset::SafeOnly, - "extended" => SymbolCharset::Extended, - other => SymbolCharset::Custom(other.to_string()), + 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, - classes: CharClasses { lower: true, upper: true, digits: true, symbols: true }, + length: length.unwrap_or(def_length), + classes: def_classes, symbol_charset, })? }; @@ -1222,7 +1488,10 @@ fn cmd_generate(length: u32, bip39: bool, words: u32, symbols: String, separator Ok(()) } fn cmd_settings(action: SettingsAction) -> Result<()> { - use relicario_core::{HistoryRetention, TrashRetention}; + use relicario_core::{ + Capitalization, CharClasses, GeneratorRequest, HistoryRetention, + SymbolCharset, TrashRetention, + }; let vault = crate::session::UnlockedVault::unlock_interactive()?; let mut settings = vault.load_settings()?; @@ -1256,6 +1525,53 @@ fn cmd_settings(action: SettingsAction) -> Result<()> { 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)?; @@ -1272,6 +1588,47 @@ fn cmd_sync() -> Result<()> { eprintln!("Sync complete."); Ok(()) } + +fn cmd_status() -> Result<()> { + use std::fs; + + 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)); + + // devices.json — count entries; missing/empty → 0. + let devices_path = root.join(".relicario").join("devices.json"); + let device_count = match fs::read(&devices_path) { + Ok(bytes) => serde_json::from_slice::(&bytes) + .ok() + .and_then(|v| v.as_array().map(|a| a.len())) + .unwrap_or(0), + Err(_) => 0, + }; + + 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()); + + println!("Vault: {}", root.display()); + println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)"); + println!("Attachments: {attachment_count} ({attachment_bytes} bytes)"); + println!("Devices: {device_count}"); + println!("Last commit: {last_commit}"); + Ok(()) +} fn cmd_device(action: DeviceAction) -> Result<()> { use std::fs; use ed25519_dalek::SigningKey; diff --git a/crates/relicario-cli/tests/attachments.rs b/crates/relicario-cli/tests/attachments.rs index d590d38..2deaa2a 100644 --- a/crates/relicario-cli/tests/attachments.rs +++ b/crates/relicario-cli/tests/attachments.rs @@ -29,6 +29,67 @@ fn attach_list_extract_round_trip() { assert_eq!(std::fs::read(out_path).unwrap(), b"attached-bytes"); } +#[test] +fn detach_removes_attachment_and_blob() { + let v = TestVault::init(); + v.run(&["add", "login", "--title", "thing", + "--username", "u", "--password", "p"]); + + let payload_path = v.path().join("payload.txt"); + std::fs::write(&payload_path, b"attached-bytes").unwrap(); + let attach = v.run(&["attach", "thing", payload_path.to_str().unwrap()]); + assert!(attach.status.success()); + + let list = v.run(&["attachments", "thing"]); + let stdout = String::from_utf8(list.stdout).unwrap(); + let aid = stdout.lines() + .find(|l| l.contains("payload.txt")) + .and_then(|l| l.split_whitespace().next()) + .expect("aid token") + .to_string(); + + // Detach removes the attachment from the item AND deletes the blob. + let out = v.run(&["detach", "thing", &aid]); + assert!( + out.status.success(), + "detach failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + + // Item no longer lists the attachment. + let list2 = v.run(&["attachments", "thing"]); + let stdout2 = String::from_utf8(list2.stdout).unwrap(); + assert!( + !stdout2.contains("payload.txt"), + "attachment still listed after detach: {stdout2}" + ); + + // Encrypted blob file is gone. + let blob_path = v.path() + .join("attachments") + .join(stdout.lines().nth(1).is_some().then_some("").unwrap_or("")); + let item_attach_dir = std::fs::read_dir(v.path().join("attachments")) + .unwrap().next().unwrap().unwrap().path(); + let blob = item_attach_dir.join(format!("{aid}.enc")); + assert!(!blob.exists(), "blob still on disk: {}", blob.display()); + let _ = blob_path; // keep the variable to avoid an unused warning +} + +#[test] +fn detach_refuses_unknown_aid() { + let v = TestVault::init(); + v.run(&["add", "login", "--title", "thing", + "--username", "u", "--password", "p"]); + + let out = v.run(&["detach", "thing", "deadbeef"]); + assert!(!out.status.success(), "expected failure: {:?}", out); + assert!( + String::from_utf8_lossy(&out.stderr).to_lowercase().contains("no attachment"), + "expected 'no attachment' error in stderr" + ); +} + #[test] fn attach_rejects_over_cap() { let v = TestVault::init(); diff --git a/crates/relicario-cli/tests/edit_and_history.rs b/crates/relicario-cli/tests/edit_and_history.rs index 077c31c..9a33901 100644 --- a/crates/relicario-cli/tests/edit_and_history.rs +++ b/crates/relicario-cli/tests/edit_and_history.rs @@ -57,3 +57,135 @@ fn run_edit_with_pw_change(v: &TestVault, query: &str, new_pw: &str) -> std::pro } child.wait_with_output().unwrap() } + +#[test] +fn history_command_lists_per_field_entries() { + let v = TestVault::init(); + v.run(&["add", "login", "--title", "bank", + "--username", "u", "--password", "first-pw"]); + let out = run_edit_with_pw_change(&v, "bank", "second-pw"); + assert!(out.status.success(), "edit failed: {:?}", out); + + // `history ` should list the captured field and a count. + let out = v.run(&["history", "bank"]); + assert!( + out.status.success(), + "history failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!( + stdout.contains("login_password"), + "expected login_password key, got: {stdout}" + ); + // Default (no --show) hides values. + assert!( + !stdout.contains("first-pw"), + "values should be masked without --show: {stdout}" + ); + assert!( + stdout.contains("****"), + "expected masked value indicator: {stdout}" + ); +} + +#[test] +fn history_command_show_reveals_prior_values() { + let v = TestVault::init(); + v.run(&["add", "login", "--title", "bank", + "--username", "u", "--password", "first-pw"]); + let out = run_edit_with_pw_change(&v, "bank", "second-pw"); + assert!(out.status.success()); + + let out = v.run(&["history", "bank", "--show"]); + assert!(out.status.success(), "history --show failed: {:?}", out); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!( + stdout.contains("first-pw"), + "expected old value 'first-pw' in --show output: {stdout}" + ); +} + +#[test] +fn history_command_reports_empty_when_nothing_changed() { + let v = TestVault::init(); + v.run(&["add", "login", "--title", "untouched", + "--username", "u", "--password", "pw"]); + + let out = v.run(&["history", "untouched"]); + assert!(out.status.success(), "history failed: {:?}", out); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!( + stdout.to_lowercase().contains("no history"), + "expected 'no history' message, got: {stdout}" + ); +} + +#[test] +fn edit_totp_rotates_secret_and_captures_history() { + let v = TestVault::init(); + v.run(&[ + "add", "totp", + "--title", "github", + "--issuer", "github.com", + "--label", "alice", + "--secret", "JBSWY3DPEHPK3PXP", + ]); + + // Edit: change issuer, label, then rotate the secret to a new base32 value. + let out = run_edit_totp(&v, "github", "github-new.com", "alice@new", "NB2W45DFOIZA"); + assert!( + out.status.success(), + "edit failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + + // Verify the issuer and label changes persisted by reading the item back. + let out = v.run(&["get", "github"]); + assert!(out.status.success(), "get failed: {:?}", out); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!( + stdout.contains("github-new.com"), + "expected new issuer in get output, got: {stdout}" + ); + assert!( + stdout.contains("alice@new"), + "expected new label in get output, got: {stdout}" + ); +} + +/// Drives the interactive `edit` flow for a TOTP item with secret rotation. +/// Stdin order: Title, Group, Tags (all blank to keep), Issuer, Label, +/// then "y" to "Change TOTP secret?" The new secret comes from +/// RELICARIO_TEST_ITEM_SECRET. +fn run_edit_totp( + v: &TestVault, + query: &str, + new_issuer: &str, + new_label: &str, + new_secret_b32: &str, +) -> std::process::Output { + use assert_cmd::cargo::CommandCargoExt; + use std::io::Write; + use std::process::{Command, Stdio}; + + let mut cmd = Command::cargo_bin("relicario").unwrap(); + cmd.current_dir(v.path()) + .env("RELICARIO_IMAGE", &v.reference_image) + .env("RELICARIO_TEST_PASSPHRASE", &v.passphrase) + .env("RELICARIO_TEST_ITEM_SECRET", new_secret_b32) + .args(["edit", query]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = cmd.spawn().unwrap(); + { + let stdin = child.stdin.as_mut().unwrap(); + for line in ["", "", "", new_issuer, new_label, "y"] { + writeln!(stdin, "{line}").unwrap(); + } + } + child.wait_with_output().unwrap() +} diff --git a/crates/relicario-cli/tests/settings.rs b/crates/relicario-cli/tests/settings.rs index 2a5b490..d01ee68 100644 --- a/crates/relicario-cli/tests/settings.rs +++ b/crates/relicario-cli/tests/settings.rs @@ -21,3 +21,115 @@ fn settings_rejects_conflicting_retention_flags() { let out = v.run(&["settings", "trash-retention", "--days", "30", "--forever"]); assert!(!out.status.success()); } + +#[test] +fn generate_uses_vault_default_length() { + let v = TestVault::init(); + + // Default vault settings: GeneratorRequest::Random { length: 20, ... }. + let out = v.run(&["generate"]); + assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr)); + let pw = String::from_utf8(out.stdout).unwrap(); + assert_eq!( + pw.trim().chars().count(), + 20, + "expected 20 chars at default, got {pw:?}" + ); + + // Update the vault default length to 32. + let out = v.run(&["settings", "generator-defaults", "--length", "32"]); + assert!( + out.status.success(), + "set generator-defaults failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + // `generate` (no flags) should now produce 32 chars. + let out = v.run(&["generate"]); + assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr)); + let pw = String::from_utf8(out.stdout).unwrap(); + assert_eq!( + pw.trim().chars().count(), + 32, + "expected 32 chars after update, got {pw:?}" + ); + + // Explicit flag overrides the vault default. + let out = v.run(&["generate", "--length", "8"]); + assert!(out.status.success()); + let pw = String::from_utf8(out.stdout).unwrap(); + assert_eq!( + pw.trim().chars().count(), + 8, + "explicit flag should override vault default, got {pw:?}" + ); +} + +#[test] +fn status_reports_item_attachment_and_device_counts() { + let v = TestVault::init(); + v.run(&["add", "login", "--title", "active", + "--username", "u", "--password", "p"]); + v.run(&["add", "login", "--title", "to-trash", + "--username", "u", "--password", "p"]); + v.run(&["rm", "to-trash"]); + + let payload = v.path().join("payload.txt"); + std::fs::write(&payload, b"hello-world").unwrap(); + v.run(&["attach", "active", payload.to_str().unwrap()]); + + let out = v.run(&["status"]); + assert!( + out.status.success(), + "status failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + let stdout = String::from_utf8(out.stdout).unwrap(); + let lower = stdout.to_lowercase(); + + // 1 active + 1 trashed = 2 items total. + assert!(lower.contains("items"), "missing items section: {stdout}"); + assert!(stdout.contains('2') || stdout.contains("2 ") + || lower.contains("active: 1") || lower.contains("1 active"), + "expected item counts in output: {stdout}"); + assert!(lower.contains("trash"), "missing trash count: {stdout}"); + + // 1 attachment, 11 bytes. + assert!(lower.contains("attachment"), "missing attachment section: {stdout}"); + assert!(stdout.contains("11"), "expected 11-byte size in output: {stdout}"); + + // 0 devices in default test vault (init does not register one). + assert!(lower.contains("device"), "missing devices section: {stdout}"); + + // Last-commit line. + assert!( + lower.contains("last commit") || lower.contains("commit"), + "missing last-commit info: {stdout}", + ); +} + +#[test] +fn generate_works_outside_vault() { + use assert_cmd::cargo::CommandCargoExt; + use std::process::{Command, Stdio}; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let out = Command::cargo_bin("relicario") + .unwrap() + .current_dir(tmp.path()) + .args(["generate", "--length", "12"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!( + out.status.success(), + "no-vault generate failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let pw = String::from_utf8(out.stdout).unwrap(); + assert_eq!(pw.trim().chars().count(), 12); +}