Adds the canonical post-mutation funnel: save_manifest_raw + groups.cache refresh in one method. Converts nine commands/*.rs mutation callsites from the manual save_manifest + refresh_groups_cache pair to a single vault.after_manifest_change(&manifest)?. save_manifest renamed to save_manifest_raw (pub(crate)) so future commands cannot accidentally bypass the cache refresh. Four of the nine sites (attach.rs add/detach, import.rs LastPass, trash.rs cmd_trash_empty's per-item save) previously skipped the cache refresh — the wrapper fixes them. refresh_groups_cache moves from main.rs to helpers.rs so the read-side warmup callers in get.rs/list.rs still reach it.
108 lines
4.4 KiB
Rust
108 lines
4.4 KiB
Rust
//! `relicario get` — print a single item, masking secrets unless `--show`.
|
|
|
|
use anyhow::{Context, Result};
|
|
|
|
pub fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
|
use relicario_core::ItemCore;
|
|
use zeroize::Zeroizing;
|
|
|
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
|
let manifest = vault.load_manifest()?;
|
|
crate::helpers::refresh_groups_cache(vault.root(), &manifest);
|
|
let entry = super::resolve_query(&manifest, &query)?;
|
|
let item = vault.load_item(&entry.id)?;
|
|
|
|
println!("ID: {}", item.id.as_str());
|
|
println!("Title: {}", item.title);
|
|
println!("Type: {:?}", item.r#type);
|
|
if let Some(g) = &item.group { println!("Group: {g}"); }
|
|
if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); }
|
|
println!("Created: {}", crate::helpers::iso8601(item.created));
|
|
println!("Modified: {}", crate::helpers::iso8601(item.modified));
|
|
if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); }
|
|
println!();
|
|
|
|
let primary_secret: Option<Zeroizing<String>> = match &item.core {
|
|
ItemCore::Login(l) => {
|
|
if let Some(u) = &l.username { println!("Username: {u}"); }
|
|
if let Some(u) = &l.url { println!("URL: {u}"); }
|
|
if let Some(t) = &l.totp {
|
|
if show {
|
|
println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret));
|
|
} else {
|
|
println!("TOTP: **** (use --show to reveal)");
|
|
}
|
|
}
|
|
l.password.clone()
|
|
}
|
|
ItemCore::SecureNote(n) => {
|
|
if show { println!("Body:\n{}", n.body.as_str()); }
|
|
else { println!("Body: ********"); }
|
|
None
|
|
}
|
|
ItemCore::Identity(i) => {
|
|
if let Some(v) = &i.full_name { println!("Name: {v}"); }
|
|
if let Some(v) = &i.email { println!("Email: {v}"); }
|
|
if let Some(v) = &i.phone { println!("Phone: {v}"); }
|
|
if let Some(v) = &i.date_of_birth { println!("DOB: {v}"); }
|
|
None
|
|
}
|
|
ItemCore::Card(c) => {
|
|
if let Some(h) = &c.holder { println!("Holder: {h}"); }
|
|
if let Some(e) = &c.expiry { println!("Expiry: {:02}/{}", e.month, e.year); }
|
|
println!("Kind: {:?}", c.kind);
|
|
c.number.clone()
|
|
}
|
|
ItemCore::Key(k) => {
|
|
if let Some(l) = &k.label { println!("Label: {l}"); }
|
|
if let Some(a) = &k.algorithm { println!("Algo: {a}"); }
|
|
if let Some(pk) = &k.public_key { println!("Pubkey: {pk}"); }
|
|
Some(k.key_material.clone())
|
|
}
|
|
ItemCore::Document(d) => {
|
|
println!("Filename: {}", d.filename);
|
|
println!("MIME: {}", d.mime_type);
|
|
None
|
|
}
|
|
ItemCore::Totp(t) => {
|
|
if let Some(i) = &t.issuer { println!("Issuer: {i}"); }
|
|
if let Some(l) = &t.label { println!("Label: {l}"); }
|
|
println!("Period: {}s", t.config.period_seconds);
|
|
println!("Digits: {}", t.config.digits);
|
|
None
|
|
}
|
|
};
|
|
|
|
if let Some(secret) = primary_secret {
|
|
if show {
|
|
println!("Secret: {}", secret.as_str());
|
|
} else {
|
|
println!("Secret: ******** (use --show to reveal, --copy to clipboard)");
|
|
}
|
|
if copy {
|
|
copy_to_clipboard_then_clear(&secret)?;
|
|
eprintln!("Copied to clipboard (auto-clears in 30s).");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing<String>) -> Result<()> {
|
|
use arboard::Clipboard;
|
|
let mut cb = Clipboard::new().context("failed to access clipboard")?;
|
|
cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?;
|
|
let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned());
|
|
// Unconditional clear (audit M6): spawn a detached thread that waits 30s
|
|
// and then rewrites the clipboard with empty string. Even if the user
|
|
// copies something else in the interim, we still overwrite once.
|
|
std::thread::spawn(move || {
|
|
std::thread::sleep(std::time::Duration::from_secs(30));
|
|
if let Ok(mut cb) = Clipboard::new() {
|
|
let _ = cb.set_text(String::new());
|
|
drop(cleared_copy); // zeroize the detached copy
|
|
}
|
|
});
|
|
Ok(())
|
|
}
|