Skip to main content

sup_xml_core/
catalog.rs

1#![forbid(unsafe_code)]  // see CONTRIBUTING.md § "Unsafe policy"
2
3//! OASIS XML Catalogs — local mappings from public/system identifiers
4//! to filesystem paths.  See § "XML Catalogs" in `COMPARISON.md` for
5//! the rationale.
6//!
7//! # What this implements
8//!
9//! Entry types from the OASIS XML Catalog spec § 6:
10//!
11//! - `<public publicId="…" uri="…"/>`           — PUBLIC-id → URI
12//! - `<system systemId="…" uri="…"/>`           — SYSTEM-id → URI
13//! - `<uri name="…" uri="…"/>`                  — generic URI alias
14//! - `<rewriteSystem systemIdStartString="…" rewritePrefix="…"/>`
15//! - `<rewriteUri uriStartString="…" rewritePrefix="…"/>`
16//! - `<delegatePublic publicIdStartString="…" catalog="…"/>`
17//! - `<delegateSystem systemIdStartString="…" catalog="…"/>`
18//! - `<delegateURI uriStartString="…" catalog="…"/>`
19//! - `<nextCatalog catalog="…"/>`               — chain to another file
20//! - `<group prefer="…">…</group>`              — scoped prefer override
21//!
22//! Discovery via the `XML_CATALOG_FILES` environment variable
23//! (libxml2-compatible) plus a built-in conventional-path list is
24//! provided through [`load_default`] and [`discover_catalog_paths`].
25//!
26//! Resolution follows OASIS § 7: exact matches before prefix
27//! rewrites before delegation before catalog chaining; longest
28//! matching prefix wins for rewrite / delegate entries.  Cycles
29//! between `<nextCatalog>` and `<delegate*>` references are broken
30//! by a per-resolution "already visited" set.
31//!
32//! # Public API
33//!
34//! [`Catalog::resolve(public_id, system_id)`] is the one entry
35//! point most callers need.  It returns `Option<String>` because
36//! rewrite entries synthesise a new URI; non-rewrite hits clone
37//! the stored mapping value.
38
39use 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/// The `prefer` attribute on `<catalog>` / `<group>` — controls
47/// whether PUBLIC or SYSTEM identifiers take precedence in
48/// resolution.  Defaults to `Public` per OASIS § 7.1.
49#[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/// One entry in a parsed catalog.  Entries are stored in document
60/// order so prefix-rewrite resolution can compare candidates by
61/// length (OASIS § 7.2.2: longest matching prefix wins, ties broken
62/// by declaration order — first wins).
63#[derive(Debug, Clone)]
64enum Entry {
65    /// `<public publicId="…" uri="…"/>` — `prefer` carried from the
66    /// enclosing `<group>` / `<catalog>` so resolution honours the
67    /// scope-local override.
68    Public  { id: String, uri: String, prefer: Prefer },
69    /// `<system systemId="…" uri="…"/>`.
70    System  { id: String, uri: String },
71    /// `<uri name="…" uri="…"/>` — generic URI alias.
72    Uri     { name: String, uri: String },
73    /// `<rewriteSystem systemIdStartString="…" rewritePrefix="…"/>`.
74    RewriteSystem { start: String, replace: String },
75    /// `<rewriteUri uriStartString="…" rewritePrefix="…"/>`.
76    RewriteUri    { start: String, replace: String },
77    /// `<delegatePublic publicIdStartString="…" catalog="…"/>` —
78    /// `catalog` is the resolved sub-catalog (loaded eagerly at
79    /// parse time so cycles are detected up front).  `None` means
80    /// the referenced file didn't exist or didn't parse, which
81    /// causes that delegation arm to be silently skipped at
82    /// resolution time (matching libxml2's behaviour).
83    DelegatePublic { start: String, sub: Option<Box<Catalog>> },
84    /// `<delegateSystem systemIdStartString="…" catalog="…"/>`.
85    DelegateSystem { start: String, sub: Option<Box<Catalog>> },
86    /// `<delegateURI uriStartString="…" catalog="…"/>`.
87    DelegateUri    { start: String, sub: Option<Box<Catalog>> },
88    /// `<nextCatalog catalog="…"/>`.  Same semantics as delegation
89    /// but unconditional — every `<nextCatalog>` is tried after the
90    /// containing catalog's own entries are exhausted.
91    NextCatalog    { sub: Option<Box<Catalog>> },
92}
93
94/// In-memory representation of one or more parsed XML catalog files.
95///
96/// Construct with [`load_default`] to use the libxml2-compatible
97/// discovery rules, or with [`from_files`] / [`parse`] when you
98/// know the catalog locations explicitly.
99#[derive(Debug, Default, Clone)]
100pub struct Catalog {
101    /// Entries in document order.  Resolution semantics — longest
102    /// prefix wins for rewrite/delegate entries — is enforced at
103    /// [`resolve`](Self::resolve) time, not at construction.
104    entries: Vec<Entry>,
105    /// `prefer` attribute on the catalog root (`<catalog prefer="…">`).
106    /// Inherited by entries declared outside any `<group>`.
107    root_prefer: Prefer,
108    /// Catalog files loaded into this instance, in load order.
109    /// Diagnostic aid — answers "which file did this come from?".
110    sources: Vec<PathBuf>,
111}
112
113impl Catalog {
114    /// Look up a `(publicId, systemId)` pair against the catalog.
115    /// Resolution follows OASIS § 7: SYSTEM exact → rewriteSystem
116    /// → delegateSystem → PUBLIC exact (per `prefer`) →
117    /// delegatePublic → nextCatalog chains.  Returns the catalog's
118    /// URI for the entity, or `None` if no entry matched anywhere
119    /// in the chain.
120    ///
121    /// The result is a [`Cow`] so direct `<public>` / `<system>`
122    /// matches return a borrow into the catalog (zero allocation)
123    /// while rewrite entries return the synthesised string owned.
124    /// Callers that need to own the URI past the catalog's lifetime
125    /// can call `.into_owned()`.
126    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    /// `<uri>` lookup — resolve a generic URI alias.  Distinct from
135    /// `<system>`: the OASIS spec keeps the two namespaces
136    /// separate.  Used by XInclude / XSLT consumers that want a
137    /// catalog-style mapping without going through DTD's
138    /// PUBLIC/SYSTEM mechanism.
139    ///
140    /// As with [`resolve`](Self::resolve), the result is a [`Cow`] —
141    /// direct `<uri>` matches borrow; `<rewriteUri>` matches own.
142    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    /// Number of entries in the catalog.  Counts every entry,
148    /// including rewrite/delegate/next ones.  Used in tests and
149    /// diagnostics.
150    pub fn len(&self) -> usize {
151        self.entries.len()
152    }
153
154    /// `true` if no entries were loaded.
155    pub fn is_empty(&self) -> bool {
156        self.entries.is_empty()
157    }
158
159    /// Files this catalog instance was loaded from, in order.
160    pub fn sources(&self) -> &[PathBuf] {
161        &self.sources
162    }
163
164    /// Build a catalog by merging the contents of multiple files.
165    /// Later entries do NOT override earlier ones — first match
166    /// wins, matching libxml2's behaviour for catalog chains.
167    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    /// Parse a catalog from raw bytes.  Useful for tests or when
187    /// the catalog isn't on disk (embedded resources, etc.).
188    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    // ── resolution ──────────────────────────────────────────────
196
197    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        // Entry types are consulted in OASIS catalog resolution order
203        // (§7); the first match wins, so the sequence below is
204        // precedence, not arbitrary.
205        //
206        // <system> exact match — borrow the stored URI.
207        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        // <rewriteSystem> — longest matching prefix wins.
215        // The synthesised URI is owned (built via `format!`).
216        if let Some(sid) = system_id {
217            if let Some(rewritten) = longest_rewrite(&self.entries, sid, /*system*/ true) {
218                return Some(Cow::Owned(rewritten));
219            }
220        }
221        // <delegateSystem> — longest matching prefix
222        // delegates to the sub-catalog; ties broken by declaration
223        // order.  If the delegate's own lookup returns None, try
224        // the next-longest delegate (OASIS § 7.2.4).  The returned
225        // Cow borrows from the sub-catalog (owned by `self` via
226        // `Box<Catalog>`) so its lifetime is `'a`.
227        if let Some(sid) = system_id {
228            if let Some(out) = longest_delegate(
229                &self.entries, sid, /*kind*/ DelegateKind::System,
230                public_id, system_id, seen,
231            ) { return Some(out); }
232        }
233        // <public> exact match — only when PUBLIC was
234        // supplied AND either the entry is not in a `prefer=system`
235        // scope, or no SYSTEM identifier was supplied at all
236        // (OASIS § 7.1.1).
237        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        // <delegatePublic> — same shape as delegateSystem.
250        if let Some(pid) = public_id {
251            let normalised = normalise_public_id(pid);
252            if let Some(out) = longest_delegate(
253                &self.entries, &normalised, /*kind*/ DelegateKind::Public,
254                public_id, system_id, seen,
255            ) { return Some(out); }
256        }
257        // <nextCatalog> chains — each one is consulted in
258        // declaration order until something matches.
259        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        // <uri> exact match → <rewriteUri> → <delegateURI> →
274        // <nextCatalog>.  Mirrors the system-id pipeline but on the
275        // uri-namespace.
276        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, /*system*/ false) {
282            return Some(Cow::Owned(out));
283        }
284        if let Some(out) = longest_delegate(
285            &self.entries, target, DelegateKind::Uri,
286            /*public*/ None, /*system*/ 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    // ── parsing ─────────────────────────────────────────────────
302
303    /// Parse catalog bytes from `source` and merge entries into
304    /// `self`.  `source` is the on-disk path the bytes came from
305    /// (used to anchor relative `catalog="…"` references in
306    /// `<nextCatalog>` / `<delegate*>`); `None` when parsing from
307    /// memory.
308    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        // Stack of `prefer` values pushed by `<catalog>` and
320        // `<group>` openings — the top of the stack is the
321        // effective `prefer` for any entry seen at this depth.
322        // We deliberately use a Vec rather than a single Cell so
323        // nested groups (rare but legal) compose correctly.
324        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                    // Detach the borrow on `tag.name()` by copying the
337                    // local-name slice — `tag.attrs()` consumes `tag`
338                    // by value, so the borrow has to be gone first.
339                    let local: Vec<u8> = strip_namespace_prefix(tag.name()).to_vec();
340                    match local.as_slice() {
341                        b"catalog" | b"group" => {
342                            // Honour an inner `prefer=` attribute; if
343                            // absent, inherit the surrounding scope.
344                            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                                // Root-level prefer is also retained on
358                                // the Catalog for any post-parse
359                                // introspection.
360                                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                        _ => {} // ignore unknown
467                    }
468                }
469                _ => continue,
470            }
471        }
472        Ok(())
473    }
474}
475
476/// Walk `entries` looking for the longest rewrite prefix matching
477/// `target`.  `is_system` selects between `RewriteSystem` and
478/// `RewriteUri`.  Returns the replaced URI, or `None` if no entry
479/// matched.
480///
481/// Per OASIS § 7.2.2, longest matching prefix wins; ties broken by
482/// declaration order (first wins).  We compute the longest match
483/// in one pass.
484fn longest_rewrite(entries: &[Entry], target: &str, is_system: bool) -> Option<String> {
485    let mut best: Option<(&str, &str)> = None;  // (start, replace)
486    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
505/// Walk `entries` looking for delegate entries whose prefix matches
506/// `target`, in longest-prefix-first order.  For each match, ask
507/// the sub-catalog to resolve `(public_id, system_id)`; the first
508/// non-`None` result wins.  Cycles are broken via `seen`.
509///
510/// Per OASIS § 7.2.4, delegation matches are ordered by *prefix
511/// length* (longest first); within a length, by declaration order.
512/// If a delegate's sub-catalog can't resolve, the next-longest
513/// delegate is tried before giving up.
514fn 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    // Collect every matching delegate, then sort by prefix length
522    // descending.  Entry count per catalog is small (typically <50)
523    // so the O(n log n) sort is irrelevant.
524    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
552/// Read `prefix-attr="…"` and `rewritePrefix="…"` off a
553/// `<rewriteSystem>` / `<rewriteUri>` start tag.  Returns the pair
554/// when both are present; `None` (silent skip) otherwise — matches
555/// libxml2's lenient parse stance on malformed catalog entries.
556fn 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
573/// Read `prefix-attr="…"` and `catalog="…"` off a `<delegate*>`
574/// start tag and eagerly load the referenced sub-catalog.  The
575/// sub-catalog is loaded relative to `base_dir` (the directory of
576/// the catalog file we're parsing).  A failure to load
577/// (file-not-found, parse error) is silently absorbed: the entry's
578/// `sub` is set to `None` and resolution will skip that delegate.
579fn 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
601/// Best-effort load of `href` (a `catalog="…"` reference) relative
602/// to `base_dir`.  Returns `None` on any failure — the OASIS spec
603/// treats missing catalog references as "skip that delegation
604/// branch" rather than a hard error, since catalog hierarchies
605/// often reference files that aren't installed in every consumer's
606/// environment.
607fn 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    // Cycle protection: skip if this file is already on the loading
622    // stack.  Without this, `<nextCatalog>` chains that loop back to
623    // an ancestor catalog (a → b → a) would recurse forever during
624    // parse.  Resolution-time cycle detection (`mark_seen`) is a
625    // second line of defence for anonymous sub-catalogs that don't
626    // have an identifying path.
627    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    // Whether or not parsing succeeded, pop the canonical path off
636    // the loading stack so sibling catalogs can reference the same
637    // file at the same depth.
638    loading.remove(&canon);
639    result.ok()?;
640    Some(Box::new(sub))
641}
642
643/// Record this sub-catalog as visited.  Returns `false` if it was
644/// already in `seen` (i.e. there's a cycle through `<nextCatalog>`
645/// / `<delegate*>`).  Cycle detection is purely a safety net;
646/// catalog files are typically a few-deep DAG in practice.
647fn mark_seen(seen: &mut HashSet<PathBuf>, sub: &Catalog) -> bool {
648    // Use the first source path as the identity for cycle detection.
649    // Anonymous sub-catalogs (loaded via Catalog::parse with no path)
650    // never participate in cycles since they aren't reachable by
651    // path; treat them as always-fresh.
652    match sub.sources.first() {
653        Some(p) => seen.insert(p.clone()),
654        None    => true,
655    }
656}
657
658/// OASIS § 7.1: normalise a PUBLIC ID for matching.  Sequences of
659/// XML whitespace (` `, `\t`, `\r`, `\n`) collapse to a single
660/// space; leading and trailing whitespace is removed.  Catalogs
661/// must store and compare PUBLIC IDs in this normalised form.
662fn normalise_public_id(s: &str) -> String {
663    let mut out = String::with_capacity(s.len());
664    let mut last_was_space = true;   // suppress leading whitespace
665    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
682/// Strip a `prefix:` namespace prefix from an element / attribute
683/// name.  Catalogs use the `urn:oasis:...:catalog` namespace but
684/// the prefix is conventionally absent (default namespace) or
685/// `cat` / `c`.  We don't currently do full namespace resolution,
686/// so we accept any prefix and look at the local name.
687fn 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
703/// Conventional catalog paths for the current OS, in priority
704/// order.  These are the locations libxml2 (and the wider XML
705/// toolchain) historically check when no `XML_CATALOG_FILES`
706/// override is present.  Used by [`discover_catalog_paths`] as
707/// the fallback list.
708///
709/// Exposed separately so callers (and tests) can reason about the
710/// platform-specific path list without going through the env-var
711/// check.
712pub 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        // Homebrew on Apple Silicon, then Intel; then MacPorts.
719        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    // Per-user catalog at ~/.xmlcatalog (libxml2 doesn't check
724    // this by default but it's a common convention).
725    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
733/// Discover XML catalog files using libxml2-compatible rules.
734///
735/// 1. If the `XML_CATALOG_FILES` environment variable is set, split
736///    it on whitespace and use those paths.  Each entry can be a
737///    plain path or a `file://` URI.  This overrides the default
738///    list entirely.
739/// 2. Otherwise, return [`conventional_paths`] for the current OS.
740///
741/// The returned list contains paths that *might* exist; callers
742/// should filter for paths that actually do (or rely on
743/// [`load_default`] which silently skips missing files).
744pub 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
754/// Load a catalog using the default discovery rules — convenience
755/// wrapper combining [`discover_catalog_paths`] and
756/// [`Catalog::from_files`].  Silently skips paths that don't exist
757/// (a fresh macOS install, for example, returns an empty catalog).
758pub 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    // ── new entry types ─────────────────────────────────────────
814
815    #[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        // Longest match wins — "http://example.com/specific/" beats
825        // "http://example.com/" even though both apply.
826        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        // Falls back to the shorter prefix when the longer one
829        // doesn't apply.
830        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        // Prefix that doesn't match the delegate is not resolved.
887        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        // Both delegates share a prefix that matches `urn:`.  The
906        // longer-prefix one is consulted first; on miss, resolution
907        // falls through to the shorter one — but here we only need
908        // to verify the basic "delegate then try next" path.
909        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        // When system_id is provided, PUBLIC in a prefer="system"
932        // group is bypassed — the lookup returns None.
933        let uri = cat.resolve(
934            Some("-//Test//Public//EN"),
935            Some("urn:irrelevant-but-present"),
936        );
937        assert_eq!(uri, None);
938        // When system_id is None, the PUBLIC entry can still match
939        // (the prefer=system rule only governs the public/system
940        // tie-break; there's no tie when system is absent).
941        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        // The outer public entry must still be resolvable — the
955        // group's prefer="system" only applied to its own scope.
956        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        // Cycle a → b → a; resolution must terminate and return
976        // None rather than recurse forever.
977        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        // The dead delegate must not prevent the later <system>
991        // entry from matching.  But OASIS order is: system exact
992        // first, so this resolves before delegate is consulted.
993        // Either way, the lookup should not error.
994        let uri = cat.resolve(None, Some("urn:thing"));
995        assert_eq!(uri.as_deref(), Some("file:///fallback.dtd"));
996    }
997
998    /// Tempdir helper — creates a per-test directory and returns
999    /// its path.  Not auto-cleaned (test isolation matters more
1000    /// than tidy `/tmp`).  Names combine the per-process pid with
1001    /// a per-call counter so two tests running in the same
1002    /// nanosecond never collide.
1003    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        // Fresh start in case a previous run left this name behind.
1010        let _ = std::fs::remove_dir_all(&dir);
1011        std::fs::create_dir_all(&dir).unwrap();
1012        dir
1013    }
1014}