1use 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
30pub const MAX_SCHEMA_RESOURCES: usize = 256;
32
33pub const MAX_SCHEMA_BYTES: usize = 8 * 1024 * 1024;
39
40pub const MAX_SCHEMA_ALIASES: usize = 128;
42
43pub const MAX_IMPORT_DEPTH: usize = 32;
45
46const XML_SCHEMA_NS: &str = "http://www.w3.org/2001/XMLSchema";
49
50#[derive(Debug, Clone)]
57pub struct SchemaSet {
58 pub root_system_id: String,
60 pub resources: BTreeMap<String, Vec<u8>>,
66 pub aliases: BTreeMap<String, String>,
71}
72
73impl SchemaSet {
74 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 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
118pub 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
132fn 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 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 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
209struct CachedFile {
211 bytes: Vec<u8>,
212 refs: Vec<String>,
213}
214
215struct Discoverer<'a> {
218 virtual_root: PathBuf,
219 canonical_root: PathBuf,
220 aliases: BTreeMap<String, String>,
221 cache: HashMap<PathBuf, CachedFile>,
223 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 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 fn offer(&mut self, vpath: &str, depth: usize) -> Result<(), ValidatorError> {
277 let canonical = canonicalize_candidate(&self.virtual_root, &self.canonical_root, vpath)?;
278 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
310fn 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
346fn 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
379fn 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 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
428fn 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
461fn 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
474fn 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
492fn 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
523fn 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 if attr.key.as_ref() != "schemaLocation" {
576 continue;
577 }
578 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 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 fn xsd_empty() -> String {
634 format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<xs:schema xmlns:xs=\"{XS}\"/>\n")
635 }
636
637 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 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 #[test]
682 fn reachable_imports_are_included() {
683 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 {
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 #[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 #[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 #[test]
908 fn symlinked_cycle_preserves_loop_key() {
909 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 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 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 #[test]
1015 fn unbounded_symlink_alias_growth_fails_closed() {
1016 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 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 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 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 #[test]
1181 fn conflicting_definitions_are_rejected() {
1182 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 #[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 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 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 #[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 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 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}