use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use burgertocow::Tracker;
use minijinja::value::{Enumerator, Object, ObjectRepr, Value};
use minijinja::{Error as MjError, ErrorKind as MjErrorKind, UndefinedBehavior};
use sha2::{Digest, Sha256};
use crate::fs::Fs;
use crate::paths::Pather;
use crate::preprocessing::{ExpandedFile, Preprocessor, SecretLineRange, TransformType};
use crate::secret::SecretRegistry;
use crate::{DodotError, Result};
const RESERVED_VARS: &[&str] = &["dodot", "env"];
#[derive(Debug)]
struct EnvLookup;
impl Object for EnvLookup {
fn repr(self: &Arc<Self>) -> ObjectRepr {
ObjectRepr::Map
}
fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
let name = key.as_str()?;
std::env::var(name).ok().map(Value::from)
}
fn enumerate(self: &Arc<Self>) -> Enumerator {
Enumerator::NonEnumerable
}
}
pub struct TemplatePreprocessor {
extensions: Vec<String>,
dodot_ns: BTreeMap<String, String>,
user_vars: BTreeMap<String, String>,
context_hash: [u8; 32],
secret_registry: Option<Arc<SecretRegistry>>,
}
impl std::fmt::Debug for TemplatePreprocessor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TemplatePreprocessor")
.field("extensions", &self.extensions)
.finish_non_exhaustive()
}
}
impl TemplatePreprocessor {
pub fn new(
extensions: Vec<String>,
user_vars: HashMap<String, String>,
pather: &dyn Pather,
) -> Result<Self> {
for name in user_vars.keys() {
if RESERVED_VARS.contains(&name.as_str()) {
return Err(DodotError::TemplateReservedVar { name: name.clone() });
}
}
let extensions: Vec<String> = extensions
.into_iter()
.map(|e| e.trim_start_matches('.').to_string())
.collect();
let dodot_ns = build_dodot_context(pather);
let user_vars: BTreeMap<String, String> = user_vars.into_iter().collect();
let context_hash = compute_context_hash(&dodot_ns, &user_vars);
Ok(Self {
extensions,
dodot_ns,
user_vars,
context_hash,
secret_registry: None,
})
}
pub fn with_secret_registry(mut self, registry: Arc<SecretRegistry>) -> Self {
self.secret_registry = Some(registry);
self
}
fn make_tracker(&self, sidecar: Arc<Mutex<Vec<SecretCallEntry>>>, render_id: u64) -> Tracker {
let mut tracker = Tracker::new();
let env = tracker.env_mut();
env.set_undefined_behavior(UndefinedBehavior::Strict);
env.add_global("dodot", Value::from(self.dodot_ns.clone()));
env.add_global("env", Value::from_object(EnvLookup));
for (name, val) in &self.user_vars {
env.add_global(name.clone(), Value::from(val.clone()));
}
match &self.secret_registry {
Some(registry) => {
let registry = registry.clone();
let sidecar = sidecar.clone();
env.add_function(
"secret",
move |reference: &str| -> std::result::Result<String, MjError> {
let secret = if let Some(cached) = registry.cache_get(reference) {
cached
} else {
let value = registry.resolve(reference).map_err(|e| {
MjError::new(MjErrorKind::InvalidOperation, e.to_string())
})?;
if value.contains_newline() {
return Err(MjError::new(
MjErrorKind::InvalidOperation,
format!(
"secret `{reference}` resolved to a multi-line value. \
Value-injection (`{{{{ secret(...) }}}}`) is single-line only. \
For multi-line secret material (TLS / SSH keys, GPG armored \
keys, service-account JSON files), use the whole-file deploy \
path: encrypt the file, drop it in a pack, reference the \
deployed path from your config. See secrets.lex §4."
),
));
}
value.expose().map_err(|_| {
MjError::new(
MjErrorKind::InvalidOperation,
format!(
"secret `{reference}` resolved to non-UTF-8 bytes; \
value-injection requires UTF-8 strings"
),
)
})?;
let arc = Arc::new(value);
registry.cache_put(reference, Arc::clone(&arc));
arc
};
let owned = secret.expose().unwrap_or("").to_string();
let mut entries = sidecar.lock().unwrap();
let sentinel = make_secret_sentinel(render_id, entries.len());
entries.push(SecretCallEntry {
sentinel: sentinel.clone(),
reference: reference.to_string(),
value: owned,
});
Ok(sentinel)
},
);
}
None => {
env.add_function(
"secret",
|reference: &str| -> std::result::Result<String, MjError> {
Err(MjError::new(
MjErrorKind::InvalidOperation,
format!(
"secret(`{reference}`) was called but no secret providers \
are configured. Either set `[secret] enabled = true` and \
enable a provider via `[secret.providers.<scheme>] enabled = \
true` in your .dodot.toml, or remove the `secret(...)` \
reference from the template."
),
))
},
);
}
}
tracker
}
}
impl Preprocessor for TemplatePreprocessor {
fn name(&self) -> &str {
"template"
}
fn transform_type(&self) -> TransformType {
TransformType::Generative
}
fn supports_reverse_merge(&self) -> bool {
true
}
fn matches_extension(&self, filename: &str) -> bool {
self.extensions.iter().any(|ext| {
filename
.strip_suffix(ext.as_str())
.is_some_and(|prefix| prefix.ends_with('.'))
})
}
fn stripped_name(&self, filename: &str) -> String {
self.extensions
.iter()
.filter_map(|ext| {
filename
.strip_suffix(ext.as_str())
.and_then(|prefix| prefix.strip_suffix('.'))
.map(|stripped| (ext.len(), stripped))
})
.max_by_key(|(len, _)| *len)
.map(|(_, stripped)| stripped.to_string())
.unwrap_or_else(|| filename.to_string())
}
fn expand(&self, source: &Path, fs: &dyn Fs) -> Result<Vec<ExpandedFile>> {
let template_str = fs.read_to_string(source)?;
let template_name = source.to_string_lossy().into_owned();
let sidecar: Arc<Mutex<Vec<SecretCallEntry>>> = Arc::new(Mutex::new(Vec::new()));
let render_id = next_render_id();
let mut tracker = self.make_tracker(sidecar.clone(), render_id);
tracker
.add_template(&template_name, &template_str)
.map_err(|e| DodotError::TemplateRender {
source_file: source.to_path_buf(),
message: format_minijinja_error(&e),
})?;
let tracked =
tracker
.render(&template_name, ())
.map_err(|e| DodotError::TemplateRender {
source_file: source.to_path_buf(),
message: format_minijinja_error(&e),
})?;
let filename = source
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let stripped = self.stripped_name(&filename);
let (rendered, tracked_str) = tracked.into_parts();
let entries = std::mem::take(&mut *sidecar.lock().unwrap());
let (rendered, tracked_str, secret_line_ranges) =
finalize_secrets(rendered, tracked_str, &entries);
Ok(vec![ExpandedFile {
relative_path: PathBuf::from(stripped),
content: rendered.into_bytes(),
is_dir: false,
tracked_render: Some(tracked_str),
context_hash: Some(self.context_hash),
secret_line_ranges,
deploy_mode: None,
}])
}
}
struct SecretCallEntry {
sentinel: String,
reference: String,
value: String,
}
static RENDER_COUNTER: AtomicU64 = AtomicU64::new(1);
fn next_render_id() -> u64 {
RENDER_COUNTER.fetch_add(1, Ordering::Relaxed)
}
fn make_secret_sentinel(render_id: u64, call_idx: usize) -> String {
let mut s = String::with_capacity(20);
s.push('\u{E000}');
s.push_str("DSEC.");
s.push_str(&render_id.to_string());
s.push('.');
s.push_str(&call_idx.to_string());
s.push('\u{E001}');
s
}
fn finalize_secrets(
rendered: String,
tracked: String,
entries: &[SecretCallEntry],
) -> (String, String, Vec<SecretLineRange>) {
let mut ranges = Vec::with_capacity(entries.len());
if !entries.is_empty() {
let line_starts = build_line_starts(&rendered);
for entry in entries {
if let Some(byte_off) = rendered.find(entry.sentinel.as_str()) {
let line = byte_offset_to_line(&line_starts, byte_off);
ranges.push(SecretLineRange {
start: line,
end: line + 1,
reference: entry.reference.clone(),
});
}
}
}
let mut final_rendered = rendered;
let mut final_tracked = tracked;
for entry in entries {
final_rendered = final_rendered.replace(entry.sentinel.as_str(), &entry.value);
final_tracked = final_tracked.replace(entry.sentinel.as_str(), &entry.value);
}
(final_rendered, final_tracked, ranges)
}
fn build_line_starts(s: &str) -> Vec<usize> {
let mut v = Vec::with_capacity(s.len() / 32 + 1);
v.push(0);
for (i, b) in s.bytes().enumerate() {
if b == b'\n' {
v.push(i + 1);
}
}
v
}
fn byte_offset_to_line(line_starts: &[usize], offset: usize) -> usize {
match line_starts.binary_search(&offset) {
Ok(line) => line,
Err(insert_pos) => insert_pos.saturating_sub(1),
}
}
fn compute_context_hash(
dodot_ns: &BTreeMap<String, String>,
user_vars: &BTreeMap<String, String>,
) -> [u8; 32] {
let mut hasher = Sha256::new();
for (k, v) in dodot_ns {
hasher.update(b"dodot");
hasher.update([0x1f]);
hasher.update(k.as_bytes());
hasher.update([0x1f]);
hasher.update(v.as_bytes());
hasher.update([0x1e]);
}
for (k, v) in user_vars {
hasher.update(b"vars");
hasher.update([0x1f]);
hasher.update(k.as_bytes());
hasher.update([0x1f]);
hasher.update(v.as_bytes());
hasher.update([0x1e]);
}
hasher.finalize().into()
}
fn build_dodot_context(pather: &dyn Pather) -> BTreeMap<String, String> {
let mut ctx = BTreeMap::new();
ctx.insert("os".into(), std::env::consts::OS.into());
ctx.insert("arch".into(), std::env::consts::ARCH.into());
if let Some(h) = cached_hostname() {
ctx.insert("hostname".into(), h.clone());
}
if let Some(u) = cached_username() {
ctx.insert("username".into(), u.clone());
}
ctx.insert("home".into(), pather.home_dir().display().to_string());
ctx.insert(
"dotfiles_root".into(),
pather.dotfiles_root().display().to_string(),
);
ctx
}
fn cached_hostname() -> Option<&'static String> {
static CACHE: OnceLock<Option<String>> = OnceLock::new();
CACHE.get_or_init(detect_hostname).as_ref()
}
fn cached_username() -> Option<&'static String> {
static CACHE: OnceLock<Option<String>> = OnceLock::new();
CACHE.get_or_init(detect_username).as_ref()
}
fn detect_hostname() -> Option<String> {
if let Ok(h) = std::env::var("HOSTNAME") {
if !h.is_empty() {
return Some(h);
}
}
let output = std::process::Command::new("hostname").output().ok()?;
if !output.status.success() {
return None;
}
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() {
None
} else {
Some(name)
}
}
fn detect_username() -> Option<String> {
for var in ["USER", "USERNAME", "LOGNAME"] {
if let Ok(v) = std::env::var(var) {
if !v.is_empty() {
return Some(v);
}
}
}
None
}
fn format_minijinja_error(err: &minijinja::Error) -> String {
use minijinja::ErrorKind;
let base = match err.kind() {
ErrorKind::UndefinedError => {
let mut msg = err.to_string();
msg.push_str(
"\n hint: define the variable in [preprocessor.template.vars] in .dodot.toml,\n or reference an environment variable with {{ env.NAME }} (with a default filter if optional)",
);
msg
}
ErrorKind::SyntaxError => err.to_string(),
_ => err.to_string(),
};
base.lines().take(10).collect::<Vec<_>>().join("\n ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::XdgPather;
fn make_pather() -> XdgPather {
XdgPather::builder()
.home("/home/alice")
.dotfiles_root("/home/alice/dotfiles")
.xdg_config_home("/home/alice/.config")
.data_dir("/home/alice/.local/share/dodot")
.build()
.unwrap()
}
fn new_pp(vars: HashMap<String, String>) -> TemplatePreprocessor {
TemplatePreprocessor::new(vec!["tmpl".into(), "template".into()], vars, &make_pather())
.unwrap()
}
#[test]
fn trait_properties() {
let pp = new_pp(HashMap::new());
assert_eq!(pp.name(), "template");
assert_eq!(pp.transform_type(), TransformType::Generative);
}
#[test]
fn matches_default_extensions() {
let pp = new_pp(HashMap::new());
assert!(pp.matches_extension("config.toml.tmpl"));
assert!(pp.matches_extension("config.toml.template"));
assert!(!pp.matches_extension("config.toml"));
assert!(!pp.matches_extension("config.tmpl.bak"));
}
#[test]
fn matches_custom_extension() {
let pp =
TemplatePreprocessor::new(vec!["j2".into()], HashMap::new(), &make_pather()).unwrap();
assert!(pp.matches_extension("nginx.conf.j2"));
assert!(!pp.matches_extension("nginx.conf.tmpl"));
}
#[test]
fn stripped_name_removes_either_extension() {
let pp = new_pp(HashMap::new());
assert_eq!(pp.stripped_name("config.toml.tmpl"), "config.toml");
assert_eq!(pp.stripped_name("config.toml.template"), "config.toml");
assert_eq!(pp.stripped_name("already-stripped"), "already-stripped");
}
#[test]
fn reserved_dodot_var_rejected() {
let mut vars = HashMap::new();
vars.insert("dodot".into(), "x".into());
let err = TemplatePreprocessor::new(vec!["tmpl".into()], vars, &make_pather()).unwrap_err();
assert!(
matches!(err, DodotError::TemplateReservedVar { ref name } if name == "dodot"),
"got: {err}"
);
}
#[test]
fn reserved_env_var_rejected() {
let mut vars = HashMap::new();
vars.insert("env".into(), "x".into());
let err = TemplatePreprocessor::new(vec!["tmpl".into()], vars, &make_pather()).unwrap_err();
assert!(matches!(err, DodotError::TemplateReservedVar { .. }));
}
#[test]
fn renders_user_var() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("greeting.tmpl", "hello {{ name }}")
.done()
.build();
let mut vars = HashMap::new();
vars.insert("name".into(), "Alice".into());
let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
let source = env.dotfiles_root.join("app/greeting.tmpl");
let result = pp.expand(&source, env.fs.as_ref()).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].relative_path, PathBuf::from("greeting"));
assert_eq!(String::from_utf8_lossy(&result[0].content), "hello Alice");
}
#[test]
fn renders_dodot_builtins() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file(
"info.tmpl",
"home={{ dodot.home }} root={{ dodot.dotfiles_root }} os={{ dodot.os }}",
)
.done()
.build();
let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
.unwrap();
let source = env.dotfiles_root.join("app/info.tmpl");
let result = pp.expand(&source, env.fs.as_ref()).unwrap();
let rendered = String::from_utf8_lossy(&result[0].content);
let home = env.paths.home_dir().display().to_string();
let root = env.paths.dotfiles_root().display().to_string();
assert!(
rendered.contains(&format!("home={home}")),
"rendered: {rendered}"
);
assert!(
rendered.contains(&format!("root={root}")),
"rendered: {rendered}"
);
assert!(rendered.contains(&format!("os={}", std::env::consts::OS)));
}
#[test]
fn renders_env_var() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("has_path.tmpl", "path={{ env.PATH }}")
.done()
.build();
let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
.unwrap();
let source = env.dotfiles_root.join("app/has_path.tmpl");
let result = pp.expand(&source, env.fs.as_ref()).unwrap();
let rendered = String::from_utf8_lossy(&result[0].content).into_owned();
assert!(rendered.starts_with("path="));
assert!(
rendered.len() > "path=".len(),
"env.PATH should have some value"
);
}
#[test]
fn missing_env_var_errors() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("bad.tmpl", "value={{ env.DEFINITELY_UNSET_VAR_ZZZ_12345 }}")
.done()
.build();
let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
.unwrap();
let source = env.dotfiles_root.join("app/bad.tmpl");
std::env::remove_var("DEFINITELY_UNSET_VAR_ZZZ_12345");
let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
assert!(
matches!(err, DodotError::TemplateRender { ref source_file, .. } if source_file == &source),
"got: {err}"
);
}
#[test]
fn undefined_user_var_errors() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("bad.tmpl", "value={{ not_defined }}")
.done()
.build();
let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
.unwrap();
let source = env.dotfiles_root.join("app/bad.tmpl");
let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
assert!(
matches!(err, DodotError::TemplateRender { ref message, .. } if message.contains("not_defined") || message.contains("undefined")),
"got: {err}"
);
}
#[test]
fn syntax_error_reports_source_file() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("broken.tmpl", "{% if %}unterminated")
.done()
.build();
let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
.unwrap();
let source = env.dotfiles_root.join("app/broken.tmpl");
let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
assert!(
matches!(err, DodotError::TemplateRender { ref source_file, .. } if source_file == &source),
"got: {err}"
);
}
#[test]
fn renders_filters_and_conditionals() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file(
"multi.tmpl",
"NAME={{ name | upper }}\n{% if show %}shown{% else %}hidden{% endif %}",
)
.done()
.build();
let mut vars = HashMap::new();
vars.insert("name".into(), "alice".into());
vars.insert("show".into(), "true".into());
let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
let source = env.dotfiles_root.join("app/multi.tmpl");
let result = pp.expand(&source, env.fs.as_ref()).unwrap();
let rendered = String::from_utf8_lossy(&result[0].content);
assert!(rendered.contains("NAME=ALICE"), "rendered: {rendered}");
assert!(rendered.contains("shown"), "rendered: {rendered}");
}
#[test]
fn renders_empty_template() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("empty.tmpl", "")
.done()
.build();
let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
.unwrap();
let source = env.dotfiles_root.join("app/empty.tmpl");
let result = pp.expand(&source, env.fs.as_ref()).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].content.is_empty());
}
#[test]
fn renders_template_without_substitutions() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("plain.tmpl", "just plain text\nno vars here")
.done()
.build();
let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
.unwrap();
let source = env.dotfiles_root.join("app/plain.tmpl");
let result = pp.expand(&source, env.fs.as_ref()).unwrap();
assert_eq!(
String::from_utf8_lossy(&result[0].content),
"just plain text\nno vars here"
);
}
#[test]
fn extension_with_leading_dot_still_matches() {
let pp = TemplatePreprocessor::new(
vec![".tmpl".into(), ".template".into()],
HashMap::new(),
&make_pather(),
)
.unwrap();
assert!(pp.matches_extension("config.toml.tmpl"));
assert!(pp.matches_extension("app.template"));
assert_eq!(pp.stripped_name("config.toml.tmpl"), "config.toml");
}
#[test]
fn overlapping_suffix_does_not_false_match() {
let pp =
TemplatePreprocessor::new(vec!["mpl".into()], HashMap::new(), &make_pather()).unwrap();
assert!(!pp.matches_extension("foo.tmpl"));
assert_eq!(pp.stripped_name("foo.tmpl"), "foo.tmpl");
assert!(pp.matches_extension("song.mpl"));
assert_eq!(pp.stripped_name("song.mpl"), "song");
}
#[test]
fn overlapping_extensions_prefer_longest_match() {
let pp = TemplatePreprocessor::new(
vec!["tmpl".into(), "j2.tmpl".into()],
HashMap::new(),
&make_pather(),
)
.unwrap();
assert_eq!(pp.stripped_name("config.j2.tmpl"), "config");
let pp_reversed = TemplatePreprocessor::new(
vec!["j2.tmpl".into(), "tmpl".into()],
HashMap::new(),
&make_pather(),
)
.unwrap();
assert_eq!(pp_reversed.stripped_name("config.j2.tmpl"), "config");
}
#[test]
fn missing_dodot_key_raises_strict_error() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("uses_missing.tmpl", "value={{ dodot.nonexistent_key_zzz }}")
.done()
.build();
let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
.unwrap();
let source = env.dotfiles_root.join("app/uses_missing.tmpl");
let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
assert!(
matches!(err, DodotError::TemplateRender { .. }),
"accessing a missing dodot.* key must error, got: {err}"
);
}
#[test]
fn missing_dodot_key_can_be_defaulted() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file(
"defaulted.tmpl",
"value={{ dodot.nonexistent_key_zzz | default(\"unknown\") }}",
)
.done()
.build();
let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
.unwrap();
let source = env.dotfiles_root.join("app/defaulted.tmpl");
let result = pp.expand(&source, env.fs.as_ref()).unwrap();
assert_eq!(String::from_utf8_lossy(&result[0].content), "value=unknown");
}
#[test]
fn env_var_default_filter_bridges_missing_vars() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file(
"cfg.tmpl",
"editor={{ env.DODOT_MISSING_VAR_ZZZ | default(\"vim\") }}",
)
.done()
.build();
let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
.unwrap();
let source = env.dotfiles_root.join("app/cfg.tmpl");
let result = pp.expand(&source, env.fs.as_ref()).unwrap();
assert_eq!(String::from_utf8_lossy(&result[0].content), "editor=vim");
}
#[test]
fn renders_for_loop_over_user_var() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file(
"loop.tmpl",
"{% for c in word %}{{ c | upper }}{% endfor %}",
)
.done()
.build();
let mut vars = HashMap::new();
vars.insert("word".into(), "hi".into());
let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
let source = env.dotfiles_root.join("app/loop.tmpl");
let result = pp.expand(&source, env.fs.as_ref()).unwrap();
assert_eq!(String::from_utf8_lossy(&result[0].content), "HI");
}
#[test]
fn renders_unicode_content_and_vars() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("greet.tmpl", "こんにちは {{ name }}! 🎉")
.done()
.build();
let mut vars = HashMap::new();
vars.insert("name".into(), "世界".into());
let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
let source = env.dotfiles_root.join("app/greet.tmpl");
let result = pp.expand(&source, env.fs.as_ref()).unwrap();
assert_eq!(
String::from_utf8_lossy(&result[0].content),
"こんにちは 世界! 🎉"
);
}
#[test]
fn rendering_is_deterministic_across_calls() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file(
"cfg.tmpl",
"name={{ name }} os={{ dodot.os }} home={{ dodot.home }}",
)
.done()
.build();
let mut vars = HashMap::new();
vars.insert("name".into(), "Alice".into());
let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
let source = env.dotfiles_root.join("app/cfg.tmpl");
let first = pp.expand(&source, env.fs.as_ref()).unwrap();
let second = pp.expand(&source, env.fs.as_ref()).unwrap();
let third = pp.expand(&source, env.fs.as_ref()).unwrap();
assert_eq!(first[0].content, second[0].content);
assert_eq!(second[0].content, third[0].content);
}
#[test]
fn stripped_name_of_literal_extension_returns_empty() {
let pp = new_pp(HashMap::new());
assert_eq!(pp.stripped_name(".tmpl"), "");
assert!(pp.matches_extension(".tmpl"));
}
#[test]
fn build_dodot_context_omits_undetected_optional_keys() {
let ctx = build_dodot_context(&make_pather());
assert!(ctx.contains_key("os"));
assert!(ctx.contains_key("arch"));
assert!(ctx.contains_key("home"));
assert!(ctx.contains_key("dotfiles_root"));
assert_eq!(ctx.contains_key("username"), detect_username().is_some());
assert_eq!(ctx.contains_key("hostname"), detect_hostname().is_some());
}
#[test]
fn expand_emits_tracked_render_with_markers_around_each_variable() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("cfg.tmpl", "name={{ name }} count={{ count }}")
.done()
.build();
let mut vars = HashMap::new();
vars.insert("name".into(), "Alice".into());
vars.insert("count".into(), "3".into());
let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
let source = env.dotfiles_root.join("app/cfg.tmpl");
let result = pp.expand(&source, env.fs.as_ref()).unwrap();
let tracked = result[0]
.tracked_render
.as_ref()
.expect("tracked render must be present for a generative preprocessor");
assert_eq!(
tracked.matches(burgertocow::VAR_START).count(),
2,
"two variable emissions should produce two start markers, got: {tracked:?}"
);
assert_eq!(
tracked.matches(burgertocow::VAR_END).count(),
2,
"two variable emissions should produce two end markers, got: {tracked:?}"
);
}
#[test]
fn expand_visible_output_matches_tracked_with_markers_stripped() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("cfg.tmpl", "user={{ name }} home={{ dodot.home }}")
.done()
.build();
let mut vars = HashMap::new();
vars.insert("name".into(), "Alice".into());
let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
let source = env.dotfiles_root.join("app/cfg.tmpl");
let result = pp.expand(&source, env.fs.as_ref()).unwrap();
let visible = String::from_utf8(result[0].content.clone()).unwrap();
let tracked = result[0].tracked_render.as_ref().unwrap();
let stripped: String = tracked
.chars()
.filter(|c| *c != burgertocow::VAR_START && *c != burgertocow::VAR_END)
.collect();
assert_eq!(visible, stripped);
}
#[test]
fn context_hash_is_populated_and_stable() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("a.tmpl", "x={{ name }}")
.done()
.build();
let mut vars = HashMap::new();
vars.insert("name".into(), "Alice".into());
let pp1 = TemplatePreprocessor::new(vec!["tmpl".into()], vars.clone(), env.paths.as_ref())
.unwrap();
let pp2 = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
assert_eq!(
pp1.context_hash, pp2.context_hash,
"identical inputs must yield identical context hashes"
);
let source = env.dotfiles_root.join("app/a.tmpl");
let r1 = pp1.expand(&source, env.fs.as_ref()).unwrap();
let r2 = pp1.expand(&source, env.fs.as_ref()).unwrap();
assert_eq!(r1[0].context_hash, r2[0].context_hash);
assert_eq!(r1[0].context_hash, Some(pp1.context_hash));
}
#[test]
fn context_hash_changes_when_user_var_changes() {
let mut vars1 = HashMap::new();
vars1.insert("name".into(), "Alice".into());
let mut vars2 = HashMap::new();
vars2.insert("name".into(), "Bob".into());
let pather = make_pather();
let pp1 = TemplatePreprocessor::new(vec!["tmpl".into()], vars1, &pather).unwrap();
let pp2 = TemplatePreprocessor::new(vec!["tmpl".into()], vars2, &pather).unwrap();
assert_ne!(pp1.context_hash, pp2.context_hash);
}
#[test]
fn context_hash_is_order_independent_for_user_vars() {
let pather = make_pather();
let mut a = HashMap::new();
a.insert("alpha".into(), "1".into());
a.insert("zeta".into(), "26".into());
let mut b = HashMap::new();
b.insert("zeta".into(), "26".into());
b.insert("alpha".into(), "1".into());
let pp_a = TemplatePreprocessor::new(vec!["tmpl".into()], a, &pather).unwrap();
let pp_b = TemplatePreprocessor::new(vec!["tmpl".into()], b, &pather).unwrap();
assert_eq!(pp_a.context_hash, pp_b.context_hash);
}
#[test]
fn empty_template_still_emits_tracked_render() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("plain.tmpl", "no vars at all")
.done()
.build();
let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
.unwrap();
let source = env.dotfiles_root.join("app/plain.tmpl");
let result = pp.expand(&source, env.fs.as_ref()).unwrap();
let tracked = result[0].tracked_render.as_ref().unwrap();
assert!(
!tracked.contains(burgertocow::VAR_START) && !tracked.contains(burgertocow::VAR_END),
"no variables → no markers, got: {tracked:?}"
);
assert_eq!(
String::from_utf8(result[0].content.clone()).unwrap(),
*tracked
);
}
fn pp_with_secrets(scheme: &str, pairs: &[(&str, &str)]) -> TemplatePreprocessor {
use crate::secret::test_support::MockSecretProvider;
use crate::secret::SecretRegistry;
use std::sync::Arc;
let mut mock = MockSecretProvider::new(scheme);
for (k, v) in pairs {
mock = mock.with(k.to_string(), v.to_string());
}
let mut registry = SecretRegistry::new();
registry.register(Arc::new(mock));
new_pp(HashMap::new()).with_secret_registry(Arc::new(registry))
}
#[test]
fn secret_function_resolves_via_registry() {
let pp = pp_with_secrets("pass", &[("path/to/db", "hunter2")]);
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file(
"config.toml.tmpl",
"password = \"{{ secret('pass:path/to/db') }}\"\n",
)
.done()
.build();
let source = env.dotfiles_root.join("app/config.toml.tmpl");
let result = pp.expand(&source, env.fs.as_ref()).unwrap();
let rendered = String::from_utf8_lossy(&result[0].content);
assert_eq!(rendered, "password = \"hunter2\"\n");
}
#[test]
fn secret_function_caches_repeated_references_within_a_render() {
use crate::secret::test_support::MockSecretProvider;
use crate::secret::SecretRegistry;
let mock = Arc::new(MockSecretProvider::new("pass").with("k", "v"));
let mut registry = SecretRegistry::new();
registry.register(mock.clone());
let pp = new_pp(HashMap::new()).with_secret_registry(Arc::new(registry));
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file(
"c.tmpl",
"a = {{ secret('pass:k') }}\nb = {{ secret('pass:k') }}\nc = {{ secret('pass:k') }}\n",
)
.done()
.build();
let source = env.dotfiles_root.join("app/c.tmpl");
let result = pp.expand(&source, env.fs.as_ref()).unwrap();
let rendered = String::from_utf8_lossy(&result[0].content);
assert_eq!(rendered, "a = v\nb = v\nc = v\n");
assert_eq!(
mock.resolve_call_count(),
1,
"within-run cache must collapse repeats"
);
assert_eq!(result[0].secret_line_ranges.len(), 3);
}
#[test]
fn secret_function_caches_across_multiple_expands_on_one_registry() {
use crate::secret::test_support::MockSecretProvider;
use crate::secret::SecretRegistry;
let mock = Arc::new(MockSecretProvider::new("pass").with("k", "v"));
let mut registry = SecretRegistry::new();
registry.register(mock.clone());
let registry = Arc::new(registry);
let pp_a = new_pp(HashMap::new()).with_secret_registry(registry.clone());
let pp_b = new_pp(HashMap::new()).with_secret_registry(registry.clone());
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("a.tmpl", "{{ secret('pass:k') }}\n")
.file("b.tmpl", "{{ secret('pass:k') }}\n")
.done()
.build();
let _ = pp_a
.expand(&env.dotfiles_root.join("app/a.tmpl"), env.fs.as_ref())
.unwrap();
let _ = pp_b
.expand(&env.dotfiles_root.join("app/b.tmpl"), env.fs.as_ref())
.unwrap();
assert_eq!(
mock.resolve_call_count(),
1,
"shared registry should serve the second expand from cache"
);
}
#[test]
fn secret_function_records_sidecar_entry_with_correct_line_range() {
let pp = pp_with_secrets("pass", &[("k1", "v1"), ("k2", "v2")]);
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file(
"c.tmpl",
"first\nsecond = {{ secret('pass:k1') }}\nthird\nfourth = {{ secret('pass:k2') }}\n",
)
.done()
.build();
let source = env.dotfiles_root.join("app/c.tmpl");
let result = pp.expand(&source, env.fs.as_ref()).unwrap();
let ranges = &result[0].secret_line_ranges;
assert_eq!(ranges.len(), 2);
assert_eq!(ranges[0].reference, "pass:k1");
assert_eq!(ranges[0].start, 1);
assert_eq!(ranges[0].end, 2);
assert_eq!(ranges[1].reference, "pass:k2");
assert_eq!(ranges[1].start, 3);
assert_eq!(ranges[1].end, 4);
}
#[test]
fn secret_function_refuses_multiline_value_per_section_3_4() {
let pp = pp_with_secrets("pass", &[("multi", "line1\nline2")]);
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("c.tmpl", "x = {{ secret('pass:multi') }}\n")
.done()
.build();
let source = env.dotfiles_root.join("app/c.tmpl");
let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("multi-line value"));
assert!(msg.contains("single-line only"));
assert!(msg.contains("whole-file deploy"));
}
#[test]
fn secret_function_propagates_provider_resolve_failure() {
let pp = pp_with_secrets("pass", &[]); let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("c.tmpl", "x = {{ secret('pass:missing') }}\n")
.done()
.build();
let source = env.dotfiles_root.join("app/c.tmpl");
let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("MockSecretProvider"));
assert!(msg.contains("missing"));
}
#[test]
fn secret_function_unknown_scheme_lists_configured_schemes() {
let pp = pp_with_secrets("pass", &[("k", "v")]);
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("c.tmpl", "x = {{ secret('op://V/I/F') }}\n")
.done()
.build();
let source = env.dotfiles_root.join("app/c.tmpl");
let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("no secret provider registered for scheme `op`"));
assert!(msg.contains("pass")); }
#[test]
fn secret_function_without_registry_errors_with_actionable_hint() {
let pp = new_pp(HashMap::new());
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("c.tmpl", "x = {{ secret('pass:k') }}\n")
.done()
.build();
let source = env.dotfiles_root.join("app/c.tmpl");
let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("no secret providers are configured"));
assert!(msg.contains("[secret.providers."));
assert!(msg.contains("pass:k"));
}
#[test]
fn secret_function_supports_multiple_schemes_in_one_template() {
use crate::secret::test_support::MockSecretProvider;
use crate::secret::SecretRegistry;
use std::sync::Arc;
let mut registry = SecretRegistry::new();
registry.register(Arc::new(
MockSecretProvider::new("pass").with("db", "from-pass"),
));
registry.register(Arc::new(
MockSecretProvider::new("op").with("//V/I/password", "from-op"),
));
let pp = new_pp(HashMap::new()).with_secret_registry(Arc::new(registry));
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file(
"c.tmpl",
"a={{ secret('pass:db') }}\nb={{ secret('op://V/I/password') }}\n",
)
.done()
.build();
let source = env.dotfiles_root.join("app/c.tmpl");
let result = pp.expand(&source, env.fs.as_ref()).unwrap();
let rendered = String::from_utf8_lossy(&result[0].content);
assert_eq!(rendered, "a=from-pass\nb=from-op\n");
assert_eq!(result[0].secret_line_ranges.len(), 2);
}
#[test]
fn secret_function_tracks_render_into_baseline() {
let pp = pp_with_secrets("pass", &[("k", "topsecret")]);
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("c.tmpl", "x = {{ secret('pass:k') }}\n")
.done()
.build();
let source = env.dotfiles_root.join("app/c.tmpl");
let result = pp.expand(&source, env.fs.as_ref()).unwrap();
let rendered = String::from_utf8_lossy(&result[0].content);
assert_eq!(rendered, "x = topsecret\n");
let tracked = result[0]
.tracked_render
.as_ref()
.expect("template render produces tracked stream");
assert!(
tracked.contains("topsecret"),
"tracked render should contain the resolved value, got: {tracked:?}"
);
}
fn entry(idx: usize, reference: &str, value: &str) -> (SecretCallEntry, String) {
let sentinel = make_secret_sentinel(0, idx);
let entry = SecretCallEntry {
sentinel: sentinel.clone(),
reference: reference.to_string(),
value: value.to_string(),
};
(entry, sentinel)
}
#[test]
fn finalize_secrets_substitutes_sentinels_and_records_line_ranges() {
let (e, sentinel) = entry(0, "pass:k", "hunter2");
let rendered = format!("header\nuser = alice\npassword = {sentinel}\nfooter\n");
let (final_rendered, _, ranges) = finalize_secrets(rendered, String::new(), &[e]);
assert_eq!(ranges.len(), 1);
assert_eq!((ranges[0].start, ranges[0].end), (2, 3));
assert_eq!(ranges[0].reference, "pass:k");
assert_eq!(
final_rendered,
"header\nuser = alice\npassword = hunter2\nfooter\n"
);
assert!(!final_rendered.contains('\u{E000}'));
}
#[test]
fn finalize_secrets_does_not_match_value_substring_outside_sentinel() {
let (e, sentinel) = entry(0, "pass:k", "hunter2");
let rendered = format!("greeting = hunter2 hi\npassword = {sentinel}\n");
let (final_rendered, _, ranges) = finalize_secrets(rendered, String::new(), &[e]);
assert_eq!(ranges.len(), 1);
assert_eq!((ranges[0].start, ranges[0].end), (1, 2));
assert_eq!(
final_rendered,
"greeting = hunter2 hi\npassword = hunter2\n"
);
}
#[test]
fn finalize_secrets_handles_two_calls_resolving_to_same_value() {
let (e1, s1) = entry(0, "pass:a", "shared");
let (e2, s2) = entry(1, "pass:b", "shared");
let rendered = format!("a = {s1}\nb = {s2}\n");
let (final_rendered, _, ranges) = finalize_secrets(rendered, String::new(), &[e1, e2]);
assert_eq!(ranges.len(), 2);
assert_eq!((ranges[0].start, ranges[0].end), (0, 1));
assert_eq!((ranges[1].start, ranges[1].end), (1, 2));
assert_eq!(final_rendered, "a = shared\nb = shared\n");
}
#[test]
fn finalize_secrets_drops_entries_whose_sentinel_was_not_emitted() {
let (e, _sentinel) = entry(0, "pass:hidden", "never-emitted");
let rendered = "clean output\n".to_string();
let (final_rendered, _, ranges) = finalize_secrets(rendered, String::new(), &[e]);
assert!(ranges.is_empty());
assert_eq!(final_rendered, "clean output\n");
}
#[test]
fn finalize_secrets_substitutes_sentinels_in_tracked_render_too() {
let (e, sentinel) = entry(0, "pass:k", "hunter2");
let tracked = format!("preamble {sentinel} epilogue");
let (_, final_tracked, _) = finalize_secrets(String::new(), tracked, &[e]);
assert_eq!(final_tracked, "preamble hunter2 epilogue");
}
}