use assert_cmd::prelude::*; use predicates::prelude::PredicateBooleanExt; use std::process::Command; fn relicario(dir: &tempfile::TempDir) -> Command { let mut cmd = Command::cargo_bin("relicario").unwrap(); cmd.current_dir(dir.path()); cmd } /// Full round-trip: init a vault with a key file, then add + get an item using /// RELICARIO_KEYFILE. #[test] fn init_keyfile_then_unlock_keyfile_round_trips() { let dir = tempfile::tempdir().unwrap(); // init with a key file relicario(&dir) .args(["init", "--key-file", "vault.relkey"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") .assert() .success(); // add an item using the generated key file relicario(&dir) .args(["add", "login", "--title", "gh", "--username", "octocat", "--password", "hunter2"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") .env("RELICARIO_KEYFILE", dir.path().join("vault.relkey")) .assert() .success(); // get the item — distinctive username must appear in stdout relicario(&dir) .args(["get", "gh", "--show"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") .env("RELICARIO_KEYFILE", dir.path().join("vault.relkey")) .assert() .success() .stdout(predicates::str::contains("octocat")); } /// Unit-level: init with --key-file writes the .relkey armor, records /// second_factor=keyfile in params.json, and gitignores the key file. #[test] fn init_keyfile_writes_relkey_and_keyfile_params() { let dir = tempfile::tempdir().unwrap(); relicario(&dir) .args(["init", "--key-file", "vault.relkey"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") .assert() .success(); // .relkey exists and is armored let relkey = std::fs::read_to_string(dir.path().join("vault.relkey")).unwrap(); assert!( relkey.starts_with("relicario-keyfile-v1\n"), "relkey must be armored: {relkey}" ); // params.json records the keyfile hint (parse — robust to pretty-print spacing) let params: serde_json::Value = serde_json::from_str( &std::fs::read_to_string(dir.path().join(".relicario/params.json")).unwrap(), ) .unwrap(); assert_eq!(params["second_factor"], "keyfile"); // SECURITY: the in-the-clear .relkey must be gitignored let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap(); assert!( gitignore.contains("vault.relkey"), ".relkey must be gitignored: {gitignore}" ); } /// SECURITY INVARIANT: the in-the-clear .relkey second factor must NEVER enter /// the git tree — not in the init commit, and not even after an aggressive `git add -A`. /// The remote/server must only ever receive opaque ciphertext, never the secret. #[test] fn relkey_secret_never_enters_git_tree() { let dir = tempfile::tempdir().unwrap(); relicario(&dir) .args(["init", "--key-file", "vault.relkey"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") .assert() .success(); let git_tracked = |label: &str| { let out = Command::new("git") .current_dir(dir.path()) .args(["ls-files"]) .output() .expect("run git ls-files"); let files = String::from_utf8_lossy(&out.stdout).into_owned(); assert!( !files.contains("vault.relkey"), "{label}: key file leaked into git tree:\n{files}" ); }; // Not in the init commit. git_tracked("after init commit"); // Survives an aggressive add -A (gitignore must hold). let status = Command::new("git") .current_dir(dir.path()) .args(["add", "-A"]) .status() .expect("run git add -A"); assert!(status.success(), "git add -A failed"); git_tracked("after git add -A"); } /// init with no second factor must fail with a clear, actionable error. #[test] fn init_without_a_factor_fails() { let dir = tempfile::tempdir().unwrap(); relicario(&dir) .args(["init"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") .assert() .failure() .stderr(predicates::str::contains("either --image")); } /// recovery-qr generate must work for a key-file vault (was broken: it hard-coded image extraction). #[test] fn recovery_qr_generate_works_for_keyfile_vault() { let dir = tempfile::tempdir().unwrap(); relicario(&dir) .args(["init", "--key-file", "vault.relkey"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") .assert() .success(); relicario(&dir) .args(["recovery-qr", "generate"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") .env("RELICARIO_KEYFILE", dir.path().join("vault.relkey")) .assert() .success() .stdout(predicates::str::contains("Recovery QR generated")); } /// `backup export --include-image` on a key-file vault must fail with a clear message. /// (Key-file vaults have no reference JPEG to bundle.) #[test] fn backup_include_image_fails_on_keyfile_vault() { let dir = tempfile::tempdir().unwrap(); relicario(&dir) .args(["init", "--key-file", "vault.relkey"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") .assert() .success(); let out_path = dir.path().join("vault.relbak"); relicario(&dir) .args(["backup", "export", out_path.to_str().unwrap(), "--include-image"]) .env("RELICARIO_TEST_BACKUP_PASSPHRASE", "strong-backup-pass-test-2026") .assert() .failure() .stderr(predicates::str::contains("not applicable to a key-file vault")); } /// A malformed key file (garbage bytes) must fail with "invalid key file" in stderr. /// This is the `keyfile_decode` → `.context("invalid key file")` path. #[test] fn malformed_keyfile_emits_invalid_key_file() { let dir = tempfile::tempdir().unwrap(); relicario(&dir) .args(["init", "--key-file", "vault.relkey"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") .assert() .success(); // Overwrite the key file with garbage — keyfile_decode will reject the header. std::fs::write(dir.path().join("vault.relkey"), b"not a valid keyfile\n").unwrap(); relicario(&dir) .args(["list"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") .env("RELICARIO_KEYFILE", dir.path().join("vault.relkey")) .assert() .failure() .stderr(predicates::str::contains("invalid key file")); } /// A well-formed but WRONG key file (valid armor, wrong 32-byte secret) must produce /// a decryption failure — NOT "invalid key file". There must be no oracle about /// which factor was wrong. #[test] fn wrong_secret_emits_decryption_failed_not_invalid_key_file() { // Vault A: the target vault. let dir_a = tempfile::tempdir().unwrap(); relicario(&dir_a) .args(["init", "--key-file", "vault.relkey"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") .assert() .success(); // Add an item so there is encrypted manifest content to exercise. relicario(&dir_a) .args(["add", "login", "--title", "test", "--username", "u", "--password", "p"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") .env("RELICARIO_KEYFILE", dir_a.path().join("vault.relkey")) .assert() .success(); // Vault B: a completely separate vault with a different random 32-byte secret. let dir_b = tempfile::tempdir().unwrap(); relicario(&dir_b) .args(["init", "--key-file", "vault.relkey"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") .assert() .success(); // Open vault A with vault B's key file: armor parses fine, but the derived // master key is wrong → manifest decryption fails with RelicarioError::Decrypt. relicario(&dir_a) .args(["list"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") .env("RELICARIO_KEYFILE", dir_b.path().join("vault.relkey")) .assert() .failure() .stderr(predicates::str::contains("decryption failed")) .stderr(predicates::str::contains("invalid key file").not()); } /// --image and --key-file are mutually exclusive (clap rejects before the handler). #[test] fn init_with_both_factors_fails() { let dir = tempfile::tempdir().unwrap(); relicario(&dir) .args(["init", "--image", "carrier.jpg", "--key-file", "vault.relkey"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") .assert() .failure(); }