diff --git a/crates/relicario-cli/src/helpers.rs b/crates/relicario-cli/src/helpers.rs index 2e7b43b..2d09b5c 100644 --- a/crates/relicario-cli/src/helpers.rs +++ b/crates/relicario-cli/src/helpers.rs @@ -83,6 +83,38 @@ pub fn humanize_age(seconds: i64) -> String { fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } } +/// Path to the plaintext `groups.cache` file used by shell completion to +/// enumerate `--group ` candidates without unlocking the vault. +/// +/// **Plaintext leak:** group names land on disk in cleartext alongside the +/// vault directory. This is intentional — the file feeds shell completion, +/// which cannot prompt for a passphrase. Set `RELICARIO_NO_GROUPS_CACHE=1` +/// to suppress the write. +pub fn groups_cache_path(vault_dir: &Path) -> PathBuf { + vault_dir.join(".relicario").join("groups.cache") +} + +/// Write the sorted set of group names to `/.relicario/groups.cache`, +/// one name per line. A no-op if `RELICARIO_NO_GROUPS_CACHE` is set. +pub fn write_groups_cache( + vault_dir: &Path, + groups: &std::collections::BTreeSet, +) -> std::io::Result<()> { + if std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() { + return Ok(()); + } + let path = groups_cache_path(vault_dir); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut body = String::new(); + for g in groups { + body.push_str(g); + body.push('\n'); + } + std::fs::write(path, body) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 1989d45..1846557 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -166,7 +166,15 @@ enum Commands { Lock, /// Emit a shell completion script for the given shell. - /// Pipe to your shell's completion file (e.g. `> /etc/bash_completion.d/relicario`). + /// + /// For `--group ` autocomplete, the bash/zsh/fish scripts read + /// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file, + /// which the CLI refreshes on every manifest read. Set + /// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion + /// will fall back to no value enumeration). + /// + /// Pipe stdout to your shell's completion location (e.g. + /// `relicario completions bash > /etc/bash_completion.d/relicario`). Completions { #[arg(value_enum)] shell: Shell, @@ -370,6 +378,24 @@ fn main() -> Result<()> { } } +/// Collect all non-empty group names from the manifest and write them to the +/// plaintext `groups.cache` file so shell completion can enumerate `--group` +/// candidates without prompting for the vault passphrase. +/// +/// Failures are silently swallowed — a missing cache is merely a UX degradation, +/// not a correctness problem. +fn refresh_groups_cache(vault_dir: &std::path::Path, manifest: &relicario_core::Manifest) { + let mut set = std::collections::BTreeSet::::new(); + for entry in manifest.items.values() { + if let Some(g) = entry.group.as_ref() { + if !g.is_empty() { + set.insert(g.clone()); + } + } + } + let _ = helpers::write_groups_cache(vault_dir, &set); +} + /// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET` /// for integration-test use (rpassword reads /dev/tty by default, which is /// unavailable in assert_cmd-spawned children). @@ -508,6 +534,7 @@ fn cmd_add(kind: AddKind) -> Result<()> { vault.save_item(&item)?; manifest.upsert(&item); vault.save_manifest(&manifest)?; + refresh_groups_cache(vault.root(), &manifest); let mut paths: Vec = vec![ format!("items/{}.enc", item.id.as_str()), @@ -848,6 +875,7 @@ fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> { let vault = crate::session::UnlockedVault::unlock_interactive()?; let manifest = vault.load_manifest()?; + refresh_groups_cache(vault.root(), &manifest); let entry = resolve_query(&manifest, &query)?; let item = vault.load_item(&entry.id)?; @@ -965,6 +993,7 @@ fn cmd_list( let vault = crate::session::UnlockedVault::unlock_interactive()?; let manifest = vault.load_manifest()?; + refresh_groups_cache(vault.root(), &manifest); let parsed_type: Option = match type_filter.as_deref() { None => None, @@ -1038,6 +1067,7 @@ fn cmd_edit(query: String) -> Result<()> { vault.save_item(&item)?; manifest.upsert(&item); vault.save_manifest(&manifest)?; + refresh_groups_cache(vault.root(), &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()); @@ -1237,6 +1267,7 @@ fn cmd_rm(query: String) -> Result<()> { vault.save_item(&item)?; manifest.upsert(&item); vault.save_manifest(&manifest)?; + refresh_groups_cache(vault.root(), &manifest); commit_paths(&vault, &format!("trash: {} ({})", item.title, item.id.as_str()), &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; eprintln!("Moved to trash: {}", item.title); @@ -1254,6 +1285,7 @@ fn cmd_restore(query: String) -> Result<()> { vault.save_item(&item)?; manifest.upsert(&item); vault.save_manifest(&manifest)?; + refresh_groups_cache(vault.root(), &manifest); commit_paths(&vault, &format!("restore: {} ({})", item.title, item.id.as_str()), &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; eprintln!("Restored: {}", item.title); @@ -1295,6 +1327,7 @@ fn cmd_purge(query: String) -> Result<()> { purge_item(&vault, &mut manifest, &id, &title)?; vault.save_manifest(&manifest)?; + refresh_groups_cache(vault.root(), &manifest); let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?; if !status.success() { anyhow::bail!("git add manifest.enc failed"); } diff --git a/crates/relicario-cli/tests/smart_inputs.rs b/crates/relicario-cli/tests/smart_inputs.rs index c5c60bb..897f2f4 100644 --- a/crates/relicario-cli/tests/smart_inputs.rs +++ b/crates/relicario-cli/tests/smart_inputs.rs @@ -1,3 +1,5 @@ +mod common; + use assert_cmd::Command; use predicates::str::contains; @@ -28,3 +30,76 @@ fn completions_fish_emits_script() { .success() .stdout(contains("complete -c relicario")); } + +#[test] +fn list_command_refreshes_groups_cache() { + let v = common::TestVault::init(); + + let out = v.run(&[ + "add", "login", + "--title", "T", + "--username", "u", + "--group", "work", + "--password", "hunter2", + ]); + assert!(out.status.success(), "add failed: {:?}", out); + + let out = v.run(&["list"]); + assert!(out.status.success(), "list failed: {:?}", out); + + let cache_path = v.path().join(".relicario/groups.cache"); + let cache = std::fs::read_to_string(&cache_path) + .unwrap_or_else(|e| panic!("groups.cache not found at {}: {e}", cache_path.display())); + assert!( + cache.lines().any(|l| l == "work"), + "expected 'work' in groups.cache, got: {cache:?}" + ); +} + +#[test] +fn no_groups_cache_env_var_suppresses_write() { + use std::process::{Command as StdCommand, Stdio}; + use assert_cmd::cargo::CommandCargoExt as _; + + let v = common::TestVault::init(); + + // Add with the env var set so no cache is created by add either. + let out = StdCommand::cargo_bin("relicario").unwrap() + .current_dir(v.path()) + .env("RELICARIO_IMAGE", &v.reference_image) + .env("RELICARIO_TEST_PASSPHRASE", &v.passphrase) + .env("RELICARIO_NO_GROUPS_CACHE", "1") + .args([ + "add", "login", + "--title", "T2", + "--username", "u", + "--group", "personal", + "--password", "hunter2", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(out.status.success(), "add failed: {:?}", out); + + // Run list with RELICARIO_NO_GROUPS_CACHE=1 — cache must NOT be written. + let out = StdCommand::cargo_bin("relicario").unwrap() + .current_dir(v.path()) + .env("RELICARIO_IMAGE", &v.reference_image) + .env("RELICARIO_TEST_PASSPHRASE", &v.passphrase) + .env("RELICARIO_NO_GROUPS_CACHE", "1") + .args(["list"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(out.status.success(), "list failed: {:?}", out); + + let cache_path = v.path().join(".relicario/groups.cache"); + assert!( + !cache_path.exists(), + "groups.cache should not exist when RELICARIO_NO_GROUPS_CACHE=1" + ); +}