refactor(cli): move cmd_edit family into commands/edit.rs
This commit is contained in:
172
crates/relicario-cli/src/commands/edit.rs
Normal file
172
crates/relicario-cli/src/commands/edit.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
//! `relicario edit <query>` — interactive per-type field editing with history capture.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
use crate::parse::base32_decode_lenient;
|
||||||
|
use crate::prompt::{prompt_keep, prompt_keep_opt, prompt_secret, prompt_yesno};
|
||||||
|
|
||||||
|
pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
||||||
|
use relicario_core::time::now_unix;
|
||||||
|
use relicario_core::ItemCore;
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::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());
|
||||||
|
|
||||||
|
if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; }
|
||||||
|
if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); }
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
let history = &mut item.field_history;
|
||||||
|
match &mut item.core {
|
||||||
|
ItemCore::Login(l) => edit_login(l, history, totp_qr)?,
|
||||||
|
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();
|
||||||
|
vault.save_item(&item)?;
|
||||||
|
manifest.upsert(&item);
|
||||||
|
vault.save_manifest(&manifest)?;
|
||||||
|
crate::refresh_groups_cache(vault.root(), &manifest);
|
||||||
|
super::commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||||
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
|
eprintln!("Updated {}", item.id.as_str());
|
||||||
|
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<relicario_core::item::FieldHistoryEntry>,
|
||||||
|
>;
|
||||||
|
|
||||||
|
fn edit_login(
|
||||||
|
l: &mut relicario_core::item_types::LoginCore,
|
||||||
|
history: &mut FieldHistory,
|
||||||
|
totp_qr: Option<PathBuf>,
|
||||||
|
) -> Result<()> {
|
||||||
|
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind};
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(path) = totp_qr {
|
||||||
|
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||||
|
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||||
|
l.totp = Some(TotpConfig {
|
||||||
|
secret: Zeroizing::new(secret_bytes),
|
||||||
|
algorithm: TotpAlgorithm::Sha1,
|
||||||
|
digits: 6,
|
||||||
|
period_seconds: 30,
|
||||||
|
kind: TotpKind::Totp,
|
||||||
|
});
|
||||||
|
eprintln!("TOTP secret set from QR image.");
|
||||||
|
}
|
||||||
|
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 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
pub mod attach;
|
pub mod attach;
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
pub mod device;
|
pub mod device;
|
||||||
|
pub mod edit;
|
||||||
pub mod generate;
|
pub mod generate;
|
||||||
pub mod get;
|
pub mod get;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ use anyhow::{Context, Result};
|
|||||||
use clap::{CommandFactory, Parser, Subcommand};
|
use clap::{CommandFactory, Parser, Subcommand};
|
||||||
use clap_complete::{generate, Shell};
|
use clap_complete::{generate, Shell};
|
||||||
|
|
||||||
use crate::commands::{commit_paths, resolve_query};
|
use crate::commands::commit_paths;
|
||||||
use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year};
|
use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year};
|
||||||
use crate::prompt::{prompt, prompt_keep, prompt_keep_opt, prompt_optional, prompt_secret, prompt_yesno};
|
use crate::prompt::{prompt, prompt_optional, prompt_secret};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
@@ -431,7 +431,7 @@ fn main() -> Result<()> {
|
|||||||
Commands::Add { kind } => cmd_add(kind),
|
Commands::Add { kind } => cmd_add(kind),
|
||||||
Commands::Get { query, show, copy } => commands::get::cmd_get(query, show, copy),
|
Commands::Get { query, show, copy } => commands::get::cmd_get(query, show, copy),
|
||||||
Commands::List { r#type, group, tag, trashed } => commands::list::cmd_list(r#type, group, tag, trashed),
|
Commands::List { r#type, group, tag, trashed } => commands::list::cmd_list(r#type, group, tag, trashed),
|
||||||
Commands::Edit { query, totp_qr } => cmd_edit(query, totp_qr),
|
Commands::Edit { query, totp_qr } => commands::edit::cmd_edit(query, totp_qr),
|
||||||
Commands::History { query, show, field } => commands::list::cmd_history(query, show, field),
|
Commands::History { query, show, field } => commands::list::cmd_history(query, show, field),
|
||||||
Commands::Rm { query } => commands::trash::cmd_rm(query),
|
Commands::Rm { query } => commands::trash::cmd_rm(query),
|
||||||
Commands::Restore { query } => commands::trash::cmd_restore(query),
|
Commands::Restore { query } => commands::trash::cmd_restore(query),
|
||||||
@@ -815,168 +815,4 @@ fn build_totp_item(
|
|||||||
Ok(item)
|
Ok(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
|
||||||
use relicario_core::time::now_unix;
|
|
||||||
use relicario_core::ItemCore;
|
|
||||||
|
|
||||||
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());
|
|
||||||
|
|
||||||
if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; }
|
|
||||||
if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); }
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
let history = &mut item.field_history;
|
|
||||||
match &mut item.core {
|
|
||||||
ItemCore::Login(l) => edit_login(l, history, totp_qr)?,
|
|
||||||
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();
|
|
||||||
vault.save_item(&item)?;
|
|
||||||
manifest.upsert(&item);
|
|
||||||
vault.save_manifest(&manifest)?;
|
|
||||||
refresh_groups_cache(vault.root(), &manifest);
|
|
||||||
commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
|
||||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
|
||||||
eprintln!("Updated {}", item.id.as_str());
|
|
||||||
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<relicario_core::item::FieldHistoryEntry>,
|
|
||||||
>;
|
|
||||||
|
|
||||||
fn edit_login(
|
|
||||||
l: &mut relicario_core::item_types::LoginCore,
|
|
||||||
history: &mut FieldHistory,
|
|
||||||
totp_qr: Option<PathBuf>,
|
|
||||||
) -> Result<()> {
|
|
||||||
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind};
|
|
||||||
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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(path) = totp_qr {
|
|
||||||
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
|
||||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
|
||||||
l.totp = Some(TotpConfig {
|
|
||||||
secret: Zeroizing::new(secret_bytes),
|
|
||||||
algorithm: TotpAlgorithm::Sha1,
|
|
||||||
digits: 6,
|
|
||||||
period_seconds: 30,
|
|
||||||
kind: TotpKind::Totp,
|
|
||||||
});
|
|
||||||
eprintln!("TOTP secret set from QR image.");
|
|
||||||
}
|
|
||||||
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 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(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user