Skip to main content

acme_proxy_core/
templating.rs

1//! The one thing the two `minijinja` environments in this crate share: how a
2//! template is *found*.
3//!
4//! `notify` renders `.j2` messages and
5//! `webadmin::pages` renders `.html` pages, and the
6//! two had byte-identical loader closures — check `template_dir` for a file of
7//! this name, fall back to the compiled-in default — differing only in which
8//! table they closed over.
9//!
10//! **Sharing the loader cannot weaken the escaping rule**, which is worth
11//! stating because it is a security control: minijinja picks auto-escaping off
12//! the template *name*, so `account/_card.html` escapes and
13//! `webhook/certificate_issued.j2` does not. That decision is made by the
14//! extension in the name, never by the loader, and
15//! `auto_escaping_is_on_for_pages_and_off_for_notify` pins both directions. A
16//! page template renamed `.j2` would turn an account contact or an EAB label
17//! into stored XSS — which is a rule about naming, and this function cannot
18//! affect it either way.
19
20use std::collections::HashMap;
21
22/// An environment that resolves a template name to a `template_dir` file first
23/// and the compiled-in default second.
24///
25/// The override is per *file*, not per directory: a `template_dir` holding only
26/// `layout.html` changes the chrome of every page and leaves everything else at
27/// its default.
28pub fn loader_env(
29    template_dir: &str,
30    embedded: &'static HashMap<&'static str, &'static str>,
31) -> minijinja::Environment<'static> {
32    let dir = (!template_dir.is_empty()).then(|| std::path::PathBuf::from(template_dir));
33    let mut env = minijinja::Environment::new();
34    env.set_loader(move |name| {
35        if let Some(dir) = &dir
36            && let Ok(contents) = std::fs::read_to_string(dir.join(name))
37        {
38            return Ok(Some(contents));
39        }
40        Ok(embedded.get(name).map(|body| (*body).to_string()))
41    });
42    env
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48    use std::sync::LazyLock;
49
50    static TABLE: LazyLock<HashMap<&'static str, &'static str>> =
51        LazyLock::new(|| HashMap::from([("a.j2", "embedded a"), ("b.j2", "embedded b")]));
52
53    #[test]
54    fn an_empty_directory_uses_the_embedded_default() {
55        let env = loader_env("", &TABLE);
56        assert_eq!(
57            env.get_template("a.j2").unwrap().render(()).unwrap(),
58            "embedded a"
59        );
60    }
61
62    /// The override is per file: overriding `a.j2` must leave `b.j2` alone.
63    #[test]
64    fn a_directory_file_beats_the_default_for_that_name_only() {
65        let dir = crate::testutil::TempDir::new("templating-loader");
66        std::fs::write(dir.as_ref().join("a.j2"), "from disk").unwrap();
67
68        let env = loader_env(dir.as_ref().to_str().unwrap(), &TABLE);
69        assert_eq!(
70            env.get_template("a.j2").unwrap().render(()).unwrap(),
71            "from disk"
72        );
73        assert_eq!(
74            env.get_template("b.j2").unwrap().render(()).unwrap(),
75            "embedded b"
76        );
77    }
78
79    /// A name in neither place is absent, not an error at load time — the
80    /// caller decides what a missing template means (permanent for a notify
81    /// delivery, a `500` for a page).
82    #[test]
83    fn an_unknown_name_is_simply_not_found() {
84        let env = loader_env("", &TABLE);
85        assert!(env.get_template("nope.j2").is_err());
86    }
87
88    /// A `template_dir` that does not exist is not a panic: `check_config`
89    /// refuses one at startup, and this layer degrades to the defaults.
90    #[test]
91    fn a_missing_directory_falls_through_to_the_defaults() {
92        let env = loader_env("/nonexistent/acme-proxy-templates", &TABLE);
93        assert_eq!(
94            env.get_template("a.j2").unwrap().render(()).unwrap(),
95            "embedded a"
96        );
97    }
98}