//! 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 { 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 { 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 { 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 ` 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: "` /// 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(()) } /// 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 ` 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") } /// Write the sorted set of group names to `/.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, ) -> 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 { 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"); } }