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>
323 lines
12 KiB
Rust
323 lines
12 KiB
Rust
//! CLI-side helpers: vault dir detection, hardened git shell-out, ISO-8601
|
||
//! timestamp formatting. Kept in their own module so every command handler
|
||
//! stays terse.
|
||
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::Command;
|
||
|
||
use anyhow::{bail, Context, Result};
|
||
use chrono::DateTime;
|
||
|
||
/// Walk up from `start` looking for a directory containing `.relicario/`.
|
||
/// Returns the vault root (the directory that contains `.relicario/`).
|
||
/// Audit L8: refuses to operate outside an initialized vault.
|
||
pub fn find_vault_dir_from(start: &Path) -> Result<PathBuf> {
|
||
let mut cur = start.to_path_buf();
|
||
loop {
|
||
if cur.join(".relicario").is_dir() {
|
||
return Ok(cur);
|
||
}
|
||
if !cur.pop() {
|
||
bail!(
|
||
"no .relicario/ directory found in {} or any parent — \
|
||
run `relicario init` first",
|
||
start.display()
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Convenience wrapper that starts the search from `std::env::current_dir()`.
|
||
pub fn vault_dir() -> Result<PathBuf> {
|
||
let cwd = std::env::current_dir().context("failed to get current directory")?;
|
||
find_vault_dir_from(&cwd)
|
||
}
|
||
|
||
/// Path to the `.relicario/` configuration directory within the vault.
|
||
#[allow(dead_code)]
|
||
pub fn relicario_dir() -> Result<PathBuf> {
|
||
Ok(vault_dir()?.join(".relicario"))
|
||
}
|
||
|
||
/// Build a hardened `git` command — no hooks, no GPG signing, no editor.
|
||
/// Audit H4: prevents vault mutations from running hostile hooks, blocking on
|
||
/// GPG passphrase prompts (which would hold the master key alive), or entering
|
||
/// $EDITOR during rebase conflict markers.
|
||
pub fn git_command(repo: &Path, args: &[&str]) -> Command {
|
||
let mut cmd = Command::new("git");
|
||
cmd.current_dir(repo);
|
||
cmd.args([
|
||
"-c", "core.hooksPath=/dev/null",
|
||
"-c", "commit.gpgsign=false",
|
||
"-c", "core.editor=true",
|
||
]);
|
||
cmd.args(args);
|
||
cmd
|
||
}
|
||
|
||
/// Run `git <args>` in `repo` with the same hardening as `git_command`,
|
||
/// capturing stdout/stderr and reproducing them on failure so the caller
|
||
/// sees git's exact diagnostic instead of just a verb.
|
||
///
|
||
/// `context` should be a short caller-supplied label like `"commit add: <id>"`
|
||
/// or `"sync: git push"`; it prefixes the bail message so the failing call is
|
||
/// identifiable from the error alone.
|
||
///
|
||
/// Trade-off vs. `git_command(...).status()`: this captures the child's stderr
|
||
/// (so live progress disappears during long-running fetches/pushes) but the
|
||
/// captured chunk is replayed verbatim on failure. The win is that
|
||
/// non-interactive callers (tests, hooks, CI, redirected stdout) finally see
|
||
/// pre-receive rejections, signing-key prompts, and dirty-tree complaints
|
||
/// instead of one-line "git X failed" bails. Use `git_command` directly when
|
||
/// live streaming is required.
|
||
pub fn git_run(repo: &Path, args: &[&str], context: &str) -> Result<()> {
|
||
let output = git_command(repo, args)
|
||
.output()
|
||
.with_context(|| format!("{context}: failed to spawn git"))?;
|
||
if !output.status.success() {
|
||
if !output.stdout.is_empty() {
|
||
eprint!("{}", String::from_utf8_lossy(&output.stdout));
|
||
}
|
||
if !output.stderr.is_empty() {
|
||
eprint!("{}", String::from_utf8_lossy(&output.stderr));
|
||
}
|
||
bail!("{context}: git failed ({})", output.status);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Stage `paths` for removal in one `git rm -rf --ignore-unmatch` invocation.
|
||
/// `--ignore-unmatch` is load-bearing: a previous partial-write crash can
|
||
/// leave the manifest entry without the corresponding `items/<id>.enc` on
|
||
/// disk, and we want the rm to succeed regardless.
|
||
pub fn git_rm(repo: &Path, paths: &[String], context: &str) -> Result<()> {
|
||
let mut args: Vec<&str> = vec!["rm", "-rf", "--ignore-unmatch"];
|
||
args.extend(paths.iter().map(String::as_str));
|
||
git_run(repo, &args, context)
|
||
}
|
||
|
||
/// Format a Unix-seconds timestamp as an ISO-8601 UTC string.
|
||
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
|
||
/// a numeric string.
|
||
pub fn iso8601(unix_seconds: i64) -> String {
|
||
DateTime::from_timestamp(unix_seconds, 0)
|
||
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||
.unwrap_or_else(|| format!("invalid-timestamp:{unix_seconds}"))
|
||
}
|
||
|
||
/// Format a duration (in seconds) as a coarse human-readable string:
|
||
/// "just now" / "5 minutes ago" / "4 days ago" / "3 months ago".
|
||
pub fn humanize_age(seconds: i64) -> String {
|
||
if seconds < 60 { return "just now".to_string(); }
|
||
if seconds < 3600 { return format!("{} minute{} ago", seconds / 60, plural(seconds / 60)); }
|
||
if seconds < 86_400 { return format!("{} hour{} ago", seconds / 3600, plural(seconds / 3600)); }
|
||
if seconds < 86_400 * 30 {
|
||
let d = seconds / 86_400;
|
||
return format!("{d} day{} ago", plural(d));
|
||
}
|
||
if seconds < 86_400 * 365 {
|
||
let m = seconds / (86_400 * 30);
|
||
return format!("{m} month{} ago", plural(m));
|
||
}
|
||
let y = seconds / (86_400 * 365);
|
||
format!("{y} year{} ago", plural(y))
|
||
}
|
||
|
||
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. In debug builds, 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")
|
||
}
|
||
|
||
/// 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.
|
||
///
|
||
/// Visibility note: this is `pub(crate)` so only `session::after_manifest_change`
|
||
/// can call it. The Plan B Phase 4 done-criterion requires every mutating
|
||
/// handler to funnel through the wrapper — exposing this helper to commands/
|
||
/// would let a caller refresh the cache without updating the manifest, breaking
|
||
/// the invariant.
|
||
pub(crate) fn refresh_groups_cache(vault_dir: &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 _ = write_groups_cache(vault_dir, &set);
|
||
}
|
||
|
||
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
||
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
|
||
/// suppresses the write (developer debugging tool). In release builds the env
|
||
/// var is ignored.
|
||
pub fn write_groups_cache(
|
||
vault_dir: &Path,
|
||
groups: &std::collections::BTreeSet<String>,
|
||
) -> std::io::Result<()> {
|
||
if cfg!(debug_assertions) && 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)
|
||
}
|
||
|
||
/// Sanitize a string for use in a git commit message subject line.
|
||
///
|
||
/// Removes all Unicode control characters (U+0000–U+001F, U+007F, and higher
|
||
/// control planes) so that newlines and escape sequences cannot corrupt `git
|
||
/// log` output. Truncates to 50 characters so the subject line stays within
|
||
/// the conventional limit.
|
||
///
|
||
/// Audit I1: item titles are user-supplied and may contain arbitrary bytes.
|
||
pub fn sanitize_for_commit(s: &str) -> String {
|
||
s.chars()
|
||
.filter(|c| !c.is_control())
|
||
.take(50)
|
||
.collect()
|
||
}
|
||
|
||
/// Decode a QR image at `path`. Returns the otpauth secret (base32) if the
|
||
/// QR decodes to an `otpauth://...` URI with a `secret` query param.
|
||
pub fn decode_totp_qr(path: &std::path::Path) -> anyhow::Result<String> {
|
||
let img = image::open(path)
|
||
.map_err(|e| anyhow::anyhow!("failed to read image: {e}"))?
|
||
.to_luma8();
|
||
let mut prepared = rqrr::PreparedImage::prepare(img);
|
||
let grids = prepared.detect_grids();
|
||
let grid = grids
|
||
.into_iter()
|
||
.next()
|
||
.ok_or_else(|| anyhow::anyhow!("no QR code found in image"))?;
|
||
let (_meta, content) = grid
|
||
.decode()
|
||
.map_err(|e| anyhow::anyhow!("QR decode failed: {e}"))?;
|
||
if !content.starts_with("otpauth://") {
|
||
return Err(anyhow::anyhow!("not a TOTP URI (expected otpauth://...)"));
|
||
}
|
||
let parsed =
|
||
url::Url::parse(&content).map_err(|e| anyhow::anyhow!("invalid otpauth URI: {e}"))?;
|
||
let secret = parsed
|
||
.query_pairs()
|
||
.find(|(k, _)| k == "secret")
|
||
.map(|(_, v)| v.to_string())
|
||
.ok_or_else(|| anyhow::anyhow!("otpauth URI missing `secret` parameter"))?;
|
||
Ok(secret)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use tempfile::TempDir;
|
||
|
||
#[test]
|
||
fn vault_dir_finds_marker_in_cwd() {
|
||
let tmp = TempDir::new().unwrap();
|
||
std::fs::create_dir(tmp.path().join(".relicario")).unwrap();
|
||
let found = find_vault_dir_from(tmp.path()).unwrap();
|
||
assert_eq!(found, tmp.path());
|
||
}
|
||
|
||
#[test]
|
||
fn vault_dir_finds_marker_in_parent() {
|
||
let tmp = TempDir::new().unwrap();
|
||
std::fs::create_dir(tmp.path().join(".relicario")).unwrap();
|
||
let subdir = tmp.path().join("sub/nested");
|
||
std::fs::create_dir_all(&subdir).unwrap();
|
||
let found = find_vault_dir_from(&subdir).unwrap();
|
||
assert_eq!(found, tmp.path());
|
||
}
|
||
|
||
#[test]
|
||
fn vault_dir_errors_when_missing() {
|
||
let tmp = TempDir::new().unwrap();
|
||
let err = find_vault_dir_from(tmp.path()).unwrap_err();
|
||
assert!(err.to_string().contains(".relicario"));
|
||
}
|
||
|
||
#[test]
|
||
fn iso8601_formats_fixed_timestamp() {
|
||
// 2026-04-19T00:00:00Z = 1776556800
|
||
assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z");
|
||
}
|
||
|
||
#[test]
|
||
fn sanitize_for_commit_strips_control_chars() {
|
||
assert_eq!(sanitize_for_commit("line1\nline2"), "line1line2");
|
||
assert_eq!(sanitize_for_commit("a\tb"), "ab");
|
||
assert_eq!(sanitize_for_commit("normal"), "normal");
|
||
assert_eq!(sanitize_for_commit("cr\r\nline"), "crline");
|
||
// ESC (U+001B) is control and gets stripped; bracket sequences are printable
|
||
assert_eq!(sanitize_for_commit("\x1b[31mred\x1b[0m"), "[31mred[0m");
|
||
}
|
||
|
||
#[test]
|
||
fn sanitize_for_commit_truncates_to_50() {
|
||
let long = "a".repeat(60);
|
||
assert_eq!(sanitize_for_commit(&long).len(), 50);
|
||
assert_eq!(sanitize_for_commit(&long), "a".repeat(50));
|
||
}
|
||
|
||
#[test]
|
||
fn sanitize_for_commit_allows_unicode() {
|
||
assert_eq!(sanitize_for_commit("cafe\u{0301}"), "cafe\u{0301}");
|
||
assert_eq!(sanitize_for_commit("emoji \u{1F4AA}"), "emoji \u{1F4AA}");
|
||
}
|
||
|
||
#[test]
|
||
fn git_run_bails_with_context_on_failure() {
|
||
// Empty tempdir — `git status` will fail with "not a git repository".
|
||
let tmp = TempDir::new().unwrap();
|
||
let err = git_run(tmp.path(), &["status"], "test_ctx").unwrap_err();
|
||
let msg = format!("{err}");
|
||
assert!(msg.contains("test_ctx"), "context not in error: {msg}");
|
||
assert!(msg.contains("git failed"), "missing failure marker: {msg}");
|
||
}
|
||
|
||
#[test]
|
||
fn git_run_succeeds_for_a_zero_exit_command() {
|
||
// `git --version` always succeeds and is independent of cwd.
|
||
let tmp = TempDir::new().unwrap();
|
||
git_run(tmp.path(), &["--version"], "version probe")
|
||
.expect("git --version should succeed");
|
||
}
|
||
|
||
#[test]
|
||
fn humanize_age_buckets() {
|
||
assert_eq!(humanize_age(0), "just now");
|
||
assert_eq!(humanize_age(59), "just now");
|
||
assert_eq!(humanize_age(60), "1 minute ago");
|
||
assert_eq!(humanize_age(120), "2 minutes ago");
|
||
assert_eq!(humanize_age(3_599), "59 minutes ago");
|
||
assert_eq!(humanize_age(3_600), "1 hour ago");
|
||
assert_eq!(humanize_age(7_200), "2 hours ago");
|
||
assert_eq!(humanize_age(86_400), "1 day ago");
|
||
assert_eq!(humanize_age(86_400 * 2), "2 days ago");
|
||
assert_eq!(humanize_age(86_400 * 30), "1 month ago");
|
||
assert_eq!(humanize_age(86_400 * 60), "2 months ago");
|
||
assert_eq!(humanize_age(86_400 * 365), "1 year ago");
|
||
assert_eq!(humanize_age(86_400 * 365 * 3), "3 years ago");
|
||
}
|
||
}
|