1#![forbid(unsafe_code)] use std::borrow::Cow;
40use std::collections::HashSet;
41use std::path::{Path, PathBuf};
42
43use crate::error::{ErrorDomain, ErrorLevel, Result, XmlError};
44use crate::xml_bytes_reader::{BytesEvent, XmlBytesReader};
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50enum Prefer {
51 Public,
52 System,
53}
54
55impl Default for Prefer {
56 fn default() -> Self { Self::Public }
57}
58
59#[derive(Debug, Clone)]
64enum Entry {
65 Public { id: String, uri: String, prefer: Prefer },
69 System { id: String, uri: String },
71 Uri { name: String, uri: String },
73 RewriteSystem { start: String, replace: String },
75 RewriteUri { start: String, replace: String },
77 DelegatePublic { start: String, sub: Option<Box<Catalog>> },
84 DelegateSystem { start: String, sub: Option<Box<Catalog>> },
86 DelegateUri { start: String, sub: Option<Box<Catalog>> },
88 NextCatalog { sub: Option<Box<Catalog>> },
92}
93
94#[derive(Debug, Default, Clone)]
100pub struct Catalog {
101 entries: Vec<Entry>,
105 root_prefer: Prefer,
108 sources: Vec<PathBuf>,
111}
112
113impl Catalog {
114 pub fn resolve<'a>(
127 &'a self,
128 public_id: Option<&str>, system_id: Option<&str>,
129 ) -> Option<Cow<'a, str>> {
130 let mut seen: HashSet<PathBuf> = HashSet::new();
131 self.resolve_inner(public_id, system_id, &mut seen)
132 }
133
134 pub fn resolve_uri<'a>(&'a self, uri: &str) -> Option<Cow<'a, str>> {
143 let mut seen: HashSet<PathBuf> = HashSet::new();
144 self.resolve_uri_inner(uri, &mut seen)
145 }
146
147 pub fn len(&self) -> usize {
151 self.entries.len()
152 }
153
154 pub fn is_empty(&self) -> bool {
156 self.entries.is_empty()
157 }
158
159 pub fn sources(&self) -> &[PathBuf] {
161 &self.sources
162 }
163
164 pub fn from_files<P: AsRef<Path>>(paths: &[P]) -> Result<Self> {
168 let mut loading: HashSet<PathBuf> = HashSet::new();
169 let mut out = Catalog::default();
170 for path in paths {
171 let path = path.as_ref();
172 let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
173 if !loading.insert(canon.clone()) { continue; }
174 let bytes = std::fs::read(path).map_err(|e| {
175 XmlError::new(
176 ErrorDomain::Io,
177 ErrorLevel::Error,
178 format!("failed to read catalog file {}: {e}", path.display()),
179 )
180 })?;
181 out.merge_from_bytes(&bytes, Some(path), &mut loading)?;
182 }
183 Ok(out)
184 }
185
186 pub fn parse(bytes: &[u8]) -> Result<Self> {
189 let mut out = Catalog::default();
190 let mut loading: HashSet<PathBuf> = HashSet::new();
191 out.merge_from_bytes(bytes, None, &mut loading)?;
192 Ok(out)
193 }
194
195 fn resolve_inner<'a>(
198 &'a self,
199 public_id: Option<&str>, system_id: Option<&str>,
200 seen: &mut HashSet<PathBuf>,
201 ) -> Option<Cow<'a, str>> {
202 if let Some(sid) = system_id {
208 for e in &self.entries {
209 if let Entry::System { id, uri } = e {
210 if id == sid { return Some(Cow::Borrowed(uri.as_str())); }
211 }
212 }
213 }
214 if let Some(sid) = system_id {
217 if let Some(rewritten) = longest_rewrite(&self.entries, sid, true) {
218 return Some(Cow::Owned(rewritten));
219 }
220 }
221 if let Some(sid) = system_id {
228 if let Some(out) = longest_delegate(
229 &self.entries, sid, DelegateKind::System,
230 public_id, system_id, seen,
231 ) { return Some(out); }
232 }
233 if let Some(pid) = public_id {
238 let normalised = normalise_public_id(pid);
239 for e in &self.entries {
240 if let Entry::Public { id, uri, prefer } = e {
241 if id == &normalised
242 && (*prefer == Prefer::Public || system_id.is_none())
243 {
244 return Some(Cow::Borrowed(uri.as_str()));
245 }
246 }
247 }
248 }
249 if let Some(pid) = public_id {
251 let normalised = normalise_public_id(pid);
252 if let Some(out) = longest_delegate(
253 &self.entries, &normalised, DelegateKind::Public,
254 public_id, system_id, seen,
255 ) { return Some(out); }
256 }
257 for e in &self.entries {
260 if let Entry::NextCatalog { sub: Some(sub) } = e {
261 if !mark_seen(seen, sub) { continue; }
262 if let Some(out) = sub.resolve_inner(public_id, system_id, seen) {
263 return Some(out);
264 }
265 }
266 }
267 None
268 }
269
270 fn resolve_uri_inner<'a>(&'a self, target: &str, seen: &mut HashSet<PathBuf>)
271 -> Option<Cow<'a, str>>
272 {
273 for e in &self.entries {
277 if let Entry::Uri { name, uri } = e {
278 if name == target { return Some(Cow::Borrowed(uri.as_str())); }
279 }
280 }
281 if let Some(out) = longest_rewrite(&self.entries, target, false) {
282 return Some(Cow::Owned(out));
283 }
284 if let Some(out) = longest_delegate(
285 &self.entries, target, DelegateKind::Uri,
286 None, None, seen,
287 ) {
288 return Some(out);
289 }
290 for e in &self.entries {
291 if let Entry::NextCatalog { sub: Some(sub) } = e {
292 if !mark_seen(seen, sub) { continue; }
293 if let Some(out) = sub.resolve_uri_inner(target, seen) {
294 return Some(out);
295 }
296 }
297 }
298 None
299 }
300
301 fn merge_from_bytes(
309 &mut self, bytes: &[u8], source: Option<&Path>,
310 loading: &mut HashSet<PathBuf>,
311 ) -> Result<()> {
312 if let Some(p) = source {
313 self.sources.push(p.to_path_buf());
314 }
315 let base_dir: Option<PathBuf> = source
316 .and_then(|p| p.parent().map(|d| d.to_path_buf()));
317
318 let mut reader = XmlBytesReader::from_bytes(bytes)?;
319 let mut prefer_stack: Vec<Prefer> = vec![self.root_prefer];
325
326 loop {
327 match reader.next()? {
328 BytesEvent::Eof => break,
329 BytesEvent::EndElement(tag) => {
330 let local = strip_namespace_prefix(tag.name());
331 if local == b"group" || local == b"catalog" {
332 prefer_stack.pop();
333 }
334 }
335 BytesEvent::StartElement(tag) => {
336 let local: Vec<u8> = strip_namespace_prefix(tag.name()).to_vec();
340 match local.as_slice() {
341 b"catalog" | b"group" => {
342 let parent = prefer_stack.last().copied()
345 .unwrap_or(Prefer::Public);
346 let mut p = parent;
347 for a in tag.attrs() {
348 let a = a?;
349 if strip_namespace_prefix(a.name) == b"prefer" {
350 p = match a.value.as_ref() {
351 b"system" => Prefer::System,
352 _ => Prefer::Public,
353 };
354 }
355 }
356 if local.as_slice() == b"catalog" {
357 self.root_prefer = p;
361 }
362 prefer_stack.push(p);
363 }
364 b"public" => {
365 let (mut pid, mut uri): (Option<String>, Option<String>)
366 = (None, None);
367 for a in tag.attrs() {
368 let a = a?;
369 match strip_namespace_prefix(a.name) {
370 b"publicId" => pid = Some(decode_utf8(&a.value)?),
371 b"uri" => uri = Some(decode_utf8(&a.value)?),
372 _ => {}
373 }
374 }
375 if let (Some(p), Some(u)) = (pid, uri) {
376 let prefer = prefer_stack.last().copied()
377 .unwrap_or(Prefer::Public);
378 self.entries.push(Entry::Public {
379 id: normalise_public_id(&p), uri: u, prefer,
380 });
381 }
382 }
383 b"system" => {
384 let (mut sid, mut uri): (Option<String>, Option<String>)
385 = (None, None);
386 for a in tag.attrs() {
387 let a = a?;
388 match strip_namespace_prefix(a.name) {
389 b"systemId" => sid = Some(decode_utf8(&a.value)?),
390 b"uri" => uri = Some(decode_utf8(&a.value)?),
391 _ => {}
392 }
393 }
394 if let (Some(s), Some(u)) = (sid, uri) {
395 self.entries.push(Entry::System { id: s, uri: u });
396 }
397 }
398 b"uri" => {
399 let (mut name, mut uri): (Option<String>, Option<String>)
400 = (None, None);
401 for a in tag.attrs() {
402 let a = a?;
403 match strip_namespace_prefix(a.name) {
404 b"name" => name = Some(decode_utf8(&a.value)?),
405 b"uri" => uri = Some(decode_utf8(&a.value)?),
406 _ => {}
407 }
408 }
409 if let (Some(n), Some(u)) = (name, uri) {
410 self.entries.push(Entry::Uri { name: n, uri: u });
411 }
412 }
413 b"rewriteSystem" => {
414 if let Some((start, replace)) =
415 parse_rewrite_attrs(tag, b"systemIdStartString")?
416 {
417 self.entries.push(Entry::RewriteSystem { start, replace });
418 }
419 }
420 b"rewriteURI" | b"rewriteUri" => {
421 if let Some((start, replace)) =
422 parse_rewrite_attrs(tag, b"uriStartString")?
423 {
424 self.entries.push(Entry::RewriteUri { start, replace });
425 }
426 }
427 b"delegatePublic" => {
428 if let Some((start, sub)) = parse_delegate_attrs(
429 tag, b"publicIdStartString",
430 base_dir.as_deref(), loading,
431 )? {
432 let start = normalise_public_id(&start);
433 self.entries.push(Entry::DelegatePublic { start, sub });
434 }
435 }
436 b"delegateSystem" => {
437 if let Some((start, sub)) = parse_delegate_attrs(
438 tag, b"systemIdStartString",
439 base_dir.as_deref(), loading,
440 )? {
441 self.entries.push(Entry::DelegateSystem { start, sub });
442 }
443 }
444 b"delegateURI" | b"delegateUri" => {
445 if let Some((start, sub)) = parse_delegate_attrs(
446 tag, b"uriStartString",
447 base_dir.as_deref(), loading,
448 )? {
449 self.entries.push(Entry::DelegateUri { start, sub });
450 }
451 }
452 b"nextCatalog" => {
453 let mut href: Option<String> = None;
454 for a in tag.attrs() {
455 let a = a?;
456 if strip_namespace_prefix(a.name) == b"catalog" {
457 href = Some(decode_utf8(&a.value)?);
458 }
459 }
460 if let Some(h) = href {
461 let sub = try_load_subcatalog(
462 &h, base_dir.as_deref(), loading);
463 self.entries.push(Entry::NextCatalog { sub });
464 }
465 }
466 _ => {} }
468 }
469 _ => continue,
470 }
471 }
472 Ok(())
473 }
474}
475
476fn longest_rewrite(entries: &[Entry], target: &str, is_system: bool) -> Option<String> {
485 let mut best: Option<(&str, &str)> = None; for e in entries {
487 let (start, replace) = match (e, is_system) {
488 (Entry::RewriteSystem { start, replace }, true) => (start.as_str(), replace.as_str()),
489 (Entry::RewriteUri { start, replace }, false) => (start.as_str(), replace.as_str()),
490 _ => continue,
491 };
492 if target.starts_with(start) {
493 match best {
494 Some((cur_start, _)) if cur_start.len() >= start.len() => {}
495 _ => best = Some((start, replace)),
496 }
497 }
498 }
499 best.map(|(start, replace)| format!("{}{}", replace, &target[start.len()..]))
500}
501
502#[derive(Clone, Copy)]
503enum DelegateKind { Public, System, Uri }
504
505fn longest_delegate<'a>(
515 entries: &'a [Entry],
516 target: &str,
517 kind: DelegateKind,
518 public_id: Option<&str>, system_id: Option<&str>,
519 seen: &mut HashSet<PathBuf>,
520) -> Option<Cow<'a, str>> {
521 let mut candidates: Vec<(&'a str, &'a Catalog)> = Vec::new();
525 for e in entries {
526 let (start, sub_opt) = match (e, kind) {
527 (Entry::DelegatePublic { start, sub }, DelegateKind::Public)
528 => (start.as_str(), sub.as_deref()),
529 (Entry::DelegateSystem { start, sub }, DelegateKind::System)
530 => (start.as_str(), sub.as_deref()),
531 (Entry::DelegateUri { start, sub }, DelegateKind::Uri)
532 => (start.as_str(), sub.as_deref()),
533 _ => continue,
534 };
535 let Some(sub) = sub_opt else { continue; };
536 if target.starts_with(start) {
537 candidates.push((start, sub));
538 }
539 }
540 candidates.sort_by_key(|(s, _)| std::cmp::Reverse(s.len()));
541 for (_, sub) in candidates {
542 if !mark_seen(seen, sub) { continue; }
543 let resolved = match kind {
544 DelegateKind::Uri => sub.resolve_uri_inner(target, seen),
545 _ => sub.resolve_inner(public_id, system_id, seen),
546 };
547 if let Some(uri) = resolved { return Some(uri); }
548 }
549 None
550}
551
552fn parse_rewrite_attrs<'r, 'src>(
557 tag: crate::xml_bytes_reader::BytesStartTag<'r, 'src>,
558 prefix_attr: &[u8],
559) -> Result<Option<(String, String)>> {
560 let (mut start, mut replace): (Option<String>, Option<String>) = (None, None);
561 for a in tag.attrs() {
562 let a = a?;
563 let n = strip_namespace_prefix(a.name);
564 if n == prefix_attr {
565 start = Some(decode_utf8(&a.value)?);
566 } else if n == b"rewritePrefix" {
567 replace = Some(decode_utf8(&a.value)?);
568 }
569 }
570 Ok(start.zip(replace))
571}
572
573fn parse_delegate_attrs<'r, 'src>(
580 tag: crate::xml_bytes_reader::BytesStartTag<'r, 'src>,
581 prefix_attr: &[u8],
582 base_dir: Option<&Path>,
583 loading: &mut HashSet<PathBuf>,
584) -> Result<Option<(String, Option<Box<Catalog>>)>> {
585 let (mut start, mut href): (Option<String>, Option<String>) = (None, None);
586 for a in tag.attrs() {
587 let a = a?;
588 let n = strip_namespace_prefix(a.name);
589 if n == prefix_attr {
590 start = Some(decode_utf8(&a.value)?);
591 } else if n == b"catalog" {
592 href = Some(decode_utf8(&a.value)?);
593 }
594 }
595 Ok(match (start, href) {
596 (Some(s), Some(h)) => Some((s, try_load_subcatalog(&h, base_dir, loading))),
597 _ => None,
598 })
599}
600
601fn try_load_subcatalog(
608 href: &str, base_dir: Option<&Path>,
609 loading: &mut HashSet<PathBuf>,
610) -> Option<Box<Catalog>> {
611 let raw = href.strip_prefix("file://").unwrap_or(href);
612 let path = if Path::new(raw).is_absolute() {
613 PathBuf::from(raw)
614 } else {
615 match base_dir {
616 Some(d) => d.join(raw),
617 None => PathBuf::from(raw),
618 }
619 };
620 if !path.exists() { return None; }
621 let canon = path.canonicalize().unwrap_or_else(|_| path.clone());
628 if !loading.insert(canon.clone()) {
629 return None;
630 }
631 let bytes = std::fs::read(&path).ok()?;
632 let mut sub = Catalog::default();
633 sub.sources.push(path.clone());
634 let result = sub.merge_from_bytes(&bytes, Some(&path), loading);
635 loading.remove(&canon);
639 result.ok()?;
640 Some(Box::new(sub))
641}
642
643fn mark_seen(seen: &mut HashSet<PathBuf>, sub: &Catalog) -> bool {
648 match sub.sources.first() {
653 Some(p) => seen.insert(p.clone()),
654 None => true,
655 }
656}
657
658fn normalise_public_id(s: &str) -> String {
663 let mut out = String::with_capacity(s.len());
664 let mut last_was_space = true; for c in s.chars() {
666 if matches!(c, ' ' | '\t' | '\r' | '\n') {
667 if !last_was_space {
668 out.push(' ');
669 last_was_space = true;
670 }
671 } else {
672 out.push(c);
673 last_was_space = false;
674 }
675 }
676 if out.ends_with(' ') {
677 out.pop();
678 }
679 out
680}
681
682fn strip_namespace_prefix(qname: &[u8]) -> &[u8] {
688 qname.iter().position(|&b| b == b':')
689 .map(|i| &qname[i + 1..])
690 .unwrap_or(qname)
691}
692
693fn decode_utf8(bytes: &[u8]) -> Result<String> {
694 std::str::from_utf8(bytes)
695 .map(|s| s.to_string())
696 .map_err(|e| XmlError::new(
697 ErrorDomain::Encoding,
698 ErrorLevel::Error,
699 format!("catalog entry value is not valid UTF-8: {e}"),
700 ))
701}
702
703pub fn conventional_paths() -> Vec<PathBuf> {
713 let mut out = vec![
714 PathBuf::from("/etc/xml/catalog"),
715 PathBuf::from("/usr/share/xml/catalog"),
716 ];
717 if cfg!(target_os = "macos") {
718 out.push(PathBuf::from("/opt/homebrew/etc/xml/catalog"));
720 out.push(PathBuf::from("/usr/local/etc/xml/catalog"));
721 out.push(PathBuf::from("/opt/local/etc/xml/catalog"));
722 }
723 if let Some(home) = std::env::var_os("HOME") {
726 let mut user = PathBuf::from(home);
727 user.push(".xmlcatalog");
728 out.push(user);
729 }
730 out
731}
732
733pub fn discover_catalog_paths() -> Vec<PathBuf> {
745 if let Ok(val) = std::env::var("XML_CATALOG_FILES") {
746 return val
747 .split_whitespace()
748 .map(|s| PathBuf::from(s.strip_prefix("file://").unwrap_or(s)))
749 .collect();
750 }
751 conventional_paths()
752}
753
754pub fn load_default() -> Result<Catalog> {
759 let paths: Vec<PathBuf> = discover_catalog_paths()
760 .into_iter()
761 .filter(|p| p.exists())
762 .collect();
763 if paths.is_empty() {
764 return Ok(Catalog::default());
765 }
766 Catalog::from_files(&paths)
767}
768
769#[cfg(test)]
770mod tests {
771 use super::*;
772
773 const SAMPLE_CATALOG: &[u8] = br#"<?xml version="1.0"?>
774<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
775 <public publicId="-//W3C//DTD XHTML 1.0 Strict//EN"
776 uri="file:///usr/share/xml/xhtml/xhtml1-strict.dtd"/>
777 <system systemId="http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"
778 uri="file:///usr/share/xml/xhtml/xhtml1-strict.dtd"/>
779 <public publicId="-//OASIS//DTD DocBook XML V5.0//EN"
780 uri="file:///usr/share/xml/docbook/docbook-5.0.dtd"/>
781</catalog>
782"#;
783
784 #[test]
785 fn parses_simple_catalog() {
786 let cat = Catalog::parse(SAMPLE_CATALOG).unwrap();
787 assert_eq!(cat.len(), 3, "expected 3 entries");
788 assert!(!cat.is_empty());
789 }
790
791 #[test]
792 fn resolves_public_id() {
793 let cat = Catalog::parse(SAMPLE_CATALOG).unwrap();
794 let uri = cat.resolve(
795 Some("-//W3C//DTD XHTML 1.0 Strict//EN"),
796 None,
797 );
798 assert_eq!(uri.as_deref(),
799 Some("file:///usr/share/xml/xhtml/xhtml1-strict.dtd"));
800 }
801
802 #[test]
803 fn resolves_system_id() {
804 let cat = Catalog::parse(SAMPLE_CATALOG).unwrap();
805 let uri = cat.resolve(
806 None,
807 Some("http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"),
808 );
809 assert_eq!(uri.as_deref(),
810 Some("file:///usr/share/xml/xhtml/xhtml1-strict.dtd"));
811 }
812
813 #[test]
816 fn rewrite_system_substitutes_longest_prefix() {
817 let cat = Catalog::parse(br#"<?xml version="1.0"?>
818<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
819 <rewriteSystem systemIdStartString="http://example.com/"
820 rewritePrefix="file:///local/example/"/>
821 <rewriteSystem systemIdStartString="http://example.com/specific/"
822 rewritePrefix="file:///mirror/specific/"/>
823</catalog>"#).unwrap();
824 let uri = cat.resolve(None, Some("http://example.com/specific/foo.dtd"));
827 assert_eq!(uri.as_deref(), Some("file:///mirror/specific/foo.dtd"));
828 let uri = cat.resolve(None, Some("http://example.com/other/bar.dtd"));
831 assert_eq!(uri.as_deref(), Some("file:///local/example/other/bar.dtd"));
832 }
833
834 #[test]
835 fn rewrite_uri_handles_uri_namespace() {
836 let cat = Catalog::parse(br#"<?xml version="1.0"?>
837<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
838 <rewriteURI uriStartString="urn:isbn:" rewritePrefix="file:///isbn/"/>
839</catalog>"#).unwrap();
840 let uri = cat.resolve_uri("urn:isbn:1234567890");
841 assert_eq!(uri.as_deref(), Some("file:///isbn/1234567890"));
842 }
843
844 #[test]
845 fn next_catalog_chains_to_a_followup_file() {
846 let dir = tempdir();
847 let leaf = dir.join("leaf.xml");
848 std::fs::write(&leaf, br#"<?xml version="1.0"?>
849<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
850 <system systemId="urn:my:thing" uri="file:///x/y.dtd"/>
851</catalog>"#).unwrap();
852 let root_path = dir.join("root.xml");
853 std::fs::write(&root_path, format!(
854 r#"<?xml version="1.0"?>
855<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
856 <nextCatalog catalog="{}"/>
857</catalog>"#,
858 leaf.display(),
859 )).unwrap();
860 let cat = Catalog::from_files(&[root_path]).unwrap();
861 let uri = cat.resolve(None, Some("urn:my:thing"));
862 assert_eq!(uri.as_deref(), Some("file:///x/y.dtd"));
863 }
864
865 #[test]
866 fn delegate_public_delegates_matching_prefix_to_subcatalog() {
867 let dir = tempdir();
868 let sub = dir.join("docbook.xml");
869 std::fs::write(&sub, br#"<?xml version="1.0"?>
870<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
871 <public publicId="-//OASIS//DTD DocBook XML V5.0//EN"
872 uri="file:///mirror/docbook-5.0.dtd"/>
873</catalog>"#).unwrap();
874 let root_path = dir.join("root.xml");
875 std::fs::write(&root_path, format!(
876 r#"<?xml version="1.0"?>
877<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
878 <delegatePublic publicIdStartString="-//OASIS//"
879 catalog="{}"/>
880</catalog>"#,
881 sub.display(),
882 )).unwrap();
883 let cat = Catalog::from_files(&[root_path]).unwrap();
884 let uri = cat.resolve(Some("-//OASIS//DTD DocBook XML V5.0//EN"), None);
885 assert_eq!(uri.as_deref(), Some("file:///mirror/docbook-5.0.dtd"));
886 let uri = cat.resolve(Some("-//W3C//something//EN"), None);
888 assert_eq!(uri, None);
889 }
890
891 #[test]
892 fn delegate_system_falls_back_when_first_subcatalog_misses() {
893 let dir = tempdir();
894 let miss = dir.join("miss.xml");
895 let hit = dir.join("hit.xml");
896 std::fs::write(&miss, br#"<?xml version="1.0"?>
897<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
898 <system systemId="urn:nope" uri="file:///never"/>
899</catalog>"#).unwrap();
900 std::fs::write(&hit, br#"<?xml version="1.0"?>
901<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
902 <system systemId="urn:thing" uri="file:///found.dtd"/>
903</catalog>"#).unwrap();
904 let root_path = dir.join("root.xml");
905 std::fs::write(&root_path, format!(
910 r#"<?xml version="1.0"?>
911<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
912 <delegateSystem systemIdStartString="urn:" catalog="{}"/>
913 <delegateSystem systemIdStartString="urn:" catalog="{}"/>
914</catalog>"#,
915 miss.display(), hit.display(),
916 )).unwrap();
917 let cat = Catalog::from_files(&[root_path]).unwrap();
918 let uri = cat.resolve(None, Some("urn:thing"));
919 assert_eq!(uri.as_deref(), Some("file:///found.dtd"));
920 }
921
922 #[test]
923 fn group_prefer_system_makes_public_entries_inert_when_system_present() {
924 let cat = Catalog::parse(br#"<?xml version="1.0"?>
925<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
926 <group prefer="system">
927 <public publicId="-//Test//Public//EN"
928 uri="file:///public-target"/>
929 </group>
930</catalog>"#).unwrap();
931 let uri = cat.resolve(
934 Some("-//Test//Public//EN"),
935 Some("urn:irrelevant-but-present"),
936 );
937 assert_eq!(uri, None);
938 let uri = cat.resolve(Some("-//Test//Public//EN"), None);
942 assert_eq!(uri.as_deref(), Some("file:///public-target"));
943 }
944
945 #[test]
946 fn group_does_not_leak_prefer_to_outer_scope() {
947 let cat = Catalog::parse(br#"<?xml version="1.0"?>
948<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
949 <group prefer="system">
950 <public publicId="inner" uri="file:///inner"/>
951 </group>
952 <public publicId="outer" uri="file:///outer"/>
953</catalog>"#).unwrap();
954 let uri = cat.resolve(Some("outer"), Some("urn:some-sys"));
957 assert_eq!(uri.as_deref(), Some("file:///outer"));
958 }
959
960 #[test]
961 fn next_catalog_cycle_is_broken_safely() {
962 let dir = tempdir();
963 let a = dir.join("a.xml");
964 let b = dir.join("b.xml");
965 std::fs::write(&a, format!(
966 r#"<?xml version="1.0"?>
967<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
968 <nextCatalog catalog="{}"/>
969</catalog>"#, b.display())).unwrap();
970 std::fs::write(&b, format!(
971 r#"<?xml version="1.0"?>
972<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
973 <nextCatalog catalog="{}"/>
974</catalog>"#, a.display())).unwrap();
975 let cat = Catalog::from_files(&[a]).unwrap();
978 let uri = cat.resolve(None, Some("urn:does-not-exist"));
979 assert_eq!(uri, None);
980 }
981
982 #[test]
983 fn missing_delegate_file_is_silently_skipped() {
984 let cat = Catalog::parse(br#"<?xml version="1.0"?>
985<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
986 <delegateSystem systemIdStartString="urn:"
987 catalog="/nowhere/missing-catalog.xml"/>
988 <system systemId="urn:thing" uri="file:///fallback.dtd"/>
989</catalog>"#).unwrap();
990 let uri = cat.resolve(None, Some("urn:thing"));
995 assert_eq!(uri.as_deref(), Some("file:///fallback.dtd"));
996 }
997
998 fn tempdir() -> PathBuf {
1004 use std::sync::atomic::{AtomicU64, Ordering};
1005 static COUNTER: AtomicU64 = AtomicU64::new(0);
1006 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1007 let name = format!("supxml-catalog-test-{}-{n}", std::process::id());
1008 let dir = std::env::temp_dir().join(name);
1009 let _ = std::fs::remove_dir_all(&dir);
1011 std::fs::create_dir_all(&dir).unwrap();
1012 dir
1013 }
1014}