feat(cli): add keygen and reencrypt commands with source directory support

This commit is contained in:
Ray Andrew 2026-02-18 02:42:50 -06:00
parent c463ea423c
commit 8808a7fc89
Signed by: rayandrew
SSH key fingerprint: SHA256:EUCV+qCSqkap8rR+p+zGjxHfKI06G0GJKgo1DIOniQY
13 changed files with 715 additions and 27 deletions

View file

@ -23,14 +23,14 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
let program = parse_config(&path)?; let program = parse_config(&path)?;
type_check(&program, &source, &path.display().to_string())?; 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)?; let mut result = evaluator.eval_sync(&program)?;
// Get environment variables to expose to hook scripts // Get environment variables to expose to hook scripts
let hook_env = evaluator.get_hook_env(); 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 // Expand glob patterns from dotfiles: blocks
let glob_count = let glob_count =
expand_dotfile_patterns(&mut result.dotfiles, &result.dotfile_patterns, &source_dir); expand_dotfile_patterns(&mut result.dotfiles, &result.dotfile_patterns, &source_dir);

View file

@ -37,7 +37,7 @@ pub fn run(
let program = parse_config(&path)?; let program = parse_config(&path)?;
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); 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)?; let result = evaluator.eval_sync(&program)?;
if result.encrypted_vars.is_empty() && result.encrypted_files.is_empty() { if result.encrypted_vars.is_empty() && result.encrypted_files.is_empty() {

View file

@ -14,11 +14,11 @@ pub fn run(config_path: Option<PathBuf>, all: bool) -> anyhow::Result<()> {
let program = parse_config(&path)?; let program = parse_config(&path)?;
type_check(&program, &source, &path.display().to_string())?; 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 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; let mut has_changes = false;
for dotfile in &result.dotfiles { for dotfile in &result.dotfiles {

View file

@ -23,11 +23,11 @@ pub fn run(
let program = parse_config(&path)?; let program = parse_config(&path)?;
type_check(&program, &source, &path.display().to_string())?; 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 result = evaluator.eval_sync(&program)?;
let mut template_vars = evaluator.get_template_variables(); let mut template_vars = evaluator.get_template_variables();
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
let config = Config::default(); let config = Config::default();
super::decrypt_encrypted_vars(&result, &config, &mut template_vars)?; super::decrypt_encrypted_vars(&result, &config, &mut template_vars)?;

View file

@ -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<PathBuf>, 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(())
}

View file

@ -11,8 +11,10 @@ pub mod encrypt;
pub mod encrypt_var; pub mod encrypt_var;
pub mod fmt; pub mod fmt;
pub mod init; pub mod init;
pub mod keygen;
pub mod lsp; pub mod lsp;
pub mod package; pub mod package;
pub mod reencrypt;
pub mod rollback; pub mod rollback;
pub mod snapshot; pub mod snapshot;
pub mod status; pub mod status;

View file

@ -7,11 +7,12 @@ use std::path::PathBuf;
pub fn install(config_path: Option<PathBuf>) -> anyhow::Result<()> { pub fn install(config_path: Option<PathBuf>) -> anyhow::Result<()> {
let path = find_config_file(config_path)?; let path = find_config_file(config_path)?;
let source = std::fs::read_to_string(&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)?; let program = parse_config(&path)?;
type_check(&program, &source, &path.display().to_string())?; 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)?; let result = evaluator.eval_sync(&program)?;
if result.packages.is_empty() { if result.packages.is_empty() {
@ -72,11 +73,12 @@ pub fn update() -> anyhow::Result<()> {
pub fn list(config_path: Option<PathBuf>) -> anyhow::Result<()> { pub fn list(config_path: Option<PathBuf>) -> anyhow::Result<()> {
let path = find_config_file(config_path)?; let path = find_config_file(config_path)?;
let source = std::fs::read_to_string(&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)?; let program = parse_config(&path)?;
type_check(&program, &source, &path.display().to_string())?; 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)?; let result = evaluator.eval_sync(&program)?;
if result.packages.is_empty() { if result.packages.is_empty() {

View file

@ -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<PathBuf>, recipients: Vec<String>) -> 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(())
}

View file

@ -12,10 +12,10 @@ pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
let program = parse_config(&path)?; let program = parse_config(&path)?;
type_check(&program, &source, &path.display().to_string())?; 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 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_file = source_dir.join(".doot-state.json");
let state = StateStore::new(&state_file); let state = StateStore::new(&state_file);

View file

@ -117,11 +117,11 @@ impl App {
let program = parse_config(&path)?; let program = parse_config(&path)?;
type_check(&program, &source, &path.display().to_string())?; 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 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 config = Config::default();
let state = StateStore::new(&config.state_file); let state = StateStore::new(&config.state_file);

View file

@ -182,6 +182,24 @@ enum Commands {
identity: Option<PathBuf>, identity: Option<PathBuf>,
}, },
/// 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<PathBuf>,
/// 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<String>,
},
/// Open source file in editor for a deployed target: `doot edit <TARGET> [-a] [-y]` /// Open source file in editor for a deployed target: `doot edit <TARGET> [-a] [-y]`
Edit { Edit {
/// Target path or dotfile name (e.g., ~/.config/nvim or nvim) /// 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))?; .map_err(|e| anyhow::anyhow!(e))?;
commands::decrypt_entries::run(cli.config, fmt, identity) 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 { target, apply, yes } => {
commands::edit::run(cli.config, target, apply, yes) commands::edit::run(cli.config, target, apply, yes)
} }

View file

@ -318,3 +318,409 @@ dotfile:
let output = sandbox.run(&["diff"]); let output = sandbox.run(&["diff"]);
assert!(output.status.success(), "diff failed: {:?}", output); 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
);
}

View file

@ -395,6 +395,9 @@ impl Default for EvalResult {
pub struct Evaluator { pub struct Evaluator {
env: Env, env: Env,
result: EvalResult, 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<std::path::PathBuf>,
} }
impl Evaluator { impl Evaluator {
@ -406,9 +409,17 @@ impl Evaluator {
Self { Self {
env, env,
result: EvalResult::default(), 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)] #[tracing::instrument(level = "trace", skip_all)]
fn init_builtins(env: &mut Env) { fn init_builtins(env: &mut Env) {
// Register the Os enum so Os::Linux, Os::MacOS, etc. can be used // 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 left_val = self.eval_expr(left).await?;
let right_val = self.eval_expr(right).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 left is a list (from a previous glob), map over it
if let Value::List(items) = left_val { if let Value::List(items) = left_val {
let right_path = Self::value_to_path(&right_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 item_path = Self::value_to_path(&item)?;
let joined = item_path.join(&right_path); let joined = item_path.join(&right_path);
let joined_str = joined.to_string_lossy(); let joined_str = joined.to_string_lossy();
if joined_str.contains('*') if has_glob(&joined_str) {
|| joined_str.contains('?') let effective = resolve_glob_base(&joined);
|| joined_str.contains('[') for entry in glob::glob(&effective.to_string_lossy())
{
for entry in glob::glob(&joined_str)
.map_err(|e| EvalError::TypeError(format!("invalid glob: {}", e)))? .map_err(|e| EvalError::TypeError(format!("invalid glob: {}", e)))?
.flatten() .flatten()
{ {
@ -1079,11 +1100,13 @@ impl Evaluator {
let right_path = Self::value_to_path(&right_val)?; let right_path = Self::value_to_path(&right_val)?;
let joined = left_path.join(right_path); 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(); let joined_str = joined.to_string_lossy();
if joined_str.contains('*') || joined_str.contains('?') || joined_str.contains('[') if has_glob(&joined_str) {
{ let effective = resolve_glob_base(&joined);
let paths: Vec<Value> = glob::glob(&joined_str) let paths: Vec<Value> = glob::glob(&effective.to_string_lossy())
.map_err(|e| EvalError::TypeError(format!("invalid glob: {}", e)))? .map_err(|e| EvalError::TypeError(format!("invalid glob: {}", e)))?
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.map(Value::Path) .map(Value::Path)