diff --git a/Cargo.lock b/Cargo.lock index f9f034f..b2c233c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/doot-cli/Cargo.toml b/crates/doot-cli/Cargo.toml index 5f7373b..8dfaddd 100644 --- a/crates/doot-cli/Cargo.toml +++ b/crates/doot-cli/Cargo.toml @@ -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 diff --git a/crates/doot-cli/src/commands/apply.rs b/crates/doot-cli/src/commands/apply.rs index 0bbb881..e2e6e69 100644 --- a/crates/doot-cli/src/commands/apply.rs +++ b/crates/doot-cli/src/commands/apply.rs @@ -119,7 +119,16 @@ pub fn run(config_path: Option, 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, 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, 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, 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 = 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, dry_run: bool, prune: bool) -> anyhow:: if should_uninstall { if let Some(mgr) = doot_core::package::get_package_manager(mgr_name) { - mgr.uninstall(std::slice::from_ref(name))?; - println!("uninstalled {}", 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!( diff --git a/crates/doot-cli/src/commands/decrypt.rs b/crates/doot-cli/src/commands/decrypt.rs index 092fc0b..99d7244 100644 --- a/crates/doot-cli/src/commands/decrypt.rs +++ b/crates/doot-cli/src/commands/decrypt.rs @@ -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) -> anyhow::Result<()> { +pub fn run( + file: PathBuf, + identity: Option, + output: Option, +) -> 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) -> 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(()) } diff --git a/crates/doot-cli/src/commands/decrypt_entries.rs b/crates/doot-cli/src/commands/decrypt_entries.rs new file mode 100644 index 0000000..8d788a7 --- /dev/null +++ b/crates/doot-cli/src/commands/decrypt_entries.rs @@ -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 { + 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, + format: OutputFormat, + identity: Option, +) -> 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::() + .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 { + 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 { + 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!("{}=", 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(()) +} diff --git a/crates/doot-cli/src/commands/decrypt_var.rs b/crates/doot-cli/src/commands/decrypt_var.rs new file mode 100644 index 0000000..a3534e3 --- /dev/null +++ b/crates/doot-cli/src/commands/decrypt_var.rs @@ -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) -> anyhow::Result { + 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::() + .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, identity: Option) -> 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(()) +} diff --git a/crates/doot-cli/src/commands/edit.rs b/crates/doot-cli/src/commands/edit.rs index ef67c5b..fb4c87b 100644 --- a/crates/doot-cli/src/commands/edit.rs +++ b/crates/doot-cli/src/commands/edit.rs @@ -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, ) -> 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))?; diff --git a/crates/doot-cli/src/commands/encrypt.rs b/crates/doot-cli/src/commands/encrypt.rs index 1dcd22c..dfc3e9f 100644 --- a/crates/doot-cli/src/commands/encrypt.rs +++ b/crates/doot-cli/src/commands/encrypt.rs @@ -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) -> 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) -> 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) -> 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() diff --git a/crates/doot-cli/src/commands/encrypt_var.rs b/crates/doot-cli/src/commands/encrypt_var.rs new file mode 100644 index 0000000..dda7201 --- /dev/null +++ b/crates/doot-cli/src/commands/encrypt_var.rs @@ -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) -> anyhow::Result> { + 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::() + .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, recipients: Vec) -> 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> = recipient_keys + .into_iter() + .map(|r| Box::new(r) as Box) + .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(()) +} diff --git a/crates/doot-cli/src/commands/mod.rs b/crates/doot-cli/src/commands/mod.rs index 1ee9ae0..1d72639 100644 --- a/crates/doot-cli/src/commands/mod.rs +++ b/crates/doot-cli/src/commands/mod.rs @@ -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, +) -> 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, + 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::() + .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(()) +} diff --git a/crates/doot-cli/src/commands/package.rs b/crates/doot-cli/src/commands/package.rs index cadaaed..da151ac 100644 --- a/crates/doot-cli/src/commands/package.rs +++ b/crates/doot-cli/src/commands/package.rs @@ -32,6 +32,7 @@ pub fn install(config_path: Option) -> 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(); diff --git a/crates/doot-cli/src/main.rs b/crates/doot-cli/src/main.rs index 404e1f6..9619d83 100644 --- a/crates/doot-cli/src/main.rs +++ b/crates/doot-cli/src/main.rs @@ -115,17 +115,17 @@ enum Commands { name: String, }, - /// Encrypt a file with age: `doot encrypt [-r RECIPIENT]` + /// Encrypt a file with age: `doot encrypt [-r RECIPIENT]...` Encrypt { /// File to encrypt file: PathBuf, - /// Recipient public key + /// Recipient public key (can be specified multiple times) #[arg(short, long)] - recipient: Option, + recipient: Vec, }, - /// Decrypt an age-encrypted file: `doot decrypt [-i IDENTITY]` + /// Decrypt an age-encrypted file: `doot decrypt [-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, + + /// Output to file instead of stdout + #[arg(short, long)] + output: Option, }, /// 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, + + /// Recipient public key (can be specified multiple times) + #[arg(short, long)] + recipient: Vec, + }, + + /// Decrypt a single base64-encoded value: `doot decrypt-var [VALUE] [-i IDENTITY]` + DecryptVar { + /// Base64-encoded ciphertext (reads from stdin if omitted) + value: Option, + + /// Path to age identity file + #[arg(short, long)] + identity: Option, + }, + + /// 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, + }, + /// Open source file in editor for a deployed target: `doot edit [-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::() + .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) } diff --git a/crates/doot-core/src/deploy/mod.rs b/crates/doot-core/src/deploy/mod.rs index 2a8c118..978783e 100644 --- a/crates/doot-core/src/deploy/mod.rs +++ b/crates/doot-core/src/deploy/mod.rs @@ -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>, + ) -> 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 { 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,12 +648,11 @@ 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) { - set_file_permissions(target, *mode)?; - return Ok(()); - } + if let Ok(p) = Pattern::new(pattern) + && match_pattern_path_suffixes(&p, target) + { + set_file_permissions(target, *mode)?; + return Ok(()); } } } @@ -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, diff --git a/crates/doot-core/src/deploy/template.rs b/crates/doot-core/src/deploy/template.rs index 7466664..1e36694 100644 --- a/crates/doot-core/src/deploy/template.rs +++ b/crates/doot-core/src/deploy/template.rs @@ -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) { + 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 { @@ -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 = items.iter().map(doot_value_to_minijinja).collect(); + Value::from(converted) + } + DootValue::Struct(_, fields) => { + let map: BTreeMap = 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 { diff --git a/crates/doot-core/src/package/mod.rs b/crates/doot-core/src/package/mod.rs index 3cae1ed..ff3f41d 100644 --- a/crates/doot-core/src/package/mod.rs +++ b/crates/doot-core/src/package/mod.rs @@ -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::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> { "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, } } diff --git a/crates/doot-core/src/package/xbps.rs b/crates/doot-core/src/package/xbps.rs new file mode 100644 index 0000000..d81d08a --- /dev/null +++ b/crates/doot-core/src/package/xbps.rs @@ -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 { + 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() + } +} diff --git a/crates/doot-core/src/state/store.rs b/crates/doot-core/src/state/store.rs index 06dc01a..485e1c0 100644 --- a/crates/doot-core/src/state/store.rs +++ b/crates/doot-core/src/state/store.rs @@ -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 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) { - return Some(*mode); - } + 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 +} diff --git a/crates/doot-lang/src/ast.rs b/crates/doot-lang/src/ast.rs index f39da10..8dc2091 100644 --- a/crates/doot-lang/src/ast.rs +++ b/crates/doot-lang/src/ast.rs @@ -35,9 +35,10 @@ pub enum Statement { EnumDecl(EnumDecl), TypeAlias(TypeAlias), Import(Import), - Dotfile(Dotfile), + Dotfile(Box), Package(Box), Secret(Secret), + Encrypted(EncryptedVars), Hook(Hook), MacroDecl(MacroDecl), MacroCall(MacroCall), @@ -161,6 +162,7 @@ pub struct Package { pub apt: Option, pub pacman: Option, pub yay: Option, + pub xbps: Option, pub when: Option, } @@ -180,6 +182,21 @@ pub struct Secret { pub mode: Option, } +/// 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, +} + /// Lifecycle hook declaration. #[derive(Clone, Debug, PartialEq)] pub struct Hook { diff --git a/crates/doot-lang/src/builtins/crypto.rs b/crates/doot-lang/src/builtins/crypto.rs index b6d3f16..2f27953 100644 --- a/crates/doot-lang/src/builtins/crypto.rs +++ b/crates/doot-lang/src/builtins/crypto.rs @@ -125,7 +125,7 @@ pub fn decrypt_age(args: &[Value]) -> Result { })?)) } -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, String> { +pub fn base64_decode(s: &str) -> Result, String> { const DECODE: [i8; 256] = { let mut table = [-1i8; 256]; let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; diff --git a/crates/doot-lang/src/evaluator.rs b/crates/doot-lang/src/evaluator.rs index cf1cc6c..107ed1d 100644 --- a/crates/doot-lang/src/evaluator.rs +++ b/crates/doot-lang/src/evaluator.rs @@ -269,6 +269,17 @@ impl Env { } vars } + + /// Returns all variables as raw Values (for template engine integration). + pub fn get_raw_variables(&self) -> HashMap { + 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, pub pacman: Option, pub yay: Option, + pub xbps: Option, } /// Evaluated secret file configuration. @@ -358,6 +370,8 @@ pub struct EvalResult { pub packages: Vec, pub secrets: Vec, pub hooks: Vec, + pub encrypted_vars: HashMap, + pub encrypted_files: HashMap, 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 { + self.env.get_raw_variables() + } + #[async_recursion(?Send)] #[tracing::instrument(level = "trace", skip_all)] async fn eval_statement(&mut self, stmt: &Statement) -> Result, 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 { diff --git a/crates/doot-lang/src/lexer.rs b/crates/doot-lang/src/lexer.rs index e4e512c..67aa54d 100644 --- a/crates/doot-lang/src/lexer.rs +++ b/crates/doot-lang/src/lexer.rs @@ -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, diff --git a/crates/doot-lang/src/macros.rs b/crates/doot-lang/src/macros.rs index f311a55..aff4cc6 100644 --- a/crates/doot-lang/src/macros.rs +++ b/crates/doot-lang/src/macros.rs @@ -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)), })), diff --git a/crates/doot-lang/src/parser.rs b/crates/doot-lang/src/parser.rs index 7c39a41..7ca82bf 100644 --- a/crates/doot-lang/src/parser.rs +++ b/crates/doot-lang/src/parser.rs @@ -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> { + // 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> { 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( - 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)), + 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).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 { } } +#[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 { match expr { // Single mode: permissions = 0o755