cli: write groups.cache for shell-completion --group enumeration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,38 @@ pub fn humanize_age(seconds: i64) -> String {
|
|||||||
|
|
||||||
fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } }
|
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 <TAB>` 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 `<vault_dir>/.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<String>,
|
||||||
|
) -> 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -166,7 +166,15 @@ enum Commands {
|
|||||||
Lock,
|
Lock,
|
||||||
|
|
||||||
/// Emit a shell completion script for the given shell.
|
/// 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 <TAB>` 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 {
|
Completions {
|
||||||
#[arg(value_enum)]
|
#[arg(value_enum)]
|
||||||
shell: Shell,
|
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::<String>::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`
|
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
||||||
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
||||||
/// unavailable in assert_cmd-spawned children).
|
/// unavailable in assert_cmd-spawned children).
|
||||||
@@ -508,6 +534,7 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
vault.save_item(&item)?;
|
vault.save_item(&item)?;
|
||||||
manifest.upsert(&item);
|
manifest.upsert(&item);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.save_manifest(&manifest)?;
|
||||||
|
refresh_groups_cache(vault.root(), &manifest);
|
||||||
|
|
||||||
let mut paths: Vec<String> = vec![
|
let mut paths: Vec<String> = vec![
|
||||||
format!("items/{}.enc", item.id.as_str()),
|
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 vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
let manifest = vault.load_manifest()?;
|
let manifest = vault.load_manifest()?;
|
||||||
|
refresh_groups_cache(vault.root(), &manifest);
|
||||||
let entry = resolve_query(&manifest, &query)?;
|
let entry = resolve_query(&manifest, &query)?;
|
||||||
let item = vault.load_item(&entry.id)?;
|
let item = vault.load_item(&entry.id)?;
|
||||||
|
|
||||||
@@ -965,6 +993,7 @@ fn cmd_list(
|
|||||||
|
|
||||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
let manifest = vault.load_manifest()?;
|
let manifest = vault.load_manifest()?;
|
||||||
|
refresh_groups_cache(vault.root(), &manifest);
|
||||||
|
|
||||||
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
|
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
|
||||||
None => None,
|
None => None,
|
||||||
@@ -1038,6 +1067,7 @@ fn cmd_edit(query: String) -> Result<()> {
|
|||||||
vault.save_item(&item)?;
|
vault.save_item(&item)?;
|
||||||
manifest.upsert(&item);
|
manifest.upsert(&item);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.save_manifest(&manifest)?;
|
||||||
|
refresh_groups_cache(vault.root(), &manifest);
|
||||||
commit_paths(&vault, &format!("edit: {} ({})", item.title, item.id.as_str()),
|
commit_paths(&vault, &format!("edit: {} ({})", item.title, item.id.as_str()),
|
||||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
eprintln!("Updated {}", item.id.as_str());
|
eprintln!("Updated {}", item.id.as_str());
|
||||||
@@ -1237,6 +1267,7 @@ fn cmd_rm(query: String) -> Result<()> {
|
|||||||
vault.save_item(&item)?;
|
vault.save_item(&item)?;
|
||||||
manifest.upsert(&item);
|
manifest.upsert(&item);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.save_manifest(&manifest)?;
|
||||||
|
refresh_groups_cache(vault.root(), &manifest);
|
||||||
commit_paths(&vault, &format!("trash: {} ({})", item.title, item.id.as_str()),
|
commit_paths(&vault, &format!("trash: {} ({})", item.title, item.id.as_str()),
|
||||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
eprintln!("Moved to trash: {}", item.title);
|
eprintln!("Moved to trash: {}", item.title);
|
||||||
@@ -1254,6 +1285,7 @@ fn cmd_restore(query: String) -> Result<()> {
|
|||||||
vault.save_item(&item)?;
|
vault.save_item(&item)?;
|
||||||
manifest.upsert(&item);
|
manifest.upsert(&item);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.save_manifest(&manifest)?;
|
||||||
|
refresh_groups_cache(vault.root(), &manifest);
|
||||||
commit_paths(&vault, &format!("restore: {} ({})", item.title, item.id.as_str()),
|
commit_paths(&vault, &format!("restore: {} ({})", item.title, item.id.as_str()),
|
||||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
eprintln!("Restored: {}", item.title);
|
eprintln!("Restored: {}", item.title);
|
||||||
@@ -1295,6 +1327,7 @@ fn cmd_purge(query: String) -> Result<()> {
|
|||||||
|
|
||||||
purge_item(&vault, &mut manifest, &id, &title)?;
|
purge_item(&vault, &mut manifest, &id, &title)?;
|
||||||
vault.save_manifest(&manifest)?;
|
vault.save_manifest(&manifest)?;
|
||||||
|
refresh_groups_cache(vault.root(), &manifest);
|
||||||
|
|
||||||
let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?;
|
let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?;
|
||||||
if !status.success() { anyhow::bail!("git add manifest.enc failed"); }
|
if !status.success() { anyhow::bail!("git add manifest.enc failed"); }
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
use assert_cmd::Command;
|
use assert_cmd::Command;
|
||||||
use predicates::str::contains;
|
use predicates::str::contains;
|
||||||
|
|
||||||
@@ -28,3 +30,76 @@ fn completions_fish_emits_script() {
|
|||||||
.success()
|
.success()
|
||||||
.stdout(contains("complete -c relicario"));
|
.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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user