1use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16
17use crate::error::XsltError;
18
19pub trait Loader {
28 fn load(&self, href: &str, base: Option<&str>) -> Result<String, XsltError>;
32
33 fn load_parsed(
44 &self, href: &str, base: Option<&str>,
45 ) -> Result<std::sync::Arc<sup_xml_tree::dom::Document>, XsltError> {
46 let text = self.load(href, base)?;
47 let opts = sup_xml_core::ParseOptions {
48 namespace_aware: true,
49 ..Default::default()
50 };
51 let doc = sup_xml_core::parse_str(&text, &opts).map_err(XsltError::from)?;
52 Ok(std::sync::Arc::new(doc))
53 }
54
55 fn resolve(&self, href: &str, base: Option<&str>) -> Result<String, XsltError> {
59 let _ = base;
60 Ok(href.to_string())
61 }
62
63 fn enumerate(&self, base: Option<&str>) -> Vec<String> {
81 let _ = base;
82 Vec::new()
83 }
84}
85
86fn strip_file_scheme(s: &str) -> &str {
101 s.strip_prefix("file://")
102 .or_else(|| s.strip_prefix("file:"))
103 .unwrap_or(s)
104}
105
106#[derive(Debug, Default)]
107pub struct FilesystemLoader {
108 allowed_roots: Vec<PathBuf>,
109 parsed_cache: std::sync::Mutex<
115 std::collections::HashMap<String, std::sync::Arc<sup_xml_tree::dom::Document>>,
116 >,
117}
118
119impl FilesystemLoader {
120 pub fn new(allowed_roots: Vec<PathBuf>) -> Self {
129 Self {
130 allowed_roots,
131 parsed_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
132 }
133 }
134
135 fn resolve_path(&self, href: &str, base: Option<&str>) -> PathBuf {
136 let href = strip_file_scheme(href);
137 let href_path = Path::new(href);
138 if href_path.is_absolute() {
139 return href_path.to_path_buf();
140 }
141 match base {
142 Some(base) => {
143 let base = strip_file_scheme(base);
144 let base_path = Path::new(base);
145 let base_dir = if base_path.extension().is_some() {
149 base_path.parent().unwrap_or(Path::new("."))
150 } else {
151 base_path
152 };
153 base_dir.join(href)
154 }
155 None => href_path.to_path_buf(),
156 }
157 }
158
159 fn is_within_allowed_root(&self, path: &Path) -> bool {
164 let canonical = match path.canonicalize() {
165 Ok(p) => p,
166 Err(_) => return false,
167 };
168 self.allowed_roots.iter().any(|root| {
169 match root.canonicalize() {
170 Ok(canon_root) => canonical.starts_with(&canon_root),
171 Err(_) => false,
172 }
173 })
174 }
175}
176
177impl Loader for FilesystemLoader {
178 fn load(&self, href: &str, base: Option<&str>) -> Result<String, XsltError> {
179 let path = self.resolve_path(href, base);
180 if !self.is_within_allowed_root(&path) {
181 return Err(XsltError::InvalidStylesheet(format!(
182 "refusing to load '{href}' (resolved to '{}'): \
183 path is not within the loader's allowed roots",
184 path.display()
185 )));
186 }
187 std::fs::read_to_string(&path).map_err(|e| XsltError::InvalidStylesheet(
188 format!("failed to load '{href}' (resolved to '{}'): {e}", path.display()),
189 ))
190 }
191
192 fn resolve(&self, href: &str, base: Option<&str>) -> Result<String, XsltError> {
193 let path = self.resolve_path(href, base);
194 let resolved = path.to_string_lossy().into_owned();
195 #[cfg(windows)]
202 let resolved = resolved.replace('\\', "/");
203 Ok(resolved)
204 }
205
206 fn load_parsed(
212 &self, href: &str, base: Option<&str>,
213 ) -> Result<std::sync::Arc<sup_xml_tree::dom::Document>, XsltError> {
214 let path = self.resolve_path(href, base);
215 let key = path.canonicalize()
216 .map(|p| p.to_string_lossy().into_owned())
217 .unwrap_or_else(|_| path.to_string_lossy().into_owned());
218 if let Some(hit) = self.parsed_cache.lock().unwrap().get(&key).cloned() {
219 return Ok(hit);
220 }
221 let text = self.load(href, base)?;
222 let opts = sup_xml_core::ParseOptions {
223 namespace_aware: true, ..Default::default()
224 };
225 let doc = sup_xml_core::parse_str(&text, &opts).map_err(XsltError::from)?;
226 let arc = std::sync::Arc::new(doc);
227 self.parsed_cache.lock().unwrap().insert(key, arc.clone());
228 Ok(arc)
229 }
230
231 fn enumerate(&self, base: Option<&str>) -> Vec<String> {
239 let base_dir = match base
240 .and_then(|b| Path::new(b).parent().map(|p| p.to_path_buf()))
241 {
242 Some(d) => d,
243 None => return Vec::new(),
244 };
245 let canon_base = match base_dir.canonicalize() {
246 Ok(p) => p,
247 Err(_) => return Vec::new(),
248 };
249 if !self.allowed_roots.iter().any(|r|
251 r.canonicalize().map(|cr| canon_base.starts_with(&cr)).unwrap_or(false))
252 {
253 return Vec::new();
254 }
255 fn walk(dir: &Path, base: &Path, out: &mut Vec<String>, depth: u32) {
256 if depth > 16 { return; }
257 let read = match std::fs::read_dir(dir) {
258 Ok(r) => r, Err(_) => return,
259 };
260 for entry in read.flatten() {
261 let p = entry.path();
262 let ty = match entry.file_type() { Ok(t) => t, Err(_) => continue };
263 if ty.is_dir() {
264 walk(&p, base, out, depth + 1);
265 } else if ty.is_file() {
266 let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
267 if matches!(ext.to_ascii_lowercase().as_str(),
268 "xml" | "xsl" | "xslt" | "txt") {
269 if let Ok(rel) = p.strip_prefix(base) {
270 out.push(rel.to_string_lossy().into_owned());
271 }
272 }
273 }
274 }
275 }
276 let mut out = Vec::new();
277 walk(&canon_base, &canon_base, &mut out, 0);
278 out
279 }
280}
281
282#[derive(Debug, Default, Clone)]
287pub struct InMemoryLoader {
288 map: HashMap<String, String>,
289}
290
291impl InMemoryLoader {
292 pub fn new() -> Self { Self { map: HashMap::new() } }
293
294 pub fn with(mut self, href: impl Into<String>, text: impl Into<String>) -> Self {
297 self.map.insert(href.into(), text.into());
298 self
299 }
300
301 pub fn insert(&mut self, href: impl Into<String>, text: impl Into<String>) {
303 self.map.insert(href.into(), text.into());
304 }
305}
306
307impl Loader for InMemoryLoader {
308 fn load(&self, href: &str, _base: Option<&str>) -> Result<String, XsltError> {
309 self.map.get(href).cloned().ok_or_else(|| XsltError::InvalidStylesheet(
310 format!("InMemoryLoader: no entry for '{href}'"),
311 ))
312 }
313}
314
315#[derive(Debug, Default, Clone, Copy)]
319pub struct NullLoader;
320
321impl Loader for NullLoader {
322 fn load(&self, href: &str, _base: Option<&str>) -> Result<String, XsltError> {
323 Err(XsltError::InvalidStylesheet(format!(
324 "no Loader was supplied; cannot resolve '{href}'. Pass a Loader \
325 to Stylesheet::compile_str_with_loader (or use FilesystemLoader / \
326 InMemoryLoader)"
327 )))
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[test]
336 fn in_memory_loader_returns_registered_content() {
337 let l = InMemoryLoader::new().with("foo.xsl", "<stylesheet/>");
338 assert_eq!(l.load("foo.xsl", None).unwrap(), "<stylesheet/>");
339 }
340
341 #[test]
342 fn in_memory_loader_errors_on_missing() {
343 let l = InMemoryLoader::new();
344 assert!(l.load("missing", None).is_err());
345 }
346
347 #[test]
348 fn null_loader_always_errors() {
349 assert!(NullLoader.load("anything", None).is_err());
350 }
351
352 #[test]
353 fn filesystem_loader_resolves_relative_to_base() {
354 let l = FilesystemLoader::new(Vec::new());
355 let p = l.resolve_path("child.xsl", Some("/abs/parent.xsl"));
356 assert_eq!(p, PathBuf::from("/abs/child.xsl"));
357 }
358
359 #[test]
360 fn filesystem_loader_treats_extensionless_base_as_directory() {
361 let l = FilesystemLoader::new(Vec::new());
363 let p = l.resolve_path("file.xsl", Some("/abs/subdir"));
364 assert_eq!(p, PathBuf::from("/abs/subdir/file.xsl"));
365 }
366
367 #[test]
368 fn filesystem_loader_absolute_href_ignores_base() {
369 let l = FilesystemLoader::new(Vec::new());
370 let p = l.resolve_path("/etc/host.xsl", Some("/somewhere/else.xsl"));
371 assert_eq!(p, PathBuf::from("/etc/host.xsl"));
372 }
373
374 #[test]
375 fn filesystem_loader_no_base_uses_href_path_directly() {
376 let l = FilesystemLoader::new(Vec::new());
377 let p = l.resolve_path("foo.xsl", None);
378 assert_eq!(p, PathBuf::from("foo.xsl"));
379 }
380
381 #[test]
382 fn filesystem_loader_load_missing_file_errors() {
383 let l = FilesystemLoader::new(Vec::new());
389 let r = l.load("/nonexistent/definitely-not-here.xsl", None);
390 assert!(r.is_err());
391 match r {
392 Err(XsltError::InvalidStylesheet(_)) => {}
393 other => panic!("expected InvalidStylesheet, got {other:?}"),
394 }
395 }
396
397 #[test]
398 fn filesystem_loader_resolve_returns_resolved_path() {
399 let l = FilesystemLoader::new(Vec::new());
400 let s = l.resolve("child.xsl", Some("/abs/parent.xsl")).unwrap();
401 assert_eq!(s, "/abs/child.xsl");
402 }
403
404 #[test]
411 fn filesystem_loader_refuses_load_outside_allowed_roots() {
412 use std::io::Write;
413 let outside = std::env::temp_dir()
415 .join(format!("sup-xml-xslt-outside-{}.xsl", std::process::id()));
416 {
417 let mut f = std::fs::File::create(&outside).unwrap();
418 f.write_all(b"<stylesheet xmlns='http://www.w3.org/1999/XSL/Transform' version='1.0'/>").unwrap();
419 }
420 let allowed_dir = std::env::temp_dir()
421 .join(format!("sup-xml-xslt-allowed-{}", std::process::id()));
422 std::fs::create_dir_all(&allowed_dir).unwrap();
423 let l = FilesystemLoader::new(vec![allowed_dir.clone()]);
425 let r = l.load(outside.to_str().unwrap(), None);
426 let _ = std::fs::remove_file(&outside);
428 let _ = std::fs::remove_dir_all(&allowed_dir);
429 assert!(
430 r.is_err(),
431 "FilesystemLoader should have refused load outside allowed roots, got Ok"
432 );
433 match r {
434 Err(XsltError::InvalidStylesheet(msg)) => {
435 assert!(
436 msg.to_lowercase().contains("allowed"),
437 "expected error mentioning allowed roots, got: {msg}"
438 );
439 }
440 other => panic!("expected InvalidStylesheet, got {other:?}"),
441 }
442 }
443
444 #[test]
447 fn filesystem_loader_loads_within_allowed_root() {
448 use std::io::Write;
449 let allowed_dir = std::env::temp_dir()
450 .join(format!("sup-xml-xslt-inside-{}", std::process::id()));
451 std::fs::create_dir_all(&allowed_dir).unwrap();
452 let inside = allowed_dir.join("ok.xsl");
453 {
454 let mut f = std::fs::File::create(&inside).unwrap();
455 f.write_all(b"<stylesheet xmlns='http://www.w3.org/1999/XSL/Transform' version='1.0'/>").unwrap();
456 }
457 let l = FilesystemLoader::new(vec![allowed_dir.clone()]);
458 let r = l.load(inside.to_str().unwrap(), None);
459 let _ = std::fs::remove_file(&inside);
460 let _ = std::fs::remove_dir_all(&allowed_dir);
461 assert!(r.is_ok(), "load inside allowed root should succeed: {r:?}");
462 }
463
464 #[test]
465 fn in_memory_loader_default_resolve_returns_href_unchanged() {
466 let l = InMemoryLoader::new();
468 let s = l.resolve("foo.xsl", Some("base.xsl")).unwrap();
469 assert_eq!(s, "foo.xsl");
470 }
471
472 #[test]
473 fn null_loader_default_resolve_returns_href_unchanged() {
474 let s = NullLoader.resolve("foo.xsl", None).unwrap();
475 assert_eq!(s, "foo.xsl");
476 }
477
478 #[test]
479 fn in_memory_loader_insert_method() {
480 let mut l = InMemoryLoader::new();
481 l.insert("a.xsl", "<a/>");
482 l.insert("b.xsl", "<b/>");
483 assert_eq!(l.load("a.xsl", None).unwrap(), "<a/>");
484 assert_eq!(l.load("b.xsl", None).unwrap(), "<b/>");
485 }
486}