runjucks_core/
loader.rs

1//! Resolving template names to source strings for [`crate::Environment::render_template`].
2
3use crate::errors::{Result, RunjucksError};
4use std::collections::HashMap;
5use std::path::{Component, Path, PathBuf};
6use std::sync::Arc;
7
8/// Loads template source by name (e.g. `"layout.html"`).
9///
10/// Implement for in-memory maps, filesystem reads, or embedders that fetch from a CDN.
11pub trait TemplateLoader: Send + Sync {
12    fn load(&self, name: &str) -> Result<String>;
13
14    /// When `Some`, parsed templates for this name may be cached in [`crate::Environment`].
15    /// Return `None` for loaders whose sources are not stable by name (e.g. dynamic closures).
16    fn cache_key(&self, name: &str) -> Option<String> {
17        let _ = name;
18        None
19    }
20}
21
22impl TemplateLoader for HashMap<String, String> {
23    fn load(&self, name: &str) -> Result<String> {
24        self.get(name)
25            .cloned()
26            .ok_or_else(|| RunjucksError::new(format!("template not found: {name}")))
27    }
28
29    fn cache_key(&self, name: &str) -> Option<String> {
30        let _ = self;
31        Some(name.to_string())
32    }
33}
34
35/// Wraps a closure as a [`TemplateLoader`].
36pub struct FnLoader<F>(pub F);
37
38impl<F> TemplateLoader for FnLoader<F>
39where
40    F: Fn(&str) -> Result<String> + Send + Sync,
41{
42    fn load(&self, name: &str) -> Result<String> {
43        (self.0)(name)
44    }
45}
46
47/// Helper to build an `Arc<dyn TemplateLoader>` from a map.
48pub fn map_loader(map: HashMap<String, String>) -> Arc<dyn TemplateLoader + Send + Sync> {
49    Arc::new(map)
50}
51
52/// Loads template files from a directory. Names are **relative** paths under `root` (POSIX-style
53/// separators work on all platforms). `..`, absolute paths, and Windows path prefixes in `name` are
54/// rejected. Resolved paths are canonicalized so symbolic links cannot escape `root`.
55#[derive(Debug)]
56pub struct FileSystemLoader {
57    root: PathBuf,
58}
59
60impl FileSystemLoader {
61    /// Creates a loader rooted at `root` (must exist; canonicalized for containment checks).
62    pub fn new(root: impl AsRef<Path>) -> Result<Self> {
63        let root = root.as_ref().canonicalize().map_err(|e| {
64            RunjucksError::new(format!(
65                "filesystem loader: cannot access root {}: {e}",
66                root.as_ref().display()
67            ))
68        })?;
69        Ok(Self { root })
70    }
71
72    fn resolve_safe(&self, name: &str) -> Result<PathBuf> {
73        let path = Path::new(name);
74        if path.is_absolute() {
75            return Err(RunjucksError::new(format!(
76                "template name must be relative, got {name:?}"
77            )));
78        }
79        let mut out = self.root.clone();
80        for c in path.components() {
81            match c {
82                Component::Normal(s) => out.push(s),
83                Component::CurDir => {}
84                Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
85                    return Err(RunjucksError::new(format!(
86                        "invalid template path (no parent segments): {name:?}"
87                    )));
88                }
89            }
90        }
91        let canon = out
92            .canonicalize()
93            .map_err(|_| RunjucksError::new(format!("template not found: {name}")))?;
94        if !canon.starts_with(&self.root) {
95            return Err(RunjucksError::new(format!(
96                "template path escapes loader root: {name}"
97            )));
98        }
99        Ok(canon)
100    }
101}
102
103impl TemplateLoader for FileSystemLoader {
104    fn load(&self, name: &str) -> Result<String> {
105        let path = self.resolve_safe(name)?;
106        std::fs::read_to_string(&path).map_err(|e| {
107            RunjucksError::new(format!("failed to read template {}: {e}", path.display()))
108        })
109    }
110
111    fn cache_key(&self, name: &str) -> Option<String> {
112        self.resolve_safe(name)
113            .ok()
114            .map(|p| p.to_string_lossy().into_owned())
115    }
116}
117
118/// Builds an `Arc` dyn loader for [`crate::Environment::loader`] from a filesystem root.
119pub fn file_system_loader(root: impl AsRef<Path>) -> Result<Arc<dyn TemplateLoader + Send + Sync>> {
120    Ok(Arc::new(FileSystemLoader::new(root)?))
121}
122