systemprompt_template_provider/traits/
loader.rs1use 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]
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}