Skip to main content

sup_xml_xslt/
loader.rs

1//! Stylesheet / document loader — pluggable resolver used by
2//! `xsl:import`, `xsl:include`, and the `document()` XPath
3//! function.
4//!
5//! The [`Loader`] trait is the abstraction; we ship a
6//! [`FilesystemLoader`] for the common case (resolve hrefs as
7//! local file paths relative to a base directory) and an
8//! [`InMemoryLoader`] for testing (fixed in-memory map).
9//!
10//! Callers that need network resolution, security policies, or
11//! catalog-based lookup can implement [`Loader`] themselves and
12//! pass it to `Stylesheet::compile_str_with_loader`.
13
14use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16
17use crate::error::XsltError;
18
19/// Resolve a stylesheet / document reference to its source text.
20///
21/// `href` is the user-supplied URI from the stylesheet (e.g. the
22/// `href=` attribute of `xsl:import`).  `base` is the URI of the
23/// containing stylesheet — used to resolve relative hrefs.  Both
24/// follow XML URI handling conventions (RFC 3986 reference
25/// resolution); we keep them as opaque strings here so loaders
26/// can choose their own scheme.
27pub trait Loader {
28    /// Load `href`'s contents.  Returns the raw source as a UTF-8
29    /// string.  Implementations should resolve `href` against
30    /// `base` when `href` is relative.
31    fn load(&self, href: &str, base: Option<&str>) -> Result<String, XsltError>;
32
33    /// Load `href`'s contents and return them as a parsed
34    /// [`sup_xml_tree::dom::Document`].  Default implementation
35    /// calls [`Self::load`] and parses fresh each time; loaders
36    /// that expect repeated lookups for the same URI (filesystem,
37    /// in-memory) should override to cache the parse result
38    /// internally so the runtime `doc()` path doesn't re-parse
39    /// the same XML thousands of times across an apply sweep.
40    ///
41    /// Returns an `Arc` so cache hits share the underlying
42    /// document without cloning the arena.
43    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    /// Compute the base URI to use for files imported FROM the
56    /// resource at `href`.  Default: returns `href` itself
57    /// (used as the base for the next level of resolution).
58    fn resolve(&self, href: &str, base: Option<&str>) -> Result<String, XsltError> {
59        let _ = base;
60        Ok(href.to_string())
61    }
62
63    /// Enumerate every URI the loader can serve.  Returned as
64    /// hrefs that, when passed to [`Self::load`] with the same
65    /// `base`, succeed.  Called by the XSLT runtime when the
66    /// stylesheet uses a dynamic `document()` / `doc()` URI
67    /// (computed via concat / variables / @attr) — every
68    /// enumerated URI is speculatively pre-loaded so that the
69    /// runtime call can resolve against the pre-loaded map.
70    ///
71    /// Default: empty.  Loaders that can't safely enumerate
72    /// (HTTP / arbitrary URI schemes / sandboxed environments)
73    /// leave it unimplemented, in which case stylesheets that
74    /// build URIs dynamically fall back to "URI not pre-loaded"
75    /// errors at runtime for any URI the static analysis missed.
76    ///
77    /// Implementations should respect the same security boundary
78    /// as `load` — e.g. `FilesystemLoader::enumerate` lists files
79    /// under `allowed_roots` only.
80    fn enumerate(&self, base: Option<&str>) -> Vec<String> {
81        let _ = base;
82        Vec::new()
83    }
84}
85
86/// Loader that resolves hrefs as filesystem paths.  Relative
87/// `href`s are joined to the directory of the parent `base` (if
88/// supplied), or to the current working directory otherwise.
89///
90/// `allowed_roots` is the **security boundary**.  A load is
91/// refused unless the resolved (and canonicalised) target path
92/// lies inside one of the listed directories.  Empty list ⇒
93/// every load is refused.  Matches the policy of the core crate's
94/// `FilesystemResolver` so untrusted stylesheets can't
95/// `<xsl:import href="/etc/passwd"/>` their way to local files.
96/// Strip a leading `file:` scheme so a `file:` URI can be treated as
97/// a local filesystem path.  Handles both the `file:///abs/path`
98/// (empty authority) and bare `file:/abs/path` spellings; non-`file:`
99/// strings (plain paths, other schemes) pass through unchanged.
100fn 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    /// Cache of canonical-path → parsed Document for repeated
110    /// `load_parsed` lookups.  Populated lazily on first hit.
111    /// Mutex because XSLT applies can run on any thread the
112    /// caller picks (the cache itself is a private impl detail).
113    /// Per-loader scope, so dropping the loader frees the cache.
114    parsed_cache: std::sync::Mutex<
115        std::collections::HashMap<String, std::sync::Arc<sup_xml_tree::dom::Document>>,
116    >,
117}
118
119impl FilesystemLoader {
120    /// Construct a loader scoped to the given root directories.
121    /// Files outside these roots — even when the href is absolute
122    /// and points at them directly — will be refused.
123    ///
124    /// Pass specific directories like the stylesheet's own folder
125    /// or `/usr/share/xml`; never pass `/`.  An empty list means
126    /// "refuse everything," which is the safest choice for an
127    /// untrusted stylesheet that's not expected to import.
128    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                // Strip the filename component if `base` looks like
146                // a file (has an extension); otherwise treat as
147                // directory.
148                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    /// `true` if `path` is under one of the allowed roots.
160    /// Symlinks are resolved before the check so that a symlink
161    /// inside an allowed root pointing outside can't escape it.
162    /// Missing or unreadable paths refuse (returns `false`).
163    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        // The result is handed back as a URI base for nested imports, so it
196        // must be '/'-separated like the hrefs callers pass.  `PathBuf`
197        // renders with `\` on Windows; normalise it.  (`load` uses the
198        // native `PathBuf` directly for the filesystem read, where the
199        // separator doesn't matter.)  Only on Windows, since `\` is a legal
200        // filename byte on Unix.
201        #[cfg(windows)]
202        let resolved = resolved.replace('\\', "/");
203        Ok(resolved)
204    }
205
206    /// Cached parse for repeated lookups of the same on-disk file.
207    /// Keys on the canonical path so `foo/../foo.xml` and
208    /// `foo.xml` (resolved against the same base) share a cache
209    /// entry.  Misses fall through to `load` + `parse_str` then
210    /// populate the cache.
211    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    /// Walk every file under the loader's allowed roots, returning
232    /// hrefs relative to `base`'s parent directory.  Only files
233    /// whose extension is a typical XML / text resource are
234    /// returned, to keep speculative pre-loading from chewing
235    /// through every binary the test directory happens to
236    /// contain.  Bounded depth (16) so a stray symlink loop can't
237    /// hang the runtime.
238    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        // Only walk within an allowed root.
250        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/// In-memory loader keyed by href.  Useful for tests and for
283/// embedding the ISO Schematron pipeline (where the skeleton
284/// stylesheets are bundled into the binary rather than loaded
285/// from disk).
286#[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    /// Register `text` as the contents of `href`.  Returns `self`
295    /// for chained insertion.
296    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    /// Mutable insertion — same as `.with(...)` but in-place.
302    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/// Null loader — every load fails.  Used as the default when no
316/// loader was supplied and the stylesheet doesn't actually need
317/// resolution (i.e. has no xsl:import / xsl:include).
318#[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        // base without an extension → treat as directory, join href directly.
362        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        // With an empty allowlist, every load refuses up-front
384        // (matches `FilesystemResolver`'s policy in core).  We
385        // still get an `InvalidStylesheet` error — the consumer-
386        // visible contract is "load failed" — but the specific
387        // message identifies the refusal, not an OS-level miss.
388        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    /// Security regression: a `FilesystemLoader` scoped to a
405    /// specific directory must refuse to read files outside that
406    /// directory, even when the stylesheet hands it an absolute
407    /// `href`.  Without an allowlist, `<xsl:import href="/etc/
408    /// passwd"/>` on an untrusted stylesheet exfiltrates local
409    /// files — same threat model as the core FilesystemResolver.
410    #[test]
411    fn filesystem_loader_refuses_load_outside_allowed_roots() {
412        use std::io::Write;
413        // Set up an "outside" file and an allowed temp dir.
414        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        // Loader scoped to allowed_dir only.
424        let l = FilesystemLoader::new(vec![allowed_dir.clone()]);
425        let r = l.load(outside.to_str().unwrap(), None);
426        // Cleanup before asserting.
427        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    /// Counterpart: a file inside an allowed root loads normally.
445    /// Locks in that the allowlist isn't just a blanket refuse.
446    #[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        // The Loader trait's default resolve() impl is what's tested here.
467        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}