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"
version = "0.1.0"
dependencies = [
"age",
"anyhow",
"blake3",
"clap",
@ -1046,6 +1047,7 @@ dependencies = [
"doot-core",
"doot-lang",
"glob",
"indexmap",
"indicatif",
"ratatui",
"serde",

View file

@ -22,5 +22,7 @@ anyhow.workspace = true
dirs.workspace = true
blake3.workspace = true
glob = "0.3"
age.workspace = true
indexmap = "2"
tracing.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
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
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())
.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 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();
for pkg in &result.packages {
if let Some(ref name) = pkg.default {
match manager.is_installed(name) {
Ok(true) => already_installed.push(name.clone()),
_ => to_install.push(name.clone()),
let name = match manager.name() {
"brew" => pkg.brew.clone().or_else(|| pkg.default.clone()),
"apt" => pkg.apt.clone().or_else(|| pkg.default.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
{
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
.packages
.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();
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 let Some(mgr) = doot_core::package::get_package_manager(mgr_name) {
match mgr.is_installed(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());
} else {
tracing::warn!(

View file

@ -1,18 +1,29 @@
use doot_core::{Config, encryption::AgeEncryption};
use std::io::Write;
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()))]
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 identity_key = if let Some(path) = identity {
std::fs::read_to_string(&path)?.trim().to_string()
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 if config.identity_file.exists() {
std::fs::read_to_string(&config.identity_file)?
.trim()
.to_string()
} else {
anyhow::bail!(
"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");
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) {
file.with_extension("")
if let Some(out_path) = output {
std::fs::write(&out_path, &decrypted)?;
eprintln!("decrypted {} -> {}", file.display(), out_path.display());
} else {
file.with_extension("decrypted")
};
std::io::stdout().write_all(&decrypted)?;
}
encryption.decrypt_file(&file, &output)?;
println!("decrypted {} -> {}", file.display(), output.display());
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},
};
use doot_lang::Evaluator;
use std::collections::HashMap;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
@ -24,9 +25,12 @@ pub fn run(
let mut evaluator = Evaluator::new();
let result = evaluator.eval_sync(&program)?;
let mut template_vars = evaluator.get_template_variables();
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
let config = Config::default();
super::decrypt_encrypted_vars(&result, &config, &mut template_vars)?;
let state = StateStore::new(&config.state_file);
let target_path = expand_tilde(&target);
@ -66,7 +70,7 @@ pub fn run(
if should_apply {
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());
} else {
println!("hint: run 'doot apply' to deploy changes");
@ -100,6 +104,7 @@ fn apply_single(
target: &PathBuf,
dotfile: &doot_lang::evaluator::DotfileConfig,
config: &Config,
template_vars: &HashMap<String, doot_lang::evaluator::Value>,
) -> anyhow::Result<()> {
let deploy_mode = match dotfile.deploy {
doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy,
@ -115,7 +120,8 @@ fn apply_single(
}
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
.render(&content)
.map_err(|e| anyhow::anyhow!("template error: {}", e))?;

View file

@ -1,18 +1,24 @@
use doot_core::{Config, encryption::AgeEncryption};
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()))]
pub fn run(file: PathBuf, recipient: Option<String>) -> anyhow::Result<()> {
let config_dir = Config::default_config_dir();
let recipient_key = if let Some(r) = recipient {
r
pub fn run(file: PathBuf, recipients: Vec<String>) -> anyhow::Result<()> {
let keys = if !recipients.is_empty() {
recipients
} 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 {
let key_file = config_dir.join("recipient.txt");
let key_file = Config::default_config_dir().join("recipient.txt");
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 {
anyhow::bail!(
"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!(
file = %file.display(),
recipient_prefix = &recipient_key[..20.min(recipient_key.len())],
num_recipients = keys.len(),
"encrypting file"
);
let mut encryption = AgeEncryption::new();
encryption.add_recipient(&recipient_key)?;
for key in &keys {
encryption.add_recipient(key)?;
}
let output = file.with_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 check;
pub mod decrypt;
pub mod decrypt_entries;
pub mod decrypt_var;
pub mod diff;
pub mod edit;
pub mod encrypt;
pub mod encrypt_var;
pub mod fmt;
pub mod init;
pub mod lsp;
@ -16,7 +19,10 @@ pub mod status;
pub mod tui;
use doot_core::Config;
use doot_lang::evaluator::{EvalResult, Value};
use doot_lang::{Lexer, Parser, TypeChecker};
use indexmap::IndexMap;
use std::collections::HashMap;
use std::path::PathBuf;
/// Resolves the config file path, checking the given path or default locations.
@ -98,3 +104,126 @@ pub fn type_check(
}
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()),
"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();

View file

@ -115,17 +115,17 @@ enum Commands {
name: String,
},
/// Encrypt a file with age: `doot encrypt <FILE> [-r RECIPIENT]`
/// Encrypt a file with age: `doot encrypt <FILE> [-r RECIPIENT]...`
Encrypt {
/// File to encrypt
file: PathBuf,
/// Recipient public key
/// Recipient public key (can be specified multiple times)
#[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 {
/// File to decrypt
file: PathBuf,
@ -133,6 +133,10 @@ enum Commands {
/// Path to age identity file
#[arg(short, long)]
identity: Option<PathBuf>,
/// Output to file instead of stdout
#[arg(short, long)]
output: Option<PathBuf>,
},
/// Manage system packages: `doot package {install|update|list}`
@ -147,6 +151,37 @@ enum Commands {
/// Launch interactive TUI: `doot 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]`
Edit {
/// 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::Snapshot { name } => commands::snapshot::run(cli.config, name),
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 {
PackageAction::Install => commands::package::install(cli.config),
PackageAction::Update => commands::package::update(),
@ -249,6 +288,14 @@ fn main() -> anyhow::Result<()> {
},
Commands::Lsp => commands::lsp::run(),
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::run(cli.config, target, apply, yes)
}

View file

@ -106,12 +106,20 @@ pub struct Deployer {
impl Deployer {
/// Creates a new deployer.
#[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 linker = Linker::new(config.clone());
let mut template_engine = TemplateEngine::new();
if let Some(vars) = template_vars {
template_engine.set_doot_variables(vars);
}
Self {
linker: Arc::new(linker),
template_engine: Arc::new(TemplateEngine::new()),
template_engine: Arc::new(template_engine),
state: Arc::new(Mutex::new(state)),
config: Arc::new(config),
sandbox,
@ -303,11 +311,16 @@ impl Deployer {
) -> Result<DeployedFile, DeployError> {
use crate::state::SyncStatus;
let permissions = if dotfile.permissions.is_empty() {
None
} else {
Some(dotfile.permissions.as_slice())
};
let changed_files = self
.state
.lock()
.unwrap()
.get_changed_files_in_dir(source, target);
.get_changed_files_in_dir_with_permissions(source, target, permissions);
tracing::trace!(
changed_count = changed_files.len(),
"directory file changes"
@ -635,16 +648,15 @@ fn apply_permissions(target: &Path, rules: &[PermissionRule]) -> Result<(), Depl
return Ok(());
}
PermissionRule::Pattern { pattern, mode } => {
if let Ok(p) = Pattern::new(pattern) {
let name = target.file_name().unwrap_or_default().to_string_lossy();
if p.matches(&name) {
if let Ok(p) = Pattern::new(pattern)
&& match_pattern_path_suffixes(&p, target)
{
set_file_permissions(target, *mode)?;
return Ok(());
}
}
}
}
}
} else if target.is_dir() {
// For directories, walk all files and apply matching rules
apply_permissions_recursive(target, target, rules)?;
@ -652,6 +664,18 @@ fn apply_permissions(target: &Path, rules: &[PermissionRule]) -> Result<(), Depl
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(
base: &Path,
current: &Path,

View file

@ -1,7 +1,7 @@
//! Template rendering for dotfiles using MiniJinja.
use minijinja::{Environment, Value};
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
/// Renders templates with Jinja2-style syntax.
@ -30,6 +30,14 @@ impl TemplateEngine {
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.
#[tracing::instrument(skip_all)]
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.
fn toml_to_minijinja(toml: &toml::Value) -> Value {
match toml {

View file

@ -3,6 +3,7 @@
pub mod apt;
pub mod brew;
pub mod pacman;
pub mod xbps;
pub mod yay;
use std::collections::HashSet;
@ -12,6 +13,7 @@ use thiserror::Error;
pub use apt::Apt;
pub use brew::Brew;
pub use pacman::Pacman;
pub use xbps::Xbps;
pub use yay::Yay;
/// Package management errors.
@ -145,6 +147,7 @@ pub fn detect_package_manager() -> Option<Box<dyn PackageManager>> {
Box::new(Brew::new()),
Box::new(Yay::new()),
Box::new(Pacman::new()),
Box::new(Xbps::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())),
"pacman" => Some(Box::new(Pacman::new())),
"yay" => Some(Box::new(Yay::new())),
"xbps" => Some(Box::new(Xbps::new())),
_ => 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,
source_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)> {
let mut changed = Vec::new();
@ -393,7 +403,14 @@ impl StateStore {
for source_file in source_files {
if let Ok(relative) = source_file.strip_prefix(source_dir) {
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 {
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 {
PermissionRule::Single(mode) => return Some(*mode),
PermissionRule::Pattern { pattern, mode } => {
if let Ok(p) = glob::Pattern::new(pattern) {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if p.matches(&name) {
if let Ok(p) = glob::Pattern::new(pattern)
&& match_pattern_path_suffixes(&p, path)
{
return Some(*mode);
}
}
}
}
}
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),
TypeAlias(TypeAlias),
Import(Import),
Dotfile(Dotfile),
Dotfile(Box<Dotfile>),
Package(Box<Package>),
Secret(Secret),
Encrypted(EncryptedVars),
Hook(Hook),
MacroDecl(MacroDecl),
MacroCall(MacroCall),
@ -161,6 +162,7 @@ pub struct Package {
pub apt: Option<PackageSpec>,
pub pacman: Option<PackageSpec>,
pub yay: Option<PackageSpec>,
pub xbps: Option<PackageSpec>,
pub when: Option<Expr>,
}
@ -180,6 +182,21 @@ pub struct Secret {
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.
#[derive(Clone, Debug, PartialEq)]
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+/";
let mut result = String::new();
@ -153,7 +153,7 @@ fn base64_encode(data: &[u8]) -> String {
result
}
fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
pub fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
const DECODE: [i8; 256] = {
let mut table = [-1i8; 256];
let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

View file

@ -269,6 +269,17 @@ impl Env {
}
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.
@ -333,6 +344,7 @@ pub struct PackageConfig {
pub apt: Option<String>,
pub pacman: Option<String>,
pub yay: Option<String>,
pub xbps: Option<String>,
}
/// Evaluated secret file configuration.
@ -358,6 +370,8 @@ pub struct EvalResult {
pub packages: Vec<PackageConfig>,
pub secrets: Vec<SecretConfig>,
pub hooks: Vec<HookConfig>,
pub encrypted_vars: HashMap<String, String>,
pub encrypted_files: HashMap<String, PathBuf>,
pub sandbox: bool,
}
@ -369,6 +383,8 @@ impl Default for EvalResult {
packages: Vec::new(),
secrets: Vec::new(),
hooks: Vec::new(),
encrypted_vars: HashMap::new(),
encrypted_files: HashMap::new(),
sandbox: true,
}
}
@ -474,6 +490,11 @@ impl Evaluator {
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)]
#[tracing::instrument(level = "trace", skip_all)]
async fn eval_statement(&mut self, stmt: &Statement) -> Result<Option<Value>, EvalError> {
@ -723,6 +744,11 @@ impl Evaluator {
} else {
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 {
default,
@ -730,6 +756,7 @@ impl Evaluator {
apt,
pacman,
yay,
xbps,
});
Ok(None)
}
@ -746,6 +773,33 @@ impl Evaluator {
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) => {
tracing::trace!("eval hook");
if let Some(ref when) = hook.when {

View file

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

View file

@ -56,7 +56,7 @@ impl MacroExpander {
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),
target: self.substitute_expr(&dotfile.target, 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(),
target_span: dotfile.target_span.clone(),
when_span: dotfile.when_span.clone(),
}),
})),
Statement::Package(pkg) => Statement::Package(Box::new(Package {
default: pkg.default.as_ref().map(|e| self.substitute_expr(e, subs)),
@ -93,6 +93,11 @@ impl MacroExpander {
cask: s.cask,
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)),
})),

View file

@ -42,9 +42,10 @@ impl Parser {
let enum_decl = Self::enum_decl_parser().map(Statement::EnumDecl);
let type_alias = Self::type_alias_parser().map(Statement::TypeAlias);
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 secret = Self::secret_parser().map(Statement::Secret);
let encrypted = Self::encrypted_parser().map(Statement::Encrypted);
let hook = Self::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);
@ -66,6 +67,7 @@ impl Parser {
dotfile,
package,
secret,
encrypted,
hook,
simple_hook,
macro_decl,
@ -149,9 +151,10 @@ impl Parser {
.ignore_then(Self::ident_parser())
.then_ignore(just(Token::Colon))
.then_ignore(just(Token::Newline).repeated())
.then_ignore(just(Token::Indent(0)).rewind().or_not())
.then_ignore(Self::indent_parser())
.then(
choice((field.map(Either::Left), method.map(Either::Right)))
.padded_by(Self::indent_parser())
.padded_by(just(Token::Newline).repeated())
.repeated(),
)
@ -311,6 +314,7 @@ impl Parser {
apt: None,
pacman: None,
yay: None,
xbps: None,
when: None,
});
@ -337,6 +341,7 @@ impl Parser {
apt: None,
pacman: None,
yay: None,
xbps: None,
when: None,
};
for (name, value) in fields {
@ -370,6 +375,13 @@ impl Parser {
tap: None,
})
}
"xbps" => {
pkg.xbps = Some(PackageSpec {
name: value,
cask: None,
tap: None,
})
}
"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>> {
let stage = Self::ident_parser().map(|s| match s.as_str() {
"BeforeDeploy" => HookStage::BeforeDeploy,
@ -654,14 +700,25 @@ impl Parser {
.delimited_by(just(Token::LBracket), just(Token::RBracket))
.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()
.then(
just(Token::LBrace)
.ignore_then(brace_ws.clone())
.ignore_then(
Self::ident_parser()
.then_ignore(just(Token::Eq))
.then(expr.clone())
.separated_by(just(Token::Comma))
.allow_trailing()
.delimited_by(just(Token::LBrace), just(Token::RBrace)),
.separated_by(just(Token::Comma).then_ignore(brace_ws.clone()))
.allow_trailing(),
)
.then_ignore(brace_ws)
.then_ignore(just(Token::RBrace)),
)
.map(|(name, fields)| {
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> {
match expr {
// Single mode: permissions = 0o755