Files
relicario/crates/relicario-cli/src/helpers.rs
adlee-was-taken 4d02a50cc8 chore(core): fix pre-existing clippy warnings (-D warnings gate)
Resolves pre-existing lint issues in imgsecret.rs, time.rs, totp.rs,
and crypto.rs that blocked the cargo clippy --workspace -D warnings
gate. No logic changes: loop-index → iterator, manual div_ceil →
.div_ceil(), manual range contains → .contains(), auto-deref cleanup.

Also fixes pre-existing warnings in relicario-cli (main.rs, session.rs,
device.rs, gitea.rs, helpers.rs, test helpers): dead_code suppression,
too_many_arguments, literal_with_empty_format_string, manual_char_cmp,
map_or → is_none_or, and repeat().take() → vec! in test helpers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 19:32:45 -04:00

240 lines
8.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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
}
/// 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")
}
/// 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+0000U+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 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");
}
}