Skip to main content

camel_component_validator/
schema_set.rs

1//! Confined virtual-root XSD schema-set discovery.
2//!
3//! Discovers the transitive `xs:import`/`xs:include` closure reachable from one
4//! root schema file and packages it as a [`SchemaSet`]: root-relative virtual
5//! lookup keys to schema bytes, plus the supplied absolute-URI alias map. The
6//! Java resolver serves supplied keys only, so every distinct reachable virtual
7//! key is transmitted even when two keys share one canonical file.
8//!
9//! Security posture (ADR-0032): schema locations and alias values are
10//! untrusted input. Reads are confined to the canonicalized schema root, every
11//! candidate is canonicalized and containment-checked before any read,
12//! non-regular files are refused (no pipe hangs), no network access exists, no
13//! external DTD/entity is loaded, and the 256-key / 8 MiB / 128-alias /
14//! 32-level caps fail closed as keys are discovered.
15
16use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
17use std::fs::File;
18use std::io::Read;
19use std::path::{Path, PathBuf};
20
21use quick_xml::NsReader;
22use quick_xml::XmlVersion;
23use quick_xml::events::BytesStart;
24use quick_xml::events::Event;
25use quick_xml::name::ResolveResult;
26use sha2::{Digest, Sha256};
27
28use crate::error::ValidatorError;
29
30/// Maximum number of transmitted [`SchemaSet::resources`] entries.
31pub const MAX_SCHEMA_RESOURCES: usize = 256;
32
33/// Maximum sum of transmitted byte values across all `resources` entries.
34///
35/// This is the schema-set total budget: an alias and a relative path that
36/// denote the same file are two entries and both byte values count, so the
37/// same bytes can legitimately consume the budget twice.
38pub const MAX_SCHEMA_BYTES: usize = 8 * 1024 * 1024;
39
40/// Maximum number of supplied absolute-URI alias mappings.
41pub const MAX_SCHEMA_ALIASES: usize = 128;
42
43/// Maximum number of newly expanded discovery states on one traversal path.
44pub const MAX_IMPORT_DEPTH: usize = 32;
45
46/// The XML Schema namespace. Only `import`/`include` elements in this
47/// namespace are reference edges; any other namespace binding is ignored.
48const XML_SCHEMA_NS: &str = "http://www.w3.org/2001/XMLSchema";
49
50/// A discovered schema set, keyed for transmission to the XML bridge.
51///
52/// Fields are public and the struct is exhaustive: `camel-component-validator`
53/// is not a contract crate (ADR-0049), matching `ValidatorConfig` and
54/// `ValidatorError`. A constructed set that omits the root key is a caller
55/// error that [`SchemaSet::root_bytes`] handles without panicking.
56#[derive(Debug, Clone)]
57pub struct SchemaSet {
58    /// Root file's normalized relative basename; the entry schema's systemId.
59    pub root_system_id: String,
60    /// Transmitted keys to schema bytes, sorted for determinism: every distinct
61    /// root-relative virtual lookup key, plus a redundant entry keyed by each
62    /// supplied alias URI carrying the same bytes (wire compatibility). Two
63    /// keys MAY share one canonical file (for example a symlink loop back to
64    /// the root), and each key counts toward the caps.
65    pub resources: BTreeMap<String, Vec<u8>>,
66    /// Supplied absolute-URI alias -> normalized root-relative virtual path.
67    /// Transmitted as the authoritative aliased-schema virtual base (the
68    /// redundant `resources` alias-URI entry supplies bytes only); retained so
69    /// replay re-supplies the exact map and the cache key covers it.
70    pub aliases: BTreeMap<String, String>,
71}
72
73impl SchemaSet {
74    /// Stable cache key: `xsd-<64 hex>`.
75    ///
76    /// Covers the root systemId, the sorted resource keys, each entry's bytes
77    /// digest, and the sorted alias map. Any changed child or alias rotates it.
78    ///
79    /// The digest is length-prefixed and domain-separated, and it hashes a
80    /// per-entry digest rather than raw concatenated bytes, so no two distinct
81    /// sets can collide by concatenation ambiguity.
82    pub fn cache_key(&self) -> String {
83        fn put(hasher: &mut Sha256, bytes: &[u8]) {
84            hasher.update((bytes.len() as u64).to_be_bytes());
85            hasher.update(bytes);
86        }
87        let mut hasher = Sha256::new();
88        hasher.update(b"rust-camel/schema-set/v1\0");
89        put(&mut hasher, self.root_system_id.as_bytes());
90        hasher.update((self.resources.len() as u64).to_be_bytes());
91        for (key, bytes) in &self.resources {
92            put(&mut hasher, key.as_bytes());
93            let mut entry = Sha256::new();
94            entry.update(bytes);
95            hasher.update(entry.finalize());
96        }
97        hasher.update((self.aliases.len() as u64).to_be_bytes());
98        for (key, value) in &self.aliases {
99            put(&mut hasher, key.as_bytes());
100            put(&mut hasher, value.as_bytes());
101        }
102        format!("xsd-{}", hex::encode(hasher.finalize()))
103    }
104
105    /// Bytes of the root schema.
106    ///
107    /// Returns an empty slice rather than panicking when an externally
108    /// constructed set does not carry the root key; discovery always populates
109    /// it.
110    pub fn root_bytes(&self) -> &[u8] {
111        self.resources
112            .get(&self.root_system_id)
113            .map(Vec::as_slice)
114            .unwrap_or(&[])
115    }
116}
117
118/// Discovers the schema set reachable from `root` under the supplied aliases.
119///
120/// `root` is the root schema file; the virtual root is that file's parent
121/// directory. `aliases` maps absolute URIs to root-relative virtual paths;
122/// every supplied alias is materialized as a redundant URI-keyed resource
123/// entry and its target participates in reachable discovery even when the root
124/// never references it.
125pub fn discover_schema_set(
126    root: &Path,
127    aliases: &BTreeMap<String, String>,
128) -> Result<SchemaSet, ValidatorError> {
129    discover_schema_set_with(root, aliases, &mut read_file_bounded)
130}
131
132/// Reader-injection seam for discovery.
133///
134/// The injected reader performs the actual filesystem read. Discovery caches
135/// the result per canonical target, so a canonical file is read exactly once
136/// no matter how many virtual keys reach it. Production passes
137/// [`read_file_bounded`]; tests inject a counting reader.
138fn discover_schema_set_with(
139    root: &Path,
140    aliases: &BTreeMap<String, String>,
141    read: &mut dyn FnMut(&Path) -> Result<Vec<u8>, ValidatorError>,
142) -> Result<SchemaSet, ValidatorError> {
143    if aliases.len() > MAX_SCHEMA_ALIASES {
144        return Err(ValidatorError::endpoint(format!(
145            "schema_aliases exceeds the {MAX_SCHEMA_ALIASES}-entry cap"
146        )));
147    }
148
149    // Bare `root.xsd` has an empty parent; treat it as `.` without touching the
150    // process CWD.
151    let parent = match root.parent() {
152        Some(p) if !p.as_os_str().is_empty() => p,
153        _ => Path::new("."),
154    };
155    let canonical_root = std::fs::canonicalize(parent).map_err(|err| {
156        ValidatorError::endpoint(format!(
157            "schema root directory '{}' cannot be canonicalized: {err}",
158            parent.display()
159        ))
160    })?;
161    let root_name = root.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
162        ValidatorError::endpoint("schema root path has no UTF-8 file name".to_string())
163    })?;
164    // The ORIGINAL basename is preserved even when the root is a symlink: the
165    // root systemId is the operator-facing name, not the symlink target's name.
166    let root_system_id = normalize_virtual(root_name)?;
167
168    let mut normalized_aliases: BTreeMap<String, String> = BTreeMap::new();
169    for (uri, value) in aliases {
170        if !is_absolute_uri(uri) {
171            return Err(ValidatorError::endpoint(format!(
172                "schema_aliases key '{uri}' is not an absolute URI"
173            )));
174        }
175        if is_absolute_uri(value) {
176            return Err(ValidatorError::endpoint(format!(
177                "schema_aliases value '{value}' for '{uri}' must be a root-relative virtual path"
178            )));
179        }
180        let decoded = decode_uri_path(value)?;
181        if is_absolute_uri(&decoded) {
182            return Err(ValidatorError::endpoint(format!(
183                "schema_aliases value '{value}' for '{uri}' percent-decodes to an absolute URI"
184            )));
185        }
186        normalized_aliases.insert(uri.clone(), normalize_virtual(&decoded)?);
187    }
188
189    let mut discoverer = Discoverer {
190        virtual_root: parent.to_path_buf(),
191        canonical_root,
192        aliases: normalized_aliases,
193        cache: HashMap::new(),
194        seen: HashSet::new(),
195        resources: BTreeMap::new(),
196        total_bytes: 0,
197        queue: VecDeque::new(),
198        read,
199    };
200    discoverer.run(&root_system_id)?;
201
202    Ok(SchemaSet {
203        root_system_id,
204        resources: discoverer.resources,
205        aliases: discoverer.aliases,
206    })
207}
208
209/// One canonical file's bytes and its parsed `xs:import`/`xs:include` targets.
210struct CachedFile {
211    bytes: Vec<u8>,
212    refs: Vec<String>,
213}
214
215/// BFS discovery state: iterative so the resource cap is reached before the
216/// depth cap on branching symlink growth.
217struct Discoverer<'a> {
218    virtual_root: PathBuf,
219    canonical_root: PathBuf,
220    aliases: BTreeMap<String, String>,
221    /// Read-once cache keyed by canonical target.
222    cache: HashMap<PathBuf, CachedFile>,
223    /// Expansion states already offered, keyed by `(canonical target,
224    /// normalized virtual path)` so two virtual bases over one canonical file
225    /// each expand their own relative children.
226    seen: HashSet<(PathBuf, String)>,
227    resources: BTreeMap<String, Vec<u8>>,
228    total_bytes: usize,
229    queue: VecDeque<(PathBuf, String, usize)>,
230    read: &'a mut dyn FnMut(&Path) -> Result<Vec<u8>, ValidatorError>,
231}
232
233impl Discoverer<'_> {
234    fn run(&mut self, root_virtual: &str) -> Result<(), ValidatorError> {
235        self.offer(root_virtual, 0)?;
236
237        // Alias seeds: the relative target and the redundant URI-keyed entry
238        // both count. Cap checks run before any clone of the aliased bytes.
239        for (uri, norm) in self.aliases.clone() {
240            self.offer(&norm, 0)?;
241            let canonical =
242                canonicalize_candidate(&self.virtual_root, &self.canonical_root, &norm)?;
243            let cached = self.cache.get(&canonical).ok_or_else(|| {
244                ValidatorError::endpoint(format!(
245                    "schema '{norm}' was not loaded into the discovery cache"
246                ))
247            })?;
248            checked_insert(
249                &mut self.resources,
250                &mut self.total_bytes,
251                &uri,
252                &cached.bytes,
253            )?;
254        }
255
256        while let Some((canonical, vpath, depth)) = self.queue.pop_front() {
257            let refs = self
258                .cache
259                .get(&canonical)
260                .map(|c| c.refs.clone())
261                .ok_or_else(|| {
262                    ValidatorError::endpoint(format!(
263                        "schema '{vpath}' was not loaded into the discovery cache"
264                    ))
265                })?;
266            for location in refs {
267                let child = resolve_ref(&vpath, &location, &self.aliases)?;
268                self.offer(&child, depth + 1)?;
269            }
270        }
271        Ok(())
272    }
273
274    /// Offers one expansion state: resolve, containment-check, read once,
275    /// transmit under its virtual key, and enqueue for reference expansion.
276    fn offer(&mut self, vpath: &str, depth: usize) -> Result<(), ValidatorError> {
277        let canonical = canonicalize_candidate(&self.virtual_root, &self.canonical_root, vpath)?;
278        // Skip a revisited pair before the depth check so cycles terminate
279        // without a depth error while every distinct virtual key is kept.
280        if !self.seen.insert((canonical.clone(), vpath.to_string())) {
281            return Ok(());
282        }
283        if depth > MAX_IMPORT_DEPTH {
284            return Err(ValidatorError::endpoint(format!(
285                "schema import/include depth exceeds the {MAX_IMPORT_DEPTH}-level cap at '{vpath}'"
286            )));
287        }
288        if !self.cache.contains_key(&canonical) {
289            let bytes = (self.read)(&canonical)?;
290            let refs = parse_schema_refs(&bytes)?;
291            self.cache
292                .insert(canonical.clone(), CachedFile { bytes, refs });
293        }
294        let cached = self.cache.get(&canonical).ok_or_else(|| {
295            ValidatorError::endpoint(format!(
296                "schema '{vpath}' was not loaded into the discovery cache"
297            ))
298        })?;
299        checked_insert(
300            &mut self.resources,
301            &mut self.total_bytes,
302            vpath,
303            &cached.bytes,
304        )?;
305        self.queue.push_back((canonical, vpath.to_string(), depth));
306        Ok(())
307    }
308}
309
310/// Insert-or-conflict helper for the transmitted resource map.
311///
312/// The same normalized virtual key reached twice with identical bytes is a
313/// no-op; different bytes for one key is a bounded conflict error. Resource
314/// and byte caps are checked before the bytes are cloned into the map.
315fn checked_insert(
316    resources: &mut BTreeMap<String, Vec<u8>>,
317    total_bytes: &mut usize,
318    key: &str,
319    bytes: &[u8],
320) -> Result<(), ValidatorError> {
321    if let Some(existing) = resources.get(key) {
322        if existing.as_slice() != bytes {
323            return Err(ValidatorError::endpoint(format!(
324                "conflicting schema definitions for virtual path '{key}': \
325                 the same normalized key was supplied with different bytes"
326            )));
327        }
328        return Ok(());
329    }
330    if resources.len() + 1 > MAX_SCHEMA_RESOURCES {
331        return Err(ValidatorError::endpoint(format!(
332            "schema set exceeds the {MAX_SCHEMA_RESOURCES}-entry resource cap at '{key}'"
333        )));
334    }
335    if total_bytes.saturating_add(bytes.len()) > MAX_SCHEMA_BYTES {
336        return Err(ValidatorError::endpoint(format!(
337            "schema set exceeds the 8 MiB ({} byte) total schema-set cap at '{key}'",
338            MAX_SCHEMA_BYTES
339        )));
340    }
341    *total_bytes += bytes.len();
342    resources.insert(key.to_string(), bytes.to_vec());
343    Ok(())
344}
345
346/// Bounded read of a regular file.
347///
348/// Non-regular files are refused before any read (a FIFO would otherwise
349/// hang), and the read is bounded at the schema-set byte cap plus one so an
350/// oversized file cannot force an unbounded allocation.
351fn read_file_bounded(path: &Path) -> Result<Vec<u8>, ValidatorError> {
352    let file = File::open(path).map_err(|err| {
353        ValidatorError::endpoint(format!("cannot read schema '{}': {err}", path.display()))
354    })?;
355    let meta = file.metadata().map_err(|err| {
356        ValidatorError::endpoint(format!("cannot stat schema '{}': {err}", path.display()))
357    })?;
358    if !meta.file_type().is_file() {
359        return Err(ValidatorError::endpoint(format!(
360            "schema '{}' is not a regular file and is refused",
361            path.display()
362        )));
363    }
364    let mut buf = Vec::new();
365    let mut limited = file.take(MAX_SCHEMA_BYTES as u64 + 1);
366    limited.read_to_end(&mut buf).map_err(|err| {
367        ValidatorError::endpoint(format!("cannot read schema '{}': {err}", path.display()))
368    })?;
369    if buf.len() > MAX_SCHEMA_BYTES {
370        return Err(ValidatorError::endpoint(format!(
371            "schema '{}' exceeds the 8 MiB ({} byte) schema-set cap",
372            path.display(),
373            MAX_SCHEMA_BYTES
374        )));
375    }
376    Ok(buf)
377}
378
379/// Resolves a `schemaLocation` against the importing schema's virtual base.
380///
381/// The absolute-URI branch matches the RAW location before any decoding, so a
382/// supplied alias is an exact mapping and an encoded absolute URI is refused
383/// with no fetch. Relative locations are percent-decoded once and then
384/// confined: an absolute filesystem path is refused BEFORE it is joined onto
385/// the importing base, because `/child.xsd` from `a/parent.xsd` would otherwise
386/// collapse to the in-root `a/child.xsd`. Only a root-relative virtual path
387/// survives to normalization.
388fn resolve_ref(
389    importer_virtual: &str,
390    location: &str,
391    aliases: &BTreeMap<String, String>,
392) -> Result<String, ValidatorError> {
393    if is_absolute_uri(location) {
394        return aliases.get(location).cloned().ok_or_else(|| {
395            ValidatorError::endpoint(format!(
396                "schema location '{location}' is an absolute URI with no supplied alias; \
397                 external resolution is refused"
398            ))
399        });
400    }
401    let decoded = decode_uri_path(location)?;
402    if is_absolute_uri(&decoded) {
403        return Err(ValidatorError::endpoint(format!(
404            "schema location '{location}' percent-decodes to an absolute URI; \
405             external resolution is refused"
406        )));
407    }
408    // Refuse absolute filesystem paths before the base join. Covers the
409    // leading `/` and any platform-absolute form (drive prefix, UNC).
410    if decoded.starts_with('/') || Path::new(&decoded).is_absolute() {
411        return Err(ValidatorError::endpoint(format!(
412            "schema location '{location}' is an absolute filesystem path; \
413             only root-relative virtual paths and supplied aliases are accepted"
414        )));
415    }
416    let dir = match importer_virtual.rfind('/') {
417        Some(idx) => &importer_virtual[..idx],
418        None => "",
419    };
420    let joined = if dir.is_empty() {
421        decoded
422    } else {
423        format!("{dir}/{decoded}")
424    };
425    normalize_virtual(&joined)
426}
427
428/// Normalizes a root-relative virtual path: drops `.`/empty segments, applies
429/// `..`, and refuses anything that escapes the virtual root or names no file.
430fn normalize_virtual(raw: &str) -> Result<String, ValidatorError> {
431    if raw.is_empty() {
432        return Err(ValidatorError::endpoint("schema path is empty".to_string()));
433    }
434    if raw.starts_with('/') {
435        return Err(ValidatorError::endpoint(format!(
436            "schema path '{raw}' is absolute; only root-relative virtual paths are accepted"
437        )));
438    }
439    let mut out: Vec<&str> = Vec::new();
440    for segment in raw.split('/') {
441        match segment {
442            "" | "." => {}
443            ".." => {
444                if out.pop().is_none() {
445                    return Err(ValidatorError::endpoint(format!(
446                        "schema path '{raw}' escapes the schema root and is refused"
447                    )));
448                }
449            }
450            other => out.push(other),
451        }
452    }
453    if out.is_empty() {
454        return Err(ValidatorError::endpoint(format!(
455            "schema path '{raw}' does not name a file"
456        )));
457    }
458    Ok(out.join("/"))
459}
460
461/// Percent-decodes a URI reference exactly once, following the workspace
462/// `percent-encoding` convention used by `ValidatorConfig::from_uri`.
463fn decode_uri_path(raw: &str) -> Result<String, ValidatorError> {
464    percent_encoding::percent_decode_str(raw)
465        .decode_utf8()
466        .map(|s| s.into_owned())
467        .map_err(|err| {
468            ValidatorError::endpoint(format!(
469                "schema location '{raw}' is not valid UTF-8 after percent-decoding: {err}"
470            ))
471        })
472}
473
474/// RFC 3986 absolute-URI test: `scheme ":" ...` with a well-formed scheme.
475///
476/// Used to fail closed on any URI-like location that has no supplied alias.
477fn is_absolute_uri(s: &str) -> bool {
478    let Some(colon) = s.find(':') else {
479        return false;
480    };
481    if colon == 0 {
482        return false;
483    }
484    let mut chars = s[..colon].chars();
485    let Some(first) = chars.next() else {
486        return false;
487    };
488    first.is_ascii_alphabetic()
489        && chars.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
490}
491
492/// Canonicalizes a virtual path and confines it to the schema root.
493///
494/// Symlinks are followed (a config-map root is supported) but the canonical
495/// target must stay inside `canonical_root` and must be a regular file.
496fn canonicalize_candidate(
497    virtual_root: &Path,
498    canonical_root: &Path,
499    virtual_path: &str,
500) -> Result<PathBuf, ValidatorError> {
501    let joined = virtual_root.join(virtual_path);
502    let canonical = std::fs::canonicalize(&joined).map_err(|err| {
503        ValidatorError::endpoint(format!(
504            "schema '{virtual_path}' cannot be resolved within the schema root: {err}"
505        ))
506    })?;
507    if !canonical.starts_with(canonical_root) {
508        return Err(ValidatorError::endpoint(format!(
509            "schema '{virtual_path}' resolves outside the schema root and is refused"
510        )));
511    }
512    let meta = std::fs::metadata(&canonical).map_err(|err| {
513        ValidatorError::endpoint(format!("cannot stat schema '{virtual_path}': {err}"))
514    })?;
515    if !meta.file_type().is_file() {
516        return Err(ValidatorError::endpoint(format!(
517            "schema '{virtual_path}' is not a regular file and is refused"
518        )));
519    }
520    Ok(canonical)
521}
522
523/// Parses the `xs:import`/`xs:include` `schemaLocation` edges of one schema.
524///
525/// Namespace-aware (`quick_xml::NsReader`): only elements resolved into the
526/// XML Schema namespace count, regardless of prefix or default-namespace form.
527/// No external DTD or entity is loaded and nothing is fetched.
528fn parse_schema_refs(bytes: &[u8]) -> Result<Vec<String>, ValidatorError> {
529    let mut reader = NsReader::from_reader(bytes);
530    reader.config_mut().trim_text(true);
531    let mut buf = Vec::new();
532    let mut refs = Vec::new();
533    loop {
534        match reader.read_resolved_event_into(&mut buf) {
535            Ok((resolve, Event::Start(start))) => collect_ref(&resolve, &start, &mut refs)?,
536            Ok((resolve, Event::Empty(start))) => collect_ref(&resolve, &start, &mut refs)?,
537            Ok((_, Event::Eof)) => break,
538            Ok(_) => {}
539            Err(err) => {
540                return Err(ValidatorError::endpoint(format!(
541                    "schema document is not well-formed XML: {err}"
542                )));
543            }
544        }
545    }
546    Ok(refs)
547}
548
549fn collect_ref(
550    resolve: &ResolveResult<'_>,
551    start: &BytesStart<'_>,
552    refs: &mut Vec<String>,
553) -> Result<(), ValidatorError> {
554    let namespace = match resolve {
555        ResolveResult::Bound(ns) => ns.0,
556        _ => return Ok(()),
557    };
558    if namespace != XML_SCHEMA_NS {
559        return Ok(());
560    }
561    let local_name = start.local_name();
562    let local: &str = local_name.as_ref();
563    if local != "import" && local != "include" {
564        return Ok(());
565    }
566    for attr in start.attributes() {
567        let attr = attr.map_err(|err| {
568            ValidatorError::endpoint(format!("schema document has an invalid attribute: {err}"))
569        })?;
570        // XML attributes are unqualified unless prefixed: the default
571        // namespace does NOT apply to attribute names. Match the raw QName so
572        // only a literal unprefixed `schemaLocation` is a reference edge and a
573        // foreign `meta:schemaLocation` is metadata, never a filesystem or
574        // network edge.
575        if attr.key.as_ref() != "schemaLocation" {
576            continue;
577        }
578        // XML attribute-value normalization: decodes character references and
579        // predefined entities only. No external DTD or entity is ever loaded.
580        let value = attr
581            .normalized_value(XmlVersion::Implicit1_0)
582            .map_err(|err| {
583                ValidatorError::endpoint(format!(
584                    "schemaLocation attribute is not valid XML text: {err}"
585                ))
586            })?;
587        refs.push(value.into_owned());
588    }
589    Ok(())
590}
591
592#[cfg(test)]
593mod tests {
594    use std::cell::RefCell;
595    use std::collections::{BTreeMap, HashMap};
596    use std::path::{Path, PathBuf};
597
598    use super::{
599        MAX_IMPORT_DEPTH, MAX_SCHEMA_BYTES, MAX_SCHEMA_RESOURCES, SchemaSet, checked_insert,
600        discover_schema_set, discover_schema_set_with, read_file_bounded,
601    };
602
603    const XS: &str = "http://www.w3.org/2001/XMLSchema";
604
605    // ── fixtures ────────────────────────────────────────────────────────
606
607    /// Builds an XSD document whose elements use `prefix` (empty string selects
608    /// the default namespace) and which carries one `kind` reference per
609    /// `refs` entry. Namespace-aware parsing must see every one of these forms
610    /// as an XML Schema `import`/`include`, never as a text match.
611    fn xsd_with_refs(prefix: &str, refs: &[(&str, &str)]) -> String {
612        let xmlns = if prefix.is_empty() {
613            format!("xmlns=\"{XS}\"")
614        } else {
615            format!("xmlns:{prefix}=\"{XS}\"")
616        };
617        let p = if prefix.is_empty() {
618            String::new()
619        } else {
620            format!("{prefix}:")
621        };
622        let mut body = String::new();
623        for (kind, loc) in refs {
624            body.push_str(&format!("  <{p}{kind} schemaLocation=\"{loc}\"/>\n"));
625        }
626        format!(
627            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<{p}schema {xmlns}>\n{body}</{p}schema>\n"
628        )
629    }
630
631    /// Empty schema: a schema with no global component, so including it twice
632    /// cannot produce a duplicate declaration.
633    fn xsd_empty() -> String {
634        format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<xs:schema xmlns:xs=\"{XS}\"/>\n")
635    }
636
637    /// Root schema declaring exactly one global element plus `xs:`-prefixed
638    /// `refs`.
639    fn xsd_root_with_element(refs: &[(&str, &str)]) -> String {
640        let mut body = String::from("  <xs:element name=\"root\" type=\"xs:string\"/>\n");
641        for (kind, loc) in refs {
642            body.push_str(&format!("  <xs:{kind} schemaLocation=\"{loc}\"/>\n"));
643        }
644        format!(
645            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<xs:schema xmlns:xs=\"{XS}\">\n{body}</xs:schema>\n"
646        )
647    }
648
649    fn write_file(dir: &Path, rel: &str, contents: &str) -> PathBuf {
650        let path = dir.join(rel);
651        if let Some(parent) = path.parent() {
652            std::fs::create_dir_all(parent).unwrap();
653        }
654        std::fs::write(&path, contents).unwrap();
655        path
656    }
657
658    fn symlink(target: &Path, link: &Path) {
659        if let Some(parent) = link.parent() {
660            std::fs::create_dir_all(parent).unwrap();
661        }
662        std::os::unix::fs::symlink(target, link).unwrap();
663    }
664
665    /// Extends `path` to `len` bytes without materializing content.
666    fn set_len(path: &Path, len: u64) {
667        std::fs::OpenOptions::new()
668            .write(true)
669            .open(path)
670            .unwrap()
671            .set_len(len)
672            .unwrap();
673    }
674
675    fn empty_aliases() -> BTreeMap<String, String> {
676        BTreeMap::new()
677    }
678
679    // ── reachable imports ───────────────────────────────────────────────
680
681    #[test]
682    fn reachable_imports_are_included() {
683        // Namespace-form table: the same import graph is discovered whether the
684        // XML Schema namespace is bound to `xsd:`, to the default namespace, or
685        // to an arbitrary prefix. The `custom` row also pins `.`/`..`
686        // normalization inside the virtual root.
687        let cases: [(&str, &str, &str); 3] = [
688            ("xsd", "xsd", "deep/grandchild.xsd"),
689            ("default", "", "deep/grandchild.xsd"),
690            ("custom", "s", "./deep/../deep/grandchild.xsd"),
691        ];
692        for (label, prefix, grand_loc) in cases {
693            let tmp = tempfile::tempdir().unwrap();
694            let root_path = write_file(
695                tmp.path(),
696                "root.xsd",
697                &xsd_with_refs(prefix, &[("import", "child.xsd")]),
698            );
699            write_file(
700                tmp.path(),
701                "child.xsd",
702                &xsd_with_refs(prefix, &[("include", grand_loc)]),
703            );
704            write_file(tmp.path(), "deep/grandchild.xsd", &xsd_empty());
705
706            let set = discover_schema_set(&root_path, &empty_aliases())
707                .unwrap_or_else(|e| panic!("{label}: discovery failed: {e}"));
708
709            for key in ["root.xsd", "child.xsd", "deep/grandchild.xsd"] {
710                assert!(
711                    set.resources.contains_key(key),
712                    "{label}: missing normalized entry '{key}' in {:?}",
713                    set.resources.keys().collect::<Vec<_>>()
714                );
715            }
716            assert_eq!(set.resources.len(), 3, "{label}: exactly three entries");
717            assert_eq!(set.root_system_id, "root.xsd", "{label}: root systemId");
718        }
719
720        // Foreign-attribute row: XML attributes are unqualified unless
721        // prefixed, so the default namespace never qualifies them and only the
722        // raw QName `schemaLocation` is a reference edge. `meta:schemaLocation`
723        // and `alt:schemaLocation` are metadata from other namespaces and MUST
724        // not create a filesystem edge (`decoy.xsd` exists on disk) or a
725        // network edge (`https://…` has no supplied alias).
726        {
727            let tmp = tempfile::tempdir().unwrap();
728            let root_xml = format!(
729                "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
730                 <xs:schema xmlns:xs=\"{XS}\" xmlns:meta=\"urn:metadata\" xmlns:alt=\"urn:alt\">\n\
731                 \x20 <xs:include schemaLocation=\"child.xsd\" meta:schemaLocation=\"decoy.xsd\"/>\n\
732                 \x20 <xs:include meta:schemaLocation=\"https://example.invalid/x.xsd\"/>\n\
733                 </xs:schema>\n"
734            );
735            let root_path = write_file(tmp.path(), "root.xsd", &root_xml);
736            write_file(tmp.path(), "child.xsd", &xsd_empty());
737            write_file(tmp.path(), "decoy.xsd", &xsd_empty());
738
739            let set = discover_schema_set(&root_path, &empty_aliases())
740                .expect("foreign-namespace schemaLocation attributes are not reference edges");
741
742            for key in ["root.xsd", "child.xsd"] {
743                assert!(
744                    set.resources.contains_key(key),
745                    "foreign-attrs: missing actual entry '{key}' in {:?}",
746                    set.resources.keys().collect::<Vec<_>>()
747                );
748            }
749            assert!(
750                !set.resources.contains_key("decoy.xsd"),
751                "foreign `meta:schemaLocation` must not create a filesystem edge: {:?}",
752                set.resources.keys().collect::<Vec<_>>()
753            );
754            assert_eq!(
755                set.resources.len(),
756                2,
757                "only the real child is discovered: {:?}",
758                set.resources.keys().collect::<Vec<_>>()
759            );
760        }
761    }
762
763    // Native Windows filesystems do not support literal '?' filename data.
764    #[cfg(unix)]
765    #[test]
766    fn encoded_fragment_filename_is_discovered_once() {
767        assert_encoded_filename_discovered_once("child%23v1.xsd", "child#v1.xsd");
768    }
769
770    #[cfg(unix)]
771    #[test]
772    fn encoded_query_filename_is_discovered_once() {
773        assert_encoded_filename_discovered_once("child%3Fv1.xsd", "child?v1.xsd");
774    }
775
776    /// Read-only override selects the fixture filesystem; Unix defaults to `/tmp` rather than
777    /// a potentially NTFS-backed TMPDIR. The selected directory must be writable and support
778    /// literal '#' and '?' filenames. Set CAMEL_TEST_SCHEMA_FIXTURE_DIR if `/tmp` does not.
779    #[cfg(unix)]
780    fn assert_encoded_filename_discovered_once(reference: &str, child_name: &str) {
781        let fixture_dir = std::env::var_os("CAMEL_TEST_SCHEMA_FIXTURE_DIR")
782            .map(PathBuf::from)
783            .unwrap_or_else(|| PathBuf::from("/tmp"));
784        let setup_error = |error: std::io::Error| {
785            format!(
786                "CAMEL_TEST_SCHEMA_FIXTURE_DIR must name a writable directory on a filesystem \
787                 supporting literal '#' and '?' filenames (selected '{}'): {error}",
788                fixture_dir.display()
789            )
790        };
791        let tmp = tempfile::tempdir_in(&fixture_dir)
792            .unwrap_or_else(|error| panic!("{}", setup_error(error)));
793        std::fs::create_dir(tmp.path().join("nested"))
794            .unwrap_or_else(|error| panic!("{}", setup_error(error)));
795        let child_key = format!("nested/{child_name}");
796        for (key, contents) in [
797            (
798                "root.xsd",
799                xsd_with_refs("xs", &[("include", &format!("nested/{reference}"))]),
800            ),
801            (
802                &child_key,
803                xsd_with_refs("xs", &[("include", "grandchild.xsd")]),
804            ),
805            ("nested/grandchild.xsd", xsd_empty()),
806            ("grandchild.xsd", xsd_empty()),
807        ] {
808            std::fs::write(tmp.path().join(key), contents)
809                .unwrap_or_else(|error| panic!("{} (fixture '{key}')", setup_error(error)));
810        }
811        let root = tmp.path().join("root.xsd");
812
813        let mut reads = HashMap::<PathBuf, usize>::new();
814        let set = discover_schema_set_with(&root, &empty_aliases(), &mut |path| {
815            *reads.entry(path.to_path_buf()).or_default() += 1;
816            read_file_bounded(path)
817        })
818        .expect("encoded filename and its relative descendant must be discovered");
819
820        assert_eq!(set.root_system_id, "root.xsd");
821        assert_eq!(set.resources.len(), 3);
822        for key in ["root.xsd", &child_key, "nested/grandchild.xsd"] {
823            assert!(set.resources.contains_key(key), "missing literal key {key}");
824            let canonical = std::fs::canonicalize(tmp.path().join(key))
825                .unwrap_or_else(|error| panic!("{} (fixture '{key}')", setup_error(error)));
826            assert_eq!(reads.get(&canonical), Some(&1), "read once: {key}");
827        }
828        assert_eq!(reads.len(), 3, "only the expected closure is read");
829    }
830
831    #[test]
832    fn unrelated_files_are_excluded() {
833        let tmp = tempfile::tempdir().unwrap();
834        let root_path = write_file(
835            tmp.path(),
836            "root.xsd",
837            &xsd_with_refs("xs", &[("import", "child.xsd")]),
838        );
839        write_file(tmp.path(), "child.xsd", &xsd_empty());
840        write_file(tmp.path(), "unrelated.xsd", &xsd_empty());
841
842        let set = discover_schema_set(&root_path, &empty_aliases()).unwrap();
843
844        assert!(
845            !set.resources.contains_key("unrelated.xsd"),
846            "unreferenced sibling must not be discovered: {:?}",
847            set.resources.keys().collect::<Vec<_>>()
848        );
849        assert_eq!(set.resources.len(), 2);
850    }
851
852    #[test]
853    fn diamond_included_once_without_duplicate_error() {
854        let tmp = tempfile::tempdir().unwrap();
855        let root_path = write_file(
856            tmp.path(),
857            "a.xsd",
858            &xsd_with_refs("xs", &[("import", "b.xsd"), ("include", "c.xsd")]),
859        );
860        write_file(
861            tmp.path(),
862            "b.xsd",
863            &xsd_with_refs("xs", &[("include", "d.xsd")]),
864        );
865        write_file(
866            tmp.path(),
867            "c.xsd",
868            &xsd_with_refs("xs", &[("include", "d.xsd")]),
869        );
870        write_file(tmp.path(), "d.xsd", &xsd_empty());
871
872        let set = discover_schema_set(&root_path, &empty_aliases())
873            .expect("diamond must not raise a duplicate error");
874
875        assert!(set.resources.contains_key("d.xsd"));
876        assert_eq!(
877            set.resources.len(),
878            4,
879            "D is transmitted exactly once: {:?}",
880            set.resources.keys().collect::<Vec<_>>()
881        );
882    }
883
884    #[test]
885    fn cyclic_include_terminates() {
886        let tmp = tempfile::tempdir().unwrap();
887        let root_path = write_file(
888            tmp.path(),
889            "a.xsd",
890            &xsd_with_refs("xs", &[("include", "b.xsd")]),
891        );
892        write_file(
893            tmp.path(),
894            "b.xsd",
895            &xsd_with_refs("xs", &[("include", "a.xsd")]),
896        );
897
898        let set = discover_schema_set(&root_path, &empty_aliases())
899            .expect("include cycle must terminate without a depth error");
900
901        assert!(set.resources.contains_key("a.xsd"));
902        assert!(set.resources.contains_key("b.xsd"));
903    }
904
905    // ── per-virtual-base expansion ──────────────────────────────────────
906
907    #[test]
908    fn symlinked_cycle_preserves_loop_key() {
909        // Root declares exactly one global element and `xs:include`s an EMPTY
910        // `loop.xsd`; `loop.xsd` `xs:include`s `loop-link.xsd`, a symlink back
911        // to the empty `loop.xsd` (NOT to the root, so the root's global
912        // element is not duplicated). `xs:include`, not a same-namespace
913        // `xs:import`, which is invalid for a same-namespace self target.
914        let tmp = tempfile::tempdir().unwrap();
915        let root_path = write_file(
916            tmp.path(),
917            "root.xsd",
918            &xsd_root_with_element(&[("include", "loop.xsd")]),
919        );
920        write_file(
921            tmp.path(),
922            "loop.xsd",
923            &xsd_with_refs("xs", &[("include", "loop-link.xsd")]),
924        );
925        symlink(Path::new("loop.xsd"), &tmp.path().join("loop-link.xsd"));
926
927        let reads = RefCell::new(HashMap::<PathBuf, usize>::new());
928        let mut read = |p: &Path| {
929            *reads.borrow_mut().entry(p.to_path_buf()).or_insert(0) += 1;
930            read_file_bounded(p)
931        };
932        let set = discover_schema_set_with(&root_path, &empty_aliases(), &mut read)
933            .expect("symlinked include cycle must terminate with no depth error");
934
935        // Both loop virtual keys are transmitted: the supplied-keys-only
936        // resolver must be able to serve `loop-link.xsd` as well as `loop.xsd`.
937        for key in ["root.xsd", "loop.xsd", "loop-link.xsd"] {
938            assert!(
939                set.resources.contains_key(key),
940                "missing virtual key '{key}' in {:?}",
941                set.resources.keys().collect::<Vec<_>>()
942            );
943        }
944        assert_eq!(
945            set.resources.len(),
946            3,
947            "{:?}",
948            set.resources.keys().collect::<Vec<_>>()
949        );
950
951        let loop_canonical = std::fs::canonicalize(tmp.path().join("loop.xsd")).unwrap();
952        assert_eq!(
953            reads.borrow().get(&loop_canonical),
954            Some(&1),
955            "the canonical empty file's bytes must be read exactly once"
956        );
957    }
958
959    #[test]
960    fn per_virtual_base_expands_distinct_children() {
961        let tmp = tempfile::tempdir().unwrap();
962        let root_path = write_file(
963            tmp.path(),
964            "root.xsd",
965            &xsd_with_refs(
966                "xs",
967                &[("include", "a/shared.xsd"), ("include", "b/shared.xsd")],
968            ),
969        );
970        // One canonical shared file, reached through two virtual keys, each of
971        // which expands `child.xsd` against its own virtual base.
972        write_file(
973            tmp.path(),
974            "shared.xsd",
975            &xsd_with_refs("xs", &[("include", "child.xsd")]),
976        );
977        symlink(Path::new("../shared.xsd"), &tmp.path().join("a/shared.xsd"));
978        symlink(Path::new("../shared.xsd"), &tmp.path().join("b/shared.xsd"));
979        write_file(tmp.path(), "a/child.xsd", &xsd_empty());
980        write_file(tmp.path(), "b/child.xsd", &xsd_empty());
981
982        let reads = RefCell::new(HashMap::<PathBuf, usize>::new());
983        let mut read = |p: &Path| {
984            *reads.borrow_mut().entry(p.to_path_buf()).or_insert(0) += 1;
985            read_file_bounded(p)
986        };
987        let set = discover_schema_set_with(&root_path, &empty_aliases(), &mut read)
988            .expect("per-virtual-base expansion must succeed");
989
990        for key in ["a/shared.xsd", "b/shared.xsd", "a/child.xsd", "b/child.xsd"] {
991            assert!(
992                set.resources.contains_key(key),
993                "missing virtual key '{key}' in {:?}",
994                set.resources.keys().collect::<Vec<_>>()
995            );
996        }
997        assert_eq!(
998            set.resources.len(),
999            5,
1000            "{:?}",
1001            set.resources.keys().collect::<Vec<_>>()
1002        );
1003
1004        let shared_canonical = std::fs::canonicalize(tmp.path().join("shared.xsd")).unwrap();
1005        assert_eq!(
1006            reads.borrow().get(&shared_canonical),
1007            Some(&1),
1008            "the canonical shared file's bytes must be read exactly once"
1009        );
1010    }
1011
1012    // ── caps ────────────────────────────────────────────────────────────
1013
1014    #[test]
1015    fn unbounded_symlink_alias_growth_fails_closed() {
1016        // Branching symlink-directory fixture. The root `xs:include`s
1017        // `b0a/n1.xsd` and `b0b/n1.xsd` where `b0a`/`b0b` are symlink
1018        // directories to `D1`; each `D_i/n_i.xsd` includes `b_ia/n_{i+1}.xsd`
1019        // and `b_ib/n_{i+1}.xsd` where `b_ia`/`b_ib` are symlink directories to
1020        // `D_{i+1}`. Distinct virtual lookup keys therefore double per level
1021        // while each level shares one canonical file.
1022        //
1023        // Both caps are distinct constraints: this fixture branches, so BFS
1024        // reaches the 256-entry resource cap at level 8 (depth 8), well before
1025        // the 32-level depth cap could fire. A linear chain of the same length
1026        // would instead hit the depth cap — see `depth_limit_is_enforced`.
1027        const LEVELS: usize = 8;
1028        let tmp = tempfile::tempdir().unwrap();
1029        let dir = tmp.path();
1030        write_file(
1031            dir,
1032            "root.xsd",
1033            &xsd_with_refs(
1034                "xs",
1035                &[("include", "b0a/n1.xsd"), ("include", "b0b/n1.xsd")],
1036            ),
1037        );
1038        symlink(&dir.join("D1"), &dir.join("b0a"));
1039        symlink(&dir.join("D1"), &dir.join("b0b"));
1040        for i in 1..=LEVELS {
1041            write_file(dir, &format!("D{i}/n{i}.xsd"), &xsd_empty());
1042            if i < LEVELS {
1043                let next = format!("D{}", i + 1);
1044                symlink(&dir.join(&next), &dir.join(format!("D{i}/b{i}a")));
1045                symlink(&dir.join(&next), &dir.join(format!("D{i}/b{i}b")));
1046            }
1047        }
1048        for i in 1..LEVELS {
1049            let next = i + 1;
1050            write_file(
1051                dir,
1052                &format!("D{i}/n{i}.xsd"),
1053                &xsd_with_refs(
1054                    "xs",
1055                    &[
1056                        ("include", &format!("b{i}a/n{next}.xsd")),
1057                        ("include", &format!("b{i}b/n{next}.xsd")),
1058                    ],
1059                ),
1060            );
1061        }
1062
1063        let err = discover_schema_set(&dir.join("root.xsd"), &empty_aliases())
1064            .expect_err("unbounded virtual-key growth must fail closed");
1065        let msg = err.to_string();
1066        assert!(
1067            msg.contains("256"),
1068            "error must name the 256-entry cap, got: {msg}"
1069        );
1070    }
1071
1072    #[test]
1073    fn depth_limit_is_enforced() {
1074        // Table: the boundary is 32 expansion levels below the root; the
1075        // over-limit row is a chain one level deeper.
1076        for (hops, expect_ok) in [(MAX_IMPORT_DEPTH, true), (MAX_IMPORT_DEPTH + 1, false)] {
1077            let tmp = tempfile::tempdir().unwrap();
1078            let root_path = write_file(
1079                tmp.path(),
1080                "root.xsd",
1081                &xsd_with_refs("xs", &[("include", "c1.xsd")]),
1082            );
1083            // root -> c1 -> ... -> c{hops}; c{hops} is a leaf.
1084            for i in 1..hops {
1085                let loc = format!("c{}.xsd", i + 1);
1086                let refs = [("include", loc.as_str())];
1087                write_file(
1088                    tmp.path(),
1089                    &format!("c{i}.xsd"),
1090                    &xsd_with_refs("xs", &refs),
1091                );
1092            }
1093            write_file(tmp.path(), &format!("c{hops}.xsd"), &xsd_empty());
1094
1095            let result = discover_schema_set(&root_path, &empty_aliases());
1096            if expect_ok {
1097                assert!(
1098                    result.is_ok(),
1099                    "chain of {hops} hops must stay within the depth cap: {:?}",
1100                    result.err().map(|e| e.to_string())
1101                );
1102            } else {
1103                let err = result.expect_err("chain past the depth cap must fail");
1104                let msg = err.to_string();
1105                assert!(
1106                    msg.contains("32"),
1107                    "error must name the 32-level depth cap, got: {msg}"
1108                );
1109            }
1110        }
1111    }
1112
1113    #[test]
1114    fn more_than_256_entries_is_rejected() {
1115        let tmp = tempfile::tempdir().unwrap();
1116        let locations: Vec<String> = (0..MAX_SCHEMA_RESOURCES)
1117            .map(|i| format!("c{i:03}.xsd"))
1118            .collect();
1119        let refs: Vec<(&str, &str)> = locations
1120            .iter()
1121            .map(|loc| ("include", loc.as_str()))
1122            .collect();
1123        let root_path = write_file(tmp.path(), "root.xsd", &xsd_with_refs("xs", &refs));
1124        for loc in &locations {
1125            write_file(tmp.path(), loc, &xsd_empty());
1126        }
1127
1128        let err = discover_schema_set(&root_path, &empty_aliases())
1129            .expect_err("root + 256 children exceeds the 256-entry cap");
1130        let msg = err.to_string();
1131        assert!(
1132            msg.contains("256"),
1133            "error must name the 256-entry cap, got: {msg}"
1134        );
1135    }
1136
1137    #[test]
1138    fn more_than_8_mib_is_rejected() {
1139        // Table: a single huge file, and an alias that duplicates the byte
1140        // weight of one file into a second transmitted entry.
1141        for label in ["huge file", "alias duplicate byte weight"] {
1142            let tmp = tempfile::tempdir().unwrap();
1143            let (root_path, aliases) = if label == "huge file" {
1144                let root_path = write_file(
1145                    tmp.path(),
1146                    "root.xsd",
1147                    &xsd_with_refs("xs", &[("include", "huge.xsd")]),
1148                );
1149                let huge = write_file(tmp.path(), "huge.xsd", &xsd_empty());
1150                set_len(&huge, MAX_SCHEMA_BYTES as u64 + 1);
1151                (root_path, BTreeMap::new())
1152            } else {
1153                let root_path = write_file(
1154                    tmp.path(),
1155                    "root.xsd",
1156                    &xsd_with_refs("xs", &[("include", "big.xsd")]),
1157                );
1158                let big = write_file(tmp.path(), "big.xsd", &xsd_empty());
1159                set_len(&big, (MAX_SCHEMA_BYTES as u64 / 2) + 1024);
1160                let mut aliases = BTreeMap::new();
1161                aliases.insert(
1162                    "http://example.com/big.xsd".to_string(),
1163                    "big.xsd".to_string(),
1164                );
1165                (root_path, aliases)
1166            };
1167
1168            let err = discover_schema_set(&root_path, &aliases)
1169                .expect_err(&format!("{label}: expected the 8 MiB cap error"));
1170            let msg = err.to_string();
1171            assert!(
1172                msg.contains("8 MiB"),
1173                "{label}: error must name the 8 MiB cap, got: {msg}"
1174            );
1175        }
1176    }
1177
1178    // ── conflicts ───────────────────────────────────────────────────────
1179
1180    #[test]
1181    fn conflicting_definitions_are_rejected() {
1182        // Unit test of the checked inserter: the map itself cannot hold two
1183        // definitions of one key, so the conflict surface is the helper.
1184        let mut resources: BTreeMap<String, Vec<u8>> = BTreeMap::new();
1185        let mut total = 0usize;
1186        checked_insert(&mut resources, &mut total, "a.xsd", b"one").unwrap();
1187        checked_insert(&mut resources, &mut total, "a.xsd", b"one")
1188            .expect("repeated reach of identical bytes is not a conflict");
1189        assert_eq!(resources.len(), 1);
1190
1191        let err = checked_insert(&mut resources, &mut total, "a.xsd", b"two")
1192            .expect_err("different bytes for one normalized key must be rejected");
1193        let msg = err.to_string();
1194        assert!(
1195            msg.contains("conflicting"),
1196            "error must name the conflict, got: {msg}"
1197        );
1198        assert!(
1199            !msg.contains("cap"),
1200            "a conflict must not be reported as a cap error, got: {msg}"
1201        );
1202    }
1203
1204    // ── confinement ─────────────────────────────────────────────────────
1205
1206    #[test]
1207    fn in_root_symlink_is_accepted() {
1208        let tmp = tempfile::tempdir().unwrap();
1209        let root_path = write_file(
1210            tmp.path(),
1211            "root.xsd",
1212            &xsd_with_refs("xs", &[("include", "link.xsd")]),
1213        );
1214        write_file(tmp.path(), "real.xsd", &xsd_empty());
1215        symlink(Path::new("real.xsd"), &tmp.path().join("link.xsd"));
1216
1217        let set = discover_schema_set(&root_path, &empty_aliases())
1218            .expect("symlink whose canonical target is inside the root must be included");
1219
1220        assert!(set.resources.contains_key("link.xsd"));
1221        assert_eq!(set.resources.len(), 2);
1222    }
1223
1224    #[test]
1225    fn escaping_symlink_is_rejected() {
1226        let inside = tempfile::tempdir().unwrap();
1227        let outside = tempfile::tempdir().unwrap();
1228        write_file(outside.path(), "evil.xsd", &xsd_empty());
1229        let root_path = write_file(
1230            inside.path(),
1231            "root.xsd",
1232            &xsd_with_refs("xs", &[("include", "escape.xsd")]),
1233        );
1234        symlink(
1235            &outside.path().join("evil.xsd"),
1236            &inside.path().join("escape.xsd"),
1237        );
1238
1239        let err = discover_schema_set(&root_path, &empty_aliases())
1240            .expect_err("symlink escaping the canonical root must be refused");
1241        assert!(!err.to_string().is_empty());
1242    }
1243
1244    #[test]
1245    fn traversal_outside_root_is_rejected() {
1246        // Table: a `..` escape, a `..` escape hidden behind an in-root prefix,
1247        // an alias value that escapes, and absolute filesystem paths — raw and
1248        // percent-encoded, from the root importer and from a nested importer.
1249        // An absolute location must be refused BEFORE it is joined onto the
1250        // importing base: `/child.xsd` from `a/parent.xsd` would otherwise
1251        // collapse to the in-root decoy `a/child.xsd`. In-root `..` stays valid
1252        // (see the `custom` row of `reachable_imports_are_included`).
1253        for (label, nested, location, alias_value) in [
1254            ("dotdot", false, "../outside.xsd", None),
1255            ("nested dotdot", false, "sub/../../outside.xsd", None),
1256            ("alias escape", false, "child.xsd", Some("../outside.xsd")),
1257            ("absolute from root", false, "/child.xsd", None),
1258            ("encoded absolute from root", false, "%2Fchild.xsd", None),
1259            ("absolute from nested", true, "/child.xsd", None),
1260            ("encoded absolute from nested", true, "%2Fchild.xsd", None),
1261        ] {
1262            let tmp = tempfile::tempdir().unwrap();
1263            let root_path = if nested {
1264                let root = write_file(
1265                    tmp.path(),
1266                    "root.xsd",
1267                    &xsd_with_refs("xs", &[("include", "a/parent.xsd")]),
1268                );
1269                write_file(
1270                    tmp.path(),
1271                    "a/parent.xsd",
1272                    &xsd_with_refs("xs", &[("include", location)]),
1273                );
1274                root
1275            } else {
1276                write_file(
1277                    tmp.path(),
1278                    "root.xsd",
1279                    &xsd_with_refs("xs", &[("include", location)]),
1280                )
1281            };
1282            // Decoys the absolute form would wrongly reach after a base join.
1283            write_file(tmp.path(), "child.xsd", &xsd_empty());
1284            write_file(tmp.path(), "a/child.xsd", &xsd_empty());
1285            let mut aliases = BTreeMap::new();
1286            if let Some(value) = alias_value {
1287                aliases.insert("http://example.com/x.xsd".to_string(), value.to_string());
1288            }
1289
1290            let err = discover_schema_set(&root_path, &aliases)
1291                .expect_err(&format!("{label}: expected refusal"));
1292            assert!(
1293                !err.to_string().is_empty(),
1294                "{label}: refusal must be a bounded, actionable error"
1295            );
1296        }
1297    }
1298
1299    // ── single file, aliases, cache key ─────────────────────────────────
1300
1301    #[test]
1302    fn single_file_without_imports_is_one_entry() {
1303        let tmp = tempfile::tempdir().unwrap();
1304        let contents = xsd_with_refs("xs", &[]);
1305        let root_path = write_file(tmp.path(), "root.xsd", &contents);
1306
1307        // Case A: no aliases — exactly one entry.
1308        let set: SchemaSet = discover_schema_set(&root_path, &empty_aliases()).unwrap();
1309        assert_eq!(
1310            set.resources.len(),
1311            1,
1312            "{:?}",
1313            set.resources.keys().collect::<Vec<_>>()
1314        );
1315        assert_eq!(set.root_system_id, "root.xsd");
1316        assert_eq!(set.root_bytes(), contents.as_bytes());
1317        let key = set.cache_key();
1318        assert!(
1319            key.starts_with("xsd-") && key.len() == 4 + 64,
1320            "cache key must be 'xsd-<64 hex>', got: {key}"
1321        );
1322        assert!(
1323            key[4..].chars().all(|c| c.is_ascii_hexdigit()),
1324            "cache key suffix must be hex, got: {key}"
1325        );
1326        let again = discover_schema_set(&root_path, &empty_aliases()).unwrap();
1327        assert_eq!(again.cache_key(), key, "cache key must be deterministic");
1328
1329        // Case B: a supplied alias is materialized as a redundant URI-keyed
1330        // entry beside the relative target, and both count.
1331        let mut aliases = BTreeMap::new();
1332        aliases.insert(
1333            "http://example.com/root.xsd".to_string(),
1334            "root.xsd".to_string(),
1335        );
1336        let aliased = discover_schema_set(&root_path, &aliases).unwrap();
1337        assert_eq!(
1338            aliased.resources.len(),
1339            2,
1340            "alias + relative count as 2 entries"
1341        );
1342        assert!(aliased.resources.contains_key("root.xsd"));
1343        assert!(
1344            aliased
1345                .resources
1346                .contains_key("http://example.com/root.xsd")
1347        );
1348        assert_eq!(
1349            aliased.aliases, aliases,
1350            "the normalized alias map is retained"
1351        );
1352        assert_ne!(aliased.cache_key(), key, "an added alias rotates the key");
1353    }
1354}