Skip to main content

systemprompt_template_provider/traits/
loader.rs

1//! `TemplateLoader` trait for source-specific template loading.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::path::Path;
7
8use async_trait::async_trait;
9use systemprompt_provider_contracts::TemplateSource;
10
11use super::error::{Result, TemplateLoaderError};
12
13#[cfg(feature = "tokio")]
14use std::io::ErrorKind;
15#[cfg(feature = "tokio")]
16use std::path::{Component, PathBuf};
17#[cfg(feature = "tokio")]
18use tokio::fs;
19
20/// `#[async_trait]` is required: this trait is consumed as
21/// `Arc<dyn TemplateLoader>` (see `DynTemplateLoader`), so it must be
22/// `dyn`-compatible — native `async fn` in traits is not.
23#[async_trait]
24pub trait TemplateLoader: Send + Sync {
25    async fn load(&self, source: &TemplateSource) -> Result<String>;
26
27    fn can_load(&self, source: &TemplateSource) -> bool;
28
29    async fn load_directory(&self, _path: &Path) -> Result<Vec<(String, String)>> {
30        Err(TemplateLoaderError::DirectoryLoadingUnsupported)
31    }
32}
33
34#[cfg(feature = "tokio")]
35#[derive(Debug, Default)]
36pub struct FileSystemLoader {
37    base_paths: Vec<PathBuf>,
38}
39
40#[cfg(feature = "tokio")]
41impl FileSystemLoader {
42    #[must_use]
43    pub const fn new(base_paths: Vec<PathBuf>) -> Self {
44        Self { base_paths }
45    }
46
47    #[must_use]
48    pub fn with_path(path: impl Into<PathBuf>) -> Self {
49        Self {
50            base_paths: vec![path.into()],
51        }
52    }
53
54    #[must_use]
55    pub fn add_path(mut self, path: impl Into<PathBuf>) -> Self {
56        self.base_paths.push(path.into());
57        self
58    }
59
60    fn has_traversal_components(path: &Path) -> bool {
61        path.components().any(|c| matches!(c, Component::ParentDir))
62    }
63
64    async fn is_within_base_paths(&self, canonical: &Path) -> Result<bool> {
65        for base in &self.base_paths {
66            match fs::canonicalize(base).await {
67                Ok(canonical_base) if canonical.starts_with(&canonical_base) => return Ok(true),
68                Ok(_) => {},
69                Err(e) if e.kind() == ErrorKind::NotFound => {},
70                Err(e) => return Err(TemplateLoaderError::io(base, e)),
71            }
72        }
73        Ok(false)
74    }
75
76    async fn canonicalize_and_validate(&self, path: &Path) -> Result<PathBuf> {
77        let canonical = fs::canonicalize(path)
78            .await
79            .map_err(|e| TemplateLoaderError::io(path, e))?;
80
81        if !self.is_within_base_paths(&canonical).await? {
82            return Err(TemplateLoaderError::OutsideBasePath(path.to_path_buf()));
83        }
84
85        Ok(canonical)
86    }
87
88    async fn try_read_from_base(&self, base: &Path, relative: &Path) -> Option<Result<String>> {
89        let full_path = base.join(relative);
90
91        match fs::canonicalize(&full_path).await {
92            Ok(canonical) => {
93                let canonical_base = match fs::canonicalize(base).await {
94                    Ok(cb) => cb,
95                    Err(e) => return Some(Err(TemplateLoaderError::io(base, e))),
96                };
97
98                if !canonical.starts_with(&canonical_base) {
99                    return Some(Err(TemplateLoaderError::OutsideBasePath(full_path)));
100                }
101
102                Some(
103                    fs::read_to_string(&canonical)
104                        .await
105                        .map_err(|e| TemplateLoaderError::io(&full_path, e)),
106                )
107            },
108            Err(e) if e.kind() == ErrorKind::NotFound => None,
109            Err(e) => Some(Err(TemplateLoaderError::io(&full_path, e))),
110        }
111    }
112}
113
114#[cfg(feature = "tokio")]
115#[async_trait]
116impl TemplateLoader for FileSystemLoader {
117    async fn load(&self, source: &TemplateSource) -> Result<String> {
118        match source {
119            TemplateSource::Embedded(content) => Ok((*content).to_owned()),
120            TemplateSource::File(path) => {
121                if Self::has_traversal_components(path) {
122                    return Err(TemplateLoaderError::DirectoryTraversal(path.clone()));
123                }
124
125                if path.is_absolute() {
126                    let canonical = self.canonicalize_and_validate(path).await?;
127                    return fs::read_to_string(&canonical)
128                        .await
129                        .map_err(|e| TemplateLoaderError::io(path, e));
130                }
131
132                if self.base_paths.is_empty() {
133                    return Err(TemplateLoaderError::NoBasePaths);
134                }
135
136                for base in &self.base_paths {
137                    if let Some(result) = self.try_read_from_base(base, path).await {
138                        return result;
139                    }
140                }
141
142                Err(TemplateLoaderError::NotFound(path.clone()))
143            },
144            TemplateSource::Directory(path) => {
145                Err(TemplateLoaderError::DirectoryNotSupported(path.clone()))
146            },
147        }
148    }
149
150    fn can_load(&self, source: &TemplateSource) -> bool {
151        matches!(
152            source,
153            TemplateSource::Embedded(_) | TemplateSource::File(_)
154        )
155    }
156
157    async fn load_directory(&self, path: &Path) -> Result<Vec<(String, String)>> {
158        if Self::has_traversal_components(path) {
159            return Err(TemplateLoaderError::DirectoryTraversal(path.to_path_buf()));
160        }
161
162        if self.base_paths.is_empty() {
163            return Err(TemplateLoaderError::NoBasePaths);
164        }
165
166        let dir_path = if path.is_absolute() {
167            self.canonicalize_and_validate(path).await?
168        } else {
169            let mut found_path = None;
170            for base in &self.base_paths {
171                let candidate = base.join(path);
172                match fs::canonicalize(&candidate).await {
173                    Ok(canonical) => {
174                        let canonical_base = fs::canonicalize(base)
175                            .await
176                            .map_err(|e| TemplateLoaderError::io(base, e))?;
177
178                        if !canonical.starts_with(&canonical_base) {
179                            return Err(TemplateLoaderError::OutsideBasePath(candidate));
180                        }
181
182                        found_path = Some(canonical);
183                        break;
184                    },
185                    Err(e) if e.kind() == ErrorKind::NotFound => {},
186                    Err(e) => return Err(TemplateLoaderError::io(&candidate, e)),
187                }
188            }
189            found_path.ok_or_else(|| TemplateLoaderError::NotFound(path.to_path_buf()))?
190        };
191
192        let mut templates = Vec::new();
193        let mut entries = fs::read_dir(&dir_path)
194            .await
195            .map_err(|e| TemplateLoaderError::io(&dir_path, e))?;
196
197        while let Some(entry) = entries
198            .next_entry()
199            .await
200            .map_err(|e| TemplateLoaderError::io(&dir_path, e))?
201        {
202            let entry_path = entry.path();
203
204            if entry_path.extension().is_some_and(|ext| ext == "html") {
205                let Some(file_stem) = entry_path.file_stem() else {
206                    continue;
207                };
208
209                let template_name = file_stem
210                    .to_str()
211                    .ok_or_else(|| TemplateLoaderError::InvalidEncoding(entry_path.clone()))?
212                    .to_owned();
213
214                let content = fs::read_to_string(&entry_path)
215                    .await
216                    .map_err(|e| TemplateLoaderError::io(&entry_path, e))?;
217
218                templates.push((template_name, content));
219            }
220        }
221
222        Ok(templates)
223    }
224}
225
226#[derive(Debug, Default, Clone, Copy)]
227pub struct EmbeddedLoader;
228
229#[async_trait]
230impl TemplateLoader for EmbeddedLoader {
231    async fn load(&self, source: &TemplateSource) -> Result<String> {
232        match source {
233            TemplateSource::Embedded(content) => Ok((*content).to_owned()),
234            _ => Err(TemplateLoaderError::EmbeddedOnly),
235        }
236    }
237
238    fn can_load(&self, source: &TemplateSource) -> bool {
239        matches!(source, TemplateSource::Embedded(_))
240    }
241}