#![forbid(unsafe_code)]
use std::borrow::Cow;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::error::{ErrorDomain, ErrorLevel, Result, XmlError};
use crate::xml_bytes_reader::{BytesEvent, XmlBytesReader};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Prefer {
Public,
System,
}
impl Default for Prefer {
fn default() -> Self { Self::Public }
}
#[derive(Debug, Clone)]
enum Entry {
Public { id: String, uri: String, prefer: Prefer },
System { id: String, uri: String },
Uri { name: String, uri: String },
RewriteSystem { start: String, replace: String },
RewriteUri { start: String, replace: String },
DelegatePublic { start: String, sub: Option<Box<Catalog>> },
DelegateSystem { start: String, sub: Option<Box<Catalog>> },
DelegateUri { start: String, sub: Option<Box<Catalog>> },
NextCatalog { sub: Option<Box<Catalog>> },
}
#[derive(Debug, Default, Clone)]
pub struct Catalog {
entries: Vec<Entry>,
root_prefer: Prefer,
sources: Vec<PathBuf>,
}
impl Catalog {
pub fn resolve<'a>(
&'a self,
public_id: Option<&str>, system_id: Option<&str>,
) -> Option<Cow<'a, str>> {
let mut seen: HashSet<PathBuf> = HashSet::new();
self.resolve_inner(public_id, system_id, &mut seen)
}
pub fn resolve_uri<'a>(&'a self, uri: &str) -> Option<Cow<'a, str>> {
let mut seen: HashSet<PathBuf> = HashSet::new();
self.resolve_uri_inner(uri, &mut seen)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn sources(&self) -> &[PathBuf] {
&self.sources
}
pub fn from_files<P: AsRef<Path>>(paths: &[P]) -> Result<Self> {
let mut loading: HashSet<PathBuf> = HashSet::new();
let mut out = Catalog::default();
for path in paths {
let path = path.as_ref();
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if !loading.insert(canon.clone()) { continue; }
let bytes = std::fs::read(path).map_err(|e| {
XmlError::new(
ErrorDomain::Io,
ErrorLevel::Error,
format!("failed to read catalog file {}: {e}", path.display()),
)
})?;
out.merge_from_bytes(&bytes, Some(path), &mut loading)?;
}
Ok(out)
}
pub fn parse(bytes: &[u8]) -> Result<Self> {
let mut out = Catalog::default();
let mut loading: HashSet<PathBuf> = HashSet::new();
out.merge_from_bytes(bytes, None, &mut loading)?;
Ok(out)
}
fn resolve_inner<'a>(
&'a self,
public_id: Option<&str>, system_id: Option<&str>,
seen: &mut HashSet<PathBuf>,
) -> Option<Cow<'a, str>> {
if let Some(sid) = system_id {
for e in &self.entries {
if let Entry::System { id, uri } = e {
if id == sid { return Some(Cow::Borrowed(uri.as_str())); }
}
}
}
if let Some(sid) = system_id {
if let Some(rewritten) = longest_rewrite(&self.entries, sid, true) {
return Some(Cow::Owned(rewritten));
}
}
if let Some(sid) = system_id {
if let Some(out) = longest_delegate(
&self.entries, sid, DelegateKind::System,
public_id, system_id, seen,
) { return Some(out); }
}
if let Some(pid) = public_id {
let normalised = normalise_public_id(pid);
for e in &self.entries {
if let Entry::Public { id, uri, prefer } = e {
if id == &normalised
&& (*prefer == Prefer::Public || system_id.is_none())
{
return Some(Cow::Borrowed(uri.as_str()));
}
}
}
}
if let Some(pid) = public_id {
let normalised = normalise_public_id(pid);
if let Some(out) = longest_delegate(
&self.entries, &normalised, DelegateKind::Public,
public_id, system_id, seen,
) { return Some(out); }
}
for e in &self.entries {
if let Entry::NextCatalog { sub: Some(sub) } = e {
if !mark_seen(seen, sub) { continue; }
if let Some(out) = sub.resolve_inner(public_id, system_id, seen) {
return Some(out);
}
}
}
None
}
fn resolve_uri_inner<'a>(&'a self, target: &str, seen: &mut HashSet<PathBuf>)
-> Option<Cow<'a, str>>
{
for e in &self.entries {
if let Entry::Uri { name, uri } = e {
if name == target { return Some(Cow::Borrowed(uri.as_str())); }
}
}
if let Some(out) = longest_rewrite(&self.entries, target, false) {
return Some(Cow::Owned(out));
}
if let Some(out) = longest_delegate(
&self.entries, target, DelegateKind::Uri,
None, None, seen,
) {
return Some(out);
}
for e in &self.entries {
if let Entry::NextCatalog { sub: Some(sub) } = e {
if !mark_seen(seen, sub) { continue; }
if let Some(out) = sub.resolve_uri_inner(target, seen) {
return Some(out);
}
}
}
None
}
fn merge_from_bytes(
&mut self, bytes: &[u8], source: Option<&Path>,
loading: &mut HashSet<PathBuf>,
) -> Result<()> {
if let Some(p) = source {
self.sources.push(p.to_path_buf());
}
let base_dir: Option<PathBuf> = source
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
let mut reader = XmlBytesReader::from_bytes(bytes)?;
let mut prefer_stack: Vec<Prefer> = vec![self.root_prefer];
loop {
match reader.next()? {
BytesEvent::Eof => break,
BytesEvent::EndElement(tag) => {
let local = strip_namespace_prefix(tag.name());
if local == b"group" || local == b"catalog" {
prefer_stack.pop();
}
}
BytesEvent::StartElement(tag) => {
let local: Vec<u8> = strip_namespace_prefix(tag.name()).to_vec();
match local.as_slice() {
b"catalog" | b"group" => {
let parent = prefer_stack.last().copied()
.unwrap_or(Prefer::Public);
let mut p = parent;
for a in tag.attrs() {
let a = a?;
if strip_namespace_prefix(a.name) == b"prefer" {
p = match a.value.as_ref() {
b"system" => Prefer::System,
_ => Prefer::Public,
};
}
}
if local.as_slice() == b"catalog" {
self.root_prefer = p;
}
prefer_stack.push(p);
}
b"public" => {
let (mut pid, mut uri): (Option<String>, Option<String>)
= (None, None);
for a in tag.attrs() {
let a = a?;
match strip_namespace_prefix(a.name) {
b"publicId" => pid = Some(decode_utf8(&a.value)?),
b"uri" => uri = Some(decode_utf8(&a.value)?),
_ => {}
}
}
if let (Some(p), Some(u)) = (pid, uri) {
let prefer = prefer_stack.last().copied()
.unwrap_or(Prefer::Public);
self.entries.push(Entry::Public {
id: normalise_public_id(&p), uri: u, prefer,
});
}
}
b"system" => {
let (mut sid, mut uri): (Option<String>, Option<String>)
= (None, None);
for a in tag.attrs() {
let a = a?;
match strip_namespace_prefix(a.name) {
b"systemId" => sid = Some(decode_utf8(&a.value)?),
b"uri" => uri = Some(decode_utf8(&a.value)?),
_ => {}
}
}
if let (Some(s), Some(u)) = (sid, uri) {
self.entries.push(Entry::System { id: s, uri: u });
}
}
b"uri" => {
let (mut name, mut uri): (Option<String>, Option<String>)
= (None, None);
for a in tag.attrs() {
let a = a?;
match strip_namespace_prefix(a.name) {
b"name" => name = Some(decode_utf8(&a.value)?),
b"uri" => uri = Some(decode_utf8(&a.value)?),
_ => {}
}
}
if let (Some(n), Some(u)) = (name, uri) {
self.entries.push(Entry::Uri { name: n, uri: u });
}
}
b"rewriteSystem" => {
if let Some((start, replace)) =
parse_rewrite_attrs(tag, b"systemIdStartString")?
{
self.entries.push(Entry::RewriteSystem { start, replace });
}
}
b"rewriteURI" | b"rewriteUri" => {
if let Some((start, replace)) =
parse_rewrite_attrs(tag, b"uriStartString")?
{
self.entries.push(Entry::RewriteUri { start, replace });
}
}
b"delegatePublic" => {
if let Some((start, sub)) = parse_delegate_attrs(
tag, b"publicIdStartString",
base_dir.as_deref(), loading,
)? {
let start = normalise_public_id(&start);
self.entries.push(Entry::DelegatePublic { start, sub });
}
}
b"delegateSystem" => {
if let Some((start, sub)) = parse_delegate_attrs(
tag, b"systemIdStartString",
base_dir.as_deref(), loading,
)? {
self.entries.push(Entry::DelegateSystem { start, sub });
}
}
b"delegateURI" | b"delegateUri" => {
if let Some((start, sub)) = parse_delegate_attrs(
tag, b"uriStartString",
base_dir.as_deref(), loading,
)? {
self.entries.push(Entry::DelegateUri { start, sub });
}
}
b"nextCatalog" => {
let mut href: Option<String> = None;
for a in tag.attrs() {
let a = a?;
if strip_namespace_prefix(a.name) == b"catalog" {
href = Some(decode_utf8(&a.value)?);
}
}
if let Some(h) = href {
let sub = try_load_subcatalog(
&h, base_dir.as_deref(), loading);
self.entries.push(Entry::NextCatalog { sub });
}
}
_ => {} }
}
_ => continue,
}
}
Ok(())
}
}
fn longest_rewrite(entries: &[Entry], target: &str, is_system: bool) -> Option<String> {
let mut best: Option<(&str, &str)> = None; for e in entries {
let (start, replace) = match (e, is_system) {
(Entry::RewriteSystem { start, replace }, true) => (start.as_str(), replace.as_str()),
(Entry::RewriteUri { start, replace }, false) => (start.as_str(), replace.as_str()),
_ => continue,
};
if target.starts_with(start) {
match best {
Some((cur_start, _)) if cur_start.len() >= start.len() => {}
_ => best = Some((start, replace)),
}
}
}
best.map(|(start, replace)| format!("{}{}", replace, &target[start.len()..]))
}
#[derive(Clone, Copy)]
enum DelegateKind { Public, System, Uri }
fn longest_delegate<'a>(
entries: &'a [Entry],
target: &str,
kind: DelegateKind,
public_id: Option<&str>, system_id: Option<&str>,
seen: &mut HashSet<PathBuf>,
) -> Option<Cow<'a, str>> {
let mut candidates: Vec<(&'a str, &'a Catalog)> = Vec::new();
for e in entries {
let (start, sub_opt) = match (e, kind) {
(Entry::DelegatePublic { start, sub }, DelegateKind::Public)
=> (start.as_str(), sub.as_deref()),
(Entry::DelegateSystem { start, sub }, DelegateKind::System)
=> (start.as_str(), sub.as_deref()),
(Entry::DelegateUri { start, sub }, DelegateKind::Uri)
=> (start.as_str(), sub.as_deref()),
_ => continue,
};
let Some(sub) = sub_opt else { continue; };
if target.starts_with(start) {
candidates.push((start, sub));
}
}
candidates.sort_by_key(|(s, _)| std::cmp::Reverse(s.len()));
for (_, sub) in candidates {
if !mark_seen(seen, sub) { continue; }
let resolved = match kind {
DelegateKind::Uri => sub.resolve_uri_inner(target, seen),
_ => sub.resolve_inner(public_id, system_id, seen),
};
if let Some(uri) = resolved { return Some(uri); }
}
None
}
fn parse_rewrite_attrs<'r, 'src>(
tag: crate::xml_bytes_reader::BytesStartTag<'r, 'src>,
prefix_attr: &[u8],
) -> Result<Option<(String, String)>> {
let (mut start, mut replace): (Option<String>, Option<String>) = (None, None);
for a in tag.attrs() {
let a = a?;
let n = strip_namespace_prefix(a.name);
if n == prefix_attr {
start = Some(decode_utf8(&a.value)?);
} else if n == b"rewritePrefix" {
replace = Some(decode_utf8(&a.value)?);
}
}
Ok(start.zip(replace))
}
fn parse_delegate_attrs<'r, 'src>(
tag: crate::xml_bytes_reader::BytesStartTag<'r, 'src>,
prefix_attr: &[u8],
base_dir: Option<&Path>,
loading: &mut HashSet<PathBuf>,
) -> Result<Option<(String, Option<Box<Catalog>>)>> {
let (mut start, mut href): (Option<String>, Option<String>) = (None, None);
for a in tag.attrs() {
let a = a?;
let n = strip_namespace_prefix(a.name);
if n == prefix_attr {
start = Some(decode_utf8(&a.value)?);
} else if n == b"catalog" {
href = Some(decode_utf8(&a.value)?);
}
}
Ok(match (start, href) {
(Some(s), Some(h)) => Some((s, try_load_subcatalog(&h, base_dir, loading))),
_ => None,
})
}
fn try_load_subcatalog(
href: &str, base_dir: Option<&Path>,
loading: &mut HashSet<PathBuf>,
) -> Option<Box<Catalog>> {
let raw = href.strip_prefix("file://").unwrap_or(href);
let path = if Path::new(raw).is_absolute() {
PathBuf::from(raw)
} else {
match base_dir {
Some(d) => d.join(raw),
None => PathBuf::from(raw),
}
};
if !path.exists() { return None; }
let canon = path.canonicalize().unwrap_or_else(|_| path.clone());
if !loading.insert(canon.clone()) {
return None;
}
let bytes = std::fs::read(&path).ok()?;
let mut sub = Catalog::default();
sub.sources.push(path.clone());
let result = sub.merge_from_bytes(&bytes, Some(&path), loading);
loading.remove(&canon);
result.ok()?;
Some(Box::new(sub))
}
fn mark_seen(seen: &mut HashSet<PathBuf>, sub: &Catalog) -> bool {
match sub.sources.first() {
Some(p) => seen.insert(p.clone()),
None => true,
}
}
fn normalise_public_id(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut last_was_space = true; for c in s.chars() {
if matches!(c, ' ' | '\t' | '\r' | '\n') {
if !last_was_space {
out.push(' ');
last_was_space = true;
}
} else {
out.push(c);
last_was_space = false;
}
}
if out.ends_with(' ') {
out.pop();
}
out
}
fn strip_namespace_prefix(qname: &[u8]) -> &[u8] {
qname.iter().position(|&b| b == b':')
.map(|i| &qname[i + 1..])
.unwrap_or(qname)
}
fn decode_utf8(bytes: &[u8]) -> Result<String> {
std::str::from_utf8(bytes)
.map(|s| s.to_string())
.map_err(|e| XmlError::new(
ErrorDomain::Encoding,
ErrorLevel::Error,
format!("catalog entry value is not valid UTF-8: {e}"),
))
}
pub fn conventional_paths() -> Vec<PathBuf> {
let mut out = vec![
PathBuf::from("/etc/xml/catalog"),
PathBuf::from("/usr/share/xml/catalog"),
];
if cfg!(target_os = "macos") {
out.push(PathBuf::from("/opt/homebrew/etc/xml/catalog"));
out.push(PathBuf::from("/usr/local/etc/xml/catalog"));
out.push(PathBuf::from("/opt/local/etc/xml/catalog"));
}
if let Some(home) = std::env::var_os("HOME") {
let mut user = PathBuf::from(home);
user.push(".xmlcatalog");
out.push(user);
}
out
}
pub fn discover_catalog_paths() -> Vec<PathBuf> {
if let Ok(val) = std::env::var("XML_CATALOG_FILES") {
return val
.split_whitespace()
.map(|s| PathBuf::from(s.strip_prefix("file://").unwrap_or(s)))
.collect();
}
conventional_paths()
}
pub fn load_default() -> Result<Catalog> {
let paths: Vec<PathBuf> = discover_catalog_paths()
.into_iter()
.filter(|p| p.exists())
.collect();
if paths.is_empty() {
return Ok(Catalog::default());
}
Catalog::from_files(&paths)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_CATALOG: &[u8] = br#"<?xml version="1.0"?>
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<public publicId="-//W3C//DTD XHTML 1.0 Strict//EN"
uri="file:///usr/share/xml/xhtml/xhtml1-strict.dtd"/>
<system systemId="http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"
uri="file:///usr/share/xml/xhtml/xhtml1-strict.dtd"/>
<public publicId="-//OASIS//DTD DocBook XML V5.0//EN"
uri="file:///usr/share/xml/docbook/docbook-5.0.dtd"/>
</catalog>
"#;
#[test]
fn parses_simple_catalog() {
let cat = Catalog::parse(SAMPLE_CATALOG).unwrap();
assert_eq!(cat.len(), 3, "expected 3 entries");
assert!(!cat.is_empty());
}
#[test]
fn resolves_public_id() {
let cat = Catalog::parse(SAMPLE_CATALOG).unwrap();
let uri = cat.resolve(
Some("-//W3C//DTD XHTML 1.0 Strict//EN"),
None,
);
assert_eq!(uri.as_deref(),
Some("file:///usr/share/xml/xhtml/xhtml1-strict.dtd"));
}
#[test]
fn resolves_system_id() {
let cat = Catalog::parse(SAMPLE_CATALOG).unwrap();
let uri = cat.resolve(
None,
Some("http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"),
);
assert_eq!(uri.as_deref(),
Some("file:///usr/share/xml/xhtml/xhtml1-strict.dtd"));
}
#[test]
fn rewrite_system_substitutes_longest_prefix() {
let cat = Catalog::parse(br#"<?xml version="1.0"?>
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<rewriteSystem systemIdStartString="http://example.com/"
rewritePrefix="file:///local/example/"/>
<rewriteSystem systemIdStartString="http://example.com/specific/"
rewritePrefix="file:///mirror/specific/"/>
</catalog>"#).unwrap();
let uri = cat.resolve(None, Some("http://example.com/specific/foo.dtd"));
assert_eq!(uri.as_deref(), Some("file:///mirror/specific/foo.dtd"));
let uri = cat.resolve(None, Some("http://example.com/other/bar.dtd"));
assert_eq!(uri.as_deref(), Some("file:///local/example/other/bar.dtd"));
}
#[test]
fn rewrite_uri_handles_uri_namespace() {
let cat = Catalog::parse(br#"<?xml version="1.0"?>
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<rewriteURI uriStartString="urn:isbn:" rewritePrefix="file:///isbn/"/>
</catalog>"#).unwrap();
let uri = cat.resolve_uri("urn:isbn:1234567890");
assert_eq!(uri.as_deref(), Some("file:///isbn/1234567890"));
}
#[test]
fn next_catalog_chains_to_a_followup_file() {
let dir = tempdir();
let leaf = dir.join("leaf.xml");
std::fs::write(&leaf, br#"<?xml version="1.0"?>
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<system systemId="urn:my:thing" uri="file:///x/y.dtd"/>
</catalog>"#).unwrap();
let root_path = dir.join("root.xml");
std::fs::write(&root_path, format!(
r#"<?xml version="1.0"?>
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<nextCatalog catalog="{}"/>
</catalog>"#,
leaf.display(),
)).unwrap();
let cat = Catalog::from_files(&[root_path]).unwrap();
let uri = cat.resolve(None, Some("urn:my:thing"));
assert_eq!(uri.as_deref(), Some("file:///x/y.dtd"));
}
#[test]
fn delegate_public_delegates_matching_prefix_to_subcatalog() {
let dir = tempdir();
let sub = dir.join("docbook.xml");
std::fs::write(&sub, br#"<?xml version="1.0"?>
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<public publicId="-//OASIS//DTD DocBook XML V5.0//EN"
uri="file:///mirror/docbook-5.0.dtd"/>
</catalog>"#).unwrap();
let root_path = dir.join("root.xml");
std::fs::write(&root_path, format!(
r#"<?xml version="1.0"?>
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<delegatePublic publicIdStartString="-//OASIS//"
catalog="{}"/>
</catalog>"#,
sub.display(),
)).unwrap();
let cat = Catalog::from_files(&[root_path]).unwrap();
let uri = cat.resolve(Some("-//OASIS//DTD DocBook XML V5.0//EN"), None);
assert_eq!(uri.as_deref(), Some("file:///mirror/docbook-5.0.dtd"));
let uri = cat.resolve(Some("-//W3C//something//EN"), None);
assert_eq!(uri, None);
}
#[test]
fn delegate_system_falls_back_when_first_subcatalog_misses() {
let dir = tempdir();
let miss = dir.join("miss.xml");
let hit = dir.join("hit.xml");
std::fs::write(&miss, br#"<?xml version="1.0"?>
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<system systemId="urn:nope" uri="file:///never"/>
</catalog>"#).unwrap();
std::fs::write(&hit, br#"<?xml version="1.0"?>
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<system systemId="urn:thing" uri="file:///found.dtd"/>
</catalog>"#).unwrap();
let root_path = dir.join("root.xml");
std::fs::write(&root_path, format!(
r#"<?xml version="1.0"?>
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<delegateSystem systemIdStartString="urn:" catalog="{}"/>
<delegateSystem systemIdStartString="urn:" catalog="{}"/>
</catalog>"#,
miss.display(), hit.display(),
)).unwrap();
let cat = Catalog::from_files(&[root_path]).unwrap();
let uri = cat.resolve(None, Some("urn:thing"));
assert_eq!(uri.as_deref(), Some("file:///found.dtd"));
}
#[test]
fn group_prefer_system_makes_public_entries_inert_when_system_present() {
let cat = Catalog::parse(br#"<?xml version="1.0"?>
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<group prefer="system">
<public publicId="-//Test//Public//EN"
uri="file:///public-target"/>
</group>
</catalog>"#).unwrap();
let uri = cat.resolve(
Some("-//Test//Public//EN"),
Some("urn:irrelevant-but-present"),
);
assert_eq!(uri, None);
let uri = cat.resolve(Some("-//Test//Public//EN"), None);
assert_eq!(uri.as_deref(), Some("file:///public-target"));
}
#[test]
fn group_does_not_leak_prefer_to_outer_scope() {
let cat = Catalog::parse(br#"<?xml version="1.0"?>
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<group prefer="system">
<public publicId="inner" uri="file:///inner"/>
</group>
<public publicId="outer" uri="file:///outer"/>
</catalog>"#).unwrap();
let uri = cat.resolve(Some("outer"), Some("urn:some-sys"));
assert_eq!(uri.as_deref(), Some("file:///outer"));
}
#[test]
fn next_catalog_cycle_is_broken_safely() {
let dir = tempdir();
let a = dir.join("a.xml");
let b = dir.join("b.xml");
std::fs::write(&a, format!(
r#"<?xml version="1.0"?>
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<nextCatalog catalog="{}"/>
</catalog>"#, b.display())).unwrap();
std::fs::write(&b, format!(
r#"<?xml version="1.0"?>
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<nextCatalog catalog="{}"/>
</catalog>"#, a.display())).unwrap();
let cat = Catalog::from_files(&[a]).unwrap();
let uri = cat.resolve(None, Some("urn:does-not-exist"));
assert_eq!(uri, None);
}
#[test]
fn missing_delegate_file_is_silently_skipped() {
let cat = Catalog::parse(br#"<?xml version="1.0"?>
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<delegateSystem systemIdStartString="urn:"
catalog="/nowhere/missing-catalog.xml"/>
<system systemId="urn:thing" uri="file:///fallback.dtd"/>
</catalog>"#).unwrap();
let uri = cat.resolve(None, Some("urn:thing"));
assert_eq!(uri.as_deref(), Some("file:///fallback.dtd"));
}
fn tempdir() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let name = format!("supxml-catalog-test-{}-{n}", std::process::id());
let dir = std::env::temp_dir().join(name);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
}