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>
240 lines
8.8 KiB
Rust
240 lines
8.8 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
|
||
}
|
||
|
||
/// 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+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 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");
|
||
}
|
||
}
|