use std::path::PathBuf; use std::process::Command; struct Sandbox { path: PathBuf, } impl Sandbox { fn new(name: &str) -> Self { let path = std::env::temp_dir().join(format!("doot-test-{}", name)); if path.exists() { std::fs::remove_dir_all(&path).unwrap(); } std::fs::create_dir_all(&path).unwrap(); Self { path } } fn run(&self, args: &[&str]) -> std::process::Output { let doot = env!("CARGO_BIN_EXE_doot"); Command::new(doot) .args(args) .env("DOOT_HOME", &self.path) .env("DOOT_TEST_MODE", "1") .output() .expect("failed to run doot") } fn config_dir(&self) -> PathBuf { self.path.join(".config/doot") } fn state_dir(&self) -> PathBuf { self.path.join(".local/state/doot") } fn config_file(&self) -> PathBuf { self.config_dir().join("doot.doot") } fn write_config(&self, content: &str) { std::fs::create_dir_all(self.config_dir()).unwrap(); std::fs::write(self.config_file(), content).unwrap(); } fn write_source(&self, path: &str, content: &str) { let full_path = self.config_dir().join(path); if let Some(parent) = full_path.parent() { std::fs::create_dir_all(parent).unwrap(); } std::fs::write(full_path, content).unwrap(); } fn is_symlink(&self, path: &PathBuf) -> bool { path.is_symlink() } fn symlink_target(&self, path: &PathBuf) -> Option { std::fs::read_link(path).ok() } } impl Drop for Sandbox { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.path); } } #[test] fn test_init_creates_structure() { let sandbox = Sandbox::new("init"); let output = sandbox.run(&["init"]); assert!(output.status.success(), "init failed: {:?}", output); assert!(sandbox.config_file().exists(), "config file not created"); assert!( sandbox.config_dir().join("config").exists(), "config dir not created" ); assert!( sandbox.state_dir().join("backups").exists(), "backups dir not created" ); assert!( sandbox.state_dir().join("snapshots").exists(), "snapshots dir not created" ); } #[test] fn test_check_valid_config() { let sandbox = Sandbox::new("check-valid"); sandbox.write_config( r#" package: "ripgrep" package: "fd" "#, ); let output = sandbox.run(&["check"]); assert!(output.status.success(), "check failed: {:?}", output); } #[test] fn test_apply_dry_run() { let sandbox = Sandbox::new("apply-dry"); sandbox.write_config( r#" dotfile: source = "config/test.conf" target = "~/.config/test/test.conf" "#, ); sandbox.write_source("config/test.conf", "test content"); let output = sandbox.run(&["apply", "-n"]); assert!(output.status.success(), "apply -n failed: {:?}", output); let target = sandbox.path.join(".config/test/test.conf"); assert!(!target.exists(), "dry run should not create files"); } #[test] fn test_apply_creates_symlink() { let sandbox = Sandbox::new("apply-symlink"); sandbox.write_config( r#" dotfile: source = "config/app.conf" target = "~/.config/app/app.conf" deploy = "link" "#, ); sandbox.write_source("config/app.conf", "app config content"); let output = sandbox.run(&["apply"]); assert!(output.status.success(), "apply failed: {:?}", output); let target = sandbox.path.join(".config/app/app.conf"); assert!(sandbox.is_symlink(&target), "target should be symlink"); let expected_source = sandbox.config_dir().join("config/app.conf"); assert_eq!( sandbox.symlink_target(&target), Some(expected_source), "symlink should point to source" ); } #[test] fn test_apply_unchanged_on_rerun() { let sandbox = Sandbox::new("apply-unchanged"); sandbox.write_config( "dotfile:\n source = \"config/app.conf\"\n target = \"~/.config/app/app.conf\"\n deploy = \"link\"\n", ); sandbox.write_source("config/app.conf", "content"); let first = sandbox.run(&["apply"]); assert!(first.status.success(), "first apply failed"); let second = sandbox.run(&["apply"]); assert!(second.status.success(), "second apply failed"); // Second apply should succeed (symlink already exists and points correctly) let target = sandbox.path.join(".config/app/app.conf"); assert!( target.is_symlink(), "target should still be symlink after second apply" ); } #[test] fn test_apply_creates_copy() { let sandbox = Sandbox::new("apply-copy"); sandbox.write_config( r#" dotfile: source = "config/app.conf" target = "~/.config/app/app.conf" "#, ); sandbox.write_source("config/app.conf", "app config content"); let output = sandbox.run(&["apply"]); assert!(output.status.success(), "apply failed: {:?}", output); let target = sandbox.path.join(".config/app/app.conf"); assert!(target.exists(), "target should exist"); assert!( !target.is_symlink(), "target should be a copy, not a symlink" ); let content = std::fs::read_to_string(&target).unwrap(); assert_eq!(content, "app config content", "content should match source"); } #[test] fn test_apply_copy_unchanged_on_rerun() { let sandbox = Sandbox::new("apply-copy-unchanged"); sandbox.write_config( "dotfile:\n source = \"config/app.conf\"\n target = \"~/.config/app/app.conf\"\n", ); sandbox.write_source("config/app.conf", "content"); let first = sandbox.run(&["apply"]); assert!(first.status.success(), "first apply failed"); let second = sandbox.run(&["apply"]); assert!(second.status.success(), "second apply failed"); let target = sandbox.path.join(".config/app/app.conf"); assert!(target.exists(), "target should exist after second apply"); assert!(!target.is_symlink(), "target should still be a copy"); } #[test] fn test_status_shows_state() { let sandbox = Sandbox::new("status"); sandbox.write_config( r#" dotfile: source = "config/app.conf" target = "~/.config/app/app.conf" "#, ); sandbox.write_source("config/app.conf", "content"); sandbox.run(&["apply"]); let output = sandbox.run(&["status"]); assert!(output.status.success(), "status failed: {:?}", output); } #[test] fn test_snapshot_and_rollback() { let sandbox = Sandbox::new("snapshot"); sandbox.write_config( r#" dotfile: source = "config/app.conf" target = "~/.config/app/app.conf" "#, ); sandbox.write_source("config/app.conf", "v1"); sandbox.run(&["apply"]); let snap_output = sandbox.run(&["snapshot", "v1"]); assert!( snap_output.status.success(), "snapshot failed: {:?}", snap_output ); let snapshot_file = sandbox.state_dir().join("snapshots/v1.json"); assert!(snapshot_file.exists(), "snapshot file not created"); } #[test] fn test_dotfile_with_when_condition() { let sandbox = Sandbox::new("conditional"); // Test that 'when' condition works - only deploy if condition is true let config = r#"dotfile: source = "config/test.conf" target = "~/.config/test.conf" when = true "#; sandbox.write_config(config); sandbox.write_source("config/test.conf", "test content"); let output = sandbox.run(&["apply"]); assert!(output.status.success(), "apply failed: {:?}", output); let target = sandbox.path.join(".config/test.conf"); assert!( target.exists(), "file should be deployed when condition is true" ); } #[test] fn test_dotfile_when_false_skips() { let sandbox = Sandbox::new("when-false"); let config = r#"dotfile: source = "config/skip.conf" target = "~/.config/skip.conf" when = false "#; sandbox.write_config(config); sandbox.write_source("config/skip.conf", "should not deploy"); let output = sandbox.run(&["apply"]); assert!(output.status.success(), "apply failed: {:?}", output); let target = sandbox.path.join(".config/skip.conf"); assert!( !target.exists(), "file should NOT be deployed when condition is false" ); } #[test] fn test_diff_shows_changes() { let sandbox = Sandbox::new("diff"); sandbox.write_config( r#" dotfile: source = "config/app.conf" target = "~/.config/app/app.conf" "#, ); sandbox.write_source("config/app.conf", "new content"); let target_dir = sandbox.path.join(".config/app"); std::fs::create_dir_all(&target_dir).unwrap(); std::fs::write(target_dir.join("app.conf"), "old content").unwrap(); let output = sandbox.run(&["diff"]); assert!(output.status.success(), "diff failed: {:?}", output); } // ── Keygen tests ── #[test] fn test_keygen_creates_identity_and_recipient() { let sandbox = Sandbox::new("keygen"); let output = sandbox.run(&["keygen", "--force"]); assert!(output.status.success(), "keygen failed: {:?}", output); let identity_file = sandbox.config_dir().join("identity.txt"); let recipient_file = sandbox.config_dir().join("recipient.txt"); assert!(identity_file.exists(), "identity.txt not created"); assert!(recipient_file.exists(), "recipient.txt not created"); let identity_content = std::fs::read_to_string(&identity_file).unwrap(); assert!( identity_content.contains("AGE-SECRET-KEY-"), "identity should contain secret key" ); assert!( identity_content.contains("# public key: age1"), "identity should contain public key comment" ); let recipient_content = std::fs::read_to_string(&recipient_file).unwrap(); assert!( recipient_content.starts_with("age1"), "recipient.txt should contain public key" ); // stdout should print the public key let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.trim().starts_with("age1"), "stdout should print public key" ); // Check identity file permissions on Unix #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::metadata(&identity_file).unwrap().permissions(); assert_eq!( perms.mode() & 0o777, 0o600, "identity file should have 0600 permissions" ); } } #[test] fn test_keygen_custom_output() { let sandbox = Sandbox::new("keygen-custom"); let custom_path = sandbox.path.join("custom/my-identity.txt"); let output = sandbox.run(&[ "keygen", "--output", custom_path.to_str().unwrap(), "--force", ]); assert!(output.status.success(), "keygen failed: {:?}", output); assert!(custom_path.exists(), "custom identity file not created"); let recipient_file = sandbox.path.join("custom/recipient.txt"); assert!( recipient_file.exists(), "recipient.txt should be created alongside identity" ); } #[test] fn test_keygen_appends_to_existing_recipient() { let sandbox = Sandbox::new("keygen-append"); // Run keygen twice let first = sandbox.run(&["keygen", "--force"]); assert!(first.status.success(), "first keygen failed"); let first_pubkey = String::from_utf8_lossy(&first.stdout).trim().to_string(); let second = sandbox.run(&["keygen", "--force"]); assert!(second.status.success(), "second keygen failed"); let second_pubkey = String::from_utf8_lossy(&second.stdout).trim().to_string(); // recipient.txt should contain the latest public key let recipient_content = std::fs::read_to_string(sandbox.config_dir().join("recipient.txt")).unwrap(); // If keys differ, both should be present if first_pubkey != second_pubkey { assert!( recipient_content.contains(&second_pubkey), "recipient.txt should contain second public key" ); } } // ── Encrypt / Decrypt tests ── #[test] fn test_encrypt_and_decrypt_file() { let sandbox = Sandbox::new("encrypt-decrypt"); // Generate keypair first let keygen_output = sandbox.run(&["keygen", "--force"]); assert!(keygen_output.status.success(), "keygen failed"); let pubkey = String::from_utf8_lossy(&keygen_output.stdout) .trim() .to_string(); // Create a plaintext file let plaintext_file = sandbox.path.join("secret.txt"); std::fs::write(&plaintext_file, "hello world").unwrap(); // Encrypt it let encrypt_output = sandbox.run(&[ "encrypt", plaintext_file.to_str().unwrap(), "--recipient", &pubkey, ]); assert!( encrypt_output.status.success(), "encrypt failed: {:?}", encrypt_output ); let encrypted_file = sandbox.path.join("secret.txt.age"); assert!(encrypted_file.exists(), "encrypted file not created"); // Encrypted file should NOT contain plaintext let encrypted_bytes = std::fs::read(&encrypted_file).unwrap(); assert_ne!( encrypted_bytes, b"hello world", "encrypted file should not be plaintext" ); // Decrypt it let identity_file = sandbox.config_dir().join("identity.txt"); let decrypt_output = sandbox.run(&[ "decrypt", encrypted_file.to_str().unwrap(), "--identity", identity_file.to_str().unwrap(), "--output", sandbox.path.join("decrypted.txt").to_str().unwrap(), ]); assert!( decrypt_output.status.success(), "decrypt failed: {:?}", decrypt_output ); let decrypted = std::fs::read_to_string(sandbox.path.join("decrypted.txt")).unwrap(); assert_eq!(decrypted, "hello world", "decrypted content should match"); } #[test] fn test_encrypt_decrypt_to_stdout() { let sandbox = Sandbox::new("decrypt-stdout"); let keygen_output = sandbox.run(&["keygen", "--force"]); assert!(keygen_output.status.success()); let pubkey = String::from_utf8_lossy(&keygen_output.stdout) .trim() .to_string(); let plaintext_file = sandbox.path.join("data.txt"); std::fs::write(&plaintext_file, "secret data").unwrap(); let encrypt_output = sandbox.run(&[ "encrypt", plaintext_file.to_str().unwrap(), "--recipient", &pubkey, ]); assert!(encrypt_output.status.success()); let encrypted_file = sandbox.path.join("data.txt.age"); let identity_file = sandbox.config_dir().join("identity.txt"); // Decrypt to stdout (no --output) let decrypt_output = sandbox.run(&[ "decrypt", encrypted_file.to_str().unwrap(), "--identity", identity_file.to_str().unwrap(), ]); assert!( decrypt_output.status.success(), "decrypt to stdout failed: {:?}", decrypt_output ); let stdout = String::from_utf8_lossy(&decrypt_output.stdout); assert_eq!(stdout, "secret data", "stdout should contain plaintext"); } #[test] fn test_encrypt_no_recipient_fails() { let sandbox = Sandbox::new("encrypt-no-recipient"); let plaintext_file = sandbox.path.join("file.txt"); std::fs::write(&plaintext_file, "data").unwrap(); // No recipient provided and no recipient.txt exists let output = sandbox.run(&["encrypt", plaintext_file.to_str().unwrap()]); assert!( !output.status.success(), "encrypt without recipient should fail" ); } #[test] fn test_decrypt_no_identity_fails() { let sandbox = Sandbox::new("decrypt-no-identity"); let dummy_file = sandbox.path.join("dummy.age"); std::fs::write(&dummy_file, "not really encrypted").unwrap(); let output = sandbox.run(&["decrypt", dummy_file.to_str().unwrap()]); assert!( !output.status.success(), "decrypt without identity should fail" ); } #[test] fn test_encrypt_var_and_decrypt_var() { let sandbox = Sandbox::new("encrypt-decrypt-var"); let keygen_output = sandbox.run(&["keygen", "--force"]); assert!(keygen_output.status.success()); let pubkey = String::from_utf8_lossy(&keygen_output.stdout) .trim() .to_string(); let identity_file = sandbox.config_dir().join("identity.txt"); // Encrypt a var (VALUE is a positional argument) let encrypt_output = sandbox.run(&["encrypt-var", "my-secret-value", "--recipient", &pubkey]); assert!( encrypt_output.status.success(), "encrypt-var failed: {:?}", encrypt_output ); let encrypted_b64 = String::from_utf8_lossy(&encrypt_output.stdout) .trim() .to_string(); assert!( !encrypted_b64.is_empty(), "encrypted var should produce base64 output" ); // Decrypt it back (VALUE is a positional argument) let decrypt_output = sandbox.run(&[ "decrypt-var", &encrypted_b64, "--identity", identity_file.to_str().unwrap(), ]); assert!( decrypt_output.status.success(), "decrypt-var failed: {:?}", decrypt_output ); let decrypted = String::from_utf8_lossy(&decrypt_output.stdout); assert_eq!( decrypted, "my-secret-value", "decrypted var should match original" ); } // ── Reencrypt tests ── #[test] fn test_reencrypt_age_files() { let sandbox = Sandbox::new("reencrypt"); // Generate two keypairs let keygen1 = sandbox.run(&["keygen", "--force"]); assert!(keygen1.status.success()); let pubkey1 = String::from_utf8_lossy(&keygen1.stdout).trim().to_string(); // Save first identity for decryption let identity_content = std::fs::read_to_string(sandbox.config_dir().join("identity.txt")).unwrap(); let keygen2 = sandbox.run(&["keygen", "--force"]); assert!(keygen2.status.success()); let pubkey2 = String::from_utf8_lossy(&keygen2.stdout).trim().to_string(); // Create a plaintext file and encrypt with first key only let plaintext_file = sandbox.path.join("secret.txt"); std::fs::write(&plaintext_file, "reencrypt me").unwrap(); let encrypt_output = sandbox.run(&[ "encrypt", plaintext_file.to_str().unwrap(), "--recipient", &pubkey1, ]); assert!(encrypt_output.status.success()); // Set up config with encrypted file reference in secrets dir let secrets_dir = sandbox.config_dir().join("secrets"); std::fs::create_dir_all(&secrets_dir).unwrap(); std::fs::copy( sandbox.path.join("secret.txt.age"), secrets_dir.join("secret.txt.age"), ) .unwrap(); sandbox.write_config( r#" package: "git" "#, ); // Write the first identity back (reencrypt needs it to decrypt) std::fs::write(sandbox.config_dir().join("identity.txt"), &identity_content).unwrap(); // Reencrypt with both recipients let reencrypt_output = sandbox.run(&[ "reencrypt", "--recipient", &pubkey1, "--recipient", &pubkey2, ]); assert!( reencrypt_output.status.success(), "reencrypt failed: {:?}\nstderr: {}", reencrypt_output, String::from_utf8_lossy(&reencrypt_output.stderr) ); // The re-encrypted file should still be decryptable with the first identity let identity_file = sandbox.config_dir().join("identity.txt"); let decrypt_output = sandbox.run(&[ "decrypt", secrets_dir.join("secret.txt.age").to_str().unwrap(), "--identity", identity_file.to_str().unwrap(), "--output", sandbox.path.join("decrypted.txt").to_str().unwrap(), ]); assert!( decrypt_output.status.success(), "decrypt after reencrypt failed: {:?}", decrypt_output ); let decrypted = std::fs::read_to_string(sandbox.path.join("decrypted.txt")).unwrap(); assert_eq!( decrypted, "reencrypt me", "content should survive reencryption" ); } #[test] fn test_reencrypt_no_identity_fails() { let sandbox = Sandbox::new("reencrypt-no-id"); sandbox.write_config("package: \"git\"\n"); let output = sandbox.run(&[ "reencrypt", "--recipient", "age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", ]); assert!( !output.status.success(), "reencrypt without identity should fail" ); } #[test] fn test_reencrypt_no_age_files_reports_zero() { let sandbox = Sandbox::new("reencrypt-none"); let keygen_output = sandbox.run(&["keygen", "--force"]); assert!(keygen_output.status.success()); let pubkey = String::from_utf8_lossy(&keygen_output.stdout) .trim() .to_string(); sandbox.write_config("package: \"git\"\n"); let output = sandbox.run(&["reencrypt", "--recipient", &pubkey]); assert!(output.status.success(), "reencrypt failed: {:?}", output); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("no .age files found"), "should report no files to reencrypt, got: {}", stdout ); }