Plan B Phase 4 wanted "every mutating handler must call refresh_groups_cache" to be a compile-time invariant, with all callers funneled through Vault::after_manifest_change. The mutating-handler sweep happened, but two read-side callsites (commands/list.rs and commands/get.rs) still called the public helper directly for opportunistic shell-completion cache freshness. Closes the gap: - helpers::refresh_groups_cache demoted from pub to pub(crate). - list.rs and get.rs drop their explicit calls. Cache freshness between mutations is unaffected: every mutating handler still funnels through after_manifest_change. The minor staleness window (manifest changed externally via git pull, no local mutation since) is the trade-off the spec accepts in exchange for the compile-time invariant. The Plan B done-criterion "grep refresh_groups_cache outside session.rs returns zero" now passes apart from the function definition itself, which lives in helpers.rs (the natural place for a flat utility). The visibility scoping achieves the architectural intent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
107 lines
4.3 KiB
Rust
107 lines
4.3 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()?;
|
|
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(())
|
|
}
|