From 8a72b5e192f769675736c4671f13de857eee8717 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 2 May 2026 16:23:10 -0400 Subject: [PATCH 1/7] feat(core): add device::fingerprint helper for SSH SHA256 fingerprints Wraps ssh-key's PublicKey::fingerprint(HashAlg::Sha256). Output format matches ssh-keygen -lf and git verify-commit --raw stderr (SHA256:<43-char base64>). Used by the upcoming relicario-server verify-commit rewrite (audit S1). Co-Authored-By: Claude Haiku 4.5 --- crates/relicario-core/src/device.rs | 33 +++++++++++++++++++++++++++++ crates/relicario-core/src/lib.rs | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/relicario-core/src/device.rs b/crates/relicario-core/src/device.rs index 4d779fc..f2ce780 100644 --- a/crates/relicario-core/src/device.rs +++ b/crates/relicario-core/src/device.rs @@ -106,6 +106,16 @@ pub fn verify(public_key_openssh: &str, data: &[u8], signature_b64: &str) -> Res Ok(verifying_key.verify(data, &signature).is_ok()) } +/// Compute the OpenSSH SHA-256 fingerprint of a public key. +/// Output format matches `ssh-keygen -lf` and `git verify-commit --raw`: +/// `SHA256:<43-char base64 without padding>`. +pub fn fingerprint(public_key_openssh: &str) -> Result { + use ssh_key::HashAlg; + let public = PublicKey::from_openssh(public_key_openssh) + .map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?; + Ok(public.fingerprint(HashAlg::Sha256).to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -132,4 +142,27 @@ mod tests { let sig = sign(&private, b"hello").unwrap(); assert!(!verify(&other_public, b"hello", &sig).unwrap()); } + + #[test] + fn fingerprint_matches_ssh_keygen_format() { + let (_, public) = generate_keypair().unwrap(); + let fp = fingerprint(&public).unwrap(); + assert!(fp.starts_with("SHA256:"), "fingerprint should start with SHA256: prefix, got {fp}"); + let body = fp.strip_prefix("SHA256:").unwrap(); + assert_eq!(body.len(), 43, "SHA-256 fingerprint body is 43 base64 chars (no padding)"); + assert!(body.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/')); + } + + #[test] + fn fingerprint_is_deterministic() { + let (_, public) = generate_keypair().unwrap(); + assert_eq!(fingerprint(&public).unwrap(), fingerprint(&public).unwrap()); + } + + #[test] + fn fingerprint_differs_per_key() { + let (_, p1) = generate_keypair().unwrap(); + let (_, p2) = generate_keypair().unwrap(); + assert_ne!(fingerprint(&p1).unwrap(), fingerprint(&p2).unwrap()); + } } diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index 4ae324e..c687383 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -85,4 +85,4 @@ pub mod import_lastpass; pub use import_lastpass::{parse_lastpass_csv, ImportWarning}; pub mod device; -pub use device::{DeviceEntry, RevokedEntry, generate_keypair, sign, verify}; +pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify}; From d539050aec5efed73f9fb9c3b9e64c873b247fe3 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 2 May 2026 16:23:24 -0400 Subject: [PATCH 2/7] chore(server): add assert_cmd/predicates/tempfile dev-deps Needed for the upcoming verify-commit acceptance suite (audit S1). Co-Authored-By: Claude Haiku 4.5 --- crates/relicario-server/Cargo.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/relicario-server/Cargo.toml b/crates/relicario-server/Cargo.toml index 75a4e5d..c773000 100644 --- a/crates/relicario-server/Cargo.toml +++ b/crates/relicario-server/Cargo.toml @@ -9,3 +9,8 @@ anyhow = "1" clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" +tempfile = "3" From efac53d527b2d57eab3607528380b0029f8b7003 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 2 May 2026 16:34:37 -0400 Subject: [PATCH 3/7] fix(server): real signature verification in pre-receive hook (audit S1) verify_commit previously loaded devices.json/revoked.json and threw both away, accepting any commit whose stderr contained "GOODSIG" or "Good signature". This left device registration and revocation as no-ops: unregistered keys could push, revoked keys kept working. The fix: - Build a temp gpg.ssh.allowedSignersFile from devices.json at the commit, passed via GIT_CONFIG_COUNT/KEY/VALUE env (no global git config mutation). - Run git verify-commit --raw and parse SHA256 fingerprint from stderr regardless of exit code (SSH git outputs the "Good" line even for keys not in allowed-signers, with "No principal matched" + exit 1). - Check revoked.json FIRST: reject if committer_ts >= revoked_at; accept historical commits (committer_ts < revoked_at). - Reject if fingerprint is not in active devices.json. - Bootstrap: accept only when BOTH devices.json AND revoked.json are empty/absent (not just devices.json alone). Acceptance: 4 integration tests covering the matrix. Co-Authored-By: Claude Sonnet 4.6 --- crates/relicario-server/Cargo.toml | 2 + crates/relicario-server/src/main.rs | 112 +++++++-- .../relicario-server/tests/verify_commit.rs | 230 ++++++++++++++++++ 3 files changed, 324 insertions(+), 20 deletions(-) create mode 100644 crates/relicario-server/tests/verify_commit.rs diff --git a/crates/relicario-server/Cargo.toml b/crates/relicario-server/Cargo.toml index c773000..6455bbc 100644 --- a/crates/relicario-server/Cargo.toml +++ b/crates/relicario-server/Cargo.toml @@ -9,6 +9,8 @@ anyhow = "1" clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +tempfile = "3" +regex = "1" [dev-dependencies] assert_cmd = "2" diff --git a/crates/relicario-server/src/main.rs b/crates/relicario-server/src/main.rs index 06dcef6..91d861a 100644 --- a/crates/relicario-server/src/main.rs +++ b/crates/relicario-server/src/main.rs @@ -1,5 +1,6 @@ //! relicario-server -- pre-receive hook for signature verification. +use std::fs; use std::process::Command; use anyhow::{Context, Result}; @@ -34,49 +35,120 @@ fn main() -> Result<()> { } fn verify_commit(commit: &str) -> Result<()> { - // Get devices.json at this commit let devices_json = match git_show(commit, ".relicario/devices.json") { Ok(json) => json, Err(_) => { - // No devices.json yet -- bootstrap mode, allow unsigned - eprintln!("OK: commit {} (bootstrap - no devices.json)", commit); + eprintln!("OK: commit {commit} (bootstrap - no devices.json)"); return Ok(()); } }; let devices: Vec = serde_json::from_str(&devices_json) .context("parse devices.json")?; - // Bootstrap: if devices.json is empty, allow unsigned - if devices.is_empty() { - eprintln!("OK: commit {} (bootstrap - empty devices.json)", commit); - return Ok(()); - } - - // Get revoked.json (may not exist) let revoked: Vec = git_show(commit, ".relicario/revoked.json") .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); - // Get commit signature + // True bootstrap: no devices ever registered and none revoked. + if devices.is_empty() && revoked.is_empty() { + eprintln!("OK: commit {commit} (bootstrap - no devices registered)"); + return Ok(()); + } + + // Build temp allowed-signers file from registered devices. + let tmp = tempfile::tempdir().context("create tempdir")?; + let allowed_path = tmp.path().join("allowed_signers"); + let mut allowed_body = String::new(); + for d in &devices { + allowed_body.push_str("relicario "); + allowed_body.push_str(d.public_key.trim()); + allowed_body.push('\n'); + } + fs::write(&allowed_path, &allowed_body).context("write allowed_signers")?; + + // Run git verify-commit --raw. Capture both exit code and stderr. + // NOTE: we do NOT short-circuit on non-zero exit here because even for + // unregistered keys git still outputs "Good ... key SHA256:..." on stderr. let output = Command::new("git") .args(["verify-commit", "--raw", commit]) + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile") + .env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str()) .output() .context("git verify-commit")?; - // Check if signed let stderr = String::from_utf8_lossy(&output.stderr); - if !stderr.contains("GOODSIG") && !stderr.contains("Good signature") { - eprintln!("REJECT: commit {} is not signed by a registered device", commit); + + // Parse the SHA-256 fingerprint from stderr. + // SSH signature output: "Good "git" signature ... with ED25519 key SHA256:" + let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex"); + let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) { + Some(m) => m.as_str().to_string(), + None => { + // No fingerprint in stderr = unsigned or completely malformed signature. + eprintln!( + "REJECT: commit {commit} — no valid signature found (stderr: {})", + stderr.trim() + ); + std::process::exit(1); + } + }; + + // Build fingerprint → entry maps. + let mut device_by_fp: std::collections::HashMap = + std::collections::HashMap::new(); + for d in &devices { + if let Ok(fp) = relicario_core::device::fingerprint(&d.public_key) { + device_by_fp.insert(fp, d); + } + } + + let mut revoked_by_fp: std::collections::HashMap = + std::collections::HashMap::new(); + for r in &revoked { + if let Ok(fp) = relicario_core::device::fingerprint(&r.public_key) { + revoked_by_fp.insert(fp, r); + } + } + + // Get committer date (NOT author date). + let ct_out = Command::new("git") + .args(["show", "-s", "--format=%ct", commit]) + .output() + .context("git show committer date")?; + let committer_ts: i64 = String::from_utf8_lossy(&ct_out.stdout) + .trim() + .parse() + .context("parse committer timestamp")?; + + // Check revocation FIRST (revoked entries may not be in devices anymore). + if let Some(r) = revoked_by_fp.get(&signing_fp) { + if committer_ts >= r.revoked_at { + eprintln!( + "REJECT: commit {commit} — signed by revoked device '{}' \ + (committer ts {committer_ts} >= revoked_at {})", + r.name, r.revoked_at + ); + std::process::exit(1); + } + // Historical commit: committer_ts < revoked_at → was valid when signed. + eprintln!( + "OK: commit {commit} — historical commit signed by '{}' before revocation", + r.name + ); + return Ok(()); + } + + // Not revoked — must be in active devices. + if !device_by_fp.contains_key(&signing_fp) { + eprintln!( + "REJECT: commit {commit} — signed by unregistered device (fingerprint {signing_fp})" + ); std::process::exit(1); } - // Ensure the signing key is not revoked. - // The allowed-signers file approach means git verify-commit already checks - // against the list; we additionally guard against revoked.json entries. - let _ = &revoked; // revoked list is loaded; enforcement via git allowed-signers - - eprintln!("OK: commit {} verified", commit); + eprintln!("OK: commit {commit} verified (signed by '{}')", device_by_fp[&signing_fp].name); Ok(()) } diff --git a/crates/relicario-server/tests/verify_commit.rs b/crates/relicario-server/tests/verify_commit.rs new file mode 100644 index 0000000..ce6e72f --- /dev/null +++ b/crates/relicario-server/tests/verify_commit.rs @@ -0,0 +1,230 @@ +//! Acceptance tests for `relicario-server verify-commit`. +//! +//! Four scenarios from audit S1: +//! 1. Registered non-revoked key → exit 0 +//! 2. Unregistered key → exit 1 (stderr contains "unregistered") +//! 3. Revoked key, commit AFTER revoked_at → exit 1 (stderr contains "revoked") +//! 4. Revoked key, commit BEFORE revoked_at (historical) → exit 0 + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use assert_cmd::Command as AssertCommand; +use predicates::prelude::*; +use relicario_core::device::{generate_keypair, DeviceEntry, RevokedEntry}; +use tempfile::TempDir; + +fn write_keypair(dir: &Path, name: &str) -> (PathBuf, PathBuf, String) { + let (priv_pem, pub_line) = generate_keypair().expect("generate keypair"); + let priv_path = dir.join(format!("{name}.key")); + let pub_path = dir.join(format!("{name}.pub")); + fs::write(&priv_path, priv_pem.as_str()).unwrap(); + fs::write(&pub_path, &pub_line).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap(); + } + (priv_path, pub_path, pub_line) +} + +fn git(repo: &Path, args: &[&str], extra_env: &[(&str, &str)]) { + let mut cmd = Command::new("git"); + cmd.current_dir(repo).args(args); + for (k, v) in extra_env { + cmd.env(k, v); + } + let status = cmd.status().expect("spawn git"); + assert!(status.success(), "git {args:?} failed"); +} + +fn init_repo(repo: &Path) { + git(repo, &["init", "-q", "-b", "main"], &[]); + git(repo, &["config", "user.email", "test@test"], &[]); + git(repo, &["config", "user.name", "test"], &[]); + git(repo, &["commit", "--allow-empty", "-q", "-m", "init"], &[]); +} + +fn sign_commit( + repo: &Path, + signing_key: &Path, + allowed_signers: &Path, + committer_unix: i64, + msg: &str, + file_path: &str, + file_content: &str, +) -> String { + fs::write(repo.join(file_path), file_content).unwrap(); + git(repo, &["add", file_path], &[]); + let date = format!("@{committer_unix} +0000"); + git( + repo, + &[ + "-c", "gpg.format=ssh", + "-c", &format!("user.signingkey={}", signing_key.display()), + "-c", &format!("gpg.ssh.allowedSignersFile={}", allowed_signers.display()), + "commit", "-S", "-q", "-m", msg, + ], + &[ + ("GIT_AUTHOR_DATE", &date), + ("GIT_COMMITTER_DATE", &date), + ], + ); + let out = Command::new("git") + .current_dir(repo) + .args(["rev-parse", "HEAD"]) + .output() + .unwrap(); + String::from_utf8(out.stdout).unwrap().trim().to_string() +} + +fn write_device_files(repo: &Path, devices: &[DeviceEntry], revoked: &[RevokedEntry]) { + let dir = repo.join(".relicario"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("devices.json"), serde_json::to_string_pretty(devices).unwrap()).unwrap(); + fs::write(dir.join("revoked.json"), serde_json::to_string_pretty(revoked).unwrap()).unwrap(); + git(repo, &["add", ".relicario"], &[]); + git(repo, &["commit", "-q", "-m", "device files"], &[]); +} + +#[test] +fn registered_non_revoked_key_accepted() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path(); + init_repo(repo); + + let (priv_a, _, pub_a) = write_keypair(repo, "alice"); + write_device_files( + repo, + &[DeviceEntry { + name: "alice".into(), + public_key: pub_a.clone(), + added_at: 1_700_000_000, + added_by: "bootstrap".into(), + }], + &[], + ); + + let allowed = repo.join("test_allowed_signers"); + fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap(); + + let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "x", "a.txt", "hi"); + + AssertCommand::cargo_bin("relicario-server") + .unwrap() + .current_dir(repo) + .args(["verify-commit", &sha]) + .assert() + .success(); +} + +#[test] +fn unregistered_key_rejected() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path(); + init_repo(repo); + + let (_, _, pub_a) = write_keypair(repo, "alice"); + let (priv_evil, _, pub_evil) = write_keypair(repo, "evil"); + + // Only Alice is registered. + write_device_files( + repo, + &[DeviceEntry { + name: "alice".into(), + public_key: pub_a.clone(), + added_at: 1_700_000_000, + added_by: "bootstrap".into(), + }], + &[], + ); + + // Evil signs against a file containing both keys so git commit signing works, + // but the binary's allowed-signers (from devices.json) only has Alice. + let allowed = repo.join("test_allowed_signers"); + fs::write( + &allowed, + format!("relicario {}\nrelicario {}\n", pub_a.trim(), pub_evil.trim()), + ) + .unwrap(); + + let sha = sign_commit(repo, &priv_evil, &allowed, 1_710_000_000, "evil", "a.txt", "hi"); + + AssertCommand::cargo_bin("relicario-server") + .unwrap() + .current_dir(repo) + .args(["verify-commit", &sha]) + .assert() + .failure() + .stderr(predicate::str::contains("unregistered")); +} + +#[test] +fn revoked_key_after_revoked_at_rejected() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path(); + init_repo(repo); + + let (priv_a, _, pub_a) = write_keypair(repo, "alice"); + + // Alice's entry is only in revoked.json (was removed from devices.json after revocation). + write_device_files( + repo, + &[], + &[RevokedEntry { + name: "alice".into(), + public_key: pub_a.clone(), + revoked_at: 1_705_000_000, + revoked_by: "admin".into(), + }], + ); + + let allowed = repo.join("test_allowed_signers"); + fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap(); + + // Commit dated AFTER revocation. + let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "post", "a.txt", "hi"); + + AssertCommand::cargo_bin("relicario-server") + .unwrap() + .current_dir(repo) + .args(["verify-commit", &sha]) + .assert() + .failure() + .stderr(predicate::str::contains("revoked")); +} + +#[test] +fn revoked_key_before_revoked_at_accepted_historical() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path(); + init_repo(repo); + + let (priv_a, _, pub_a) = write_keypair(repo, "alice"); + + // Same as above: Alice only in revoked.json. + write_device_files( + repo, + &[], + &[RevokedEntry { + name: "alice".into(), + public_key: pub_a.clone(), + revoked_at: 1_705_000_000, + revoked_by: "admin".into(), + }], + ); + + let allowed = repo.join("test_allowed_signers"); + fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap(); + + // Commit dated BEFORE revocation -- historical case must pass. + let sha = sign_commit(repo, &priv_a, &allowed, 1_700_000_000, "historical", "a.txt", "hi"); + + AssertCommand::cargo_bin("relicario-server") + .unwrap() + .current_dir(repo) + .args(["verify-commit", &sha]) + .assert() + .success(); +} From 6a1c6d58750fd08ac0aee8cd05c86761636080b2 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 2 May 2026 17:16:11 -0400 Subject: [PATCH 4/7] fix(core,cli): harden backup-restore tar unpack against path traversal (audit S2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_backup_restore previously called tar::Archive::unpack with default settings, allowing malicious .relbak archives to escape the target directory via .. entries, absolute paths, or symlinks. No size cap meant tar bombs could exhaust disk space. Replaced with relicario_core::safe_unpack_git_archive which: - Rejects .. (ParentDir), absolute (RootDir), and drive-prefix (Prefix) components with "path traversal blocked" error. - Rejects symlinks and hardlinks outright. - Checks declared header size before reading body; rejects entries or cumulative totals exceeding the caller's cap. - Returns (relative-path, bytes) pairs; the CLI re-checks dest.starts_with(git_dir) after OS-level path resolution. - CLI cap: min(100 × compressed size, 1 GiB). Acceptance: 5 unit tests in relicario-core (traversal, absolute path, symlink, size bomb, happy path); existing CLI backup roundtrip tests remain green. Co-Authored-By: Claude Sonnet 4.6 --- crates/relicario-cli/src/main.rs | 29 +++- crates/relicario-core/src/error.rs | 4 + crates/relicario-core/src/lib.rs | 3 + crates/relicario-core/src/tar_safe.rs | 138 +++++++++++++++ crates/relicario-core/tests/safe_unpack.rs | 187 +++++++++++++++++++++ 5 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 crates/relicario-core/src/tar_safe.rs create mode 100644 crates/relicario-core/tests/safe_unpack.rs diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index cf44257..3c4029a 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -1718,9 +1718,32 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> { // .git/ history. if let Some(tar_bytes) = &unpacked.git_archive { - let mut archive = tar::Archive::new(tar_bytes.as_slice()); - archive.unpack(target.join(".git")) - .with_context(|| "failed to untar .git/")?; + // Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower. + let cap = std::cmp::min( + (tar_bytes.len() as u64).saturating_mul(100), + relicario_core::DEFAULT_MAX_UNCOMPRESSED, + ); + let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap) + .with_context(|| "failed to safely unpack .git/ archive")?; + let git_dir = target.join(".git"); + for (rel_path, body) in entries { + let dest = git_dir.join(&rel_path); + // Paranoid OS-level check even after textual validation in core. + if !dest.starts_with(&git_dir) { + anyhow::bail!( + "tar entry {} resolved outside .git/ (path traversal blocked)", + rel_path.display() + ); + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("create parent {}", parent.display()) + })?; + } + fs::write(&dest, &body).with_context(|| { + format!("write {}", dest.display()) + })?; + } } else { // No history bundled — start a fresh git repo. let status = crate::helpers::git_command(&target, &["init"]).status()?; diff --git a/crates/relicario-core/src/error.rs b/crates/relicario-core/src/error.rs index f2be80d..5b3d131 100644 --- a/crates/relicario-core/src/error.rs +++ b/crates/relicario-core/src/error.rs @@ -51,6 +51,10 @@ pub enum RelicarioError { #[error("backup envelope schema v{found}; this Relicario reads v{expected}")] BackupSchemaMismatch { found: u32, expected: u32 }, + /// An error during backup restore (e.g., tar safety validation failure). + #[error("backup restore: {0}")] + BackupRestore(String), + /// CSV header doesn't match the LastPass column layout. #[error("unrecognized CSV header — expected LastPass export format ({0})")] ImportCsvHeader(String), diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index c687383..e7b49a1 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -86,3 +86,6 @@ pub use import_lastpass::{parse_lastpass_csv, ImportWarning}; pub mod device; pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify}; + +pub mod tar_safe; +pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED}; diff --git a/crates/relicario-core/src/tar_safe.rs b/crates/relicario-core/src/tar_safe.rs new file mode 100644 index 0000000..c78d5d0 --- /dev/null +++ b/crates/relicario-core/src/tar_safe.rs @@ -0,0 +1,138 @@ +//! Safe tar unpacking for backup restore. +//! +//! The standard `tar::Archive::unpack` has no guards against path traversal, +//! absolute paths, symlinks, hardlinks, or tar bombs. This module replaces it +//! with `safe_unpack_git_archive`, which validates every entry before returning +//! `(relative_path, bytes)` pairs to the caller. + +use std::io::Read; +use std::path::{Component, PathBuf}; + +use tar::EntryType; + +use crate::error::{RelicarioError, Result}; + +/// Default cap on total uncompressed bytes extracted in one restore (1 GiB). +pub const DEFAULT_MAX_UNCOMPRESSED: u64 = 1024 * 1024 * 1024; + +/// Decode `tar_bytes` and return `(relative_path, file_bytes)` pairs for +/// regular files only. +/// +/// # Errors +/// +/// Returns `Err(RelicarioError::BackupRestore(...))` if: +/// +/// - Any path component is `..` (`Component::ParentDir`) — "path traversal blocked". +/// - Any path starts with `/` (`Component::RootDir`) — "path traversal blocked". +/// - Any path has a Windows drive prefix (`Component::Prefix`) — "path traversal blocked". +/// - An entry is a symlink or hardlink — "symlink/link rejected". +/// - An entry's declared size exceeds `max_uncompressed_bytes` — "size cap exceeded". +/// - The running total of all entry sizes exceeds `max_uncompressed_bytes` — "size cap exceeded". +/// - An entry has an unexpected type (not regular file, not directory) — "unexpected entry type". +pub fn safe_unpack_git_archive( + tar_bytes: &[u8], + max_uncompressed_bytes: u64, +) -> Result)>> { + let mut archive = tar::Archive::new(tar_bytes); + let entries = archive + .entries() + .map_err(|e| RelicarioError::BackupRestore(format!("failed to read tar entries: {e}")))?; + + let mut result: Vec<(PathBuf, Vec)> = Vec::new(); + let mut cumulative: u64 = 0; + + for entry in entries { + let mut entry = entry.map_err(|e| { + RelicarioError::BackupRestore(format!("failed to read tar entry: {e}")) + })?; + + let header = entry.header(); + let entry_type = header.entry_type(); + + // Reject symlinks and hardlinks. + match entry_type { + EntryType::Symlink => { + return Err(RelicarioError::BackupRestore( + "symlink entry rejected".to_string(), + )); + } + EntryType::Link => { + return Err(RelicarioError::BackupRestore( + "hardlink entry rejected".to_string(), + )); + } + EntryType::Directory => { + // Directories are implicit — skip without reading body. + continue; + } + EntryType::Regular | EntryType::Continuous | EntryType::GNUSparse => { + // These are normal file types; fall through to path checks. + } + _ => { + return Err(RelicarioError::BackupRestore(format!( + "unexpected entry type: {:?}", + entry_type + ))); + } + } + + // Validate the path. + let path = entry.path().map_err(|e| { + RelicarioError::BackupRestore(format!("invalid path in tar entry: {e}")) + })?; + let path = path.into_owned(); + + for component in path.components() { + match component { + Component::ParentDir => { + return Err(RelicarioError::BackupRestore( + "path traversal blocked: entry contains '..' component".to_string(), + )); + } + Component::RootDir => { + return Err(RelicarioError::BackupRestore( + "path traversal blocked: entry has absolute path".to_string(), + )); + } + Component::Prefix(_) => { + return Err(RelicarioError::BackupRestore( + "path traversal blocked: entry has Windows drive prefix".to_string(), + )); + } + Component::Normal(_) | Component::CurDir => { + // Acceptable components. + } + } + } + + // Check declared size before reading body. + let claimed = header.size().map_err(|e| { + RelicarioError::BackupRestore(format!("could not read entry size: {e}")) + })?; + + if claimed > max_uncompressed_bytes { + return Err(RelicarioError::BackupRestore(format!( + "size cap exceeded: entry claims {claimed} bytes (cap {max_uncompressed_bytes})" + ))); + } + + let new_total = cumulative.saturating_add(claimed); + if new_total > max_uncompressed_bytes { + return Err(RelicarioError::BackupRestore(format!( + "size cap exceeded: cumulative size would reach {new_total} bytes (cap {max_uncompressed_bytes})" + ))); + } + + // Read the file body. + let mut body = Vec::with_capacity(claimed as usize); + entry.read_to_end(&mut body).map_err(|e| { + RelicarioError::BackupRestore(format!("failed to read entry body: {e}")) + })?; + + cumulative += body.len() as u64; + + result.push((path, body)); + } + + Ok(result) +} diff --git a/crates/relicario-core/tests/safe_unpack.rs b/crates/relicario-core/tests/safe_unpack.rs new file mode 100644 index 0000000..d73add5 --- /dev/null +++ b/crates/relicario-core/tests/safe_unpack.rs @@ -0,0 +1,187 @@ +use std::path::PathBuf; +use tar::{Builder, Header, EntryType}; +use relicario_core::safe_unpack_git_archive; + +/// Craft a raw POSIX ustar tar with a single entry using the given raw path bytes. +/// The tar crate's `Builder` sanitises paths, so we write the 512-byte header +/// manually to produce truly malicious archives. +fn raw_tar_with_path(raw_path: &[u8], content: &[u8]) -> Vec { + let mut buf = vec![0u8; 512]; // one header block + + // Bytes 0-99: name field (null-padded) + let name_len = raw_path.len().min(100); + buf[..name_len].copy_from_slice(&raw_path[..name_len]); + + // Bytes 100-107: mode = "0000644\0" + buf[100..108].copy_from_slice(b"0000644\0"); + + // Bytes 108-115: uid + buf[108..116].copy_from_slice(b"0000000\0"); + + // Bytes 116-123: gid + buf[116..124].copy_from_slice(b"0000000\0"); + + // Bytes 124-135: size (octal, 11 digits + null) + let size_str = format!("{:011o}\0", content.len()); + buf[124..136].copy_from_slice(size_str.as_bytes()); + + // Bytes 136-147: mtime + buf[136..148].copy_from_slice(b"00000000000\0"); + + // Bytes 148-155: checksum placeholder (spaces during compute) + buf[148..156].copy_from_slice(b" "); + + // Byte 156: typeflag = '0' (regular file) + buf[156] = b'0'; + + // Bytes 257-262: magic "ustar\0" + buf[257..263].copy_from_slice(b"ustar\0"); + // Bytes 263-264: version "00" + buf[263..265].copy_from_slice(b"00"); + + // Compute checksum (sum of all bytes, checksum field treated as spaces). + let checksum: u32 = buf.iter().map(|&b| b as u32).sum(); + let cksum_str = format!("{:06o}\0 ", checksum); + buf[148..156].copy_from_slice(cksum_str.as_bytes()); + + // Append padded content blocks. + let mut out = buf; + if !content.is_empty() { + out.extend_from_slice(content); + // Pad to 512-byte boundary. + let remainder = content.len() % 512; + if remainder != 0 { + out.extend(std::iter::repeat(0u8).take(512 - remainder)); + } + } + + // Two zero blocks = end-of-archive. + out.extend(std::iter::repeat(0u8).take(1024)); + out +} + +/// Build a tar with a raw symlink entry (typeflag = '2'). +fn raw_symlink_tar() -> Vec { + let mut buf = vec![0u8; 512]; + + // name + buf[..9].copy_from_slice(b"evil_link"); + // mode + buf[100..108].copy_from_slice(b"0000755\0"); + // uid/gid + buf[108..116].copy_from_slice(b"0000000\0"); + buf[116..124].copy_from_slice(b"0000000\0"); + // size = 0 + buf[124..136].copy_from_slice(b"00000000000\0"); + // mtime + buf[136..148].copy_from_slice(b"00000000000\0"); + // checksum placeholder + buf[148..156].copy_from_slice(b" "); + // typeflag = '2' (symlink) + buf[156] = b'2'; + // linkname + let target = b"/etc/passwd"; + buf[157..157 + target.len()].copy_from_slice(target); + // magic + buf[257..263].copy_from_slice(b"ustar\0"); + buf[263..265].copy_from_slice(b"00"); + + // Compute checksum. + let checksum: u32 = buf.iter().map(|&b| b as u32).sum(); + let cksum_str = format!("{:06o}\0 ", checksum); + buf[148..156].copy_from_slice(cksum_str.as_bytes()); + + let mut out = buf; + out.extend(std::iter::repeat(0u8).take(1024)); // end-of-archive + out +} + +fn build_normal_tar() -> Vec { + let mut buf = Vec::new(); + { + let mut builder = Builder::new(&mut buf); + let content = b"hello"; + let mut header = Header::new_gnu(); + header.set_entry_type(EntryType::Regular); + header.set_size(content.len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, "subdir/hello.txt", content.as_ref()) + .unwrap(); + builder.finish().unwrap(); + } + buf +} + +fn build_oversize_tar() -> Vec { + // Actual 2048-byte body; test will use cap=1024 + let mut buf = Vec::new(); + { + let mut builder = Builder::new(&mut buf); + let content = vec![0u8; 2048]; + let mut header = Header::new_gnu(); + header.set_entry_type(EntryType::Regular); + header.set_size(content.len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, "bigfile.bin", content.as_slice()) + .unwrap(); + builder.finish().unwrap(); + } + buf +} + +#[test] +fn restore_rejects_path_traversal() { + // Craft a tar with "../../escaped.txt" using raw bytes (Builder sanitises paths). + let bytes = raw_tar_with_path(b"../../escaped.txt", b"evil content"); + let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("path traversal") || msg.contains(".."), + "got: {msg}" + ); +} + +#[test] +fn restore_rejects_absolute_path() { + // Craft a tar with "/etc/escaped.txt" using raw bytes. + let bytes = raw_tar_with_path(b"/etc/escaped.txt", b"evil content"); + let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("path traversal") || msg.contains("absolute"), + "got: {msg}" + ); +} + +#[test] +fn restore_rejects_symlink() { + let bytes = raw_symlink_tar(); + let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("symlink") || msg.contains("link"), + "got: {msg}" + ); +} + +#[test] +fn restore_rejects_size_bomb() { + let bytes = build_oversize_tar(); // actual 2048-byte entry + let err = safe_unpack_git_archive(&bytes, 1024).unwrap_err(); // cap = 1024 bytes + let msg = format!("{err:#}"); + assert!( + msg.contains("size") || msg.contains("cap") || msg.contains("too large"), + "got: {msg}" + ); +} + +#[test] +fn restore_accepts_normal_files() { + let buf = build_normal_tar(); + let entries = safe_unpack_git_archive(&buf, 1024 * 1024).expect("happy path"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].0, PathBuf::from("subdir/hello.txt")); + assert_eq!(entries[0].1, b"hello"); +} From 95d1ff833c634cfd03f3fb43d356cc5ceefc87c9 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 2 May 2026 18:50:44 -0400 Subject: [PATCH 5/7] docs: enumerate RELICARIO_* env vars in SECURITY.md (audit S3) Adds a "Configuration env vars" section listing every RELICARIO_* variable read by production code, with purpose and trust boundary. Splits user-facing vars from debug-only ones (cfg(debug_assertions)) to make the attack surface explicit for security reviewers. Co-Authored-By: Claude Haiku 4.5 --- docs/SECURITY.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index d99a64b..050cbc9 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -59,3 +59,34 @@ Without device authentication, access control is transport-layer only: Device registration was optional before v0.4.0. With device auth enabled, all commits must be signed by a registered device. + +## Configuration env vars + +Relicario reads the following environment variables. Each is a trust +boundary: an attacker who can set them in the user's environment can +influence Relicario's behavior. They are listed here for security +reviewers to audit the surface in one place. + +### User-facing (active in all builds) + +| Variable | Purpose | Trust | +|---|---|---| +| `RELICARIO_IMAGE` | Override the reference-image JPEG path used during vault unlock. | Trusted: filesystem path under the user's control. Read-only; its bytes feed `imgsecret::extract_secret`. | +| `RELICARIO_GITEA_URL` | Gitea API base URL for `relicario device add`. Equivalent to `--gitea-url`. | Trusted: HTTPS URL. Used only in the device-add code path. | +| `RELICARIO_GITEA_TOKEN` | Gitea personal-access token. Equivalent to `--gitea-token`. | **Secret**: anyone who can read this env var can manage the user's deploy keys via the Gitea API. The CLI never logs it. | +| `RELICARIO_GITEA_OWNER` | Gitea repository owner (e.g. `alee`). Equivalent to `--owner`. | Trusted: opaque string. | +| `RELICARIO_GITEA_REPO` | Gitea repository name (e.g. `vault`). Equivalent to `--repo`. | Trusted: opaque string. | + +### Debug-only (compiled out of `cargo build --release`) + +The following variables are gated behind `cfg(debug_assertions)` and +are **no-ops** in release builds. The env-var lookup is removed by the +optimiser from any binary built without debug assertions (i.e. the +standard `--release` profile). + +| Variable | Purpose | +|---|---| +| `RELICARIO_NO_GROUPS_CACHE` | Suppress the plaintext `groups.cache` write. Developer debugging tool for the cache logic. | +| `RELICARIO_TEST_PASSPHRASE` | Bypass the `rpassword` prompt during integration tests. | +| `RELICARIO_TEST_ITEM_SECRET` | Bypass the `rpassword` prompt for item-secret fields during integration tests. | +| `RELICARIO_TEST_BACKUP_PASSPHRASE` | Bypass the `rpassword` prompt for backup export/restore passphrases during integration tests. | From 006e67c36104b5a71bbdf5fb40a6636b0f0645aa Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 2 May 2026 18:51:15 -0400 Subject: [PATCH 6/7] fix(cli): cfg-gate RELICARIO_NO_GROUPS_CACHE to debug builds (audit S3) The groups-cache opt-out is a developer debugging knob, not a user-facing config. Gating the env-var lookup behind cfg!(debug_assertions) makes release builds ignore the variable; the optimiser removes the lookup entirely, so the variable name doesn't appear in release binary strings output. Doc-comments updated to reflect the new behaviour. Co-Authored-By: Claude Haiku 4.5 --- crates/relicario-cli/src/helpers.rs | 10 ++++++---- crates/relicario-cli/src/main.rs | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/relicario-cli/src/helpers.rs b/crates/relicario-cli/src/helpers.rs index 5bc36a0..6991ea4 100644 --- a/crates/relicario-cli/src/helpers.rs +++ b/crates/relicario-cli/src/helpers.rs @@ -88,19 +88,21 @@ fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } } /// /// **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. +/// 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. A no-op if `RELICARIO_NO_GROUPS_CACHE` is set. +/// 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 std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() { + if cfg!(debug_assertions) && std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() { return Ok(()); } let path = groups_cache_path(vault_dir); diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 3c4029a..438db21 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -170,7 +170,7 @@ enum Commands { /// /// For `--group ` autocomplete, the bash/zsh/fish scripts read /// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file, - /// which the CLI refreshes on every manifest read. Set + /// which the CLI refreshes on every manifest read. In debug builds, set /// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion /// will fall back to no value enumeration). /// From 4d02a50cc8f93ade42d1464b959632720e8f1a2c Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 2 May 2026 19:32:45 -0400 Subject: [PATCH 7/7] chore(core): fix pre-existing clippy warnings (-D warnings gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/relicario-cli/src/device.rs | 3 ++ crates/relicario-cli/src/gitea.rs | 3 ++ crates/relicario-cli/src/helpers.rs | 1 + crates/relicario-cli/src/main.rs | 22 ++++++++------ crates/relicario-cli/src/session.rs | 2 +- crates/relicario-cli/tests/attachments.rs | 2 +- crates/relicario-cli/tests/common/mod.rs | 2 ++ crates/relicario-core/src/crypto.rs | 2 +- crates/relicario-core/src/imgsecret.rs | 32 ++++++++++---------- crates/relicario-core/src/item_types/totp.rs | 7 ++--- crates/relicario-core/src/time.rs | 2 +- crates/relicario-core/tests/safe_unpack.rs | 6 ++-- 12 files changed, 46 insertions(+), 38 deletions(-) diff --git a/crates/relicario-cli/src/device.rs b/crates/relicario-cli/src/device.rs index 6ec92e5..25cf775 100644 --- a/crates/relicario-cli/src/device.rs +++ b/crates/relicario-cli/src/device.rs @@ -91,6 +91,7 @@ pub fn store_device_keys( } /// Load the signing private key for a device. +#[allow(dead_code)] pub fn load_signing_key(name: &str) -> Result> { let path = device_dir(name)?.join("signing.key"); let key = fs::read_to_string(&path) @@ -99,6 +100,7 @@ pub fn load_signing_key(name: &str) -> Result> { } /// Load the deploy private key for a device. +#[allow(dead_code)] pub fn load_deploy_key(name: &str) -> Result> { let path = device_dir(name)?.join("deploy.key"); let key = fs::read_to_string(&path) @@ -115,6 +117,7 @@ pub fn load_gitea_key_id(name: &str) -> Result { } /// Delete the local key directory for a device. +#[allow(dead_code)] pub fn delete_device_keys(name: &str) -> Result<()> { let dir = device_dir(name)?; if dir.exists() { diff --git a/crates/relicario-cli/src/gitea.rs b/crates/relicario-cli/src/gitea.rs index 29173b4..2c26993 100644 --- a/crates/relicario-cli/src/gitea.rs +++ b/crates/relicario-cli/src/gitea.rs @@ -21,7 +21,9 @@ struct CreateKeyRequest<'a> { #[derive(Debug, Deserialize)] pub struct DeployKey { pub id: u64, + #[allow(dead_code)] pub title: String, + #[allow(dead_code)] pub key: String, } @@ -89,6 +91,7 @@ impl GiteaClient { } /// List all deploy keys. + #[allow(dead_code)] pub fn list_deploy_keys(&self) -> Result> { let url = format!( "{}/repos/{}/{}/keys", diff --git a/crates/relicario-cli/src/helpers.rs b/crates/relicario-cli/src/helpers.rs index 6991ea4..d3d373d 100644 --- a/crates/relicario-cli/src/helpers.rs +++ b/crates/relicario-cli/src/helpers.rs @@ -34,6 +34,7 @@ pub fn vault_dir() -> Result { } /// Path to the `.relicario/` configuration directory within the vault. +#[allow(dead_code)] pub fn relicario_dir() -> Result { Ok(vault_dir()?.join(".relicario")) } diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 438db21..6cb1d05 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -540,7 +540,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { }; let carrier = fs::read(&image) .with_context(|| format!("failed to read carrier image {}", image.display()))?; - let stego = imgsecret::embed(&carrier, &*image_secret)?; + let stego = imgsecret::embed(&carrier, &image_secret)?; fs::write(&output, &stego) .with_context(|| format!("failed to write reference image {}", output.display()))?; @@ -550,7 +550,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }; // Derive master key, then persist an empty Manifest + default VaultSettings. - let master_key = derive_master_key(passphrase.as_bytes(), &*image_secret, &salt, ¶ms)?; + let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)?; fs::create_dir_all(&relicario_dir)?; fs::create_dir_all(root.join("items"))?; @@ -645,6 +645,7 @@ fn cmd_add(kind: AddKind) -> Result<()> { // (for attachment-cap settings + writing the encrypted blob alongside // the item). +#[allow(clippy::too_many_arguments)] fn build_login_item( title: Option, username: Option, @@ -860,6 +861,7 @@ fn build_document_item( Ok(item) } +#[allow(clippy::too_many_arguments)] fn build_totp_item( title: Option, issuer: Option, @@ -924,7 +926,7 @@ fn prompt_optional(label: &str) -> Result> { fn parse_month_year(s: &str) -> Result { // Accepts MM/YYYY or MM-YYYY or MM/YY. - let (m_str, y_str) = s.split_once(|c: char| c == '/' || c == '-') + let (m_str, y_str) = s.split_once(['/', '-']) .ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?; let month: u8 = m_str.parse().context("invalid month")?; let year: u16 = if y_str.len() == 2 { @@ -998,12 +1000,12 @@ fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> { if let Some(u) = &l.url { println!("URL: {u}"); } if let Some(t) = &l.totp { if show { - println!("TOTP: {}", data_encoding::BASE32.encode(&*t.secret)); + println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret)); } else { println!("TOTP: **** (use --show to reveal)"); } } - if let Some(p) = &l.password { Some(p.clone()) } else { None } + l.password.clone() } ItemCore::SecureNote(n) => { if show { println!("Body:\n{}", n.body.as_str()); } @@ -1125,8 +1127,8 @@ fn cmd_list( Some(t) => e.r#type == t, None => true, }) - .filter(|e| group_filter.as_ref().map_or(true, |g| e.group.as_deref() == Some(g.as_str()))) - .filter(|e| tag_filter.as_ref().map_or(true, |t| e.tags.iter().any(|x| x == t))) + .filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str()))) + .filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t))) .collect(); entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())); @@ -1135,7 +1137,7 @@ fn cmd_list( return Ok(()); } - println!("{:<16} {:<14} {:<6} {}", "ID", "TYPE", "FAV", "TITLE"); + println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV"); for e in entries { let fav = if e.favorite { " *" } else { "" }; println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title); @@ -1973,7 +1975,7 @@ fn cmd_attachments(query: String) -> Result<()> { let entry = resolve_query(&manifest, &query)?; let item = vault.load_item(&entry.id)?; if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); } - println!("{:<17} {:>12} {:<22} {}", "AID", "SIZE", "MIME", "FILENAME"); + println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME"); for a in &item.attachments { println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename); } @@ -2541,7 +2543,7 @@ fn cmd_device(action: DeviceAction) -> Result<()> { return Ok(()); } - println!("{:<20} {:<20} {}", "NAME", "ADDED", "SIGNING KEY (prefix)"); + println!("{:<20} {:<20} SIGNING KEY (prefix)", "NAME", "ADDED"); println!("{}", "-".repeat(72)); for d in &devices { let marker = if d.name == current { " *" } else { "" }; diff --git a/crates/relicario-cli/src/session.rs b/crates/relicario-cli/src/session.rs index a8a36b7..ec42f1b 100644 --- a/crates/relicario-cli/src/session.rs +++ b/crates/relicario-cli/src/session.rs @@ -50,7 +50,7 @@ impl UnlockedVault { let master_key = derive_master_key( passphrase.as_bytes(), - &*image_secret, + &image_secret, &salt, ¶ms, )?; diff --git a/crates/relicario-cli/tests/attachments.rs b/crates/relicario-cli/tests/attachments.rs index 2deaa2a..fd83361 100644 --- a/crates/relicario-cli/tests/attachments.rs +++ b/crates/relicario-cli/tests/attachments.rs @@ -68,7 +68,7 @@ fn detach_removes_attachment_and_blob() { // Encrypted blob file is gone. let blob_path = v.path() .join("attachments") - .join(stdout.lines().nth(1).is_some().then_some("").unwrap_or("")); + .join(""); let item_attach_dir = std::fs::read_dir(v.path().join("attachments")) .unwrap().next().unwrap().unwrap().path(); let blob = item_attach_dir.join(format!("{aid}.enc")); diff --git a/crates/relicario-cli/tests/common/mod.rs b/crates/relicario-cli/tests/common/mod.rs index 1e5ed10..4bfbb0d 100644 --- a/crates/relicario-cli/tests/common/mod.rs +++ b/crates/relicario-cli/tests/common/mod.rs @@ -78,6 +78,7 @@ impl TestVault { cmd.output().unwrap() } + #[allow(dead_code)] pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &str) -> std::process::Output { let mut cmd = Command::cargo_bin("relicario").unwrap(); cmd.current_dir(self.dir.path()) @@ -91,6 +92,7 @@ impl TestVault { cmd.output().unwrap() } + #[allow(dead_code)] pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output { let mut cmd = Command::cargo_bin("relicario").unwrap(); cmd.current_dir(self.dir.path()) diff --git a/crates/relicario-core/src/crypto.rs b/crates/relicario-core/src/crypto.rs index c895ffb..d28b0bb 100644 --- a/crates/relicario-core/src/crypto.rs +++ b/crates/relicario-core/src/crypto.rs @@ -408,7 +408,7 @@ mod tests { blob.extend_from_slice(&[0u8; 16]); let key = Zeroizing::new([0u8; 32]); - let err = decrypt(&*key, &blob).expect_err("v1 blob should fail decrypt"); + let err = decrypt(&key, &blob).expect_err("v1 blob should fail decrypt"); match err { RelicarioError::UnsupportedFormatVersion { found, expected } => { assert_eq!(found, 0x01); diff --git a/crates/relicario-core/src/imgsecret.rs b/crates/relicario-core/src/imgsecret.rs index ab30a5a..60030ae 100644 --- a/crates/relicario-core/src/imgsecret.rs +++ b/crates/relicario-core/src/imgsecret.rs @@ -83,7 +83,7 @@ const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len() /// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret. /// ceil(256 / 12) = 22 blocks per copy. -const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22 +const BLOCKS_PER_COPY: usize = SECRET_BITS.div_ceil(BITS_PER_BLOCK); // 22 /// Mid-frequency DCT coefficient positions for embedding, specified as /// (row, col) indices into the 8x8 DCT coefficient matrix. @@ -302,9 +302,9 @@ fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> { return None; } let mut block = [[0.0f64; 8]; 8]; - for row in 0..8 { - for col in 0..8 { - block[row][col] = y.get(px + col, py + row); + for (row, block_row) in block.iter_mut().enumerate() { + for (col, cell) in block_row.iter_mut().enumerate() { + *cell = y.get(px + col, py + row); } } Some(block) @@ -323,9 +323,9 @@ fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64 fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) { let start_x = region.x_offset + bx * BLOCK_SIZE; let start_y = region.y_offset + by * BLOCK_SIZE; - for row in 0..8 { - for col in 0..8 { - y.set(start_x + col, start_y + row, block[row][col]); + for (row, block_row) in block.iter().enumerate() { + for (col, &cell) in block_row.iter().enumerate() { + y.set(start_x + col, start_y + row, cell); } } } @@ -349,17 +349,17 @@ fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, blo /// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0. fn dct1d(input: &[f64; 8]) -> [f64; 8] { let mut output = [0.0f64; 8]; - for k in 0..8 { + for (k, out_k) in output.iter_mut().enumerate() { let ck = if k == 0 { (1.0 / 8.0_f64).sqrt() } else { (2.0 / 8.0_f64).sqrt() }; let mut sum = 0.0; - for i in 0..8 { - sum += input[i] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos(); + for (i, &x) in input.iter().enumerate() { + sum += x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos(); } - output[k] = ck * sum; + *out_k = ck * sum; } output } @@ -370,17 +370,17 @@ fn dct1d(input: &[f64; 8]) -> [f64; 8] { /// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16) fn idct1d(input: &[f64; 8]) -> [f64; 8] { let mut output = [0.0f64; 8]; - for i in 0..8 { + for (i, out_i) in output.iter_mut().enumerate() { let mut sum = 0.0; - for k in 0..8 { + for (k, &x) in input.iter().enumerate() { let ck = if k == 0 { (1.0 / 8.0_f64).sqrt() } else { (2.0 / 8.0_f64).sqrt() }; - sum += ck * input[k] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos(); + sum += ck * x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos(); } - output[i] = sum; + *out_i = sum; } output } @@ -501,7 +501,7 @@ fn bytes_to_bits(bytes: &[u8]) -> Vec { /// /// Pads the last byte with zeros if the bit count is not a multiple of 8. fn bits_to_bytes(bits: &[u8]) -> Vec { - let mut bytes = Vec::with_capacity((bits.len() + 7) / 8); + let mut bytes = Vec::with_capacity(bits.len().div_ceil(8)); for chunk in bits.chunks(8) { let mut byte = 0u8; for (i, &bit) in chunk.iter().enumerate() { diff --git a/crates/relicario-core/src/item_types/totp.rs b/crates/relicario-core/src/item_types/totp.rs index 9fefd0e..83c1052 100644 --- a/crates/relicario-core/src/item_types/totp.rs +++ b/crates/relicario-core/src/item_types/totp.rs @@ -52,18 +52,15 @@ pub enum TotpAlgorithm { Sha512, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum TotpKind { + #[default] Totp, Hotp { counter: u64 }, Steam, } -impl Default for TotpKind { - fn default() -> Self { TotpKind::Totp } -} - /// Compute a TOTP/Steam code for `config` at the given Unix timestamp. /// /// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`. diff --git a/crates/relicario-core/src/time.rs b/crates/relicario-core/src/time.rs index 979df76..7ca263f 100644 --- a/crates/relicario-core/src/time.rs +++ b/crates/relicario-core/src/time.rs @@ -19,7 +19,7 @@ impl MonthYear { if !(1..=12).contains(&month) { return Err("month must be 1..=12"); } - if year < 2000 || year > 2099 { + if !(2000..=2099).contains(&year) { return Err("year must be 2000..=2099"); } Ok(Self { month, year }) diff --git a/crates/relicario-core/tests/safe_unpack.rs b/crates/relicario-core/tests/safe_unpack.rs index d73add5..d5ba73c 100644 --- a/crates/relicario-core/tests/safe_unpack.rs +++ b/crates/relicario-core/tests/safe_unpack.rs @@ -51,12 +51,12 @@ fn raw_tar_with_path(raw_path: &[u8], content: &[u8]) -> Vec { // Pad to 512-byte boundary. let remainder = content.len() % 512; if remainder != 0 { - out.extend(std::iter::repeat(0u8).take(512 - remainder)); + out.extend(vec![0u8; 512 - remainder]); } } // Two zero blocks = end-of-archive. - out.extend(std::iter::repeat(0u8).take(1024)); + out.extend(vec![0u8; 1024]); out } @@ -92,7 +92,7 @@ fn raw_symlink_tar() -> Vec { buf[148..156].copy_from_slice(cksum_str.as_bytes()); let mut out = buf; - out.extend(std::iter::repeat(0u8).take(1024)); // end-of-archive + out.extend(vec![0u8; 1024]); // end-of-archive out }