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:
@@ -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"); }
|
||||
|
||||
Reference in New Issue
Block a user