From 06c8903e2bdc2831e451dfc4a783d49d687f67b6 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 22:22:45 -0400 Subject: [PATCH] =?UTF-8?q?feat(cli):=20relicario=20edit=20=E2=80=94=20int?= =?UTF-8?q?eractive=20field=20updates=20+=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Title/group/tags always optional. Per-type prompts for core secret fields (Login.password, Card.number, Key.material, SecureNote.body) push the old value to field_history via a synthetic core: FieldId so rotation is audit-traceable. --- crates/relicario-cli/src/main.rs | 141 ++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 1 deletion(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index f7a8bea..ee70fa3 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -818,7 +818,146 @@ fn cmd_list( } Ok(()) } -fn cmd_edit(_query: String) -> Result<()> { bail!("not yet implemented"); } +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()?; + 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()); + + // 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. + 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(rpassword::prompt_password("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(rpassword::prompt_password("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."); + } + } + + item.modified = now_unix(); + vault.save_item(&item)?; + manifest.upsert(&item); + vault.save_manifest(&manifest)?; + commit_paths(&vault, &format!("edit: {} ({})", item.title, item.id.as_str()), + &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; + eprintln!("Updated {}", item.id.as_str()); + 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_rm(_query: String) -> Result<()> { bail!("not yet implemented"); } fn cmd_restore(_query: String) -> Result<()> { bail!("not yet implemented"); } fn cmd_purge(_query: String) -> Result<()> { bail!("not yet implemented"); }