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(())
|
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_rm(_query: String) -> Result<()> { bail!("not yet implemented"); }
|
||||||
fn cmd_restore(_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"); }
|
fn cmd_purge(_query: String) -> Result<()> { bail!("not yet implemented"); }
|
||||||
|
|||||||
Reference in New Issue
Block a user