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)?;
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)?;
|
||||||
|
|
|
||||||
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 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;
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
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)?;
|
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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue