feat(cli): add age encryption support and decrypt-entries command

This commit is contained in:
Ray Andrew 2026-02-17 15:08:28 -06:00
parent bee2ceff00
commit 7346c11a6e
Signed by: rayandrew
SSH key fingerprint: SHA256:EUCV+qCSqkap8rR+p+zGjxHfKI06G0GJKgo1DIOniQY
23 changed files with 1171 additions and 68 deletions

2
Cargo.lock generated
View file

@ -1038,6 +1038,7 @@ dependencies = [
name = "doot-cli" name = "doot-cli"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"age",
"anyhow", "anyhow",
"blake3", "blake3",
"clap", "clap",
@ -1046,6 +1047,7 @@ dependencies = [
"doot-core", "doot-core",
"doot-lang", "doot-lang",
"glob", "glob",
"indexmap",
"indicatif", "indicatif",
"ratatui", "ratatui",
"serde", "serde",

View file

@ -22,5 +22,7 @@ anyhow.workspace = true
dirs.workspace = true dirs.workspace = true
blake3.workspace = true blake3.workspace = true
glob = "0.3" glob = "0.3"
age.workspace = true
indexmap = "2"
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true

View file

@ -119,7 +119,16 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
// For directories, check individual files for smarter merging // For directories, check individual files for smarter merging
if full_source.is_dir() { if full_source.is_dir() {
let changed_files = state.get_changed_files_in_dir(&full_source, &dotfile.target); let permissions = if dotfile.permissions.is_empty() {
None
} else {
Some(dotfile.permissions.as_slice())
};
let changed_files = state.get_changed_files_in_dir_with_permissions(
&full_source,
&dotfile.target,
permissions,
);
// Filter out excluded files before checking for changes // Filter out excluded files before checking for changes
let changed_files: Vec<_> = changed_files let changed_files: Vec<_> = changed_files
@ -426,7 +435,9 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
.filter(|batch| !batch.is_empty()) .filter(|batch| !batch.is_empty())
.collect(); .collect();
let deployer = Deployer::new(config, result.sandbox); let mut template_vars = evaluator.get_template_variables();
super::decrypt_encrypted_vars(&result, &config, &mut template_vars)?;
let deployer = Deployer::new(config, result.sandbox, Some(&template_vars));
let progress = if !dry_run { let progress = if !dry_run {
let pb = ProgressBar::new(deploy_set.len() as u64); let pb = ProgressBar::new(deploy_set.len() as u64);
@ -518,10 +529,18 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
let mut already_installed = Vec::new(); let mut already_installed = Vec::new();
for pkg in &result.packages { for pkg in &result.packages {
if let Some(ref name) = pkg.default { let name = match manager.name() {
match manager.is_installed(name) { "brew" => pkg.brew.clone().or_else(|| pkg.default.clone()),
Ok(true) => already_installed.push(name.clone()), "apt" => pkg.apt.clone().or_else(|| pkg.default.clone()),
_ => to_install.push(name.clone()), "pacman" => pkg.pacman.clone().or_else(|| pkg.default.clone()),
"yay" => pkg.yay.clone().or_else(|| pkg.default.clone()),
"xbps" => pkg.xbps.clone().or_else(|| pkg.default.clone()),
_ => pkg.default.clone(),
};
if let Some(name) = name {
match manager.is_installed(&name) {
Ok(true) => already_installed.push(name),
_ => to_install.push(name),
} }
} }
} }
@ -580,10 +599,20 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
// Prune packages removed from config // Prune packages removed from config
{ {
let mgr_name = doot_core::package::detect_package_manager()
.map(|m| m.name().to_string())
.unwrap_or_default();
let configured_names: std::collections::HashSet<String> = result let configured_names: std::collections::HashSet<String> = result
.packages .packages
.iter() .iter()
.filter_map(|p| p.default.clone()) .filter_map(|p| match mgr_name.as_str() {
"brew" => p.brew.clone().or_else(|| p.default.clone()),
"apt" => p.apt.clone().or_else(|| p.default.clone()),
"pacman" => p.pacman.clone().or_else(|| p.default.clone()),
"yay" => p.yay.clone().or_else(|| p.default.clone()),
"xbps" => p.xbps.clone().or_else(|| p.default.clone()),
_ => p.default.clone(),
})
.collect(); .collect();
let state_for_prune = StateStore::new(&state_file); let state_for_prune = StateStore::new(&state_file);
@ -612,8 +641,22 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
if should_uninstall { if should_uninstall {
if let Some(mgr) = doot_core::package::get_package_manager(mgr_name) { if let Some(mgr) = doot_core::package::get_package_manager(mgr_name) {
mgr.uninstall(std::slice::from_ref(name))?; match mgr.is_installed(name) {
println!("uninstalled {}", name); Ok(true) => {
mgr.uninstall(std::slice::from_ref(name))?;
println!("uninstalled {}", name);
}
Ok(false) => {
println!("{} already removed from system", name);
}
Err(e) => {
tracing::warn!(
package = %name,
error = %e,
"could not check if package is installed, skipping uninstall"
);
}
}
uninstalled.push(name.clone()); uninstalled.push(name.clone());
} else { } else {
tracing::warn!( tracing::warn!(

View file

@ -1,18 +1,29 @@
use doot_core::{Config, encryption::AgeEncryption}; use doot_core::{Config, encryption::AgeEncryption};
use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
/// Decrypts an age-encrypted file. /// Extracts the AGE-SECRET-KEY line from identity file content (filters comments).
fn extract_identity_key(raw: &str) -> String {
raw.lines()
.find(|line| line.starts_with("AGE-SECRET-KEY-"))
.map(|s| s.trim().to_string())
.unwrap_or_else(|| raw.trim().to_string())
}
/// Decrypts an age-encrypted file to stdout (default) or to a file with --output.
#[tracing::instrument(skip_all, fields(file = %file.display()))] #[tracing::instrument(skip_all, fields(file = %file.display()))]
pub fn run(file: PathBuf, identity: Option<PathBuf>) -> anyhow::Result<()> { pub fn run(
file: PathBuf,
identity: Option<PathBuf>,
output: Option<PathBuf>,
) -> anyhow::Result<()> {
let config = Config::default(); let config = Config::default();
let identity_key = if let Some(path) = identity { let identity_raw = if let Some(path) = identity {
std::fs::read_to_string(&path)?.trim().to_string() std::fs::read_to_string(&path)?
} else if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") { } else if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {
key key
} else if config.identity_file.exists() { } else if config.identity_file.exists() {
std::fs::read_to_string(&config.identity_file)? std::fs::read_to_string(&config.identity_file)?
.trim()
.to_string()
} else { } else {
anyhow::bail!( anyhow::bail!(
"no identity specified. use --identity, DOOT_AGE_IDENTITY env var, or {}", "no identity specified. use --identity, DOOT_AGE_IDENTITY env var, or {}",
@ -20,18 +31,20 @@ pub fn run(file: PathBuf, identity: Option<PathBuf>) -> anyhow::Result<()> {
); );
}; };
let identity_key = extract_identity_key(&identity_raw);
tracing::debug!(file = %file.display(), "decrypting file"); tracing::debug!(file = %file.display(), "decrypting file");
let encryption = AgeEncryption::new().with_identity(&identity_key)?; let encryption = AgeEncryption::new().with_identity(&identity_key)?;
let data = std::fs::read(&file)?;
let decrypted = encryption.decrypt(&data)?;
let output = if file.extension().map(|e| e == "age").unwrap_or(false) { if let Some(out_path) = output {
file.with_extension("") std::fs::write(&out_path, &decrypted)?;
eprintln!("decrypted {} -> {}", file.display(), out_path.display());
} else { } else {
file.with_extension("decrypted") std::io::stdout().write_all(&decrypted)?;
}; }
encryption.decrypt_file(&file, &output)?;
println!("decrypted {} -> {}", file.display(), output.display());
Ok(()) Ok(())
} }

View file

@ -0,0 +1,230 @@
use super::{find_config_file, parse_config};
use doot_core::Config;
use doot_lang::Evaluator;
use doot_lang::builtins::crypto::base64_decode;
use std::io::Read;
use std::path::PathBuf;
/// Output format for decrypt-entries.
#[derive(Clone, Debug)]
pub enum OutputFormat {
Plain,
Pretty,
Json,
}
impl std::str::FromStr for OutputFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"plain" => Ok(OutputFormat::Plain),
"pretty" => Ok(OutputFormat::Pretty),
"json" => Ok(OutputFormat::Json),
_ => Err(format!("unknown format: {}", s)),
}
}
}
/// Decrypts all `encrypted:` entries from a doot config file and displays them.
#[tracing::instrument(skip_all)]
pub fn run(
config_path: Option<PathBuf>,
format: OutputFormat,
identity: Option<PathBuf>,
) -> anyhow::Result<()> {
let path = find_config_file(config_path)?;
let program = parse_config(&path)?;
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
let mut evaluator = Evaluator::new();
let result = evaluator.eval_sync(&program)?;
if result.encrypted_vars.is_empty() && result.encrypted_files.is_empty() {
println!("no encrypted entries found");
return Ok(());
}
// Resolve identity
let config = Config::new(source_dir.clone());
let identity_raw: String = if let Some(ref path) = identity {
std::fs::read_to_string(path)?
} else 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. use --identity, DOOT_AGE_IDENTITY env var, or create {}",
config.identity_file.display()
);
};
let identity_key = identity_raw
.lines()
.find(|line: &&str| line.starts_with("AGE-SECRET-KEY-"))
.map(|s: &str| s.trim().to_string())
.unwrap_or_else(|| identity_raw.trim().to_string());
let age_identity = identity_key
.parse::<age::x25519::Identity>()
.map_err(|e| anyhow::anyhow!("invalid age identity: {}", e))?;
// Decrypt inline vars
let mut decrypted_vars = Vec::new();
for (name, ciphertext_b64) in &result.encrypted_vars {
let plaintext = decrypt_base64(ciphertext_b64, &age_identity, name)?;
decrypted_vars.push((name.clone(), plaintext));
}
decrypted_vars.sort_by(|a, b| a.0.cmp(&b.0));
// Decrypt file entries
let mut decrypted_files = Vec::new();
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()
};
let content = decrypt_file(&full_path, &age_identity, name)?;
decrypted_files.push((name.clone(), rel_path.display().to_string(), content));
}
decrypted_files.sort_by(|a, b| a.0.cmp(&b.0));
match format {
OutputFormat::Plain => print_plain(&decrypted_vars, &decrypted_files),
OutputFormat::Pretty => print_pretty(&decrypted_vars, &decrypted_files),
OutputFormat::Json => print_json(&decrypted_vars, &decrypted_files)?,
}
Ok(())
}
fn decrypt_base64(
ciphertext_b64: &str,
identity: &age::x25519::Identity,
name: &str,
) -> anyhow::Result<String> {
let encrypted_bytes = base64_decode(ciphertext_b64)
.map_err(|e| anyhow::anyhow!("invalid base64 for '{}': {}", name, e))?;
let decryptor = match age::Decryptor::new(&encrypted_bytes[..])
.map_err(|e| anyhow::anyhow!("decryption error for '{}': {}", name, e))?
{
age::Decryptor::Recipients(d) => d,
_ => anyhow::bail!("unexpected decryptor type for '{}'", name),
};
let mut decrypted = vec![];
let mut reader = decryptor
.decrypt(std::iter::once(identity as &dyn age::Identity))
.map_err(|e| anyhow::anyhow!("decryption failed for '{}': {}", name, e))?;
reader
.read_to_end(&mut decrypted)
.map_err(|e| anyhow::anyhow!("read error decrypting '{}': {}", name, e))?;
String::from_utf8(decrypted)
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in decrypted '{}': {}", name, e))
}
fn decrypt_file(
path: &std::path::Path,
identity: &age::x25519::Identity,
name: &str,
) -> anyhow::Result<String> {
let encrypted_bytes = std::fs::read(path).map_err(|e| {
anyhow::anyhow!(
"cannot read encrypted file '{}' ({}): {}",
name,
path.display(),
e
)
})?;
let decryptor = match age::Decryptor::new(&encrypted_bytes[..])
.map_err(|e| anyhow::anyhow!("decryption error for file '{}': {}", name, e))?
{
age::Decryptor::Recipients(d) => d,
_ => anyhow::bail!("unexpected decryptor type for file '{}'", name),
};
let mut decrypted = vec![];
let mut reader = decryptor
.decrypt(std::iter::once(identity as &dyn age::Identity))
.map_err(|e| anyhow::anyhow!("decryption failed for file '{}': {}", name, e))?;
reader
.read_to_end(&mut decrypted)
.map_err(|e| anyhow::anyhow!("read error decrypting file '{}': {}", name, e))?;
String::from_utf8(decrypted)
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in decrypted file '{}': {}", name, e))
}
fn print_plain(vars: &[(String, String)], files: &[(String, String, String)]) {
for (name, value) in vars {
println!("{}={}", name, value);
}
for (name, path, _) in files {
println!("{}=<file:{}>", name, path);
}
}
fn print_pretty(vars: &[(String, String)], files: &[(String, String, String)]) {
if !vars.is_empty() {
println!("Encrypted Variables:");
println!("{}", "-".repeat(60));
for (name, value) in vars {
let preview = if value.len() > 50 {
format!("{}...", &value[..50])
} else {
value.clone()
};
println!(" {:<20} {}", name, preview);
}
}
if !files.is_empty() {
if !vars.is_empty() {
println!();
}
println!("Encrypted Files:");
println!("{}", "-".repeat(60));
for (name, path, content) in files {
let preview = if content.len() > 50 {
format!("{}...", &content[..50])
} else {
content.clone()
};
let preview = preview.replace('\n', "\\n");
println!(" {:<20} {} ({})", name, path, preview);
}
}
}
fn print_json(vars: &[(String, String)], files: &[(String, String, String)]) -> anyhow::Result<()> {
let mut vars_map = serde_json::Map::new();
for (name, value) in vars {
vars_map.insert(name.clone(), serde_json::Value::String(value.clone()));
}
let mut files_map = serde_json::Map::new();
for (name, path, content) in files {
let mut entry = serde_json::Map::new();
entry.insert("path".to_string(), serde_json::Value::String(path.clone()));
entry.insert(
"content".to_string(),
serde_json::Value::String(content.clone()),
);
files_map.insert(name.clone(), serde_json::Value::Object(entry));
}
let output = serde_json::json!({
"vars": vars_map,
"files": files_map,
});
println!("{}", serde_json::to_string_pretty(&output)?);
Ok(())
}

View file

@ -0,0 +1,72 @@
use doot_core::Config;
use doot_lang::builtins::crypto::base64_decode;
use std::io::{self, Read};
use std::path::PathBuf;
/// Resolves the age identity key from CLI flag, env var, or default file.
fn resolve_identity(identity: Option<PathBuf>) -> anyhow::Result<age::x25519::Identity> {
let identity_raw = if let Some(path) = identity {
std::fs::read_to_string(&path)?
} else if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {
key
} else {
let id_file = Config::default_config_dir().join("identity.txt");
if id_file.exists() {
std::fs::read_to_string(&id_file)?
} else {
anyhow::bail!(
"no identity available. use --identity, DOOT_AGE_IDENTITY env var, or create {}",
id_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());
identity_key
.parse::<age::x25519::Identity>()
.map_err(|e| anyhow::anyhow!("invalid age identity: {}", e))
}
/// Decrypts a single base64-encoded ciphertext and prints the plaintext.
#[tracing::instrument(skip_all)]
pub fn run(value: Option<String>, identity: Option<PathBuf>) -> anyhow::Result<()> {
let ciphertext_b64 = if let Some(v) = value {
v
} else {
let mut buf = String::new();
io::stdin().read_to_string(&mut buf)?;
buf.trim().to_string()
};
let identity = resolve_identity(identity)?;
let encrypted_bytes =
base64_decode(&ciphertext_b64).map_err(|e| anyhow::anyhow!("invalid base64: {}", e))?;
let decryptor = match age::Decryptor::new(&encrypted_bytes[..])
.map_err(|e| anyhow::anyhow!("decryption error: {}", e))?
{
age::Decryptor::Recipients(d) => d,
_ => anyhow::bail!("unexpected decryptor type"),
};
let mut decrypted = vec![];
let mut reader = decryptor
.decrypt(std::iter::once(&identity as &dyn age::Identity))
.map_err(|e| anyhow::anyhow!("decryption failed: {}", e))?;
io::Read::read_to_end(&mut reader, &mut decrypted)
.map_err(|e| anyhow::anyhow!("read error: {}", e))?;
let plaintext = String::from_utf8(decrypted)
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in decrypted data: {}", e))?;
print!("{}", plaintext);
Ok(())
}

View file

@ -5,6 +5,7 @@ use doot_core::{
state::{DeployMode, StateStore}, state::{DeployMode, StateStore},
}; };
use doot_lang::Evaluator; use doot_lang::Evaluator;
use std::collections::HashMap;
use std::io::{self, Write}; use std::io::{self, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
@ -24,9 +25,12 @@ pub fn run(
let mut evaluator = Evaluator::new(); let mut evaluator = Evaluator::new();
let result = evaluator.eval_sync(&program)?; 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 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)?;
let state = StateStore::new(&config.state_file); let state = StateStore::new(&config.state_file);
let target_path = expand_tilde(&target); let target_path = expand_tilde(&target);
@ -66,7 +70,7 @@ pub fn run(
if should_apply { if should_apply {
if let Some(df) = dotfile { if let Some(df) = dotfile {
apply_single(&source_file, &df.target, df, &config)?; apply_single(&source_file, &df.target, df, &config, &template_vars)?;
println!("applied changes to {}", df.target.display()); println!("applied changes to {}", df.target.display());
} else { } else {
println!("hint: run 'doot apply' to deploy changes"); println!("hint: run 'doot apply' to deploy changes");
@ -100,6 +104,7 @@ fn apply_single(
target: &PathBuf, target: &PathBuf,
dotfile: &doot_lang::evaluator::DotfileConfig, dotfile: &doot_lang::evaluator::DotfileConfig,
config: &Config, config: &Config,
template_vars: &HashMap<String, doot_lang::evaluator::Value>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let deploy_mode = match dotfile.deploy { let deploy_mode = match dotfile.deploy {
doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy, doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy,
@ -115,7 +120,8 @@ fn apply_single(
} }
let content = std::fs::read_to_string(source)?; let content = std::fs::read_to_string(source)?;
let engine = TemplateEngine::new(); let mut engine = TemplateEngine::new();
engine.set_doot_variables(template_vars);
let rendered = engine let rendered = engine
.render(&content) .render(&content)
.map_err(|e| anyhow::anyhow!("template error: {}", e))?; .map_err(|e| anyhow::anyhow!("template error: {}", e))?;

View file

@ -1,18 +1,24 @@
use doot_core::{Config, encryption::AgeEncryption}; use doot_core::{Config, encryption::AgeEncryption};
use std::path::PathBuf; use std::path::PathBuf;
/// Encrypts a file using age encryption. /// Encrypts a file using age encryption with multi-recipient support.
#[tracing::instrument(skip_all, fields(file = %file.display()))] #[tracing::instrument(skip_all, fields(file = %file.display()))]
pub fn run(file: PathBuf, recipient: Option<String>) -> anyhow::Result<()> { pub fn run(file: PathBuf, recipients: Vec<String>) -> anyhow::Result<()> {
let config_dir = Config::default_config_dir(); let keys = if !recipients.is_empty() {
let recipient_key = if let Some(r) = recipient { recipients
r
} else if let Ok(key) = std::env::var("DOOT_AGE_RECIPIENT") { } else if let Ok(key) = std::env::var("DOOT_AGE_RECIPIENT") {
key key.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
} else { } else {
let key_file = config_dir.join("recipient.txt"); let key_file = Config::default_config_dir().join("recipient.txt");
if key_file.exists() { if key_file.exists() {
std::fs::read_to_string(&key_file)?.trim().to_string() std::fs::read_to_string(&key_file)?
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect()
} else { } else {
anyhow::bail!( anyhow::bail!(
"no recipient specified. use --recipient, DOOT_AGE_RECIPIENT env var, or {}", "no recipient specified. use --recipient, DOOT_AGE_RECIPIENT env var, or {}",
@ -21,14 +27,20 @@ pub fn run(file: PathBuf, recipient: Option<String>) -> anyhow::Result<()> {
} }
}; };
if keys.is_empty() {
anyhow::bail!("no recipient keys found");
}
tracing::debug!( tracing::debug!(
file = %file.display(), file = %file.display(),
recipient_prefix = &recipient_key[..20.min(recipient_key.len())], num_recipients = keys.len(),
"encrypting file" "encrypting file"
); );
let mut encryption = AgeEncryption::new(); let mut encryption = AgeEncryption::new();
encryption.add_recipient(&recipient_key)?; for key in &keys {
encryption.add_recipient(key)?;
}
let output = file.with_extension( let output = file.with_extension(
file.extension() file.extension()

View file

@ -0,0 +1,79 @@
use doot_core::Config;
use doot_lang::builtins::crypto::base64_encode;
use std::io::{self, Read, Write};
/// Resolves recipient keys from CLI flags, env var, or recipient.txt (supports multiple keys).
fn resolve_recipients(recipients: Vec<String>) -> anyhow::Result<Vec<age::x25519::Recipient>> {
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 recipient specified. use --recipient, DOOT_AGE_RECIPIENT env var, or {}",
key_file.display()
);
}
};
if keys.is_empty() {
anyhow::bail!("no recipient keys found");
}
keys.iter()
.map(|k| {
k.parse::<age::x25519::Recipient>()
.map_err(|e| anyhow::anyhow!("invalid recipient '{}': {}", k, e))
})
.collect()
}
/// Encrypts a value string with age and outputs base64 for use in `encrypted:` blocks.
#[tracing::instrument(skip_all)]
pub fn run(value: Option<String>, recipients: Vec<String>) -> anyhow::Result<()> {
let plaintext = if let Some(v) = value {
v
} else {
let mut buf = String::new();
io::stdin().read_to_string(&mut buf)?;
buf.trim_end().to_string()
};
let recipient_keys = resolve_recipients(recipients)?;
let recipients_boxed: Vec<Box<dyn age::Recipient + Send>> = recipient_keys
.into_iter()
.map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)
.collect();
let encryptor =
age::Encryptor::with_recipients(recipients_boxed).expect("failed to create encryptor");
let mut encrypted = vec![];
let mut writer = encryptor
.wrap_output(&mut encrypted)
.map_err(|e| anyhow::anyhow!("encryption error: {}", e))?;
writer
.write_all(plaintext.as_bytes())
.map_err(|e| anyhow::anyhow!("encryption error: {}", e))?;
writer
.finish()
.map_err(|e| anyhow::anyhow!("encryption error: {}", e))?;
let encoded = base64_encode(&encrypted);
println!("{}", encoded);
Ok(())
}

View file

@ -3,9 +3,12 @@
pub mod apply; pub mod apply;
pub mod check; pub mod check;
pub mod decrypt; pub mod decrypt;
pub mod decrypt_entries;
pub mod decrypt_var;
pub mod diff; pub mod diff;
pub mod edit; pub mod edit;
pub mod encrypt; pub mod encrypt;
pub mod encrypt_var;
pub mod fmt; pub mod fmt;
pub mod init; pub mod init;
pub mod lsp; pub mod lsp;
@ -16,7 +19,10 @@ pub mod status;
pub mod tui; pub mod tui;
use doot_core::Config; use doot_core::Config;
use doot_lang::evaluator::{EvalResult, Value};
use doot_lang::{Lexer, Parser, TypeChecker}; use doot_lang::{Lexer, Parser, TypeChecker};
use indexmap::IndexMap;
use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
/// Resolves the config file path, checking the given path or default locations. /// Resolves the config file path, checking the given path or default locations.
@ -98,3 +104,126 @@ pub fn type_check(
} }
Ok(()) Ok(())
} }
/// Decrypts encrypted vars and files from `EvalResult` and inserts them into template variables
/// as `Value::Struct("encrypted", { KEY: "plaintext", ... })`.
pub fn decrypt_encrypted_vars(
result: &EvalResult,
config: &Config,
template_vars: &mut HashMap<String, Value>,
) -> anyhow::Result<()> {
decrypt_encrypted_vars_with_source_dir(result, config, template_vars, None)
}
/// Decrypts encrypted vars and files, resolving file paths relative to `source_dir`.
pub fn decrypt_encrypted_vars_with_source_dir(
result: &EvalResult,
config: &Config,
template_vars: &mut HashMap<String, Value>,
source_dir: Option<&std::path::Path>,
) -> anyhow::Result<()> {
if result.encrypted_vars.is_empty() && result.encrypted_files.is_empty() {
return Ok(());
}
// Read identity key (filter out comment lines from age-keygen output)
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!(
"encrypted entries found but no identity available. set DOOT_AGE_IDENTITY env var 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());
let identity = identity_key
.parse::<age::x25519::Identity>()
.map_err(|e| anyhow::anyhow!("invalid age identity: {}", e))?;
let mut decrypted_map = IndexMap::new();
// Decrypt inline vars
for (name, ciphertext_b64) in &result.encrypted_vars {
let encrypted_bytes = doot_lang::builtins::crypto::base64_decode(ciphertext_b64)
.map_err(|e| anyhow::anyhow!("invalid base64 for encrypted var '{}': {}", name, e))?;
let decryptor = match age::Decryptor::new(&encrypted_bytes[..])
.map_err(|e| anyhow::anyhow!("decryption error for '{}': {}", name, e))?
{
age::Decryptor::Recipients(d) => d,
_ => anyhow::bail!("unexpected decryptor type for '{}'", name),
};
let mut decrypted = vec![];
let mut reader = decryptor
.decrypt(std::iter::once(&identity as &dyn age::Identity))
.map_err(|e| anyhow::anyhow!("decryption failed for '{}': {}", name, e))?;
use std::io::Read;
reader
.read_to_end(&mut decrypted)
.map_err(|e| anyhow::anyhow!("read error decrypting '{}': {}", name, e))?;
let plaintext = String::from_utf8(decrypted)
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in decrypted '{}': {}", name, e))?;
decrypted_map.insert(name.clone(), Value::Str(plaintext));
}
// Decrypt file entries
for (name, rel_path) in &result.encrypted_files {
let full_path = if rel_path.is_relative() {
source_dir
.map(|d| d.join(rel_path))
.unwrap_or_else(|| rel_path.clone())
} else {
rel_path.clone()
};
let encrypted_bytes = std::fs::read(&full_path).map_err(|e| {
anyhow::anyhow!(
"cannot read encrypted file '{}' ({}): {}",
name,
full_path.display(),
e
)
})?;
let decryptor = match age::Decryptor::new(&encrypted_bytes[..])
.map_err(|e| anyhow::anyhow!("decryption error for file '{}': {}", name, e))?
{
age::Decryptor::Recipients(d) => d,
_ => anyhow::bail!("unexpected decryptor type for file '{}'", name),
};
let mut decrypted = vec![];
let mut reader = decryptor
.decrypt(std::iter::once(&identity as &dyn age::Identity))
.map_err(|e| anyhow::anyhow!("decryption failed for file '{}': {}", name, e))?;
use std::io::Read;
reader
.read_to_end(&mut decrypted)
.map_err(|e| anyhow::anyhow!("read error decrypting file '{}': {}", name, e))?;
let plaintext = String::from_utf8(decrypted)
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in decrypted file '{}': {}", name, e))?;
decrypted_map.insert(name.clone(), Value::Str(plaintext));
}
template_vars.insert(
"encrypted".to_string(),
Value::Struct("encrypted".to_string(), decrypted_map),
);
Ok(())
}

View file

@ -32,6 +32,7 @@ pub fn install(config_path: Option<PathBuf>) -> anyhow::Result<()> {
"apt" => p.apt.clone().or_else(|| p.default.clone()), "apt" => p.apt.clone().or_else(|| p.default.clone()),
"pacman" => p.pacman.clone().or_else(|| p.default.clone()), "pacman" => p.pacman.clone().or_else(|| p.default.clone()),
"yay" => p.yay.clone().or_else(|| p.default.clone()), "yay" => p.yay.clone().or_else(|| p.default.clone()),
"xbps" => p.xbps.clone().or_else(|| p.default.clone()),
_ => p.default.clone(), _ => p.default.clone(),
}) })
.collect(); .collect();

View file

@ -115,17 +115,17 @@ enum Commands {
name: String, name: String,
}, },
/// Encrypt a file with age: `doot encrypt <FILE> [-r RECIPIENT]` /// Encrypt a file with age: `doot encrypt <FILE> [-r RECIPIENT]...`
Encrypt { Encrypt {
/// File to encrypt /// File to encrypt
file: PathBuf, file: PathBuf,
/// Recipient public key /// Recipient public key (can be specified multiple times)
#[arg(short, long)] #[arg(short, long)]
recipient: Option<String>, recipient: Vec<String>,
}, },
/// Decrypt an age-encrypted file: `doot decrypt <FILE> [-i IDENTITY]` /// Decrypt an age-encrypted file: `doot decrypt <FILE> [-i IDENTITY] [-o OUTPUT]`
Decrypt { Decrypt {
/// File to decrypt /// File to decrypt
file: PathBuf, file: PathBuf,
@ -133,6 +133,10 @@ enum Commands {
/// Path to age identity file /// Path to age identity file
#[arg(short, long)] #[arg(short, long)]
identity: Option<PathBuf>, identity: Option<PathBuf>,
/// Output to file instead of stdout
#[arg(short, long)]
output: Option<PathBuf>,
}, },
/// Manage system packages: `doot package {install|update|list}` /// Manage system packages: `doot package {install|update|list}`
@ -147,6 +151,37 @@ enum Commands {
/// Launch interactive TUI: `doot tui` /// Launch interactive TUI: `doot tui`
Tui, Tui,
/// Encrypt a value for use in `encrypted:` blocks: `doot encrypt-var [VALUE] [-r RECIPIENT]...`
EncryptVar {
/// Value to encrypt (reads from stdin if omitted)
value: Option<String>,
/// Recipient public key (can be specified multiple times)
#[arg(short, long)]
recipient: Vec<String>,
},
/// Decrypt a single base64-encoded value: `doot decrypt-var [VALUE] [-i IDENTITY]`
DecryptVar {
/// Base64-encoded ciphertext (reads from stdin if omitted)
value: Option<String>,
/// Path to age identity file
#[arg(short, long)]
identity: Option<PathBuf>,
},
/// Decrypt all encrypted entries from a doot file: `doot decrypt-entries [--format FORMAT]`
DecryptEntries {
/// Output format: plain, pretty, json
#[arg(short, long, default_value = "plain")]
format: String,
/// Path to age identity file
#[arg(short, long)]
identity: Option<PathBuf>,
},
/// 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)
@ -241,7 +276,11 @@ fn main() -> anyhow::Result<()> {
Commands::Rollback { snapshot } => commands::rollback::run(cli.config, snapshot), Commands::Rollback { snapshot } => commands::rollback::run(cli.config, snapshot),
Commands::Snapshot { name } => commands::snapshot::run(cli.config, name), Commands::Snapshot { name } => commands::snapshot::run(cli.config, name),
Commands::Encrypt { file, recipient } => commands::encrypt::run(file, recipient), Commands::Encrypt { file, recipient } => commands::encrypt::run(file, recipient),
Commands::Decrypt { file, identity } => commands::decrypt::run(file, identity), Commands::Decrypt {
file,
identity,
output,
} => commands::decrypt::run(file, identity, output),
Commands::Package { action } => match action { Commands::Package { action } => match action {
PackageAction::Install => commands::package::install(cli.config), PackageAction::Install => commands::package::install(cli.config),
PackageAction::Update => commands::package::update(), PackageAction::Update => commands::package::update(),
@ -249,6 +288,14 @@ fn main() -> anyhow::Result<()> {
}, },
Commands::Lsp => commands::lsp::run(), Commands::Lsp => commands::lsp::run(),
Commands::Tui => commands::tui::run(cli.config), Commands::Tui => commands::tui::run(cli.config),
Commands::EncryptVar { value, recipient } => commands::encrypt_var::run(value, recipient),
Commands::DecryptVar { value, identity } => commands::decrypt_var::run(value, identity),
Commands::DecryptEntries { format, identity } => {
let fmt = format
.parse::<commands::decrypt_entries::OutputFormat>()
.map_err(|e| anyhow::anyhow!(e))?;
commands::decrypt_entries::run(cli.config, fmt, identity)
}
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

@ -106,12 +106,20 @@ pub struct Deployer {
impl Deployer { impl Deployer {
/// Creates a new deployer. /// Creates a new deployer.
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub fn new(config: Config, sandbox: bool) -> Self { pub fn new(
config: Config,
sandbox: bool,
template_vars: Option<&std::collections::HashMap<String, doot_lang::evaluator::Value>>,
) -> Self {
let state = StateStore::new(&config.state_file); let state = StateStore::new(&config.state_file);
let linker = Linker::new(config.clone()); let linker = Linker::new(config.clone());
let mut template_engine = TemplateEngine::new();
if let Some(vars) = template_vars {
template_engine.set_doot_variables(vars);
}
Self { Self {
linker: Arc::new(linker), linker: Arc::new(linker),
template_engine: Arc::new(TemplateEngine::new()), template_engine: Arc::new(template_engine),
state: Arc::new(Mutex::new(state)), state: Arc::new(Mutex::new(state)),
config: Arc::new(config), config: Arc::new(config),
sandbox, sandbox,
@ -303,11 +311,16 @@ impl Deployer {
) -> Result<DeployedFile, DeployError> { ) -> Result<DeployedFile, DeployError> {
use crate::state::SyncStatus; use crate::state::SyncStatus;
let permissions = if dotfile.permissions.is_empty() {
None
} else {
Some(dotfile.permissions.as_slice())
};
let changed_files = self let changed_files = self
.state .state
.lock() .lock()
.unwrap() .unwrap()
.get_changed_files_in_dir(source, target); .get_changed_files_in_dir_with_permissions(source, target, permissions);
tracing::trace!( tracing::trace!(
changed_count = changed_files.len(), changed_count = changed_files.len(),
"directory file changes" "directory file changes"
@ -635,12 +648,11 @@ fn apply_permissions(target: &Path, rules: &[PermissionRule]) -> Result<(), Depl
return Ok(()); return Ok(());
} }
PermissionRule::Pattern { pattern, mode } => { PermissionRule::Pattern { pattern, mode } => {
if let Ok(p) = Pattern::new(pattern) { if let Ok(p) = Pattern::new(pattern)
let name = target.file_name().unwrap_or_default().to_string_lossy(); && match_pattern_path_suffixes(&p, target)
if p.matches(&name) { {
set_file_permissions(target, *mode)?; set_file_permissions(target, *mode)?;
return Ok(()); return Ok(());
}
} }
} }
} }
@ -652,6 +664,18 @@ fn apply_permissions(target: &Path, rules: &[PermissionRule]) -> Result<(), Depl
Ok(()) Ok(())
} }
/// Matches a glob pattern against progressively longer path suffixes.
fn match_pattern_path_suffixes(pattern: &Pattern, path: &Path) -> bool {
let components: Vec<_> = path.components().collect();
for i in (0..components.len()).rev() {
let suffix: std::path::PathBuf = components[i..].iter().collect();
if pattern.matches(&suffix.to_string_lossy()) {
return true;
}
}
false
}
fn apply_permissions_recursive( fn apply_permissions_recursive(
base: &Path, base: &Path,
current: &Path, current: &Path,

View file

@ -1,7 +1,7 @@
//! Template rendering for dotfiles using MiniJinja. //! Template rendering for dotfiles using MiniJinja.
use minijinja::{Environment, Value}; use minijinja::{Environment, Value};
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf; use std::path::PathBuf;
/// Renders templates with Jinja2-style syntax. /// Renders templates with Jinja2-style syntax.
@ -30,6 +30,14 @@ impl TemplateEngine {
self.variables.insert(key, value.into()); self.variables.insert(key, value.into());
} }
/// Sets multiple variables from doot evaluator values.
pub fn set_doot_variables(&mut self, vars: &HashMap<String, doot_lang::evaluator::Value>) {
for (key, value) in vars {
self.variables
.insert(key.clone(), doot_value_to_minijinja(value));
}
}
/// Renders a template string. /// Renders a template string.
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub fn render(&self, template: &str) -> Result<String, String> { pub fn render(&self, template: &str) -> Result<String, String> {
@ -426,6 +434,32 @@ fn json_to_minijinja(json: &serde_json::Value) -> Value {
} }
} }
/// Converts a doot evaluator Value to a minijinja Value.
fn doot_value_to_minijinja(val: &doot_lang::evaluator::Value) -> Value {
use doot_lang::evaluator::Value as DootValue;
match val {
DootValue::Int(n) => Value::from(*n),
DootValue::Float(n) => Value::from(*n),
DootValue::Str(s) => Value::from(s.as_str()),
DootValue::Bool(b) => Value::from(*b),
DootValue::Path(p) => Value::from(p.display().to_string()),
DootValue::List(items) => {
let converted: Vec<Value> = items.iter().map(doot_value_to_minijinja).collect();
Value::from(converted)
}
DootValue::Struct(_, fields) => {
let map: BTreeMap<String, Value> = fields
.iter()
.map(|(k, v)| (k.clone(), doot_value_to_minijinja(v)))
.collect();
Value::from_iter(map)
}
DootValue::Enum(_, variant) => Value::from(variant.as_str()),
DootValue::None => Value::UNDEFINED,
_ => Value::UNDEFINED, // Function, Lambda, Future
}
}
/// Converts toml::Value to minijinja::Value. /// Converts toml::Value to minijinja::Value.
fn toml_to_minijinja(toml: &toml::Value) -> Value { fn toml_to_minijinja(toml: &toml::Value) -> Value {
match toml { match toml {

View file

@ -3,6 +3,7 @@
pub mod apt; pub mod apt;
pub mod brew; pub mod brew;
pub mod pacman; pub mod pacman;
pub mod xbps;
pub mod yay; pub mod yay;
use std::collections::HashSet; use std::collections::HashSet;
@ -12,6 +13,7 @@ use thiserror::Error;
pub use apt::Apt; pub use apt::Apt;
pub use brew::Brew; pub use brew::Brew;
pub use pacman::Pacman; pub use pacman::Pacman;
pub use xbps::Xbps;
pub use yay::Yay; pub use yay::Yay;
/// Package management errors. /// Package management errors.
@ -145,6 +147,7 @@ pub fn detect_package_manager() -> Option<Box<dyn PackageManager>> {
Box::new(Brew::new()), Box::new(Brew::new()),
Box::new(Yay::new()), Box::new(Yay::new()),
Box::new(Pacman::new()), Box::new(Pacman::new()),
Box::new(Xbps::new()),
Box::new(Apt::new()), Box::new(Apt::new()),
]; ];
@ -163,6 +166,7 @@ pub fn get_package_manager(name: &str) -> Option<Box<dyn PackageManager>> {
"apt" => Some(Box::new(Apt::new())), "apt" => Some(Box::new(Apt::new())),
"pacman" => Some(Box::new(Pacman::new())), "pacman" => Some(Box::new(Pacman::new())),
"yay" => Some(Box::new(Yay::new())), "yay" => Some(Box::new(Yay::new())),
"xbps" => Some(Box::new(Xbps::new())),
_ => None, _ => None,
} }
} }

View file

@ -0,0 +1,184 @@
use super::{PackageError, PackageManager};
use std::io::Write;
use std::process::{Command, Stdio};
pub struct Xbps {
dry_run: bool,
use_sudo: bool,
}
impl Xbps {
pub fn new() -> Self {
Self {
dry_run: false,
use_sudo: true,
}
}
pub fn dry_run(mut self, dry_run: bool) -> Self {
self.dry_run = dry_run;
self
}
pub fn use_sudo(mut self, use_sudo: bool) -> Self {
self.use_sudo = use_sudo;
self
}
#[tracing::instrument(skip(self))]
fn run_xbps(&self, cmd: &str, args: &[&str]) -> Result<(), PackageError> {
if self.dry_run {
let prefix = if self.use_sudo { "sudo " } else { "" };
println!("[dry-run] {}{} {}", prefix, cmd, args.join(" "));
return Ok(());
}
let output = if self.use_sudo {
Command::new("sudo").arg(cmd).args(args).output()?
} else {
Command::new(cmd).args(args).output()?
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let message = if stderr.trim().is_empty() {
stdout
} else {
stderr
};
let pkg_names: Vec<&str> = args
.iter()
.filter(|a| !a.starts_with('-'))
.copied()
.collect();
return Err(PackageError::InstallFailed {
package: pkg_names.join(" "),
message,
});
}
Ok(())
}
#[tracing::instrument(skip(self, password))]
fn run_xbps_with_password(
&self,
cmd: &str,
args: &[&str],
password: &str,
) -> Result<(), PackageError> {
if self.dry_run {
println!("[dry-run] sudo {} {}", cmd, args.join(" "));
return Ok(());
}
let mut child = Command::new("sudo")
.arg("-S")
.arg(cmd)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
writeln!(stdin, "{}", password).ok();
}
let output = child.wait_with_output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let message = if stderr.trim().is_empty() {
stdout
} else {
stderr
};
let pkg_names: Vec<&str> = args
.iter()
.filter(|a| !a.starts_with('-'))
.copied()
.collect();
return Err(PackageError::InstallFailed {
package: pkg_names.join(" "),
message,
});
}
Ok(())
}
}
impl PackageManager for Xbps {
fn name(&self) -> &'static str {
"xbps"
}
fn is_available(&self) -> bool {
std::path::Path::new("/usr/bin/xbps-install").exists()
}
fn needs_sudo(&self) -> bool {
self.use_sudo
}
fn install(&self, packages: &[String]) -> Result<(), PackageError> {
if packages.is_empty() {
return Ok(());
}
let mut args = vec!["-y"];
for pkg in packages {
args.push(pkg);
}
self.run_xbps("xbps-install", &args)
}
fn install_with_sudo(&self, packages: &[String], password: &str) -> Result<(), PackageError> {
if packages.is_empty() {
return Ok(());
}
let mut args = vec!["-y"];
for pkg in packages {
args.push(pkg);
}
self.run_xbps_with_password("xbps-install", &args, password)
}
fn uninstall(&self, packages: &[String]) -> Result<(), PackageError> {
if packages.is_empty() {
return Ok(());
}
let mut args = vec!["-y"];
for pkg in packages {
args.push(pkg);
}
self.run_xbps("xbps-remove", &args)
}
fn is_installed(&self, package: &str) -> Result<bool, PackageError> {
let output = Command::new("xbps-query").arg(package).output()?;
Ok(output.status.success())
}
fn update(&self) -> Result<(), PackageError> {
self.run_xbps("xbps-install", &["-S"])
}
fn upgrade(&self) -> Result<(), PackageError> {
self.run_xbps("xbps-install", &["-Syu"])
}
}
impl Default for Xbps {
fn default() -> Self {
Self::new()
}
}

View file

@ -383,6 +383,16 @@ impl StateStore {
&self, &self,
source_dir: &Path, source_dir: &Path,
target_dir: &Path, target_dir: &Path,
) -> Vec<(PathBuf, PathBuf, SyncStatus)> {
self.get_changed_files_in_dir_with_permissions(source_dir, target_dir, None)
}
/// Returns files that have changed in a directory, including permission changes.
pub fn get_changed_files_in_dir_with_permissions(
&self,
source_dir: &Path,
target_dir: &Path,
permissions: Option<&[PermissionRule]>,
) -> Vec<(PathBuf, PathBuf, SyncStatus)> { ) -> Vec<(PathBuf, PathBuf, SyncStatus)> {
let mut changed = Vec::new(); let mut changed = Vec::new();
@ -393,7 +403,14 @@ impl StateStore {
for source_file in source_files { for source_file in source_files {
if let Ok(relative) = source_file.strip_prefix(source_dir) { if let Ok(relative) = source_file.strip_prefix(source_dir) {
let target_file = target_dir.join(relative); let target_file = target_dir.join(relative);
let status = self.check_sync_status(&source_file, &target_file); let status = self.check_sync_status_with_config(
&source_file,
&target_file,
None,
None,
permissions,
None,
);
if status != SyncStatus::Synced { if status != SyncStatus::Synced {
changed.push((source_file, target_file, status)); changed.push((source_file, target_file, status));
@ -524,14 +541,27 @@ pub fn expected_mode_for_file(path: &Path, rules: &[PermissionRule]) -> Option<u
match rule { match rule {
PermissionRule::Single(mode) => return Some(*mode), PermissionRule::Single(mode) => return Some(*mode),
PermissionRule::Pattern { pattern, mode } => { PermissionRule::Pattern { pattern, mode } => {
if let Ok(p) = glob::Pattern::new(pattern) { if let Ok(p) = glob::Pattern::new(pattern)
let name = path.file_name().unwrap_or_default().to_string_lossy(); && match_pattern_path_suffixes(&p, path)
if p.matches(&name) { {
return Some(*mode); return Some(*mode);
}
} }
} }
} }
} }
None None
} }
/// Matches a glob pattern against progressively longer path suffixes.
/// For example, given path `/home/user/.config/service/pipewire/run` and pattern `*/run`,
/// it tries: `run`, `pipewire/run` (match!), `service/pipewire/run`, etc.
fn match_pattern_path_suffixes(pattern: &glob::Pattern, path: &Path) -> bool {
let components: Vec<_> = path.components().collect();
for i in (0..components.len()).rev() {
let suffix: std::path::PathBuf = components[i..].iter().collect();
if pattern.matches(&suffix.to_string_lossy()) {
return true;
}
}
false
}

View file

@ -35,9 +35,10 @@ pub enum Statement {
EnumDecl(EnumDecl), EnumDecl(EnumDecl),
TypeAlias(TypeAlias), TypeAlias(TypeAlias),
Import(Import), Import(Import),
Dotfile(Dotfile), Dotfile(Box<Dotfile>),
Package(Box<Package>), Package(Box<Package>),
Secret(Secret), Secret(Secret),
Encrypted(EncryptedVars),
Hook(Hook), Hook(Hook),
MacroDecl(MacroDecl), MacroDecl(MacroDecl),
MacroCall(MacroCall), MacroCall(MacroCall),
@ -161,6 +162,7 @@ pub struct Package {
pub apt: Option<PackageSpec>, pub apt: Option<PackageSpec>,
pub pacman: Option<PackageSpec>, pub pacman: Option<PackageSpec>,
pub yay: Option<PackageSpec>, pub yay: Option<PackageSpec>,
pub xbps: Option<PackageSpec>,
pub when: Option<Expr>, pub when: Option<Expr>,
} }
@ -180,6 +182,21 @@ pub struct Secret {
pub mode: Option<u32>, pub mode: Option<u32>,
} }
/// Entry in an `encrypted:` block — either an inline var or a file reference.
#[derive(Clone, Debug, PartialEq)]
pub enum EncryptedEntry {
/// Inline base64-encoded encrypted value: `KEY = "base64..."`
Var(Ident, Expr),
/// Encrypted file reference: `KEY = file("path/to/file.age")`
File(Ident, Expr),
}
/// Encrypted variable declarations (for template use).
#[derive(Clone, Debug, PartialEq)]
pub struct EncryptedVars {
pub entries: Vec<EncryptedEntry>,
}
/// Lifecycle hook declaration. /// Lifecycle hook declaration.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct Hook { pub struct Hook {

View file

@ -125,7 +125,7 @@ pub fn decrypt_age(args: &[Value]) -> Result<Value, EvalError> {
})?)) })?))
} }
fn base64_encode(data: &[u8]) -> String { pub fn base64_encode(data: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut result = String::new(); let mut result = String::new();
@ -153,7 +153,7 @@ fn base64_encode(data: &[u8]) -> String {
result result
} }
fn base64_decode(s: &str) -> Result<Vec<u8>, String> { pub fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
const DECODE: [i8; 256] = { const DECODE: [i8; 256] = {
let mut table = [-1i8; 256]; let mut table = [-1i8; 256];
let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

View file

@ -269,6 +269,17 @@ impl Env {
} }
vars vars
} }
/// Returns all variables as raw Values (for template engine integration).
pub fn get_raw_variables(&self) -> HashMap<String, Value> {
let mut vars = HashMap::new();
for scope in &self.scopes {
for (name, value) in scope {
vars.insert(name.clone(), value.clone());
}
}
vars
}
} }
/// Deploy mode for dotfiles. /// Deploy mode for dotfiles.
@ -333,6 +344,7 @@ pub struct PackageConfig {
pub apt: Option<String>, pub apt: Option<String>,
pub pacman: Option<String>, pub pacman: Option<String>,
pub yay: Option<String>, pub yay: Option<String>,
pub xbps: Option<String>,
} }
/// Evaluated secret file configuration. /// Evaluated secret file configuration.
@ -358,6 +370,8 @@ pub struct EvalResult {
pub packages: Vec<PackageConfig>, pub packages: Vec<PackageConfig>,
pub secrets: Vec<SecretConfig>, pub secrets: Vec<SecretConfig>,
pub hooks: Vec<HookConfig>, pub hooks: Vec<HookConfig>,
pub encrypted_vars: HashMap<String, String>,
pub encrypted_files: HashMap<String, PathBuf>,
pub sandbox: bool, pub sandbox: bool,
} }
@ -369,6 +383,8 @@ impl Default for EvalResult {
packages: Vec::new(), packages: Vec::new(),
secrets: Vec::new(), secrets: Vec::new(),
hooks: Vec::new(), hooks: Vec::new(),
encrypted_vars: HashMap::new(),
encrypted_files: HashMap::new(),
sandbox: true, sandbox: true,
} }
} }
@ -474,6 +490,11 @@ impl Evaluator {
vars vars
} }
/// Returns all variables as raw Values for template rendering.
pub fn get_template_variables(&self) -> HashMap<String, Value> {
self.env.get_raw_variables()
}
#[async_recursion(?Send)] #[async_recursion(?Send)]
#[tracing::instrument(level = "trace", skip_all)] #[tracing::instrument(level = "trace", skip_all)]
async fn eval_statement(&mut self, stmt: &Statement) -> Result<Option<Value>, EvalError> { async fn eval_statement(&mut self, stmt: &Statement) -> Result<Option<Value>, EvalError> {
@ -723,6 +744,11 @@ impl Evaluator {
} else { } else {
None None
}; };
let xbps = if let Some(ref s) = pkg.xbps {
Some(self.eval_to_string(&s.name).await?)
} else {
None
};
self.result.packages.push(PackageConfig { self.result.packages.push(PackageConfig {
default, default,
@ -730,6 +756,7 @@ impl Evaluator {
apt, apt,
pacman, pacman,
yay, yay,
xbps,
}); });
Ok(None) Ok(None)
} }
@ -746,6 +773,33 @@ impl Evaluator {
Ok(None) Ok(None)
} }
Statement::Encrypted(encrypted) => {
for entry in &encrypted.entries {
match entry {
crate::ast::EncryptedEntry::Var(name, expr) => {
let val = self.eval_expr(expr).await?;
match val {
Value::Str(s) => {
self.result.encrypted_vars.insert(name.clone(), s);
}
_ => {
return Err(EvalError::TypeError(format!(
"encrypted var '{}' must be a string, got {}",
name,
val.type_name()
)));
}
}
}
crate::ast::EncryptedEntry::File(name, expr) => {
let path = self.eval_to_path(expr).await?;
self.result.encrypted_files.insert(name.clone(), path);
}
}
}
Ok(None)
}
Statement::Hook(hook) => { Statement::Hook(hook) => {
tracing::trace!("eval hook"); tracing::trace!("eval hook");
if let Some(ref when) = hook.when { if let Some(ref when) = hook.when {

View file

@ -34,6 +34,7 @@ pub enum Token {
Dotfile, Dotfile,
Package, Package,
Secret, Secret,
Encrypted,
Hook, Hook,
BeforeDeploy, BeforeDeploy,
AfterDeploy, AfterDeploy,
@ -115,6 +116,7 @@ impl fmt::Display for Token {
Token::Dotfile => write!(f, "dotfile"), Token::Dotfile => write!(f, "dotfile"),
Token::Package => write!(f, "package"), Token::Package => write!(f, "package"),
Token::Secret => write!(f, "secret"), Token::Secret => write!(f, "secret"),
Token::Encrypted => write!(f, "encrypted"),
Token::Hook => write!(f, "hook"), Token::Hook => write!(f, "hook"),
Token::BeforeDeploy => write!(f, "before_deploy"), Token::BeforeDeploy => write!(f, "before_deploy"),
Token::AfterDeploy => write!(f, "after_deploy"), Token::AfterDeploy => write!(f, "after_deploy"),
@ -253,6 +255,7 @@ impl Lexer {
"dotfile" => Token::Dotfile, "dotfile" => Token::Dotfile,
"package" => Token::Package, "package" => Token::Package,
"secret" => Token::Secret, "secret" => Token::Secret,
"encrypted" => Token::Encrypted,
"hook" => Token::Hook, "hook" => Token::Hook,
"before_deploy" => Token::BeforeDeploy, "before_deploy" => Token::BeforeDeploy,
"after_deploy" => Token::AfterDeploy, "after_deploy" => Token::AfterDeploy,

View file

@ -56,7 +56,7 @@ impl MacroExpander {
value: self.substitute_expr(&decl.value, subs), value: self.substitute_expr(&decl.value, subs),
}), }),
Statement::Dotfile(dotfile) => Statement::Dotfile(Dotfile { Statement::Dotfile(dotfile) => Statement::Dotfile(Box::new(Dotfile {
source: self.substitute_expr(&dotfile.source, subs), source: self.substitute_expr(&dotfile.source, subs),
target: self.substitute_expr(&dotfile.target, subs), target: self.substitute_expr(&dotfile.target, subs),
when: dotfile.when.as_ref().map(|e| self.substitute_expr(e, subs)), when: dotfile.when.as_ref().map(|e| self.substitute_expr(e, subs)),
@ -69,7 +69,7 @@ impl MacroExpander {
source_span: dotfile.source_span.clone(), source_span: dotfile.source_span.clone(),
target_span: dotfile.target_span.clone(), target_span: dotfile.target_span.clone(),
when_span: dotfile.when_span.clone(), when_span: dotfile.when_span.clone(),
}), })),
Statement::Package(pkg) => Statement::Package(Box::new(Package { Statement::Package(pkg) => Statement::Package(Box::new(Package {
default: pkg.default.as_ref().map(|e| self.substitute_expr(e, subs)), default: pkg.default.as_ref().map(|e| self.substitute_expr(e, subs)),
@ -93,6 +93,11 @@ impl MacroExpander {
cask: s.cask, cask: s.cask,
tap: s.tap.clone(), tap: s.tap.clone(),
}), }),
xbps: pkg.xbps.as_ref().map(|s| PackageSpec {
name: self.substitute_expr(&s.name, subs),
cask: s.cask,
tap: s.tap.clone(),
}),
when: pkg.when.as_ref().map(|e| self.substitute_expr(e, subs)), when: pkg.when.as_ref().map(|e| self.substitute_expr(e, subs)),
})), })),

View file

@ -42,9 +42,10 @@ impl Parser {
let enum_decl = Self::enum_decl_parser().map(Statement::EnumDecl); let enum_decl = Self::enum_decl_parser().map(Statement::EnumDecl);
let type_alias = Self::type_alias_parser().map(Statement::TypeAlias); let type_alias = Self::type_alias_parser().map(Statement::TypeAlias);
let import = Self::import_parser().map(Statement::Import); let import = Self::import_parser().map(Statement::Import);
let dotfile = Self::dotfile_parser().map(Statement::Dotfile); let dotfile = Self::dotfile_parser().map(|d| Statement::Dotfile(Box::new(d)));
let package = Self::package_parser().map(|p| Statement::Package(Box::new(p))); let package = Self::package_parser().map(|p| Statement::Package(Box::new(p)));
let secret = Self::secret_parser().map(Statement::Secret); let secret = Self::secret_parser().map(Statement::Secret);
let encrypted = Self::encrypted_parser().map(Statement::Encrypted);
let hook = Self::hook_parser().map(Statement::Hook); let hook = Self::hook_parser().map(Statement::Hook);
let simple_hook = Self::simple_hook_parser().map(Statement::Hook); let simple_hook = Self::simple_hook_parser().map(Statement::Hook);
let macro_decl = Self::macro_decl_parser(stmt.clone()).map(Statement::MacroDecl); let macro_decl = Self::macro_decl_parser(stmt.clone()).map(Statement::MacroDecl);
@ -66,6 +67,7 @@ impl Parser {
dotfile, dotfile,
package, package,
secret, secret,
encrypted,
hook, hook,
simple_hook, simple_hook,
macro_decl, macro_decl,
@ -149,9 +151,10 @@ impl Parser {
.ignore_then(Self::ident_parser()) .ignore_then(Self::ident_parser())
.then_ignore(just(Token::Colon)) .then_ignore(just(Token::Colon))
.then_ignore(just(Token::Newline).repeated()) .then_ignore(just(Token::Newline).repeated())
.then_ignore(just(Token::Indent(0)).rewind().or_not()) .then_ignore(Self::indent_parser())
.then( .then(
choice((field.map(Either::Left), method.map(Either::Right))) choice((field.map(Either::Left), method.map(Either::Right)))
.padded_by(Self::indent_parser())
.padded_by(just(Token::Newline).repeated()) .padded_by(just(Token::Newline).repeated())
.repeated(), .repeated(),
) )
@ -311,6 +314,7 @@ impl Parser {
apt: None, apt: None,
pacman: None, pacman: None,
yay: None, yay: None,
xbps: None,
when: None, when: None,
}); });
@ -337,6 +341,7 @@ impl Parser {
apt: None, apt: None,
pacman: None, pacman: None,
yay: None, yay: None,
xbps: None,
when: None, when: None,
}; };
for (name, value) in fields { for (name, value) in fields {
@ -370,6 +375,13 @@ impl Parser {
tap: None, tap: None,
}) })
} }
"xbps" => {
pkg.xbps = Some(PackageSpec {
name: value,
cask: None,
tap: None,
})
}
"when" => pkg.when = Some(value), "when" => pkg.when = Some(value),
_ => {} _ => {}
} }
@ -419,6 +431,40 @@ impl Parser {
}) })
} }
fn encrypted_parser() -> impl chumsky::Parser<Token, EncryptedVars, Error = Simple<Token>> {
// file("path") syntax
let file_entry = Self::ident_parser()
.then_ignore(just(Token::Eq))
.then(
select! { Token::Ident(s) if s == "file" => () }.ignore_then(
Self::expr_parser().delimited_by(just(Token::LParen), just(Token::RParen)),
),
)
.map(|(name, path_expr)| EncryptedEntry::File(name, path_expr));
// Plain inline var: KEY = "base64..."
let var_entry = Self::ident_parser()
.then_ignore(just(Token::Eq))
.then(Self::expr_parser())
.map(|(name, expr)| EncryptedEntry::Var(name, expr));
let entry = file_entry.or(var_entry);
just(Token::Encrypted)
.ignore_then(just(Token::Colon))
.ignore_then(just(Token::Newline).repeated())
.ignore_then(Self::indent_parser())
.ignore_then(
entry
.padded_by(Self::indent_parser())
.padded_by(just(Token::Newline).repeated())
.repeated()
.at_least(1),
)
.then_ignore(just(Token::Dedent).or_not())
.map(|entries| EncryptedVars { entries })
}
fn hook_parser() -> impl chumsky::Parser<Token, Hook, Error = Simple<Token>> { fn hook_parser() -> impl chumsky::Parser<Token, Hook, Error = Simple<Token>> {
let stage = Self::ident_parser().map(|s| match s.as_str() { let stage = Self::ident_parser().map(|s| match s.as_str() {
"BeforeDeploy" => HookStage::BeforeDeploy, "BeforeDeploy" => HookStage::BeforeDeploy,
@ -654,14 +700,25 @@ impl Parser {
.delimited_by(just(Token::LBracket), just(Token::RBracket)) .delimited_by(just(Token::LBracket), just(Token::RBracket))
.map(Expr::List); .map(Expr::List);
// Allow newlines/indent/dedent inside struct braces for multi-line init
let brace_ws = just(Token::Newline)
.or(filter(|t: &Token| matches!(t, Token::Indent(_))))
.or(just(Token::Dedent))
.repeated();
let struct_init = Self::ident_parser() let struct_init = Self::ident_parser()
.then( .then(
Self::ident_parser() just(Token::LBrace)
.then_ignore(just(Token::Eq)) .ignore_then(brace_ws.clone())
.then(expr.clone()) .ignore_then(
.separated_by(just(Token::Comma)) Self::ident_parser()
.allow_trailing() .then_ignore(just(Token::Eq))
.delimited_by(just(Token::LBrace), just(Token::RBrace)), .then(expr.clone())
.separated_by(just(Token::Comma).then_ignore(brace_ws.clone()))
.allow_trailing(),
)
.then_ignore(brace_ws)
.then_ignore(just(Token::RBrace)),
) )
.map(|(name, fields)| { .map(|(name, fields)| {
let map: HashMap<_, _> = fields.into_iter().collect(); let map: HashMap<_, _> = fields.into_iter().collect();
@ -939,6 +996,61 @@ fn expr_to_string_list(expr: &Expr) -> Vec<String> {
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::lexer::Lexer;
fn parse_source(src: &str) -> Program {
let tokens = Lexer::lex(src).expect("lexer failed");
Parser::parse(tokens).expect("parser failed")
}
#[test]
fn test_encrypted_inline_vars() {
let src =
"encrypted:\n API_KEY = \"base64ciphertext\"\n DB_PASS = \"anotherciphertext\"\n";
let program = parse_source(src);
assert_eq!(program.statements.len(), 1);
if let Statement::Encrypted(enc) = &program.statements[0].node {
assert_eq!(enc.entries.len(), 2);
assert!(matches!(&enc.entries[0], EncryptedEntry::Var(name, _) if name == "API_KEY"));
assert!(matches!(&enc.entries[1], EncryptedEntry::Var(name, _) if name == "DB_PASS"));
} else {
panic!("expected Encrypted statement");
}
}
#[test]
fn test_encrypted_file_entries() {
let src = "encrypted:\n SSH_KEY = file(\"secrets/id_rsa.age\")\n CONFIG = file(\"secrets/app.conf.age\")\n";
let program = parse_source(src);
assert_eq!(program.statements.len(), 1);
if let Statement::Encrypted(enc) = &program.statements[0].node {
assert_eq!(enc.entries.len(), 2);
assert!(matches!(&enc.entries[0], EncryptedEntry::File(name, _) if name == "SSH_KEY"));
assert!(matches!(&enc.entries[1], EncryptedEntry::File(name, _) if name == "CONFIG"));
} else {
panic!("expected Encrypted statement");
}
}
#[test]
fn test_encrypted_mixed_entries() {
let src = "encrypted:\n API_KEY = \"base64ciphertext\"\n SSH_KEY = file(\"secrets/id_rsa.age\")\n TOKEN = \"anotherbase64\"\n";
let program = parse_source(src);
assert_eq!(program.statements.len(), 1);
if let Statement::Encrypted(enc) = &program.statements[0].node {
assert_eq!(enc.entries.len(), 3);
assert!(matches!(&enc.entries[0], EncryptedEntry::Var(name, _) if name == "API_KEY"));
assert!(matches!(&enc.entries[1], EncryptedEntry::File(name, _) if name == "SSH_KEY"));
assert!(matches!(&enc.entries[2], EncryptedEntry::Var(name, _) if name == "TOKEN"));
} else {
panic!("expected Encrypted statement");
}
}
}
fn expr_to_permission_rules(expr: &Expr) -> Vec<PermissionRule> { fn expr_to_permission_rules(expr: &Expr) -> Vec<PermissionRule> {
match expr { match expr {
// Single mode: permissions = 0o755 // Single mode: permissions = 0o755