use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::error::XsltError;
pub trait Loader {
fn load(&self, href: &str, base: Option<&str>) -> Result<String, XsltError>;
fn load_parsed(
&self, href: &str, base: Option<&str>,
) -> Result<std::sync::Arc<sup_xml_tree::dom::Document>, XsltError> {
let text = self.load(href, base)?;
let opts = sup_xml_core::ParseOptions {
namespace_aware: true,
..Default::default()
};
let doc = sup_xml_core::parse_str(&text, &opts).map_err(XsltError::from)?;
Ok(std::sync::Arc::new(doc))
}
fn resolve(&self, href: &str, base: Option<&str>) -> Result<String, XsltError> {
let _ = base;
Ok(href.to_string())
}
fn enumerate(&self, base: Option<&str>) -> Vec<String> {
let _ = base;
Vec::new()
}
}
fn strip_file_scheme(s: &str) -> &str {
s.strip_prefix("file://")
.or_else(|| s.strip_prefix("file:"))
.unwrap_or(s)
}
#[derive(Debug, Default)]
pub struct FilesystemLoader {
allowed_roots: Vec<PathBuf>,
parsed_cache: std::sync::Mutex<
std::collections::HashMap<String, std::sync::Arc<sup_xml_tree::dom::Document>>,
>,
}
impl FilesystemLoader {
pub fn new(allowed_roots: Vec<PathBuf>) -> Self {
Self {
allowed_roots,
parsed_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
}
}
fn resolve_path(&self, href: &str, base: Option<&str>) -> PathBuf {
let href = strip_file_scheme(href);
let href_path = Path::new(href);
if href_path.is_absolute() {
return href_path.to_path_buf();
}
match base {
Some(base) => {
let base = strip_file_scheme(base);
let base_path = Path::new(base);
let base_dir = if base_path.extension().is_some() {
base_path.parent().unwrap_or(Path::new("."))
} else {
base_path
};
base_dir.join(href)
}
None => href_path.to_path_buf(),
}
}
fn is_within_allowed_root(&self, path: &Path) -> bool {
let canonical = match path.canonicalize() {
Ok(p) => p,
Err(_) => return false,
};
self.allowed_roots.iter().any(|root| {
match root.canonicalize() {
Ok(canon_root) => canonical.starts_with(&canon_root),
Err(_) => false,
}
})
}
}
impl Loader for FilesystemLoader {
fn load(&self, href: &str, base: Option<&str>) -> Result<String, XsltError> {
let path = self.resolve_path(href, base);
if !self.is_within_allowed_root(&path) {
return Err(XsltError::InvalidStylesheet(format!(
"refusing to load '{href}' (resolved to '{}'): \
path is not within the loader's allowed roots",
path.display()
)));
}
std::fs::read_to_string(&path).map_err(|e| XsltError::InvalidStylesheet(
format!("failed to load '{href}' (resolved to '{}'): {e}", path.display()),
))
}
fn resolve(&self, href: &str, base: Option<&str>) -> Result<String, XsltError> {
let path = self.resolve_path(href, base);
let resolved = path.to_string_lossy().into_owned();
#[cfg(windows)]
let resolved = resolved.replace('\\', "/");
Ok(resolved)
}
fn load_parsed(
&self, href: &str, base: Option<&str>,
) -> Result<std::sync::Arc<sup_xml_tree::dom::Document>, XsltError> {
let path = self.resolve_path(href, base);
let key = path.canonicalize()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| path.to_string_lossy().into_owned());
if let Some(hit) = self.parsed_cache.lock().unwrap().get(&key).cloned() {
return Ok(hit);
}
let text = self.load(href, base)?;
let opts = sup_xml_core::ParseOptions {
namespace_aware: true, ..Default::default()
};
let doc = sup_xml_core::parse_str(&text, &opts).map_err(XsltError::from)?;
let arc = std::sync::Arc::new(doc);
self.parsed_cache.lock().unwrap().insert(key, arc.clone());
Ok(arc)
}
fn enumerate(&self, base: Option<&str>) -> Vec<String> {
let base_dir = match base
.and_then(|b| Path::new(b).parent().map(|p| p.to_path_buf()))
{
Some(d) => d,
None => return Vec::new(),
};
let canon_base = match base_dir.canonicalize() {
Ok(p) => p,
Err(_) => return Vec::new(),
};
if !self.allowed_roots.iter().any(|r|
r.canonicalize().map(|cr| canon_base.starts_with(&cr)).unwrap_or(false))
{
return Vec::new();
}
fn walk(dir: &Path, base: &Path, out: &mut Vec<String>, depth: u32) {
if depth > 16 { return; }
let read = match std::fs::read_dir(dir) {
Ok(r) => r, Err(_) => return,
};
for entry in read.flatten() {
let p = entry.path();
let ty = match entry.file_type() { Ok(t) => t, Err(_) => continue };
if ty.is_dir() {
walk(&p, base, out, depth + 1);
} else if ty.is_file() {
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
if matches!(ext.to_ascii_lowercase().as_str(),
"xml" | "xsl" | "xslt" | "txt") {
if let Ok(rel) = p.strip_prefix(base) {
out.push(rel.to_string_lossy().into_owned());
}
}
}
}
}
let mut out = Vec::new();
walk(&canon_base, &canon_base, &mut out, 0);
out
}
}
#[derive(Debug, Default, Clone)]
pub struct InMemoryLoader {
map: HashMap<String, String>,
}
impl InMemoryLoader {
pub fn new() -> Self { Self { map: HashMap::new() } }
pub fn with(mut self, href: impl Into<String>, text: impl Into<String>) -> Self {
self.map.insert(href.into(), text.into());
self
}
pub fn insert(&mut self, href: impl Into<String>, text: impl Into<String>) {
self.map.insert(href.into(), text.into());
}
}
impl Loader for InMemoryLoader {
fn load(&self, href: &str, _base: Option<&str>) -> Result<String, XsltError> {
self.map.get(href).cloned().ok_or_else(|| XsltError::InvalidStylesheet(
format!("InMemoryLoader: no entry for '{href}'"),
))
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NullLoader;
impl Loader for NullLoader {
fn load(&self, href: &str, _base: Option<&str>) -> Result<String, XsltError> {
Err(XsltError::InvalidStylesheet(format!(
"no Loader was supplied; cannot resolve '{href}'. Pass a Loader \
to Stylesheet::compile_str_with_loader (or use FilesystemLoader / \
InMemoryLoader)"
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn in_memory_loader_returns_registered_content() {
let l = InMemoryLoader::new().with("foo.xsl", "<stylesheet/>");
assert_eq!(l.load("foo.xsl", None).unwrap(), "<stylesheet/>");
}
#[test]
fn in_memory_loader_errors_on_missing() {
let l = InMemoryLoader::new();
assert!(l.load("missing", None).is_err());
}
#[test]
fn null_loader_always_errors() {
assert!(NullLoader.load("anything", None).is_err());
}
#[test]
fn filesystem_loader_resolves_relative_to_base() {
let l = FilesystemLoader::new(Vec::new());
let p = l.resolve_path("child.xsl", Some("/abs/parent.xsl"));
assert_eq!(p, PathBuf::from("/abs/child.xsl"));
}
#[test]
fn filesystem_loader_treats_extensionless_base_as_directory() {
let l = FilesystemLoader::new(Vec::new());
let p = l.resolve_path("file.xsl", Some("/abs/subdir"));
assert_eq!(p, PathBuf::from("/abs/subdir/file.xsl"));
}
#[test]
fn filesystem_loader_absolute_href_ignores_base() {
let l = FilesystemLoader::new(Vec::new());
let p = l.resolve_path("/etc/host.xsl", Some("/somewhere/else.xsl"));
assert_eq!(p, PathBuf::from("/etc/host.xsl"));
}
#[test]
fn filesystem_loader_no_base_uses_href_path_directly() {
let l = FilesystemLoader::new(Vec::new());
let p = l.resolve_path("foo.xsl", None);
assert_eq!(p, PathBuf::from("foo.xsl"));
}
#[test]
fn filesystem_loader_load_missing_file_errors() {
let l = FilesystemLoader::new(Vec::new());
let r = l.load("/nonexistent/definitely-not-here.xsl", None);
assert!(r.is_err());
match r {
Err(XsltError::InvalidStylesheet(_)) => {}
other => panic!("expected InvalidStylesheet, got {other:?}"),
}
}
#[test]
fn filesystem_loader_resolve_returns_resolved_path() {
let l = FilesystemLoader::new(Vec::new());
let s = l.resolve("child.xsl", Some("/abs/parent.xsl")).unwrap();
assert_eq!(s, "/abs/child.xsl");
}
#[test]
fn filesystem_loader_refuses_load_outside_allowed_roots() {
use std::io::Write;
let outside = std::env::temp_dir()
.join(format!("sup-xml-xslt-outside-{}.xsl", std::process::id()));
{
let mut f = std::fs::File::create(&outside).unwrap();
f.write_all(b"<stylesheet xmlns='http://www.w3.org/1999/XSL/Transform' version='1.0'/>").unwrap();
}
let allowed_dir = std::env::temp_dir()
.join(format!("sup-xml-xslt-allowed-{}", std::process::id()));
std::fs::create_dir_all(&allowed_dir).unwrap();
let l = FilesystemLoader::new(vec![allowed_dir.clone()]);
let r = l.load(outside.to_str().unwrap(), None);
let _ = std::fs::remove_file(&outside);
let _ = std::fs::remove_dir_all(&allowed_dir);
assert!(
r.is_err(),
"FilesystemLoader should have refused load outside allowed roots, got Ok"
);
match r {
Err(XsltError::InvalidStylesheet(msg)) => {
assert!(
msg.to_lowercase().contains("allowed"),
"expected error mentioning allowed roots, got: {msg}"
);
}
other => panic!("expected InvalidStylesheet, got {other:?}"),
}
}
#[test]
fn filesystem_loader_loads_within_allowed_root() {
use std::io::Write;
let allowed_dir = std::env::temp_dir()
.join(format!("sup-xml-xslt-inside-{}", std::process::id()));
std::fs::create_dir_all(&allowed_dir).unwrap();
let inside = allowed_dir.join("ok.xsl");
{
let mut f = std::fs::File::create(&inside).unwrap();
f.write_all(b"<stylesheet xmlns='http://www.w3.org/1999/XSL/Transform' version='1.0'/>").unwrap();
}
let l = FilesystemLoader::new(vec![allowed_dir.clone()]);
let r = l.load(inside.to_str().unwrap(), None);
let _ = std::fs::remove_file(&inside);
let _ = std::fs::remove_dir_all(&allowed_dir);
assert!(r.is_ok(), "load inside allowed root should succeed: {r:?}");
}
#[test]
fn in_memory_loader_default_resolve_returns_href_unchanged() {
let l = InMemoryLoader::new();
let s = l.resolve("foo.xsl", Some("base.xsl")).unwrap();
assert_eq!(s, "foo.xsl");
}
#[test]
fn null_loader_default_resolve_returns_href_unchanged() {
let s = NullLoader.resolve("foo.xsl", None).unwrap();
assert_eq!(s, "foo.xsl");
}
#[test]
fn in_memory_loader_insert_method() {
let mut l = InMemoryLoader::new();
l.insert("a.xsl", "<a/>");
l.insert("b.xsl", "<b/>");
assert_eq!(l.load("a.xsl", None).unwrap(), "<a/>");
assert_eq!(l.load("b.xsl", None).unwrap(), "<b/>");
}
}