feat(cli): relicario edit — interactive field updates + history

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:<key>
FieldId so rotation is audit-traceable.
This commit is contained in:
adlee-was-taken
2026-04-19 22:22:45 -04:00
parent 377d73355b
commit 06c8903e2b

View File

@@ -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<Option<String>> {
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<Option<String>> {
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<bool> {
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<relicario_core::FieldId, Vec<relicario_core::item::FieldHistoryEntry>>,
synthetic_key: &str,
old_value: zeroize::Zeroizing<String>,
) {
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"); }