From 8808a7fc890bffccab93aa76b68d39c8bc8d5eba Mon Sep 17 00:00:00 2001 From: Ray Andrew Date: Wed, 18 Feb 2026 02:42:50 -0600 Subject: [PATCH] feat(cli): add keygen and reencrypt commands with source directory support --- crates/doot-cli/src/commands/apply.rs | 6 +- .../doot-cli/src/commands/decrypt_entries.rs | 2 +- crates/doot-cli/src/commands/diff.rs | 6 +- crates/doot-cli/src/commands/edit.rs | 6 +- crates/doot-cli/src/commands/keygen.rs | 69 +++ crates/doot-cli/src/commands/mod.rs | 2 + crates/doot-cli/src/commands/package.rs | 6 +- crates/doot-cli/src/commands/reencrypt.rs | 166 +++++++ crates/doot-cli/src/commands/status.rs | 6 +- crates/doot-cli/src/commands/tui.rs | 6 +- crates/doot-cli/src/main.rs | 20 + crates/doot-cli/tests/e2e.rs | 406 ++++++++++++++++++ crates/doot-lang/src/evaluator.rs | 41 +- 13 files changed, 715 insertions(+), 27 deletions(-) create mode 100644 crates/doot-cli/src/commands/keygen.rs create mode 100644 crates/doot-cli/src/commands/reencrypt.rs diff --git a/crates/doot-cli/src/commands/apply.rs b/crates/doot-cli/src/commands/apply.rs index e2e6e69..1a11dda 100644 --- a/crates/doot-cli/src/commands/apply.rs +++ b/crates/doot-cli/src/commands/apply.rs @@ -23,14 +23,14 @@ pub fn run(config_path: Option, dry_run: bool, prune: bool) -> anyhow:: let program = parse_config(&path)?; type_check(&program, &source, &path.display().to_string())?; - let mut evaluator = Evaluator::new(); + let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); + + let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone()); let mut result = evaluator.eval_sync(&program)?; // Get environment variables to expose to hook scripts let hook_env = evaluator.get_hook_env(); - let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); - // Expand glob patterns from dotfiles: blocks let glob_count = expand_dotfile_patterns(&mut result.dotfiles, &result.dotfile_patterns, &source_dir); diff --git a/crates/doot-cli/src/commands/decrypt_entries.rs b/crates/doot-cli/src/commands/decrypt_entries.rs index 8d788a7..d56ca7b 100644 --- a/crates/doot-cli/src/commands/decrypt_entries.rs +++ b/crates/doot-cli/src/commands/decrypt_entries.rs @@ -37,7 +37,7 @@ pub fn run( let program = parse_config(&path)?; let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); - let mut evaluator = Evaluator::new(); + let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone()); let result = evaluator.eval_sync(&program)?; if result.encrypted_vars.is_empty() && result.encrypted_files.is_empty() { diff --git a/crates/doot-cli/src/commands/diff.rs b/crates/doot-cli/src/commands/diff.rs index 5b051e5..f10570a 100644 --- a/crates/doot-cli/src/commands/diff.rs +++ b/crates/doot-cli/src/commands/diff.rs @@ -14,11 +14,11 @@ pub fn run(config_path: Option, all: bool) -> anyhow::Result<()> { let program = parse_config(&path)?; type_check(&program, &source, &path.display().to_string())?; - let mut evaluator = Evaluator::new(); - let result = evaluator.eval_sync(&program)?; - let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); + let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone()); + let result = evaluator.eval_sync(&program)?; + let mut has_changes = false; for dotfile in &result.dotfiles { diff --git a/crates/doot-cli/src/commands/edit.rs b/crates/doot-cli/src/commands/edit.rs index fb4c87b..8d58cfc 100644 --- a/crates/doot-cli/src/commands/edit.rs +++ b/crates/doot-cli/src/commands/edit.rs @@ -23,11 +23,11 @@ pub fn run( let program = parse_config(&path)?; type_check(&program, &source, &path.display().to_string())?; - let mut evaluator = Evaluator::new(); + let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); + + let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone()); let result = evaluator.eval_sync(&program)?; let mut template_vars = evaluator.get_template_variables(); - - let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); let config = Config::default(); super::decrypt_encrypted_vars(&result, &config, &mut template_vars)?; diff --git a/crates/doot-cli/src/commands/keygen.rs b/crates/doot-cli/src/commands/keygen.rs new file mode 100644 index 0000000..b3d16ca --- /dev/null +++ b/crates/doot-cli/src/commands/keygen.rs @@ -0,0 +1,69 @@ +use doot_core::{Config, encryption::AgeEncryption}; +use std::io::{self, Write}; +use std::path::PathBuf; + +/// Generates an age keypair, writing identity to identity.txt and appending public key to recipient.txt. +#[tracing::instrument(skip_all)] +pub fn run(output: Option, force: bool) -> anyhow::Result<()> { + let config_dir = Config::default_config_dir(); + let identity_file = output.unwrap_or_else(|| config_dir.join("identity.txt")); + let recipient_file = identity_file + .parent() + .unwrap_or(&config_dir) + .join("recipient.txt"); + + if identity_file.exists() && !force { + eprint!( + "identity file already exists: {}\noverwrite? [y/N] ", + identity_file.display() + ); + io::stderr().flush()?; + let mut answer = String::new(); + io::stdin().read_line(&mut answer)?; + if !answer.trim().eq_ignore_ascii_case("y") { + println!("aborted"); + return Ok(()); + } + } + + let (secret_key, public_key) = AgeEncryption::generate_keypair(); + + // Write identity file (same format as age-keygen) + let identity_content = format!("# public key: {}\n{}\n", public_key, secret_key); + + if let Some(parent) = identity_file.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&identity_file, &identity_content)?; + + // Restrict permissions on identity file (Unix only) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&identity_file, std::fs::Permissions::from_mode(0o600))?; + } + + // Append public key to recipient.txt (or create it) + if recipient_file.exists() { + let existing = std::fs::read_to_string(&recipient_file)?; + if !existing.contains(&public_key) { + let mut content = existing.trim_end().to_string(); + content.push('\n'); + content.push_str(&public_key); + content.push('\n'); + std::fs::write(&recipient_file, content)?; + eprintln!("added public key to {}", recipient_file.display()); + } else { + eprintln!("public key already in {}", recipient_file.display()); + } + } else { + std::fs::write(&recipient_file, format!("{}\n", public_key))?; + eprintln!("created {}", recipient_file.display()); + } + + eprintln!("identity: {}", identity_file.display()); + eprintln!("recipient: {}", recipient_file.display()); + println!("{}", public_key); + + Ok(()) +} diff --git a/crates/doot-cli/src/commands/mod.rs b/crates/doot-cli/src/commands/mod.rs index e18cd4c..c481d0a 100644 --- a/crates/doot-cli/src/commands/mod.rs +++ b/crates/doot-cli/src/commands/mod.rs @@ -11,8 +11,10 @@ pub mod encrypt; pub mod encrypt_var; pub mod fmt; pub mod init; +pub mod keygen; pub mod lsp; pub mod package; +pub mod reencrypt; pub mod rollback; pub mod snapshot; pub mod status; diff --git a/crates/doot-cli/src/commands/package.rs b/crates/doot-cli/src/commands/package.rs index da151ac..5a3cf1a 100644 --- a/crates/doot-cli/src/commands/package.rs +++ b/crates/doot-cli/src/commands/package.rs @@ -7,11 +7,12 @@ use std::path::PathBuf; pub fn install(config_path: Option) -> anyhow::Result<()> { let path = find_config_file(config_path)?; let source = std::fs::read_to_string(&path)?; + let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); let program = parse_config(&path)?; type_check(&program, &source, &path.display().to_string())?; - let mut evaluator = Evaluator::new(); + let mut evaluator = Evaluator::new().with_source_dir(source_dir); let result = evaluator.eval_sync(&program)?; if result.packages.is_empty() { @@ -72,11 +73,12 @@ pub fn update() -> anyhow::Result<()> { pub fn list(config_path: Option) -> anyhow::Result<()> { let path = find_config_file(config_path)?; let source = std::fs::read_to_string(&path)?; + let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); let program = parse_config(&path)?; type_check(&program, &source, &path.display().to_string())?; - let mut evaluator = Evaluator::new(); + let mut evaluator = Evaluator::new().with_source_dir(source_dir); let result = evaluator.eval_sync(&program)?; if result.packages.is_empty() { diff --git a/crates/doot-cli/src/commands/reencrypt.rs b/crates/doot-cli/src/commands/reencrypt.rs new file mode 100644 index 0000000..24cbba1 --- /dev/null +++ b/crates/doot-cli/src/commands/reencrypt.rs @@ -0,0 +1,166 @@ +use doot_core::{Config, encryption::AgeEncryption}; +use std::path::PathBuf; + +/// Re-encrypts all .age files in the secrets directory with current recipients. +/// Useful after adding a new recipient key to recipient.txt. +#[tracing::instrument(skip_all)] +pub fn run(config_path: Option, recipients: Vec) -> anyhow::Result<()> { + let path = super::find_config_file(config_path)?; + let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); + let config = Config::new(source_dir.clone()); + + // Resolve identity for decryption + let identity_raw = if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") { + key + } else if config.identity_file.exists() { + std::fs::read_to_string(&config.identity_file)? + } else { + anyhow::bail!( + "no identity available for decryption. set DOOT_AGE_IDENTITY or create {}", + config.identity_file.display() + ); + }; + + let identity_key = identity_raw + .lines() + .find(|line| line.starts_with("AGE-SECRET-KEY-")) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| identity_raw.trim().to_string()); + + // Resolve recipients for re-encryption + let keys = if !recipients.is_empty() { + recipients + } else if let Ok(key) = std::env::var("DOOT_AGE_RECIPIENT") { + key.lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect() + } else { + let key_file = Config::default_config_dir().join("recipient.txt"); + if key_file.exists() { + std::fs::read_to_string(&key_file)? + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .collect() + } else { + anyhow::bail!( + "no recipients specified. use --recipient, DOOT_AGE_RECIPIENT, or {}", + key_file.display() + ); + } + }; + + if keys.is_empty() { + anyhow::bail!("no recipient keys found"); + } + + // Also re-encrypt encrypted vars from the doot config + let program = super::parse_config(&path)?; + let mut evaluator = doot_lang::Evaluator::new().with_source_dir(source_dir.clone()); + let result = evaluator.eval_sync(&program)?; + + let mut count = 0; + + // Re-encrypt .age files referenced in encrypted: block + for (name, rel_path) in &result.encrypted_files { + let full_path = if rel_path.is_relative() { + source_dir.join(rel_path) + } else { + rel_path.clone() + }; + + if !full_path.exists() { + eprintln!("skip {}: file not found ({})", name, full_path.display()); + continue; + } + + reencrypt_file(&full_path, &identity_key, &keys)?; + eprintln!("re-encrypted {}: {}", name, full_path.display()); + count += 1; + } + + // Re-encrypt secret: block .age files + for secret in &result.secrets { + let full_path = if secret.source.is_relative() { + source_dir.join(&secret.source) + } else { + secret.source.clone() + }; + + if !full_path.exists() { + eprintln!("skip secret {}: file not found", full_path.display()); + continue; + } + + reencrypt_file(&full_path, &identity_key, &keys)?; + eprintln!("re-encrypted secret: {}", full_path.display()); + count += 1; + } + + // Also scan for any .age files in the secrets directory + let secrets_dir = source_dir.join("secrets"); + if secrets_dir.is_dir() { + for entry in std::fs::read_dir(&secrets_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().map(|e| e == "age").unwrap_or(false) { + // Skip if already re-encrypted above + let already_done = result.encrypted_files.values().any(|p| { + let fp = if p.is_relative() { + source_dir.join(p) + } else { + p.clone() + }; + fp == path + }) || result.secrets.iter().any(|s| { + let fp = if s.source.is_relative() { + source_dir.join(&s.source) + } else { + s.source.clone() + }; + fp == path + }); + + if already_done { + continue; + } + + reencrypt_file(&path, &identity_key, &keys)?; + eprintln!("re-encrypted: {}", path.display()); + count += 1; + } + } + } + + if count == 0 { + println!("no .age files found to re-encrypt"); + } else { + println!( + "re-encrypted {} file(s) with {} recipient(s)", + count, + keys.len() + ); + } + + Ok(()) +} + +fn reencrypt_file(path: &PathBuf, identity_key: &str, recipients: &[String]) -> anyhow::Result<()> { + // Decrypt + let decryption = AgeEncryption::new().with_identity(identity_key)?; + let data = std::fs::read(path)?; + let plaintext = decryption.decrypt(&data)?; + + // Re-encrypt with all recipients + let mut encryption = AgeEncryption::new(); + for key in recipients { + encryption.add_recipient(key)?; + } + let ciphertext = encryption.encrypt(&plaintext)?; + + // Write back + std::fs::write(path, ciphertext)?; + + Ok(()) +} diff --git a/crates/doot-cli/src/commands/status.rs b/crates/doot-cli/src/commands/status.rs index 5a8cc01..efc93ce 100644 --- a/crates/doot-cli/src/commands/status.rs +++ b/crates/doot-cli/src/commands/status.rs @@ -12,10 +12,10 @@ pub fn run(config_path: Option) -> anyhow::Result<()> { let program = parse_config(&path)?; type_check(&program, &source, &path.display().to_string())?; - let mut evaluator = Evaluator::new(); - let result = evaluator.eval_sync(&program)?; - let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); + + let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone()); + let result = evaluator.eval_sync(&program)?; let state_file = source_dir.join(".doot-state.json"); let state = StateStore::new(&state_file); diff --git a/crates/doot-cli/src/commands/tui.rs b/crates/doot-cli/src/commands/tui.rs index 9b078c2..8ed9a73 100644 --- a/crates/doot-cli/src/commands/tui.rs +++ b/crates/doot-cli/src/commands/tui.rs @@ -117,11 +117,11 @@ impl App { let program = parse_config(&path)?; type_check(&program, &source, &path.display().to_string())?; - let mut evaluator = Evaluator::new(); - let result = evaluator.eval_sync(&program)?; - let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); + let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone()); + let result = evaluator.eval_sync(&program)?; + let config = Config::default(); let state = StateStore::new(&config.state_file); diff --git a/crates/doot-cli/src/main.rs b/crates/doot-cli/src/main.rs index 9619d83..95edb7e 100644 --- a/crates/doot-cli/src/main.rs +++ b/crates/doot-cli/src/main.rs @@ -182,6 +182,24 @@ enum Commands { identity: Option, }, + /// Generate an age keypair: `doot keygen [-o OUTPUT] [--force]` + Keygen { + /// Output path for identity file (default: ~/.config/doot/identity.txt) + #[arg(short, long)] + output: Option, + + /// Overwrite existing identity file without prompting + #[arg(short, long)] + force: bool, + }, + + /// Re-encrypt all .age files with current recipients: `doot reencrypt [-r RECIPIENT]...` + Reencrypt { + /// Recipient public key (can be specified multiple times) + #[arg(short, long)] + recipient: Vec, + }, + /// Open source file in editor for a deployed target: `doot edit [-a] [-y]` Edit { /// Target path or dotfile name (e.g., ~/.config/nvim or nvim) @@ -296,6 +314,8 @@ fn main() -> anyhow::Result<()> { .map_err(|e| anyhow::anyhow!(e))?; commands::decrypt_entries::run(cli.config, fmt, identity) } + Commands::Keygen { output, force } => commands::keygen::run(output, force), + Commands::Reencrypt { recipient } => commands::reencrypt::run(cli.config, recipient), Commands::Edit { target, apply, yes } => { commands::edit::run(cli.config, target, apply, yes) } diff --git a/crates/doot-cli/tests/e2e.rs b/crates/doot-cli/tests/e2e.rs index f60b9f0..d290ff7 100644 --- a/crates/doot-cli/tests/e2e.rs +++ b/crates/doot-cli/tests/e2e.rs @@ -318,3 +318,409 @@ dotfile: 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 + ); +} diff --git a/crates/doot-lang/src/evaluator.rs b/crates/doot-lang/src/evaluator.rs index 107ed1d..78949f8 100644 --- a/crates/doot-lang/src/evaluator.rs +++ b/crates/doot-lang/src/evaluator.rs @@ -395,6 +395,9 @@ impl Default for EvalResult { pub struct Evaluator { env: Env, result: EvalResult, + /// Base directory of the config file. Used to resolve relative glob patterns + /// so they expand correctly regardless of the current working directory. + source_dir: Option, } impl Evaluator { @@ -406,9 +409,17 @@ impl Evaluator { Self { env, result: EvalResult::default(), + source_dir: None, } } + /// Sets the config source directory so relative glob patterns are resolved + /// relative to the config file rather than the current working directory. + pub fn with_source_dir(mut self, dir: std::path::PathBuf) -> Self { + self.source_dir = Some(dir); + self + } + #[tracing::instrument(level = "trace", skip_all)] fn init_builtins(env: &mut Env) { // Register the Os enum so Os::Linux, Os::MacOS, etc. can be used @@ -1050,6 +1061,18 @@ impl Evaluator { let left_val = self.eval_expr(left).await?; let right_val = self.eval_expr(right).await?; + let has_glob = |s: &str| s.contains('*') || s.contains('?') || s.contains('['); + + // Resolve a glob pattern to an absolute base using source_dir when + // available, so patterns like "config" / "*" expand relative to the + // config file rather than the current working directory. + let resolve_glob_base = |p: &std::path::Path| -> std::path::PathBuf { + if p.is_relative() && let Some(ref sd) = self.source_dir { + return sd.join(p); + } + p.to_path_buf() + }; + // If left is a list (from a previous glob), map over it if let Value::List(items) = left_val { let right_path = Self::value_to_path(&right_val)?; @@ -1058,11 +1081,9 @@ impl Evaluator { let item_path = Self::value_to_path(&item)?; let joined = item_path.join(&right_path); let joined_str = joined.to_string_lossy(); - if joined_str.contains('*') - || joined_str.contains('?') - || joined_str.contains('[') - { - for entry in glob::glob(&joined_str) + if has_glob(&joined_str) { + let effective = resolve_glob_base(&joined); + for entry in glob::glob(&effective.to_string_lossy()) .map_err(|e| EvalError::TypeError(format!("invalid glob: {}", e)))? .flatten() { @@ -1079,11 +1100,13 @@ impl Evaluator { let right_path = Self::value_to_path(&right_val)?; let joined = left_path.join(right_path); - // If the resulting path contains glob wildcards, expand as glob + // Expand glob wildcards using source_dir as the base for relative + // patterns, so "config" / "*" resolves from the config directory + // rather than from wherever the user ran doot. let joined_str = joined.to_string_lossy(); - if joined_str.contains('*') || joined_str.contains('?') || joined_str.contains('[') - { - let paths: Vec = glob::glob(&joined_str) + if has_glob(&joined_str) { + let effective = resolve_glob_base(&joined); + let paths: Vec = glob::glob(&effective.to_string_lossy()) .map_err(|e| EvalError::TypeError(format!("invalid glob: {}", e)))? .filter_map(|e| e.ok()) .map(Value::Path)