feat(cli): add age encryption support and decrypt-entries command
This commit is contained in:
parent
bee2ceff00
commit
7346c11a6e
23 changed files with 1171 additions and 68 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1038,6 +1038,7 @@ dependencies = [
|
||||||
name = "doot-cli"
|
name = "doot-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"age",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"blake3",
|
"blake3",
|
||||||
"clap",
|
"clap",
|
||||||
|
|
@ -1046,6 +1047,7 @@ dependencies = [
|
||||||
"doot-core",
|
"doot-core",
|
||||||
"doot-lang",
|
"doot-lang",
|
||||||
"glob",
|
"glob",
|
||||||
|
"indexmap",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
|
|
@ -22,5 +22,7 @@ anyhow.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
blake3.workspace = true
|
blake3.workspace = true
|
||||||
glob = "0.3"
|
glob = "0.3"
|
||||||
|
age.workspace = true
|
||||||
|
indexmap = "2"
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,16 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
|
||||||
|
|
||||||
// For directories, check individual files for smarter merging
|
// For directories, check individual files for smarter merging
|
||||||
if full_source.is_dir() {
|
if full_source.is_dir() {
|
||||||
let changed_files = state.get_changed_files_in_dir(&full_source, &dotfile.target);
|
let permissions = if dotfile.permissions.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(dotfile.permissions.as_slice())
|
||||||
|
};
|
||||||
|
let changed_files = state.get_changed_files_in_dir_with_permissions(
|
||||||
|
&full_source,
|
||||||
|
&dotfile.target,
|
||||||
|
permissions,
|
||||||
|
);
|
||||||
|
|
||||||
// Filter out excluded files before checking for changes
|
// Filter out excluded files before checking for changes
|
||||||
let changed_files: Vec<_> = changed_files
|
let changed_files: Vec<_> = changed_files
|
||||||
|
|
@ -426,7 +435,9 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
|
||||||
.filter(|batch| !batch.is_empty())
|
.filter(|batch| !batch.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let deployer = Deployer::new(config, result.sandbox);
|
let mut template_vars = evaluator.get_template_variables();
|
||||||
|
super::decrypt_encrypted_vars(&result, &config, &mut template_vars)?;
|
||||||
|
let deployer = Deployer::new(config, result.sandbox, Some(&template_vars));
|
||||||
|
|
||||||
let progress = if !dry_run {
|
let progress = if !dry_run {
|
||||||
let pb = ProgressBar::new(deploy_set.len() as u64);
|
let pb = ProgressBar::new(deploy_set.len() as u64);
|
||||||
|
|
@ -518,10 +529,18 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
|
||||||
let mut already_installed = Vec::new();
|
let mut already_installed = Vec::new();
|
||||||
|
|
||||||
for pkg in &result.packages {
|
for pkg in &result.packages {
|
||||||
if let Some(ref name) = pkg.default {
|
let name = match manager.name() {
|
||||||
match manager.is_installed(name) {
|
"brew" => pkg.brew.clone().or_else(|| pkg.default.clone()),
|
||||||
Ok(true) => already_installed.push(name.clone()),
|
"apt" => pkg.apt.clone().or_else(|| pkg.default.clone()),
|
||||||
_ => to_install.push(name.clone()),
|
"pacman" => pkg.pacman.clone().or_else(|| pkg.default.clone()),
|
||||||
|
"yay" => pkg.yay.clone().or_else(|| pkg.default.clone()),
|
||||||
|
"xbps" => pkg.xbps.clone().or_else(|| pkg.default.clone()),
|
||||||
|
_ => pkg.default.clone(),
|
||||||
|
};
|
||||||
|
if let Some(name) = name {
|
||||||
|
match manager.is_installed(&name) {
|
||||||
|
Ok(true) => already_installed.push(name),
|
||||||
|
_ => to_install.push(name),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -580,10 +599,20 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
|
||||||
|
|
||||||
// Prune packages removed from config
|
// Prune packages removed from config
|
||||||
{
|
{
|
||||||
|
let mgr_name = doot_core::package::detect_package_manager()
|
||||||
|
.map(|m| m.name().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
let configured_names: std::collections::HashSet<String> = result
|
let configured_names: std::collections::HashSet<String> = result
|
||||||
.packages
|
.packages
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|p| p.default.clone())
|
.filter_map(|p| match mgr_name.as_str() {
|
||||||
|
"brew" => p.brew.clone().or_else(|| p.default.clone()),
|
||||||
|
"apt" => p.apt.clone().or_else(|| p.default.clone()),
|
||||||
|
"pacman" => p.pacman.clone().or_else(|| p.default.clone()),
|
||||||
|
"yay" => p.yay.clone().or_else(|| p.default.clone()),
|
||||||
|
"xbps" => p.xbps.clone().or_else(|| p.default.clone()),
|
||||||
|
_ => p.default.clone(),
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let state_for_prune = StateStore::new(&state_file);
|
let state_for_prune = StateStore::new(&state_file);
|
||||||
|
|
@ -612,8 +641,22 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
|
||||||
|
|
||||||
if should_uninstall {
|
if should_uninstall {
|
||||||
if let Some(mgr) = doot_core::package::get_package_manager(mgr_name) {
|
if let Some(mgr) = doot_core::package::get_package_manager(mgr_name) {
|
||||||
|
match mgr.is_installed(name) {
|
||||||
|
Ok(true) => {
|
||||||
mgr.uninstall(std::slice::from_ref(name))?;
|
mgr.uninstall(std::slice::from_ref(name))?;
|
||||||
println!("uninstalled {}", name);
|
println!("uninstalled {}", name);
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
println!("{} already removed from system", name);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
package = %name,
|
||||||
|
error = %e,
|
||||||
|
"could not check if package is installed, skipping uninstall"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
uninstalled.push(name.clone());
|
uninstalled.push(name.clone());
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,29 @@
|
||||||
use doot_core::{Config, encryption::AgeEncryption};
|
use doot_core::{Config, encryption::AgeEncryption};
|
||||||
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Decrypts an age-encrypted file.
|
/// Extracts the AGE-SECRET-KEY line from identity file content (filters comments).
|
||||||
|
fn extract_identity_key(raw: &str) -> String {
|
||||||
|
raw.lines()
|
||||||
|
.find(|line| line.starts_with("AGE-SECRET-KEY-"))
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|| raw.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypts an age-encrypted file to stdout (default) or to a file with --output.
|
||||||
#[tracing::instrument(skip_all, fields(file = %file.display()))]
|
#[tracing::instrument(skip_all, fields(file = %file.display()))]
|
||||||
pub fn run(file: PathBuf, identity: Option<PathBuf>) -> anyhow::Result<()> {
|
pub fn run(
|
||||||
|
file: PathBuf,
|
||||||
|
identity: Option<PathBuf>,
|
||||||
|
output: Option<PathBuf>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
let config = Config::default();
|
let config = Config::default();
|
||||||
let identity_key = if let Some(path) = identity {
|
let identity_raw = if let Some(path) = identity {
|
||||||
std::fs::read_to_string(&path)?.trim().to_string()
|
std::fs::read_to_string(&path)?
|
||||||
} else if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {
|
} else if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {
|
||||||
key
|
key
|
||||||
} else if config.identity_file.exists() {
|
} else if config.identity_file.exists() {
|
||||||
std::fs::read_to_string(&config.identity_file)?
|
std::fs::read_to_string(&config.identity_file)?
|
||||||
.trim()
|
|
||||||
.to_string()
|
|
||||||
} else {
|
} else {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"no identity specified. use --identity, DOOT_AGE_IDENTITY env var, or {}",
|
"no identity specified. use --identity, DOOT_AGE_IDENTITY env var, or {}",
|
||||||
|
|
@ -20,18 +31,20 @@ pub fn run(file: PathBuf, identity: Option<PathBuf>) -> anyhow::Result<()> {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let identity_key = extract_identity_key(&identity_raw);
|
||||||
|
|
||||||
tracing::debug!(file = %file.display(), "decrypting file");
|
tracing::debug!(file = %file.display(), "decrypting file");
|
||||||
|
|
||||||
let encryption = AgeEncryption::new().with_identity(&identity_key)?;
|
let encryption = AgeEncryption::new().with_identity(&identity_key)?;
|
||||||
|
let data = std::fs::read(&file)?;
|
||||||
|
let decrypted = encryption.decrypt(&data)?;
|
||||||
|
|
||||||
let output = if file.extension().map(|e| e == "age").unwrap_or(false) {
|
if let Some(out_path) = output {
|
||||||
file.with_extension("")
|
std::fs::write(&out_path, &decrypted)?;
|
||||||
|
eprintln!("decrypted {} -> {}", file.display(), out_path.display());
|
||||||
} else {
|
} else {
|
||||||
file.with_extension("decrypted")
|
std::io::stdout().write_all(&decrypted)?;
|
||||||
};
|
}
|
||||||
|
|
||||||
encryption.decrypt_file(&file, &output)?;
|
|
||||||
|
|
||||||
println!("decrypted {} -> {}", file.display(), output.display());
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
230
crates/doot-cli/src/commands/decrypt_entries.rs
Normal file
230
crates/doot-cli/src/commands/decrypt_entries.rs
Normal 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(())
|
||||||
|
}
|
||||||
72
crates/doot-cli/src/commands/decrypt_var.rs
Normal file
72
crates/doot-cli/src/commands/decrypt_var.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ use doot_core::{
|
||||||
state::{DeployMode, StateStore},
|
state::{DeployMode, StateStore},
|
||||||
};
|
};
|
||||||
use doot_lang::Evaluator;
|
use doot_lang::Evaluator;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
@ -24,9 +25,12 @@ pub fn run(
|
||||||
|
|
||||||
let mut evaluator = Evaluator::new();
|
let mut evaluator = Evaluator::new();
|
||||||
let result = evaluator.eval_sync(&program)?;
|
let result = evaluator.eval_sync(&program)?;
|
||||||
|
let mut template_vars = evaluator.get_template_variables();
|
||||||
|
|
||||||
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
|
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
|
||||||
let config = Config::default();
|
let config = Config::default();
|
||||||
|
|
||||||
|
super::decrypt_encrypted_vars(&result, &config, &mut template_vars)?;
|
||||||
let state = StateStore::new(&config.state_file);
|
let state = StateStore::new(&config.state_file);
|
||||||
|
|
||||||
let target_path = expand_tilde(&target);
|
let target_path = expand_tilde(&target);
|
||||||
|
|
@ -66,7 +70,7 @@ pub fn run(
|
||||||
|
|
||||||
if should_apply {
|
if should_apply {
|
||||||
if let Some(df) = dotfile {
|
if let Some(df) = dotfile {
|
||||||
apply_single(&source_file, &df.target, df, &config)?;
|
apply_single(&source_file, &df.target, df, &config, &template_vars)?;
|
||||||
println!("applied changes to {}", df.target.display());
|
println!("applied changes to {}", df.target.display());
|
||||||
} else {
|
} else {
|
||||||
println!("hint: run 'doot apply' to deploy changes");
|
println!("hint: run 'doot apply' to deploy changes");
|
||||||
|
|
@ -100,6 +104,7 @@ fn apply_single(
|
||||||
target: &PathBuf,
|
target: &PathBuf,
|
||||||
dotfile: &doot_lang::evaluator::DotfileConfig,
|
dotfile: &doot_lang::evaluator::DotfileConfig,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
|
template_vars: &HashMap<String, doot_lang::evaluator::Value>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let deploy_mode = match dotfile.deploy {
|
let deploy_mode = match dotfile.deploy {
|
||||||
doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy,
|
doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy,
|
||||||
|
|
@ -115,7 +120,8 @@ fn apply_single(
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = std::fs::read_to_string(source)?;
|
let content = std::fs::read_to_string(source)?;
|
||||||
let engine = TemplateEngine::new();
|
let mut engine = TemplateEngine::new();
|
||||||
|
engine.set_doot_variables(template_vars);
|
||||||
let rendered = engine
|
let rendered = engine
|
||||||
.render(&content)
|
.render(&content)
|
||||||
.map_err(|e| anyhow::anyhow!("template error: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("template error: {}", e))?;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,24 @@
|
||||||
use doot_core::{Config, encryption::AgeEncryption};
|
use doot_core::{Config, encryption::AgeEncryption};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Encrypts a file using age encryption.
|
/// Encrypts a file using age encryption with multi-recipient support.
|
||||||
#[tracing::instrument(skip_all, fields(file = %file.display()))]
|
#[tracing::instrument(skip_all, fields(file = %file.display()))]
|
||||||
pub fn run(file: PathBuf, recipient: Option<String>) -> anyhow::Result<()> {
|
pub fn run(file: PathBuf, recipients: Vec<String>) -> anyhow::Result<()> {
|
||||||
let config_dir = Config::default_config_dir();
|
let keys = if !recipients.is_empty() {
|
||||||
let recipient_key = if let Some(r) = recipient {
|
recipients
|
||||||
r
|
|
||||||
} else if let Ok(key) = std::env::var("DOOT_AGE_RECIPIENT") {
|
} else if let Ok(key) = std::env::var("DOOT_AGE_RECIPIENT") {
|
||||||
key
|
key.lines()
|
||||||
|
.map(|l| l.trim().to_string())
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
let key_file = config_dir.join("recipient.txt");
|
let key_file = Config::default_config_dir().join("recipient.txt");
|
||||||
if key_file.exists() {
|
if key_file.exists() {
|
||||||
std::fs::read_to_string(&key_file)?.trim().to_string()
|
std::fs::read_to_string(&key_file)?
|
||||||
|
.lines()
|
||||||
|
.map(|l| l.trim().to_string())
|
||||||
|
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||||
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"no recipient specified. use --recipient, DOOT_AGE_RECIPIENT env var, or {}",
|
"no recipient specified. use --recipient, DOOT_AGE_RECIPIENT env var, or {}",
|
||||||
|
|
@ -21,14 +27,20 @@ pub fn run(file: PathBuf, recipient: Option<String>) -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if keys.is_empty() {
|
||||||
|
anyhow::bail!("no recipient keys found");
|
||||||
|
}
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
file = %file.display(),
|
file = %file.display(),
|
||||||
recipient_prefix = &recipient_key[..20.min(recipient_key.len())],
|
num_recipients = keys.len(),
|
||||||
"encrypting file"
|
"encrypting file"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut encryption = AgeEncryption::new();
|
let mut encryption = AgeEncryption::new();
|
||||||
encryption.add_recipient(&recipient_key)?;
|
for key in &keys {
|
||||||
|
encryption.add_recipient(key)?;
|
||||||
|
}
|
||||||
|
|
||||||
let output = file.with_extension(
|
let output = file.with_extension(
|
||||||
file.extension()
|
file.extension()
|
||||||
|
|
|
||||||
79
crates/doot-cli/src/commands/encrypt_var.rs
Normal file
79
crates/doot-cli/src/commands/encrypt_var.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,12 @@
|
||||||
pub mod apply;
|
pub mod apply;
|
||||||
pub mod check;
|
pub mod check;
|
||||||
pub mod decrypt;
|
pub mod decrypt;
|
||||||
|
pub mod decrypt_entries;
|
||||||
|
pub mod decrypt_var;
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
pub mod edit;
|
pub mod edit;
|
||||||
pub mod encrypt;
|
pub mod encrypt;
|
||||||
|
pub mod encrypt_var;
|
||||||
pub mod fmt;
|
pub mod fmt;
|
||||||
pub mod init;
|
pub mod init;
|
||||||
pub mod lsp;
|
pub mod lsp;
|
||||||
|
|
@ -16,7 +19,10 @@ pub mod status;
|
||||||
pub mod tui;
|
pub mod tui;
|
||||||
|
|
||||||
use doot_core::Config;
|
use doot_core::Config;
|
||||||
|
use doot_lang::evaluator::{EvalResult, Value};
|
||||||
use doot_lang::{Lexer, Parser, TypeChecker};
|
use doot_lang::{Lexer, Parser, TypeChecker};
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Resolves the config file path, checking the given path or default locations.
|
/// Resolves the config file path, checking the given path or default locations.
|
||||||
|
|
@ -98,3 +104,126 @@ pub fn type_check(
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decrypts encrypted vars and files from `EvalResult` and inserts them into template variables
|
||||||
|
/// as `Value::Struct("encrypted", { KEY: "plaintext", ... })`.
|
||||||
|
pub fn decrypt_encrypted_vars(
|
||||||
|
result: &EvalResult,
|
||||||
|
config: &Config,
|
||||||
|
template_vars: &mut HashMap<String, Value>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
decrypt_encrypted_vars_with_source_dir(result, config, template_vars, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypts encrypted vars and files, resolving file paths relative to `source_dir`.
|
||||||
|
pub fn decrypt_encrypted_vars_with_source_dir(
|
||||||
|
result: &EvalResult,
|
||||||
|
config: &Config,
|
||||||
|
template_vars: &mut HashMap<String, Value>,
|
||||||
|
source_dir: Option<&std::path::Path>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
if result.encrypted_vars.is_empty() && result.encrypted_files.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read identity key (filter out comment lines from age-keygen output)
|
||||||
|
let identity_raw = if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {
|
||||||
|
key
|
||||||
|
} else if config.identity_file.exists() {
|
||||||
|
std::fs::read_to_string(&config.identity_file)?
|
||||||
|
} else {
|
||||||
|
anyhow::bail!(
|
||||||
|
"encrypted entries found but no identity available. set DOOT_AGE_IDENTITY env var or create {}",
|
||||||
|
config.identity_file.display()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let identity_key = identity_raw
|
||||||
|
.lines()
|
||||||
|
.find(|line| line.starts_with("AGE-SECRET-KEY-"))
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|| identity_raw.trim().to_string());
|
||||||
|
|
||||||
|
let identity = identity_key
|
||||||
|
.parse::<age::x25519::Identity>()
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid age identity: {}", e))?;
|
||||||
|
|
||||||
|
let mut decrypted_map = IndexMap::new();
|
||||||
|
|
||||||
|
// Decrypt inline vars
|
||||||
|
for (name, ciphertext_b64) in &result.encrypted_vars {
|
||||||
|
let encrypted_bytes = doot_lang::builtins::crypto::base64_decode(ciphertext_b64)
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid base64 for encrypted var '{}': {}", name, e))?;
|
||||||
|
|
||||||
|
let decryptor = match age::Decryptor::new(&encrypted_bytes[..])
|
||||||
|
.map_err(|e| anyhow::anyhow!("decryption error for '{}': {}", name, e))?
|
||||||
|
{
|
||||||
|
age::Decryptor::Recipients(d) => d,
|
||||||
|
_ => anyhow::bail!("unexpected decryptor type for '{}'", name),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut decrypted = vec![];
|
||||||
|
let mut reader = decryptor
|
||||||
|
.decrypt(std::iter::once(&identity as &dyn age::Identity))
|
||||||
|
.map_err(|e| anyhow::anyhow!("decryption failed for '{}': {}", name, e))?;
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
reader
|
||||||
|
.read_to_end(&mut decrypted)
|
||||||
|
.map_err(|e| anyhow::anyhow!("read error decrypting '{}': {}", name, e))?;
|
||||||
|
|
||||||
|
let plaintext = String::from_utf8(decrypted)
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in decrypted '{}': {}", name, e))?;
|
||||||
|
|
||||||
|
decrypted_map.insert(name.clone(), Value::Str(plaintext));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt file entries
|
||||||
|
for (name, rel_path) in &result.encrypted_files {
|
||||||
|
let full_path = if rel_path.is_relative() {
|
||||||
|
source_dir
|
||||||
|
.map(|d| d.join(rel_path))
|
||||||
|
.unwrap_or_else(|| rel_path.clone())
|
||||||
|
} else {
|
||||||
|
rel_path.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let encrypted_bytes = std::fs::read(&full_path).map_err(|e| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"cannot read encrypted file '{}' ({}): {}",
|
||||||
|
name,
|
||||||
|
full_path.display(),
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let decryptor = match age::Decryptor::new(&encrypted_bytes[..])
|
||||||
|
.map_err(|e| anyhow::anyhow!("decryption error for file '{}': {}", name, e))?
|
||||||
|
{
|
||||||
|
age::Decryptor::Recipients(d) => d,
|
||||||
|
_ => anyhow::bail!("unexpected decryptor type for file '{}'", name),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut decrypted = vec![];
|
||||||
|
let mut reader = decryptor
|
||||||
|
.decrypt(std::iter::once(&identity as &dyn age::Identity))
|
||||||
|
.map_err(|e| anyhow::anyhow!("decryption failed for file '{}': {}", name, e))?;
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
reader
|
||||||
|
.read_to_end(&mut decrypted)
|
||||||
|
.map_err(|e| anyhow::anyhow!("read error decrypting file '{}': {}", name, e))?;
|
||||||
|
|
||||||
|
let plaintext = String::from_utf8(decrypted)
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in decrypted file '{}': {}", name, e))?;
|
||||||
|
|
||||||
|
decrypted_map.insert(name.clone(), Value::Str(plaintext));
|
||||||
|
}
|
||||||
|
|
||||||
|
template_vars.insert(
|
||||||
|
"encrypted".to_string(),
|
||||||
|
Value::Struct("encrypted".to_string(), decrypted_map),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ pub fn install(config_path: Option<PathBuf>) -> anyhow::Result<()> {
|
||||||
"apt" => p.apt.clone().or_else(|| p.default.clone()),
|
"apt" => p.apt.clone().or_else(|| p.default.clone()),
|
||||||
"pacman" => p.pacman.clone().or_else(|| p.default.clone()),
|
"pacman" => p.pacman.clone().or_else(|| p.default.clone()),
|
||||||
"yay" => p.yay.clone().or_else(|| p.default.clone()),
|
"yay" => p.yay.clone().or_else(|| p.default.clone()),
|
||||||
|
"xbps" => p.xbps.clone().or_else(|| p.default.clone()),
|
||||||
_ => p.default.clone(),
|
_ => p.default.clone(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
|
||||||
|
|
@ -115,17 +115,17 @@ enum Commands {
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Encrypt a file with age: `doot encrypt <FILE> [-r RECIPIENT]`
|
/// Encrypt a file with age: `doot encrypt <FILE> [-r RECIPIENT]...`
|
||||||
Encrypt {
|
Encrypt {
|
||||||
/// File to encrypt
|
/// File to encrypt
|
||||||
file: PathBuf,
|
file: PathBuf,
|
||||||
|
|
||||||
/// Recipient public key
|
/// Recipient public key (can be specified multiple times)
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
recipient: Option<String>,
|
recipient: Vec<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Decrypt an age-encrypted file: `doot decrypt <FILE> [-i IDENTITY]`
|
/// Decrypt an age-encrypted file: `doot decrypt <FILE> [-i IDENTITY] [-o OUTPUT]`
|
||||||
Decrypt {
|
Decrypt {
|
||||||
/// File to decrypt
|
/// File to decrypt
|
||||||
file: PathBuf,
|
file: PathBuf,
|
||||||
|
|
@ -133,6 +133,10 @@ enum Commands {
|
||||||
/// Path to age identity file
|
/// Path to age identity file
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
identity: Option<PathBuf>,
|
identity: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Output to file instead of stdout
|
||||||
|
#[arg(short, long)]
|
||||||
|
output: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Manage system packages: `doot package {install|update|list}`
|
/// Manage system packages: `doot package {install|update|list}`
|
||||||
|
|
@ -147,6 +151,37 @@ enum Commands {
|
||||||
/// Launch interactive TUI: `doot tui`
|
/// Launch interactive TUI: `doot tui`
|
||||||
Tui,
|
Tui,
|
||||||
|
|
||||||
|
/// Encrypt a value for use in `encrypted:` blocks: `doot encrypt-var [VALUE] [-r RECIPIENT]...`
|
||||||
|
EncryptVar {
|
||||||
|
/// Value to encrypt (reads from stdin if omitted)
|
||||||
|
value: Option<String>,
|
||||||
|
|
||||||
|
/// Recipient public key (can be specified multiple times)
|
||||||
|
#[arg(short, long)]
|
||||||
|
recipient: Vec<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Decrypt a single base64-encoded value: `doot decrypt-var [VALUE] [-i IDENTITY]`
|
||||||
|
DecryptVar {
|
||||||
|
/// Base64-encoded ciphertext (reads from stdin if omitted)
|
||||||
|
value: Option<String>,
|
||||||
|
|
||||||
|
/// Path to age identity file
|
||||||
|
#[arg(short, long)]
|
||||||
|
identity: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Decrypt all encrypted entries from a doot file: `doot decrypt-entries [--format FORMAT]`
|
||||||
|
DecryptEntries {
|
||||||
|
/// Output format: plain, pretty, json
|
||||||
|
#[arg(short, long, default_value = "plain")]
|
||||||
|
format: String,
|
||||||
|
|
||||||
|
/// Path to age identity file
|
||||||
|
#[arg(short, long)]
|
||||||
|
identity: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
|
||||||
/// Open source file in editor for a deployed target: `doot edit <TARGET> [-a] [-y]`
|
/// Open source file in editor for a deployed target: `doot edit <TARGET> [-a] [-y]`
|
||||||
Edit {
|
Edit {
|
||||||
/// Target path or dotfile name (e.g., ~/.config/nvim or nvim)
|
/// Target path or dotfile name (e.g., ~/.config/nvim or nvim)
|
||||||
|
|
@ -241,7 +276,11 @@ fn main() -> anyhow::Result<()> {
|
||||||
Commands::Rollback { snapshot } => commands::rollback::run(cli.config, snapshot),
|
Commands::Rollback { snapshot } => commands::rollback::run(cli.config, snapshot),
|
||||||
Commands::Snapshot { name } => commands::snapshot::run(cli.config, name),
|
Commands::Snapshot { name } => commands::snapshot::run(cli.config, name),
|
||||||
Commands::Encrypt { file, recipient } => commands::encrypt::run(file, recipient),
|
Commands::Encrypt { file, recipient } => commands::encrypt::run(file, recipient),
|
||||||
Commands::Decrypt { file, identity } => commands::decrypt::run(file, identity),
|
Commands::Decrypt {
|
||||||
|
file,
|
||||||
|
identity,
|
||||||
|
output,
|
||||||
|
} => commands::decrypt::run(file, identity, output),
|
||||||
Commands::Package { action } => match action {
|
Commands::Package { action } => match action {
|
||||||
PackageAction::Install => commands::package::install(cli.config),
|
PackageAction::Install => commands::package::install(cli.config),
|
||||||
PackageAction::Update => commands::package::update(),
|
PackageAction::Update => commands::package::update(),
|
||||||
|
|
@ -249,6 +288,14 @@ fn main() -> anyhow::Result<()> {
|
||||||
},
|
},
|
||||||
Commands::Lsp => commands::lsp::run(),
|
Commands::Lsp => commands::lsp::run(),
|
||||||
Commands::Tui => commands::tui::run(cli.config),
|
Commands::Tui => commands::tui::run(cli.config),
|
||||||
|
Commands::EncryptVar { value, recipient } => commands::encrypt_var::run(value, recipient),
|
||||||
|
Commands::DecryptVar { value, identity } => commands::decrypt_var::run(value, identity),
|
||||||
|
Commands::DecryptEntries { format, identity } => {
|
||||||
|
let fmt = format
|
||||||
|
.parse::<commands::decrypt_entries::OutputFormat>()
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
commands::decrypt_entries::run(cli.config, fmt, identity)
|
||||||
|
}
|
||||||
Commands::Edit { target, apply, yes } => {
|
Commands::Edit { target, apply, yes } => {
|
||||||
commands::edit::run(cli.config, target, apply, yes)
|
commands::edit::run(cli.config, target, apply, yes)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -106,12 +106,20 @@ pub struct Deployer {
|
||||||
impl Deployer {
|
impl Deployer {
|
||||||
/// Creates a new deployer.
|
/// Creates a new deployer.
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
pub fn new(config: Config, sandbox: bool) -> Self {
|
pub fn new(
|
||||||
|
config: Config,
|
||||||
|
sandbox: bool,
|
||||||
|
template_vars: Option<&std::collections::HashMap<String, doot_lang::evaluator::Value>>,
|
||||||
|
) -> Self {
|
||||||
let state = StateStore::new(&config.state_file);
|
let state = StateStore::new(&config.state_file);
|
||||||
let linker = Linker::new(config.clone());
|
let linker = Linker::new(config.clone());
|
||||||
|
let mut template_engine = TemplateEngine::new();
|
||||||
|
if let Some(vars) = template_vars {
|
||||||
|
template_engine.set_doot_variables(vars);
|
||||||
|
}
|
||||||
Self {
|
Self {
|
||||||
linker: Arc::new(linker),
|
linker: Arc::new(linker),
|
||||||
template_engine: Arc::new(TemplateEngine::new()),
|
template_engine: Arc::new(template_engine),
|
||||||
state: Arc::new(Mutex::new(state)),
|
state: Arc::new(Mutex::new(state)),
|
||||||
config: Arc::new(config),
|
config: Arc::new(config),
|
||||||
sandbox,
|
sandbox,
|
||||||
|
|
@ -303,11 +311,16 @@ impl Deployer {
|
||||||
) -> Result<DeployedFile, DeployError> {
|
) -> Result<DeployedFile, DeployError> {
|
||||||
use crate::state::SyncStatus;
|
use crate::state::SyncStatus;
|
||||||
|
|
||||||
|
let permissions = if dotfile.permissions.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(dotfile.permissions.as_slice())
|
||||||
|
};
|
||||||
let changed_files = self
|
let changed_files = self
|
||||||
.state
|
.state
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.get_changed_files_in_dir(source, target);
|
.get_changed_files_in_dir_with_permissions(source, target, permissions);
|
||||||
tracing::trace!(
|
tracing::trace!(
|
||||||
changed_count = changed_files.len(),
|
changed_count = changed_files.len(),
|
||||||
"directory file changes"
|
"directory file changes"
|
||||||
|
|
@ -635,16 +648,15 @@ fn apply_permissions(target: &Path, rules: &[PermissionRule]) -> Result<(), Depl
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
PermissionRule::Pattern { pattern, mode } => {
|
PermissionRule::Pattern { pattern, mode } => {
|
||||||
if let Ok(p) = Pattern::new(pattern) {
|
if let Ok(p) = Pattern::new(pattern)
|
||||||
let name = target.file_name().unwrap_or_default().to_string_lossy();
|
&& match_pattern_path_suffixes(&p, target)
|
||||||
if p.matches(&name) {
|
{
|
||||||
set_file_permissions(target, *mode)?;
|
set_file_permissions(target, *mode)?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else if target.is_dir() {
|
} else if target.is_dir() {
|
||||||
// For directories, walk all files and apply matching rules
|
// For directories, walk all files and apply matching rules
|
||||||
apply_permissions_recursive(target, target, rules)?;
|
apply_permissions_recursive(target, target, rules)?;
|
||||||
|
|
@ -652,6 +664,18 @@ fn apply_permissions(target: &Path, rules: &[PermissionRule]) -> Result<(), Depl
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Matches a glob pattern against progressively longer path suffixes.
|
||||||
|
fn match_pattern_path_suffixes(pattern: &Pattern, path: &Path) -> bool {
|
||||||
|
let components: Vec<_> = path.components().collect();
|
||||||
|
for i in (0..components.len()).rev() {
|
||||||
|
let suffix: std::path::PathBuf = components[i..].iter().collect();
|
||||||
|
if pattern.matches(&suffix.to_string_lossy()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn apply_permissions_recursive(
|
fn apply_permissions_recursive(
|
||||||
base: &Path,
|
base: &Path,
|
||||||
current: &Path,
|
current: &Path,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! Template rendering for dotfiles using MiniJinja.
|
//! Template rendering for dotfiles using MiniJinja.
|
||||||
|
|
||||||
use minijinja::{Environment, Value};
|
use minijinja::{Environment, Value};
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Renders templates with Jinja2-style syntax.
|
/// Renders templates with Jinja2-style syntax.
|
||||||
|
|
@ -30,6 +30,14 @@ impl TemplateEngine {
|
||||||
self.variables.insert(key, value.into());
|
self.variables.insert(key, value.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets multiple variables from doot evaluator values.
|
||||||
|
pub fn set_doot_variables(&mut self, vars: &HashMap<String, doot_lang::evaluator::Value>) {
|
||||||
|
for (key, value) in vars {
|
||||||
|
self.variables
|
||||||
|
.insert(key.clone(), doot_value_to_minijinja(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Renders a template string.
|
/// Renders a template string.
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
pub fn render(&self, template: &str) -> Result<String, String> {
|
pub fn render(&self, template: &str) -> Result<String, String> {
|
||||||
|
|
@ -426,6 +434,32 @@ fn json_to_minijinja(json: &serde_json::Value) -> Value {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converts a doot evaluator Value to a minijinja Value.
|
||||||
|
fn doot_value_to_minijinja(val: &doot_lang::evaluator::Value) -> Value {
|
||||||
|
use doot_lang::evaluator::Value as DootValue;
|
||||||
|
match val {
|
||||||
|
DootValue::Int(n) => Value::from(*n),
|
||||||
|
DootValue::Float(n) => Value::from(*n),
|
||||||
|
DootValue::Str(s) => Value::from(s.as_str()),
|
||||||
|
DootValue::Bool(b) => Value::from(*b),
|
||||||
|
DootValue::Path(p) => Value::from(p.display().to_string()),
|
||||||
|
DootValue::List(items) => {
|
||||||
|
let converted: Vec<Value> = items.iter().map(doot_value_to_minijinja).collect();
|
||||||
|
Value::from(converted)
|
||||||
|
}
|
||||||
|
DootValue::Struct(_, fields) => {
|
||||||
|
let map: BTreeMap<String, Value> = fields
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), doot_value_to_minijinja(v)))
|
||||||
|
.collect();
|
||||||
|
Value::from_iter(map)
|
||||||
|
}
|
||||||
|
DootValue::Enum(_, variant) => Value::from(variant.as_str()),
|
||||||
|
DootValue::None => Value::UNDEFINED,
|
||||||
|
_ => Value::UNDEFINED, // Function, Lambda, Future
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Converts toml::Value to minijinja::Value.
|
/// Converts toml::Value to minijinja::Value.
|
||||||
fn toml_to_minijinja(toml: &toml::Value) -> Value {
|
fn toml_to_minijinja(toml: &toml::Value) -> Value {
|
||||||
match toml {
|
match toml {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
pub mod apt;
|
pub mod apt;
|
||||||
pub mod brew;
|
pub mod brew;
|
||||||
pub mod pacman;
|
pub mod pacman;
|
||||||
|
pub mod xbps;
|
||||||
pub mod yay;
|
pub mod yay;
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
@ -12,6 +13,7 @@ use thiserror::Error;
|
||||||
pub use apt::Apt;
|
pub use apt::Apt;
|
||||||
pub use brew::Brew;
|
pub use brew::Brew;
|
||||||
pub use pacman::Pacman;
|
pub use pacman::Pacman;
|
||||||
|
pub use xbps::Xbps;
|
||||||
pub use yay::Yay;
|
pub use yay::Yay;
|
||||||
|
|
||||||
/// Package management errors.
|
/// Package management errors.
|
||||||
|
|
@ -145,6 +147,7 @@ pub fn detect_package_manager() -> Option<Box<dyn PackageManager>> {
|
||||||
Box::new(Brew::new()),
|
Box::new(Brew::new()),
|
||||||
Box::new(Yay::new()),
|
Box::new(Yay::new()),
|
||||||
Box::new(Pacman::new()),
|
Box::new(Pacman::new()),
|
||||||
|
Box::new(Xbps::new()),
|
||||||
Box::new(Apt::new()),
|
Box::new(Apt::new()),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -163,6 +166,7 @@ pub fn get_package_manager(name: &str) -> Option<Box<dyn PackageManager>> {
|
||||||
"apt" => Some(Box::new(Apt::new())),
|
"apt" => Some(Box::new(Apt::new())),
|
||||||
"pacman" => Some(Box::new(Pacman::new())),
|
"pacman" => Some(Box::new(Pacman::new())),
|
||||||
"yay" => Some(Box::new(Yay::new())),
|
"yay" => Some(Box::new(Yay::new())),
|
||||||
|
"xbps" => Some(Box::new(Xbps::new())),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
184
crates/doot-core/src/package/xbps.rs
Normal file
184
crates/doot-core/src/package/xbps.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -383,6 +383,16 @@ impl StateStore {
|
||||||
&self,
|
&self,
|
||||||
source_dir: &Path,
|
source_dir: &Path,
|
||||||
target_dir: &Path,
|
target_dir: &Path,
|
||||||
|
) -> Vec<(PathBuf, PathBuf, SyncStatus)> {
|
||||||
|
self.get_changed_files_in_dir_with_permissions(source_dir, target_dir, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns files that have changed in a directory, including permission changes.
|
||||||
|
pub fn get_changed_files_in_dir_with_permissions(
|
||||||
|
&self,
|
||||||
|
source_dir: &Path,
|
||||||
|
target_dir: &Path,
|
||||||
|
permissions: Option<&[PermissionRule]>,
|
||||||
) -> Vec<(PathBuf, PathBuf, SyncStatus)> {
|
) -> Vec<(PathBuf, PathBuf, SyncStatus)> {
|
||||||
let mut changed = Vec::new();
|
let mut changed = Vec::new();
|
||||||
|
|
||||||
|
|
@ -393,7 +403,14 @@ impl StateStore {
|
||||||
for source_file in source_files {
|
for source_file in source_files {
|
||||||
if let Ok(relative) = source_file.strip_prefix(source_dir) {
|
if let Ok(relative) = source_file.strip_prefix(source_dir) {
|
||||||
let target_file = target_dir.join(relative);
|
let target_file = target_dir.join(relative);
|
||||||
let status = self.check_sync_status(&source_file, &target_file);
|
let status = self.check_sync_status_with_config(
|
||||||
|
&source_file,
|
||||||
|
&target_file,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
permissions,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
if status != SyncStatus::Synced {
|
if status != SyncStatus::Synced {
|
||||||
changed.push((source_file, target_file, status));
|
changed.push((source_file, target_file, status));
|
||||||
|
|
@ -524,14 +541,27 @@ pub fn expected_mode_for_file(path: &Path, rules: &[PermissionRule]) -> Option<u
|
||||||
match rule {
|
match rule {
|
||||||
PermissionRule::Single(mode) => return Some(*mode),
|
PermissionRule::Single(mode) => return Some(*mode),
|
||||||
PermissionRule::Pattern { pattern, mode } => {
|
PermissionRule::Pattern { pattern, mode } => {
|
||||||
if let Ok(p) = glob::Pattern::new(pattern) {
|
if let Ok(p) = glob::Pattern::new(pattern)
|
||||||
let name = path.file_name().unwrap_or_default().to_string_lossy();
|
&& match_pattern_path_suffixes(&p, path)
|
||||||
if p.matches(&name) {
|
{
|
||||||
return Some(*mode);
|
return Some(*mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Matches a glob pattern against progressively longer path suffixes.
|
||||||
|
/// For example, given path `/home/user/.config/service/pipewire/run` and pattern `*/run`,
|
||||||
|
/// it tries: `run`, `pipewire/run` (match!), `service/pipewire/run`, etc.
|
||||||
|
fn match_pattern_path_suffixes(pattern: &glob::Pattern, path: &Path) -> bool {
|
||||||
|
let components: Vec<_> = path.components().collect();
|
||||||
|
for i in (0..components.len()).rev() {
|
||||||
|
let suffix: std::path::PathBuf = components[i..].iter().collect();
|
||||||
|
if pattern.matches(&suffix.to_string_lossy()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,10 @@ pub enum Statement {
|
||||||
EnumDecl(EnumDecl),
|
EnumDecl(EnumDecl),
|
||||||
TypeAlias(TypeAlias),
|
TypeAlias(TypeAlias),
|
||||||
Import(Import),
|
Import(Import),
|
||||||
Dotfile(Dotfile),
|
Dotfile(Box<Dotfile>),
|
||||||
Package(Box<Package>),
|
Package(Box<Package>),
|
||||||
Secret(Secret),
|
Secret(Secret),
|
||||||
|
Encrypted(EncryptedVars),
|
||||||
Hook(Hook),
|
Hook(Hook),
|
||||||
MacroDecl(MacroDecl),
|
MacroDecl(MacroDecl),
|
||||||
MacroCall(MacroCall),
|
MacroCall(MacroCall),
|
||||||
|
|
@ -161,6 +162,7 @@ pub struct Package {
|
||||||
pub apt: Option<PackageSpec>,
|
pub apt: Option<PackageSpec>,
|
||||||
pub pacman: Option<PackageSpec>,
|
pub pacman: Option<PackageSpec>,
|
||||||
pub yay: Option<PackageSpec>,
|
pub yay: Option<PackageSpec>,
|
||||||
|
pub xbps: Option<PackageSpec>,
|
||||||
pub when: Option<Expr>,
|
pub when: Option<Expr>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,6 +182,21 @@ pub struct Secret {
|
||||||
pub mode: Option<u32>,
|
pub mode: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Entry in an `encrypted:` block — either an inline var or a file reference.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum EncryptedEntry {
|
||||||
|
/// Inline base64-encoded encrypted value: `KEY = "base64..."`
|
||||||
|
Var(Ident, Expr),
|
||||||
|
/// Encrypted file reference: `KEY = file("path/to/file.age")`
|
||||||
|
File(Ident, Expr),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypted variable declarations (for template use).
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct EncryptedVars {
|
||||||
|
pub entries: Vec<EncryptedEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Lifecycle hook declaration.
|
/// Lifecycle hook declaration.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct Hook {
|
pub struct Hook {
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ pub fn decrypt_age(args: &[Value]) -> Result<Value, EvalError> {
|
||||||
})?))
|
})?))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn base64_encode(data: &[u8]) -> String {
|
pub fn base64_encode(data: &[u8]) -> String {
|
||||||
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
let mut result = String::new();
|
let mut result = String::new();
|
||||||
|
|
||||||
|
|
@ -153,7 +153,7 @@ fn base64_encode(data: &[u8]) -> String {
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
|
pub fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
|
||||||
const DECODE: [i8; 256] = {
|
const DECODE: [i8; 256] = {
|
||||||
let mut table = [-1i8; 256];
|
let mut table = [-1i8; 256];
|
||||||
let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,17 @@ impl Env {
|
||||||
}
|
}
|
||||||
vars
|
vars
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns all variables as raw Values (for template engine integration).
|
||||||
|
pub fn get_raw_variables(&self) -> HashMap<String, Value> {
|
||||||
|
let mut vars = HashMap::new();
|
||||||
|
for scope in &self.scopes {
|
||||||
|
for (name, value) in scope {
|
||||||
|
vars.insert(name.clone(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vars
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deploy mode for dotfiles.
|
/// Deploy mode for dotfiles.
|
||||||
|
|
@ -333,6 +344,7 @@ pub struct PackageConfig {
|
||||||
pub apt: Option<String>,
|
pub apt: Option<String>,
|
||||||
pub pacman: Option<String>,
|
pub pacman: Option<String>,
|
||||||
pub yay: Option<String>,
|
pub yay: Option<String>,
|
||||||
|
pub xbps: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluated secret file configuration.
|
/// Evaluated secret file configuration.
|
||||||
|
|
@ -358,6 +370,8 @@ pub struct EvalResult {
|
||||||
pub packages: Vec<PackageConfig>,
|
pub packages: Vec<PackageConfig>,
|
||||||
pub secrets: Vec<SecretConfig>,
|
pub secrets: Vec<SecretConfig>,
|
||||||
pub hooks: Vec<HookConfig>,
|
pub hooks: Vec<HookConfig>,
|
||||||
|
pub encrypted_vars: HashMap<String, String>,
|
||||||
|
pub encrypted_files: HashMap<String, PathBuf>,
|
||||||
pub sandbox: bool,
|
pub sandbox: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -369,6 +383,8 @@ impl Default for EvalResult {
|
||||||
packages: Vec::new(),
|
packages: Vec::new(),
|
||||||
secrets: Vec::new(),
|
secrets: Vec::new(),
|
||||||
hooks: Vec::new(),
|
hooks: Vec::new(),
|
||||||
|
encrypted_vars: HashMap::new(),
|
||||||
|
encrypted_files: HashMap::new(),
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -474,6 +490,11 @@ impl Evaluator {
|
||||||
vars
|
vars
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns all variables as raw Values for template rendering.
|
||||||
|
pub fn get_template_variables(&self) -> HashMap<String, Value> {
|
||||||
|
self.env.get_raw_variables()
|
||||||
|
}
|
||||||
|
|
||||||
#[async_recursion(?Send)]
|
#[async_recursion(?Send)]
|
||||||
#[tracing::instrument(level = "trace", skip_all)]
|
#[tracing::instrument(level = "trace", skip_all)]
|
||||||
async fn eval_statement(&mut self, stmt: &Statement) -> Result<Option<Value>, EvalError> {
|
async fn eval_statement(&mut self, stmt: &Statement) -> Result<Option<Value>, EvalError> {
|
||||||
|
|
@ -723,6 +744,11 @@ impl Evaluator {
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
let xbps = if let Some(ref s) = pkg.xbps {
|
||||||
|
Some(self.eval_to_string(&s.name).await?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
self.result.packages.push(PackageConfig {
|
self.result.packages.push(PackageConfig {
|
||||||
default,
|
default,
|
||||||
|
|
@ -730,6 +756,7 @@ impl Evaluator {
|
||||||
apt,
|
apt,
|
||||||
pacman,
|
pacman,
|
||||||
yay,
|
yay,
|
||||||
|
xbps,
|
||||||
});
|
});
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
@ -746,6 +773,33 @@ impl Evaluator {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Statement::Encrypted(encrypted) => {
|
||||||
|
for entry in &encrypted.entries {
|
||||||
|
match entry {
|
||||||
|
crate::ast::EncryptedEntry::Var(name, expr) => {
|
||||||
|
let val = self.eval_expr(expr).await?;
|
||||||
|
match val {
|
||||||
|
Value::Str(s) => {
|
||||||
|
self.result.encrypted_vars.insert(name.clone(), s);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(EvalError::TypeError(format!(
|
||||||
|
"encrypted var '{}' must be a string, got {}",
|
||||||
|
name,
|
||||||
|
val.type_name()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::ast::EncryptedEntry::File(name, expr) => {
|
||||||
|
let path = self.eval_to_path(expr).await?;
|
||||||
|
self.result.encrypted_files.insert(name.clone(), path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
Statement::Hook(hook) => {
|
Statement::Hook(hook) => {
|
||||||
tracing::trace!("eval hook");
|
tracing::trace!("eval hook");
|
||||||
if let Some(ref when) = hook.when {
|
if let Some(ref when) = hook.when {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ pub enum Token {
|
||||||
Dotfile,
|
Dotfile,
|
||||||
Package,
|
Package,
|
||||||
Secret,
|
Secret,
|
||||||
|
Encrypted,
|
||||||
Hook,
|
Hook,
|
||||||
BeforeDeploy,
|
BeforeDeploy,
|
||||||
AfterDeploy,
|
AfterDeploy,
|
||||||
|
|
@ -115,6 +116,7 @@ impl fmt::Display for Token {
|
||||||
Token::Dotfile => write!(f, "dotfile"),
|
Token::Dotfile => write!(f, "dotfile"),
|
||||||
Token::Package => write!(f, "package"),
|
Token::Package => write!(f, "package"),
|
||||||
Token::Secret => write!(f, "secret"),
|
Token::Secret => write!(f, "secret"),
|
||||||
|
Token::Encrypted => write!(f, "encrypted"),
|
||||||
Token::Hook => write!(f, "hook"),
|
Token::Hook => write!(f, "hook"),
|
||||||
Token::BeforeDeploy => write!(f, "before_deploy"),
|
Token::BeforeDeploy => write!(f, "before_deploy"),
|
||||||
Token::AfterDeploy => write!(f, "after_deploy"),
|
Token::AfterDeploy => write!(f, "after_deploy"),
|
||||||
|
|
@ -253,6 +255,7 @@ impl Lexer {
|
||||||
"dotfile" => Token::Dotfile,
|
"dotfile" => Token::Dotfile,
|
||||||
"package" => Token::Package,
|
"package" => Token::Package,
|
||||||
"secret" => Token::Secret,
|
"secret" => Token::Secret,
|
||||||
|
"encrypted" => Token::Encrypted,
|
||||||
"hook" => Token::Hook,
|
"hook" => Token::Hook,
|
||||||
"before_deploy" => Token::BeforeDeploy,
|
"before_deploy" => Token::BeforeDeploy,
|
||||||
"after_deploy" => Token::AfterDeploy,
|
"after_deploy" => Token::AfterDeploy,
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ impl MacroExpander {
|
||||||
value: self.substitute_expr(&decl.value, subs),
|
value: self.substitute_expr(&decl.value, subs),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
Statement::Dotfile(dotfile) => Statement::Dotfile(Dotfile {
|
Statement::Dotfile(dotfile) => Statement::Dotfile(Box::new(Dotfile {
|
||||||
source: self.substitute_expr(&dotfile.source, subs),
|
source: self.substitute_expr(&dotfile.source, subs),
|
||||||
target: self.substitute_expr(&dotfile.target, subs),
|
target: self.substitute_expr(&dotfile.target, subs),
|
||||||
when: dotfile.when.as_ref().map(|e| self.substitute_expr(e, subs)),
|
when: dotfile.when.as_ref().map(|e| self.substitute_expr(e, subs)),
|
||||||
|
|
@ -69,7 +69,7 @@ impl MacroExpander {
|
||||||
source_span: dotfile.source_span.clone(),
|
source_span: dotfile.source_span.clone(),
|
||||||
target_span: dotfile.target_span.clone(),
|
target_span: dotfile.target_span.clone(),
|
||||||
when_span: dotfile.when_span.clone(),
|
when_span: dotfile.when_span.clone(),
|
||||||
}),
|
})),
|
||||||
|
|
||||||
Statement::Package(pkg) => Statement::Package(Box::new(Package {
|
Statement::Package(pkg) => Statement::Package(Box::new(Package {
|
||||||
default: pkg.default.as_ref().map(|e| self.substitute_expr(e, subs)),
|
default: pkg.default.as_ref().map(|e| self.substitute_expr(e, subs)),
|
||||||
|
|
@ -93,6 +93,11 @@ impl MacroExpander {
|
||||||
cask: s.cask,
|
cask: s.cask,
|
||||||
tap: s.tap.clone(),
|
tap: s.tap.clone(),
|
||||||
}),
|
}),
|
||||||
|
xbps: pkg.xbps.as_ref().map(|s| PackageSpec {
|
||||||
|
name: self.substitute_expr(&s.name, subs),
|
||||||
|
cask: s.cask,
|
||||||
|
tap: s.tap.clone(),
|
||||||
|
}),
|
||||||
when: pkg.when.as_ref().map(|e| self.substitute_expr(e, subs)),
|
when: pkg.when.as_ref().map(|e| self.substitute_expr(e, subs)),
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,10 @@ impl Parser {
|
||||||
let enum_decl = Self::enum_decl_parser().map(Statement::EnumDecl);
|
let enum_decl = Self::enum_decl_parser().map(Statement::EnumDecl);
|
||||||
let type_alias = Self::type_alias_parser().map(Statement::TypeAlias);
|
let type_alias = Self::type_alias_parser().map(Statement::TypeAlias);
|
||||||
let import = Self::import_parser().map(Statement::Import);
|
let import = Self::import_parser().map(Statement::Import);
|
||||||
let dotfile = Self::dotfile_parser().map(Statement::Dotfile);
|
let dotfile = Self::dotfile_parser().map(|d| Statement::Dotfile(Box::new(d)));
|
||||||
let package = Self::package_parser().map(|p| Statement::Package(Box::new(p)));
|
let package = Self::package_parser().map(|p| Statement::Package(Box::new(p)));
|
||||||
let secret = Self::secret_parser().map(Statement::Secret);
|
let secret = Self::secret_parser().map(Statement::Secret);
|
||||||
|
let encrypted = Self::encrypted_parser().map(Statement::Encrypted);
|
||||||
let hook = Self::hook_parser().map(Statement::Hook);
|
let hook = Self::hook_parser().map(Statement::Hook);
|
||||||
let simple_hook = Self::simple_hook_parser().map(Statement::Hook);
|
let simple_hook = Self::simple_hook_parser().map(Statement::Hook);
|
||||||
let macro_decl = Self::macro_decl_parser(stmt.clone()).map(Statement::MacroDecl);
|
let macro_decl = Self::macro_decl_parser(stmt.clone()).map(Statement::MacroDecl);
|
||||||
|
|
@ -66,6 +67,7 @@ impl Parser {
|
||||||
dotfile,
|
dotfile,
|
||||||
package,
|
package,
|
||||||
secret,
|
secret,
|
||||||
|
encrypted,
|
||||||
hook,
|
hook,
|
||||||
simple_hook,
|
simple_hook,
|
||||||
macro_decl,
|
macro_decl,
|
||||||
|
|
@ -149,9 +151,10 @@ impl Parser {
|
||||||
.ignore_then(Self::ident_parser())
|
.ignore_then(Self::ident_parser())
|
||||||
.then_ignore(just(Token::Colon))
|
.then_ignore(just(Token::Colon))
|
||||||
.then_ignore(just(Token::Newline).repeated())
|
.then_ignore(just(Token::Newline).repeated())
|
||||||
.then_ignore(just(Token::Indent(0)).rewind().or_not())
|
.then_ignore(Self::indent_parser())
|
||||||
.then(
|
.then(
|
||||||
choice((field.map(Either::Left), method.map(Either::Right)))
|
choice((field.map(Either::Left), method.map(Either::Right)))
|
||||||
|
.padded_by(Self::indent_parser())
|
||||||
.padded_by(just(Token::Newline).repeated())
|
.padded_by(just(Token::Newline).repeated())
|
||||||
.repeated(),
|
.repeated(),
|
||||||
)
|
)
|
||||||
|
|
@ -311,6 +314,7 @@ impl Parser {
|
||||||
apt: None,
|
apt: None,
|
||||||
pacman: None,
|
pacman: None,
|
||||||
yay: None,
|
yay: None,
|
||||||
|
xbps: None,
|
||||||
when: None,
|
when: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -337,6 +341,7 @@ impl Parser {
|
||||||
apt: None,
|
apt: None,
|
||||||
pacman: None,
|
pacman: None,
|
||||||
yay: None,
|
yay: None,
|
||||||
|
xbps: None,
|
||||||
when: None,
|
when: None,
|
||||||
};
|
};
|
||||||
for (name, value) in fields {
|
for (name, value) in fields {
|
||||||
|
|
@ -370,6 +375,13 @@ impl Parser {
|
||||||
tap: None,
|
tap: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
"xbps" => {
|
||||||
|
pkg.xbps = Some(PackageSpec {
|
||||||
|
name: value,
|
||||||
|
cask: None,
|
||||||
|
tap: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
"when" => pkg.when = Some(value),
|
"when" => pkg.when = Some(value),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
@ -419,6 +431,40 @@ impl Parser {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn encrypted_parser() -> impl chumsky::Parser<Token, EncryptedVars, Error = Simple<Token>> {
|
||||||
|
// file("path") syntax
|
||||||
|
let file_entry = Self::ident_parser()
|
||||||
|
.then_ignore(just(Token::Eq))
|
||||||
|
.then(
|
||||||
|
select! { Token::Ident(s) if s == "file" => () }.ignore_then(
|
||||||
|
Self::expr_parser().delimited_by(just(Token::LParen), just(Token::RParen)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map(|(name, path_expr)| EncryptedEntry::File(name, path_expr));
|
||||||
|
|
||||||
|
// Plain inline var: KEY = "base64..."
|
||||||
|
let var_entry = Self::ident_parser()
|
||||||
|
.then_ignore(just(Token::Eq))
|
||||||
|
.then(Self::expr_parser())
|
||||||
|
.map(|(name, expr)| EncryptedEntry::Var(name, expr));
|
||||||
|
|
||||||
|
let entry = file_entry.or(var_entry);
|
||||||
|
|
||||||
|
just(Token::Encrypted)
|
||||||
|
.ignore_then(just(Token::Colon))
|
||||||
|
.ignore_then(just(Token::Newline).repeated())
|
||||||
|
.ignore_then(Self::indent_parser())
|
||||||
|
.ignore_then(
|
||||||
|
entry
|
||||||
|
.padded_by(Self::indent_parser())
|
||||||
|
.padded_by(just(Token::Newline).repeated())
|
||||||
|
.repeated()
|
||||||
|
.at_least(1),
|
||||||
|
)
|
||||||
|
.then_ignore(just(Token::Dedent).or_not())
|
||||||
|
.map(|entries| EncryptedVars { entries })
|
||||||
|
}
|
||||||
|
|
||||||
fn hook_parser() -> impl chumsky::Parser<Token, Hook, Error = Simple<Token>> {
|
fn hook_parser() -> impl chumsky::Parser<Token, Hook, Error = Simple<Token>> {
|
||||||
let stage = Self::ident_parser().map(|s| match s.as_str() {
|
let stage = Self::ident_parser().map(|s| match s.as_str() {
|
||||||
"BeforeDeploy" => HookStage::BeforeDeploy,
|
"BeforeDeploy" => HookStage::BeforeDeploy,
|
||||||
|
|
@ -654,14 +700,25 @@ impl Parser {
|
||||||
.delimited_by(just(Token::LBracket), just(Token::RBracket))
|
.delimited_by(just(Token::LBracket), just(Token::RBracket))
|
||||||
.map(Expr::List);
|
.map(Expr::List);
|
||||||
|
|
||||||
|
// Allow newlines/indent/dedent inside struct braces for multi-line init
|
||||||
|
let brace_ws = just(Token::Newline)
|
||||||
|
.or(filter(|t: &Token| matches!(t, Token::Indent(_))))
|
||||||
|
.or(just(Token::Dedent))
|
||||||
|
.repeated();
|
||||||
|
|
||||||
let struct_init = Self::ident_parser()
|
let struct_init = Self::ident_parser()
|
||||||
.then(
|
.then(
|
||||||
|
just(Token::LBrace)
|
||||||
|
.ignore_then(brace_ws.clone())
|
||||||
|
.ignore_then(
|
||||||
Self::ident_parser()
|
Self::ident_parser()
|
||||||
.then_ignore(just(Token::Eq))
|
.then_ignore(just(Token::Eq))
|
||||||
.then(expr.clone())
|
.then(expr.clone())
|
||||||
.separated_by(just(Token::Comma))
|
.separated_by(just(Token::Comma).then_ignore(brace_ws.clone()))
|
||||||
.allow_trailing()
|
.allow_trailing(),
|
||||||
.delimited_by(just(Token::LBrace), just(Token::RBrace)),
|
)
|
||||||
|
.then_ignore(brace_ws)
|
||||||
|
.then_ignore(just(Token::RBrace)),
|
||||||
)
|
)
|
||||||
.map(|(name, fields)| {
|
.map(|(name, fields)| {
|
||||||
let map: HashMap<_, _> = fields.into_iter().collect();
|
let map: HashMap<_, _> = fields.into_iter().collect();
|
||||||
|
|
@ -939,6 +996,61 @@ fn expr_to_string_list(expr: &Expr) -> Vec<String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::lexer::Lexer;
|
||||||
|
|
||||||
|
fn parse_source(src: &str) -> Program {
|
||||||
|
let tokens = Lexer::lex(src).expect("lexer failed");
|
||||||
|
Parser::parse(tokens).expect("parser failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encrypted_inline_vars() {
|
||||||
|
let src =
|
||||||
|
"encrypted:\n API_KEY = \"base64ciphertext\"\n DB_PASS = \"anotherciphertext\"\n";
|
||||||
|
let program = parse_source(src);
|
||||||
|
assert_eq!(program.statements.len(), 1);
|
||||||
|
if let Statement::Encrypted(enc) = &program.statements[0].node {
|
||||||
|
assert_eq!(enc.entries.len(), 2);
|
||||||
|
assert!(matches!(&enc.entries[0], EncryptedEntry::Var(name, _) if name == "API_KEY"));
|
||||||
|
assert!(matches!(&enc.entries[1], EncryptedEntry::Var(name, _) if name == "DB_PASS"));
|
||||||
|
} else {
|
||||||
|
panic!("expected Encrypted statement");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encrypted_file_entries() {
|
||||||
|
let src = "encrypted:\n SSH_KEY = file(\"secrets/id_rsa.age\")\n CONFIG = file(\"secrets/app.conf.age\")\n";
|
||||||
|
let program = parse_source(src);
|
||||||
|
assert_eq!(program.statements.len(), 1);
|
||||||
|
if let Statement::Encrypted(enc) = &program.statements[0].node {
|
||||||
|
assert_eq!(enc.entries.len(), 2);
|
||||||
|
assert!(matches!(&enc.entries[0], EncryptedEntry::File(name, _) if name == "SSH_KEY"));
|
||||||
|
assert!(matches!(&enc.entries[1], EncryptedEntry::File(name, _) if name == "CONFIG"));
|
||||||
|
} else {
|
||||||
|
panic!("expected Encrypted statement");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encrypted_mixed_entries() {
|
||||||
|
let src = "encrypted:\n API_KEY = \"base64ciphertext\"\n SSH_KEY = file(\"secrets/id_rsa.age\")\n TOKEN = \"anotherbase64\"\n";
|
||||||
|
let program = parse_source(src);
|
||||||
|
assert_eq!(program.statements.len(), 1);
|
||||||
|
if let Statement::Encrypted(enc) = &program.statements[0].node {
|
||||||
|
assert_eq!(enc.entries.len(), 3);
|
||||||
|
assert!(matches!(&enc.entries[0], EncryptedEntry::Var(name, _) if name == "API_KEY"));
|
||||||
|
assert!(matches!(&enc.entries[1], EncryptedEntry::File(name, _) if name == "SSH_KEY"));
|
||||||
|
assert!(matches!(&enc.entries[2], EncryptedEntry::Var(name, _) if name == "TOKEN"));
|
||||||
|
} else {
|
||||||
|
panic!("expected Encrypted statement");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn expr_to_permission_rules(expr: &Expr) -> Vec<PermissionRule> {
|
fn expr_to_permission_rules(expr: &Expr) -> Vec<PermissionRule> {
|
||||||
match expr {
|
match expr {
|
||||||
// Single mode: permissions = 0o755
|
// Single mode: permissions = 0o755
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue