feat(cli): add keygen and reencrypt commands with source directory support
This commit is contained in:
parent
c463ea423c
commit
8808a7fc89
13 changed files with 715 additions and 27 deletions
|
|
@ -23,14 +23,14 @@ pub fn run(config_path: Option<PathBuf>, 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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ pub fn run(config_path: Option<PathBuf>, 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 {
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
69
crates/doot-cli/src/commands/keygen.rs
Normal file
69
crates/doot-cli/src/commands/keygen.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ use std::path::PathBuf;
|
|||
pub fn install(config_path: Option<PathBuf>) -> 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<PathBuf>) -> 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() {
|
||||
|
|
|
|||
166
crates/doot-cli/src/commands/reencrypt.rs
Normal file
166
crates/doot-cli/src/commands/reencrypt.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -12,10 +12,10 @@ pub fn run(config_path: Option<PathBuf>) -> 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -182,6 +182,24 @@ enum Commands {
|
|||
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]`
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
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<Value> = glob::glob(&joined_str)
|
||||
if has_glob(&joined_str) {
|
||||
let effective = resolve_glob_base(&joined);
|
||||
let paths: Vec<Value> = glob::glob(&effective.to_string_lossy())
|
||||
.map_err(|e| EvalError::TypeError(format!("invalid glob: {}", e)))?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(Value::Path)
|
||||
|
|
|
|||
Loading…
Reference in a new issue