Skip to main content

sup_xml_core/xsd/
resolver.rs

1//! Pluggable schema resolution for `<xs:import>` / `<xs:include>` /
2//! `<xs:redefine>`.
3//!
4//! When the parser hits one of these directives, it asks the configured
5//! [`SchemaResolver`] to fetch the referenced schema bytes.  The default
6//! resolver looks for files relative to a base directory; users with
7//! catalogs, network sources, or embedded schemas plug in their own.
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12/// Resolves a schema-location hint to the XSD bytes for that schema.
13///
14/// Two conventions matter for implementations:
15///
16/// * Returning `Ok(None)` declines the resolution (the parser then
17///   produces a clear "could not resolve" compile error).
18/// * Returning `Err(_)` propagates an I/O failure; the parser wraps it
19///   in a [`SchemaCompileError`](super::error::SchemaCompileError).
20///
21/// Implementations should be cheap to clone — the parser recursively
22/// invokes them while processing nested includes.
23///
24/// The resolver is consumed synchronously by
25/// [`Schema::compile_with`](super::Schema::compile_with) and never
26/// stored or sent across threads, so no `Send`/`Sync` bound is
27/// required — letting a resolver borrow a non-`Sync` loader for the
28/// duration of one compile.
29pub trait SchemaResolver {
30    fn resolve(
31        &self,
32        location: &str,
33        target_namespace: Option<&str>,
34    ) -> Result<Option<Vec<u8>>, std::io::Error>;
35}
36
37/// A resolver that always declines.  Used by
38/// [`Schema::compile_str`](super::Schema::compile_str) for single-file
39/// schemas — any `<xs:import>` or `<xs:include>` becomes a compile error.
40#[derive(Debug, Clone, Copy, Default)]
41pub struct NoResolver;
42
43impl SchemaResolver for NoResolver {
44    fn resolve(&self, _location: &str, _target_ns: Option<&str>)
45        -> Result<Option<Vec<u8>>, std::io::Error>
46    {
47        Ok(None)
48    }
49}
50
51/// Look for schema files on the filesystem relative to a base directory.
52///
53/// Refuses to traverse upward past the base (any `..` that would escape
54/// is rejected) — schemas are an attack-surface input, and we don't want
55/// `<xs:import schemaLocation="../../../etc/passwd"/>` to read arbitrary
56/// files.  Absolute paths are likewise rejected.
57#[derive(Debug, Clone)]
58pub struct FsResolver {
59    base_dir: PathBuf,
60}
61
62impl FsResolver {
63    pub fn new(base_dir: impl Into<PathBuf>) -> Self {
64        Self { base_dir: base_dir.into() }
65    }
66}
67
68impl SchemaResolver for FsResolver {
69    fn resolve(&self, location: &str, _target_ns: Option<&str>)
70        -> Result<Option<Vec<u8>>, std::io::Error>
71    {
72        let p = Path::new(location);
73        if p.is_absolute() {
74            return Err(std::io::Error::new(
75                std::io::ErrorKind::PermissionDenied,
76                format!("absolute schemaLocation rejected: {location:?}"),
77            ));
78        }
79        // Resolve and canonicalise to verify it stays under base_dir.
80        let candidate = self.base_dir.join(p);
81        // Walk components; reject any `..` that would escape `base_dir`.
82        // We don't use canonicalize() because it requires the file to
83        // exist *and* dereferences symlinks (which can themselves
84        // traverse).  Stick to syntactic containment.
85        let base_components: Vec<_> = self.base_dir.components().collect();
86        let cand_components: Vec<_> = candidate.components().collect();
87        if cand_components.len() < base_components.len()
88            || cand_components[..base_components.len()] != base_components[..]
89        {
90            return Err(std::io::Error::new(
91                std::io::ErrorKind::PermissionDenied,
92                format!("schemaLocation escapes base directory: {location:?}"),
93            ));
94        }
95        // Reject parent traversal anywhere in the tail.
96        let tail = &cand_components[base_components.len()..];
97        if tail.iter().any(|c| matches!(c, std::path::Component::ParentDir)) {
98            return Err(std::io::Error::new(
99                std::io::ErrorKind::PermissionDenied,
100                format!("schemaLocation contains '..': {location:?}"),
101            ));
102        }
103        match std::fs::read(&candidate) {
104            Ok(b) => Ok(Some(b)),
105            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
106            Err(e) => Err(e),
107        }
108    }
109}
110
111/// Map literal `schemaLocation` strings to schema bytes held in memory.
112/// Useful for embedding schemas in a binary, for tests, or for proxying
113/// to an in-process schema cache.
114#[derive(Debug, Default, Clone)]
115pub struct InMemoryResolver {
116    map: HashMap<String, Vec<u8>>,
117}
118
119impl InMemoryResolver {
120    pub fn new() -> Self { Self::default() }
121
122    pub fn insert(&mut self, location: impl Into<String>, bytes: impl Into<Vec<u8>>) {
123        self.map.insert(location.into(), bytes.into());
124    }
125
126    /// Builder-style — same as [`insert`] but consumes/returns `self`
127    /// so resolvers can be constructed fluently.
128    pub fn with(mut self, location: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
129        self.insert(location, bytes);
130        self
131    }
132}
133
134impl SchemaResolver for InMemoryResolver {
135    fn resolve(&self, location: &str, _target_ns: Option<&str>)
136        -> Result<Option<Vec<u8>>, std::io::Error>
137    {
138        Ok(self.map.get(location).cloned())
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn no_resolver_always_declines() {
148        let r = NoResolver;
149        assert!(r.resolve("anything.xsd", None).unwrap().is_none());
150    }
151
152    #[test]
153    fn in_memory_resolves_known_locations() {
154        let r = InMemoryResolver::new()
155            .with("a.xsd", b"<a/>".to_vec())
156            .with("b.xsd", b"<b/>".to_vec());
157        assert_eq!(r.resolve("a.xsd", None).unwrap().as_deref(), Some(b"<a/>".as_slice()));
158        assert!(r.resolve("c.xsd", None).unwrap().is_none());
159    }
160
161    #[test]
162    fn fs_resolver_rejects_absolute() {
163        let r = FsResolver::new("/tmp");
164        let err = r.resolve("/etc/passwd", None).unwrap_err();
165        assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
166    }
167
168    #[test]
169    fn fs_resolver_rejects_parent_traversal() {
170        let r = FsResolver::new("/tmp/schemas");
171        let err = r.resolve("../etc/passwd", None).unwrap_err();
172        assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
173    }
174}