Skip to main content

libxml_rs/xml/catalog/
mod.rs

1//! XML Catalog support (§26, §85 Phase 4).
2//!
3//! OASIS XML Catalog resolution, catalog lookup order, precedence,
4//! catalog loading, SGML catalog compatibility.
5//!
6//! Implements the OASIS XML Catalog specification (xCatalog) with
7//! compatibility for SGML (SOLEX) catalog format.
8//!
9//! # UPSTREAM-PARITY
10//!
11//! Matches the libxml2 catalog behavior:
12//! - XML Catalog format (OASIS TR 9401:1999)
13//! - SGML catalog format (SOLEX)
14//! - Environment variables: XML_CATALOG_FILES, SGML_CATALOG_FILES
15//! - Default catalog location: /etc/xml/catalog
16//! - Catalog entry types: public, system, rewriteSystem, rewriteURI,
17//!   delegatePublic, delegateSystem, delegateURI, nextCatalog, group
18//! - Resolution order: public → system → URI
19//!
20//! # Upstream contract
21//!
22//! Mirrors upstream `catalog.c` (`SRC-LIBXML2-2.15.0-CATALOG-C`, parity
23//! target libxml2 2.15.3 oracle). Export samples: `xmlNewCatalog`,
24//! `xmlFreeCatalog`, `xmlLoadACatalog`, `xmlLoadSGMLSuperCatalog`,
25//! `xmlConvertSGMLCatalog`, `xmlACatalogAdd`. `xmlLoadCatalog` returns
26//! int 0/1 (success/error) per upstream catalog.c (SECURITY_HISTORY §5.1).
27//!
28//! # Conceptual behavior
29//!
30//! Implements the OASIS XML Catalog model (OASIS-CATALOG-1.0) plus the
31//! SGML/SOLEX format: files are detected as XML or SGML by content
32//! (`detect_catalog_format`), parsed into a common `CatalogEntry` model,
33//! and resolved in upstream order public → system → URI, honoring
34//! rewriteSystem/rewriteURI, delegate*, nextCatalog chains and the
35//! `XML_CATALOG_FILES` / `SGML_CATALOG_FILES` environment variables.
36//!
37//! # Ownership & safety invariants
38//!
39//! `xmlLoadCatalog` / `xmlNewCatalog` return a catalog the caller frees
40//! with `xmlFreeCatalog`; `xmlCatalogAdd` copies values into the catalog
41//! (the caller keeps its own strings); `xmlCatalogGetEntries` returns
42//! borrowed internal structures the caller must not free (OWNERSHIP_ATLAS
43//! §5). The global default catalog is guarded by a RwLock.
44//!
45//! # Historical quirks & epochs
46//!
47//! The SGML (SOLEX) path is a compatibility surface inherited from the
48//! 1990s SGML catalog world (OASIS TR 9401:1999 lineage) and predates the
49//! XML Catalog 1.0 spec; upstream has kept it since the 2.0 era. The
50//! `xmlLoadCatalog` int-return contract was corrected during the 11.1-V
51//! security audit to match catalog.c.
52//!
53//! # Deliberate oddities
54//!
55//! SGML directives that do not participate in resolution (SGMLDECL,
56//! DOCTYPE, ENTITY, OVERRIDE) are intentionally ignored rather than
57//! rejected; the XML/SGML split is by content sniffing, not by file
58//! extension, matching upstream `xmlLoadACatalog` detection.
59//!
60//! # Proving courts
61//!
62//! CLI-XMLCATALOG court family exercises catalog resolution end-to-end;
63//! the xmlcatalog CLI differential probes (CLI-XMLCATALOG-*) compare
64//! output byte-identical against the oracle (R-000122/R-000123 locked the
65//! option parsing and shell-command semantics).
66//!
67//! # Tempting simplifications that would break parity
68//!
69//! Do not parse SGML catalogs with the XML parser (or vice versa): the two
70//! formats have different directives and quote rules, and the content
71//! sniff must stay. Do not flatten nextCatalog/delegate chains into a
72//! single entry list — precedence and re-resolution semantics would
73//! change. Do not return borrowed internals to the caller as owned: that
74//! would double-free.
75
76#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
77
78use core::ffi::c_void;
79use std::ffi::CStr;
80use std::fs;
81use std::os::raw::{c_char, c_int};
82use std::path::Path;
83use std::ptr;
84
85use once_cell::sync::Lazy;
86use parking_lot::RwLock;
87
88use crate::abi::allocator::xmlFreeImpl;
89use crate::abi::structs::{_xmlDoc, _xmlNode};
90use crate::abi::types::xmlChar;
91use crate::xml::string::{bytes_to_xmlstr, xmlstr_to_bytes};
92
93// ═══════════════════════════════════════════════════════════════════════════════
94// Constants
95// ═══════════════════════════════════════════════════════════════════════════════
96
97/// Catalog allow value: no catalogs allowed.
98pub(crate) const XML_CATA_ALLOW_NONE: i32 = 0;
99
100/// Catalog allow value: only global catalogs.
101pub(crate) const XML_CATA_ALLOW_GLOBAL: i32 = 1;
102
103/// Catalog allow value: document catalogs allowed.
104pub(crate) const XML_CATA_ALLOW_DOCUMENT: i32 = 2;
105
106/// Catalog allow value: all catalogs allowed.
107pub(crate) const XML_CATA_ALLOW_ALL: i32 = 3;
108
109/// Default catalog file path.
110const DEFAULT_CATALOG: &str = "/etc/xml/catalog";
111
112/// Environment variable for XML catalog files.
113const XML_CATALOG_FILES_ENV: &str = "XML_CATALOG_FILES";
114
115/// Environment variable for SGML catalog files.
116const SGML_CATALOG_FILES_ENV: &str = "SGML_CATALOG_FILES";
117
118/// Maximum catalog file size (10 MB).
119const MAX_CATALOG_FILE_SIZE: usize = 10_485_760;
120
121// ═══════════════════════════════════════════════════════════════════════════════
122// Catalog Entry Types
123// ═══════════════════════════════════════════════════════════════════════════════
124
125/// A single catalog entry.
126#[derive(Clone, Debug)]
127pub enum CatalogEntry {
128    /// `<public publicId="..." uri="..."/>`
129    Public {
130        /// The public identifier (`publicId`) to match.
131        public_id: Vec<u8>,
132        /// The replacement URI (`uri`) for the matching public identifier.
133        uri: Vec<u8>,
134    },
135    /// `<system systemId="..." uri="..."/>`
136    System {
137        /// The system identifier (`systemId`) to match.
138        system_id: Vec<u8>,
139        /// The replacement URI (`uri`) for the matching system identifier.
140        uri: Vec<u8>,
141    },
142    /// `<rewriteSystem systemIdStartString="..." rewritePrefix="..."/>`
143    RewriteSystem {
144        /// The system-identifier prefix (`systemIdStartString`) to match.
145        prefix: Vec<u8>,
146        /// The rewrite prefix (`rewritePrefix`) that replaces the matched prefix.
147        rewrite: Vec<u8>,
148    },
149    /// `<rewriteURI uriStartString="..." rewritePrefix="..."/>`
150    RewriteURI {
151        /// The URI prefix (`uriStartString`) to match.
152        prefix: Vec<u8>,
153        /// The rewrite prefix (`rewritePrefix`) that replaces the matched prefix.
154        rewrite: Vec<u8>,
155    },
156    /// `<delegatePublic publicIdStartString="..." catalog="..."/>`
157    DelegatePublic {
158        /// The public-identifier prefix (`publicIdStartString`) to match.
159        prefix: Vec<u8>,
160        /// The URI (`catalog`) of the catalog to delegate matching to.
161        catalog: Vec<u8>,
162    },
163    /// `<delegateSystem systemIdStartString="..." catalog="..."/>`
164    DelegateSystem {
165        /// The system-identifier prefix (`systemIdStartString`) to match.
166        prefix: Vec<u8>,
167        /// The URI (`catalog`) of the catalog to delegate matching to.
168        catalog: Vec<u8>,
169    },
170    /// `<delegateURI uriStartString="..." catalog="..."/>`
171    DelegateURI {
172        /// The URI prefix (`uriStartString`) to match.
173        prefix: Vec<u8>,
174        /// The URI (`catalog`) of the catalog to delegate matching to.
175        catalog: Vec<u8>,
176    },
177    /// `<nextCatalog catalog="..."/>`
178    NextCatalog {
179        /// The URI (`catalog`) of the next catalog to search.
180        catalog: Vec<u8>,
181    },
182}
183
184/// Indicates the format of a loaded catalog.
185#[derive(Clone, Copy, Debug, PartialEq)]
186enum CatalogFormat {
187    Xml,
188    Sgml,
189}
190
191/// Metadata about a loaded catalog file.
192#[allow(dead_code)]
193#[derive(Clone, Debug)]
194struct CatalogInfo {
195    path: Vec<u8>,
196    format: CatalogFormat,
197}
198
199// ═══════════════════════════════════════════════════════════════════════════════
200// Global Catalog State
201// ═══════════════════════════════════════════════════════════════════════════════
202
203/// Global catalog registry state.
204struct CatalogState {
205    /// All catalog entries, in load order.
206    entries: Vec<CatalogEntry>,
207    /// Information about loaded catalog files.
208    catalogs: Vec<CatalogInfo>,
209    /// Whether the subsystem has been initialized.
210    initialized: bool,
211    /// Catalog resolution allow value.
212    allow: i32,
213}
214
215impl CatalogState {
216    const fn new() -> Self {
217        Self {
218            entries: Vec::new(),
219            catalogs: Vec::new(),
220            initialized: false,
221            allow: XML_CATA_ALLOW_ALL,
222        }
223    }
224
225    /// Clear all catalog data.
226    fn clear(&mut self) {
227        self.entries.clear();
228        self.catalogs.clear();
229        self.allow = XML_CATA_ALLOW_ALL;
230    }
231}
232
233/// Global catalog registry, protected by a read-write lock.
234static CATALOG_STATE: Lazy<RwLock<CatalogState>> = Lazy::new(|| RwLock::new(CatalogState::new()));
235
236// ═══════════════════════════════════════════════════════════════════════════════
237// Internal Helpers
238// ═══════════════════════════════════════════════════════════════════════════════
239
240/// Trim leading and trailing whitespace from a byte slice.
241fn trim_whitespace(bytes: &[u8]) -> &[u8] {
242    let start = bytes
243        .iter()
244        .position(|b| !b.is_ascii_whitespace())
245        .unwrap_or(bytes.len());
246    let end = bytes
247        .iter()
248        .rposition(|b| !b.is_ascii_whitespace())
249        .map_or(0, |p| p + 1);
250    &bytes[start..end]
251}
252
253/// Check if a byte slice starts with a given prefix (case-sensitive).
254#[allow(dead_code)]
255fn starts_with(data: &[u8], prefix: &[u8]) -> bool {
256    if data.len() < prefix.len() {
257        return false;
258    }
259    data[..prefix.len()] == prefix[..]
260}
261
262/// Check if a byte slice starts with a given prefix (case-insensitive ASCII).
263#[allow(dead_code)]
264fn starts_with_ignore_ascii_case(data: &[u8], prefix: &[u8]) -> bool {
265    if data.len() < prefix.len() {
266        return false;
267    }
268    data[..prefix.len()]
269        .iter()
270        .zip(prefix.iter())
271        .all(|(a, b)| a.eq_ignore_ascii_case(b))
272}
273
274/// Extract a quoted attribute value from bytes.
275///
276/// Searches for `name="..."` or `name='...'` starting at position `pos`.
277/// Returns `(value_bytes, end_pos)` or `None`.
278fn extract_attr_value<'a>(data: &'a [u8], name: &[u8], pos: usize) -> Option<(&'a [u8], usize)> {
279    let remaining = &data[pos..];
280    // Find name
281    let name_pos = find_subsequence(remaining, name)?;
282    let after_name = name_pos + name.len();
283    let after_name_slice = &remaining[after_name..];
284
285    // Skip whitespace and =
286    let eq_pos = after_name_slice.iter().position(|b| *b == b'=')?;
287
288    // Check for quote — offset is relative to `data` (absolute)
289    let rel_quote_start = after_name_slice[eq_pos + 1..]
290        .iter()
291        .position(|b| *b == b'"' || *b == b'\'')
292        .map(|p| after_name + eq_pos + 1 + p)?;
293    let abs_quote_start = pos + rel_quote_start;
294    let quote_char = data[abs_quote_start];
295    // Find matching close quote
296    let value_start = abs_quote_start + 1;
297    let value_end = data[value_start..]
298        .iter()
299        .position(|b| *b == quote_char)
300        .map(|p| value_start + p)?;
301
302    Some((&data[value_start..value_end], value_end + 1))
303}
304
305/// Find a subsequence in a byte slice.
306fn find_subsequence(data: &[u8], seq: &[u8]) -> Option<usize> {
307    if seq.is_empty() {
308        return Some(0);
309    }
310    data.windows(seq.len()).position(|w| w == seq)
311}
312
313/// Extract a simple token (non-whitespace bytes) from a line, starting at `pos`.
314fn extract_token(line: &[u8], pos: usize) -> Option<(&[u8], usize)> {
315    let line = &line[pos..];
316    let start = line.iter().position(|b| !b.is_ascii_whitespace())?;
317    let end = line[start..]
318        .iter()
319        .position(|b| b.is_ascii_whitespace())
320        .map(|p| start + p)
321        .unwrap_or(line.len());
322    Some((&line[start..end], pos + end))
323}
324
325/// Extract a quoted token from a line (may use " or ' quotes), starting at `pos`.
326fn extract_quoted_token(line: &[u8], pos: usize) -> Option<(&[u8], usize)> {
327    let line = &line[pos..];
328    let start = line.iter().position(|b| !b.is_ascii_whitespace())?;
329    if start >= line.len() {
330        return None;
331    }
332    let quote_char = line[start];
333    if quote_char != b'"' && quote_char != b'\'' {
334        // Not quoted — extract as simple token
335        return extract_token(line, 0);
336    }
337    let value_start = start + 1;
338    let end = line[value_start..]
339        .iter()
340        .position(|b| *b == quote_char)
341        .map(|p| value_start + p)?;
342    Some((&line[value_start..end], pos + end + 1))
343}
344
345// ═══════════════════════════════════════════════════════════════════════════════
346// Catalog Parsing — SGML Format
347// ═══════════════════════════════════════════════════════════════════════════════
348
349/// Parse a single line of an SGML catalog.
350///
351/// SGML catalog format lines:
352/// - `PUBLIC "publicId" "uri"`
353/// - `SYSTEM "systemId" "uri"`
354/// - `URI "uri" "replacement"`
355/// - `OVERRIDE YES|NO`
356/// - `CATALOG "path"` (delegation to another catalog)
357/// - `SGMLDECL "path"` (ignored)
358/// - `DOCTYPE "name" "uri"` (ignored for catalog resolution)
359/// - `ENTITY "name" "uri"` (ignored for catalog resolution)
360/// - `LINKTYPE "name" "uri"` (ignored)
361/// - `NOTATION "name" "uri"` (ignored)
362/// - Comments start with `--`
363fn parse_sgml_line(line: &[u8], entries: &mut Vec<CatalogEntry>) {
364    let trimmed = trim_whitespace(line);
365    if trimmed.is_empty() || trimmed.starts_with(b"--") {
366        return;
367    }
368
369    // Extract the directive
370    let Some((directive, after_directive)) = extract_token(trimmed, 0) else {
371        return;
372    };
373
374    match directive {
375        b"PUBLIC" | b"public" => {
376            let Some((pub_id, after_pub)) = extract_quoted_token(trimmed, after_directive) else {
377                return;
378            };
379            let Some((uri, _)) = extract_quoted_token(trimmed, after_pub) else {
380                return;
381            };
382            entries.push(CatalogEntry::Public {
383                public_id: pub_id.to_vec(),
384                uri: uri.to_vec(),
385            });
386        }
387        b"SYSTEM" | b"system" => {
388            let Some((sys_id, after_sys)) = extract_quoted_token(trimmed, after_directive) else {
389                return;
390            };
391            let Some((uri, _)) = extract_quoted_token(trimmed, after_sys) else {
392                return;
393            };
394            entries.push(CatalogEntry::System {
395                system_id: sys_id.to_vec(),
396                uri: uri.to_vec(),
397            });
398        }
399        b"URI" | b"uri" => {
400            // SGML URI is treated like a system entry in libxml2
401            let Some((uri_id, after_uri)) = extract_quoted_token(trimmed, after_directive) else {
402                return;
403            };
404            let Some((replacement, _)) = extract_quoted_token(trimmed, after_uri) else {
405                return;
406            };
407            entries.push(CatalogEntry::System {
408                system_id: uri_id.to_vec(),
409                uri: replacement.to_vec(),
410            });
411        }
412        b"CATALOG" | b"catalog" => {
413            let Some((path, _)) = extract_quoted_token(trimmed, after_directive) else {
414                return;
415            };
416            entries.push(CatalogEntry::NextCatalog {
417                catalog: path.to_vec(),
418            });
419        }
420        _ => {
421            // Other directives (SGMLDECL, DOCTYPE, ENTITY, etc.) are ignored
422        }
423    }
424}
425
426/// Parse SGML catalog content.
427fn parse_sgml_catalog(data: &[u8], entries: &mut Vec<CatalogEntry>) {
428    for line in data.split(|b| *b == b'\n') {
429        parse_sgml_line(line, entries);
430    }
431}
432
433// ═══════════════════════════════════════════════════════════════════════════════
434// Catalog Parsing — XML Catalog Format
435// ═══════════════════════════════════════════════════════════════════════════════
436
437/// Parse an XML Catalog file content.
438///
439/// Uses simple tag scanning rather than a full XML parser, matching
440/// libxml2's approach which has its own catalog-specific parser.
441fn parse_xml_catalog(data: &[u8], entries: &mut Vec<CatalogEntry>) {
442    let mut pos = 0;
443    let len = data.len();
444
445    while pos < len {
446        // Find next '<'
447        let Some(lt_pos) = data[pos..].iter().position(|b| *b == b'<') else {
448            break;
449        };
450        let tag_start = pos + lt_pos;
451
452        // Check if this is a closing tag or self-closing
453        if tag_start + 1 >= len {
454            break;
455        }
456
457        let is_closing = data[tag_start + 1] == b'/';
458        if is_closing {
459            // Skip to '>'
460            let Some(gt_pos) = data[tag_start..].iter().position(|b| *b == b'>') else {
461                break;
462            };
463            pos = tag_start + gt_pos + 1;
464            continue;
465        }
466
467        // Check if it's a comment or PI
468        if data[tag_start + 1] == b'!' || data[tag_start + 1] == b'?' {
469            let Some(gt_pos) = data[tag_start..].iter().position(|b| *b == b'>') else {
470                break;
471            };
472            pos = tag_start + gt_pos + 1;
473            continue;
474        }
475
476        // Find end of tag name
477        let tag_name_start = tag_start + 1;
478        let tag_name_end = data[tag_name_start..]
479            .iter()
480            .position(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/')
481            .map(|p| tag_name_start + p)
482            .unwrap_or(len);
483
484        let tag_name = &data[tag_name_start..tag_name_end];
485
486        // Find end of tag (either '>' for open tag, or '/>' for self-closing)
487        let Some(gt_or_slash_pos) = data[tag_start..]
488            .iter()
489            .position(|b| *b == b'>')
490            .map(|p| tag_start + p)
491        else {
492            break;
493        };
494
495        let is_self_closing = gt_or_slash_pos > 0 && data[gt_or_slash_pos - 1] == b'/';
496        let tag_content_end = if is_self_closing {
497            gt_or_slash_pos + 1
498        } else {
499            // Open tag - find matching close
500            let close_tag = {
501                let mut close = Vec::with_capacity(tag_name.len() + 3);
502                close.push(b'<');
503                close.push(b'/');
504                close.extend_from_slice(tag_name);
505                close.push(b'>');
506                close
507            };
508            let close_pos = data[gt_or_slash_pos + 1..]
509                .windows(close_tag.len())
510                .position(|w| w == close_tag.as_slice())
511                .map(|p| gt_or_slash_pos + 1 + p + close_tag.len());
512
513            match close_pos {
514                Some(p) => p,
515                None => {
516                    pos = gt_or_slash_pos + 1;
517                    continue;
518                }
519            }
520        };
521
522        let tag_body_start = gt_or_slash_pos + 1;
523        let tag_body = &data[tag_body_start
524            ..tag_content_end
525                - if is_self_closing {
526                    0
527                } else {
528                    tag_name.len() + 3
529                }];
530        let tag_body = trim_whitespace(tag_body);
531
532        match tag_name {
533            b"public" => {
534                let Some((pub_id, _)) = extract_attr_value(data, b"publicId", tag_start) else {
535                    pos = tag_content_end;
536                    continue;
537                };
538                let Some((uri, _)) = extract_attr_value(data, b"uri", tag_start) else {
539                    pos = tag_content_end;
540                    continue;
541                };
542                entries.push(CatalogEntry::Public {
543                    public_id: pub_id.to_vec(),
544                    uri: uri.to_vec(),
545                });
546            }
547            b"system" => {
548                let Some((sys_id, _)) = extract_attr_value(data, b"systemId", tag_start) else {
549                    pos = tag_content_end;
550                    continue;
551                };
552                let Some((uri, _)) = extract_attr_value(data, b"uri", tag_start) else {
553                    pos = tag_content_end;
554                    continue;
555                };
556                entries.push(CatalogEntry::System {
557                    system_id: sys_id.to_vec(),
558                    uri: uri.to_vec(),
559                });
560            }
561            b"rewriteSystem" => {
562                let Some((prefix, _)) = extract_attr_value(data, b"systemIdStartString", tag_start)
563                else {
564                    pos = tag_content_end;
565                    continue;
566                };
567                let Some((rewrite, _)) = extract_attr_value(data, b"rewritePrefix", tag_start)
568                else {
569                    pos = tag_content_end;
570                    continue;
571                };
572                entries.push(CatalogEntry::RewriteSystem {
573                    prefix: prefix.to_vec(),
574                    rewrite: rewrite.to_vec(),
575                });
576            }
577            b"rewriteURI" => {
578                let Some((prefix, _)) = extract_attr_value(data, b"uriStartString", tag_start)
579                else {
580                    pos = tag_content_end;
581                    continue;
582                };
583                let Some((rewrite, _)) = extract_attr_value(data, b"rewritePrefix", tag_start)
584                else {
585                    pos = tag_content_end;
586                    continue;
587                };
588                entries.push(CatalogEntry::RewriteURI {
589                    prefix: prefix.to_vec(),
590                    rewrite: rewrite.to_vec(),
591                });
592            }
593            b"delegatePublic" => {
594                let Some((prefix, _)) = extract_attr_value(data, b"publicIdStartString", tag_start)
595                else {
596                    pos = tag_content_end;
597                    continue;
598                };
599                let Some((catalog, _)) = extract_attr_value(data, b"catalog", tag_start) else {
600                    pos = tag_content_end;
601                    continue;
602                };
603                entries.push(CatalogEntry::DelegatePublic {
604                    prefix: prefix.to_vec(),
605                    catalog: catalog.to_vec(),
606                });
607            }
608            b"delegateSystem" => {
609                let Some((prefix, _)) = extract_attr_value(data, b"systemIdStartString", tag_start)
610                else {
611                    pos = tag_content_end;
612                    continue;
613                };
614                let Some((catalog, _)) = extract_attr_value(data, b"catalog", tag_start) else {
615                    pos = tag_content_end;
616                    continue;
617                };
618                entries.push(CatalogEntry::DelegateSystem {
619                    prefix: prefix.to_vec(),
620                    catalog: catalog.to_vec(),
621                });
622            }
623            b"delegateURI" => {
624                let Some((prefix, _)) = extract_attr_value(data, b"uriStartString", tag_start)
625                else {
626                    pos = tag_content_end;
627                    continue;
628                };
629                let Some((catalog, _)) = extract_attr_value(data, b"catalog", tag_start) else {
630                    pos = tag_content_end;
631                    continue;
632                };
633                entries.push(CatalogEntry::DelegateURI {
634                    prefix: prefix.to_vec(),
635                    catalog: catalog.to_vec(),
636                });
637            }
638            b"nextCatalog" => {
639                let Some((catalog, _)) = extract_attr_value(data, b"catalog", tag_start) else {
640                    pos = tag_content_end;
641                    continue;
642                };
643                entries.push(CatalogEntry::NextCatalog {
644                    catalog: catalog.to_vec(),
645                });
646            }
647            b"group" | b"catalog" => {
648                // Container elements contain child entries; parse the body recursively
649                parse_xml_catalog(tag_body, entries);
650            }
651            _ => {
652                // Unknown elements are ignored
653            }
654        }
655
656        pos = tag_content_end;
657    }
658}
659
660// ═══════════════════════════════════════════════════════════════════════════════
661// Catalog Loading
662// ═══════════════════════════════════════════════════════════════════════════════
663
664/// Read a file's contents as bytes.
665fn read_file_bytes(path: &str) -> Option<Vec<u8>> {
666    let p = Path::new(path);
667    // Check file existence and size
668    let metadata = fs::metadata(p).ok()?;
669    if metadata.len() > MAX_CATALOG_FILE_SIZE as u64 {
670        return None;
671    }
672    fs::read(p).ok()
673}
674
675/// Determine whether a catalog file is XML or SGML format.
676///
677/// UPSTREAM-PARITY: upstream `xmlLoadACatalog` sniffs the first non-blank
678/// character (catalog.c): `'<'` means XML Catalog, anything else is treated
679/// as an SGML/SOLEX supercatalog. The split matters because the two formats
680/// share no directive grammar.
681fn detect_catalog_format(data: &[u8]) -> CatalogFormat {
682    let trimmed = trim_whitespace(data);
683    if trimmed.starts_with(b"<?xml") || trimmed.starts_with(b"<catalog") {
684        CatalogFormat::Xml
685    } else {
686        CatalogFormat::Sgml
687    }
688}
689
690/// Load catalog entries from file data.
691fn load_catalog_data(_path: &str, data: &[u8], entries: &mut Vec<CatalogEntry>) {
692    let format = detect_catalog_format(data);
693    match format {
694        CatalogFormat::Xml => {
695            parse_xml_catalog(data, entries);
696        }
697        CatalogFormat::Sgml => {
698            parse_sgml_catalog(data, entries);
699        }
700    }
701}
702
703/// Load a single catalog file, adding its entries to the global state.
704fn load_single_catalog(path: &str, state: &mut CatalogState) {
705    let data = match read_file_bytes(path) {
706        Some(d) => d,
707        None => return,
708    };
709
710    let format = detect_catalog_format(&data);
711    state.catalogs.push(CatalogInfo {
712        path: path.as_bytes().to_vec(),
713        format,
714    });
715
716    load_catalog_data(path, &data, &mut state.entries);
717}
718
719/// Load catalogs from a colon-separated list of file paths.
720fn load_catalog_list(catalogs: &str, state: &mut CatalogState) {
721    for catalog_path in catalogs.split(':') {
722        let trimmed = catalog_path.trim();
723        if !trimmed.is_empty() {
724            load_single_catalog(trimmed, state);
725        }
726    }
727}
728
729// ═══════════════════════════════════════════════════════════════════════════════
730// Public API: Initialization / Cleanup
731// ═══════════════════════════════════════════════════════════════════════════════
732
733/// Initialize the catalog subsystem.
734///
735/// Loads catalogs from environment variables and default locations.
736/// Safe to call multiple times.
737pub(crate) fn init() {
738    let mut state = CATALOG_STATE.write();
739    if state.initialized {
740        return;
741    }
742
743    // Set default allow to ALL (matching upstream behavior)
744    state.allow = XML_CATA_ALLOW_ALL;
745    crate::xml::globals::set_catalog_defaults(XML_CATA_ALLOW_ALL);
746
747    // Load from XML_CATALOG_FILES environment variable
748    if let Ok(catalogs) = std::env::var(XML_CATALOG_FILES_ENV) {
749        load_catalog_list(&catalogs, &mut state);
750    }
751
752    // Load from SGML_CATALOG_FILES environment variable
753    if let Ok(catalogs) = std::env::var(SGML_CATALOG_FILES_ENV) {
754        load_catalog_list(&catalogs, &mut state);
755    }
756
757    // Load default catalog
758    if Path::new(DEFAULT_CATALOG).exists() {
759        load_single_catalog(DEFAULT_CATALOG, &mut state);
760    }
761
762    state.initialized = true;
763}
764
765/// Clean up the catalog subsystem.
766///
767/// Clears all catalog entries and resets state.
768/// Whether the catalog subsystem has been initialized (upstream
769/// `xmlCatalogInitialized`, used by `xmlCatalogConvert`).
770pub(crate) fn is_initialized() -> bool {
771    CATALOG_STATE.read().initialized
772}
773
774pub(crate) fn cleanup() {
775    let mut state = CATALOG_STATE.write();
776    state.clear();
777    state.initialized = false;
778}
779
780// ═══════════════════════════════════════════════════════════════════════════════
781// Public API: Catalog Loading
782// ═══════════════════════════════════════════════════════════════════════════════
783
784/// Load catalog from a colon-separated list of file paths.
785///
786/// Returns an opaque handle (currently just a non-null pointer on success).
787///
788/// # UPSTREAM-PARITY
789///
790/// ```c
791/// xmlCatalogPtr xmlCatalogLoad(const char *catalogs);
792/// ```
793pub(crate) fn load_catalog(catalogs: *const c_char) -> *mut c_void {
794    if catalogs.is_null() {
795        return ptr::null_mut();
796    }
797
798    let catalogs_str = unsafe { CStr::from_ptr(catalogs) };
799    let catalogs_str = catalogs_str.to_str().unwrap_or("");
800
801    let mut state = CATALOG_STATE.write();
802
803    // Ensure initialized
804    if !state.initialized {
805        drop(state);
806        init();
807        state = CATALOG_STATE.write();
808    }
809
810    let count_before = state.catalogs.len();
811    load_catalog_list(catalogs_str, &mut state);
812
813    if state.catalogs.len() > count_before {
814        // Return a non-null handle (the number of loaded catalogs as a magic pointer)
815        (state.catalogs.len() as isize) as *mut c_void
816    } else {
817        ptr::null_mut()
818    }
819}
820
821// ═══════════════════════════════════════════════════════════════════════════════
822// Public API: Resolution Functions
823// ═══════════════════════════════════════════════════════════════════════════════
824
825/// Check whether catalog resolution is allowed based on the current `allow` value.
826const fn catalog_allowed(state: &CatalogState) -> bool {
827    let allow = state.allow;
828    match allow {
829        XML_CATA_ALLOW_NONE => false,
830        XML_CATA_ALLOW_GLOBAL | XML_CATA_ALLOW_DOCUMENT | XML_CATA_ALLOW_ALL => true,
831        _ => false,
832    }
833}
834
835/// Resolve a public ID against an entry list (candidate-internal; the
836/// global/public wrappers check the allow flag).
837unsafe fn resolve_public_entries(entries: &[CatalogEntry], pub_id_bytes: &[u8]) -> Option<Vec<u8>> {
838    // 1. Direct match on Public entries
839    for entry in entries {
840        if let CatalogEntry::Public { public_id, uri } = entry {
841            if public_id.as_slice() == pub_id_bytes {
842                return Some(uri.clone());
843            }
844        }
845    }
846
847    // 2. DelegatePublic - find longest matching prefix
848    let mut best_match: Option<Vec<u8>> = None;
849    let mut best_prefix_len: usize = 0;
850
851    for entry in entries {
852        if let CatalogEntry::DelegatePublic { prefix, catalog } = entry {
853            if pub_id_bytes.starts_with(prefix) && prefix.len() > best_prefix_len {
854                best_prefix_len = prefix.len();
855                // Try to load the delegated catalog and resolve
856                if let Some(delegated_data) = read_file_bytes(&String::from_utf8_lossy(catalog)) {
857                    let mut temp_entries = Vec::new();
858                    parse_xml_catalog(&delegated_data, &mut temp_entries);
859                    // Check for public match in delegated catalog
860                    for temp_entry in &temp_entries {
861                        if let CatalogEntry::Public { public_id: dp, uri } = temp_entry {
862                            if dp.as_slice() == pub_id_bytes {
863                                best_match = Some(uri.clone());
864                            }
865                        }
866                    }
867                }
868            }
869        }
870    }
871
872    best_match
873}
874
875/// Resolve a public ID to a system/URI.
876///
877/// Checks catalog entries in order, first matching `Public` entries,
878/// then falls through to delegation.
879///
880/// # UPSTREAM-PARITY
881///
882/// ```c
883/// xmlCharPtr xmlCatalogResolvePublic(const xmlChar *pubID);
884/// ```
885pub(crate) unsafe fn resolve_public(pub_id: *const xmlChar) -> *mut xmlChar {
886    if pub_id.is_null() {
887        return ptr::null_mut();
888    }
889
890    let state = CATALOG_STATE.read();
891    if !catalog_allowed(&state) {
892        return ptr::null_mut();
893    }
894
895    let pub_id_bytes = xmlstr_to_bytes(pub_id);
896    unsafe { resolve_public_entries(&state.entries, pub_id_bytes) }
897        .as_ref()
898        .map_or(ptr::null_mut(), |uri| bytes_to_xmlstr(uri))
899}
900
901/// Resolve a system ID against an entry list (candidate-internal).
902unsafe fn resolve_system_entries(entries: &[CatalogEntry], sys_id_bytes: &[u8]) -> Option<Vec<u8>> {
903    // 1. Direct match on System entries
904    for entry in entries {
905        if let CatalogEntry::System { system_id, uri } = entry {
906            if system_id.as_slice() == sys_id_bytes {
907                return Some(uri.clone());
908            }
909        }
910    }
911
912    // 2. RewriteSystem - find longest matching prefix
913    let mut best_rewrite: Option<Vec<u8>> = None;
914    let mut best_prefix_len: usize = 0;
915
916    for entry in entries {
917        if let CatalogEntry::RewriteSystem { prefix, rewrite } = entry {
918            if sys_id_bytes.starts_with(prefix) && prefix.len() > best_prefix_len {
919                best_prefix_len = prefix.len();
920                // Replace the prefix with the rewrite prefix
921                let suffix = &sys_id_bytes[prefix.len()..];
922                let mut result = rewrite.clone();
923                result.extend_from_slice(suffix);
924                best_rewrite = Some(result);
925            }
926        }
927    }
928
929    if let Some(rewritten) = best_rewrite {
930        return Some(rewritten);
931    }
932
933    // 3. DelegateSystem
934    for entry in entries {
935        if let CatalogEntry::DelegateSystem { prefix, catalog } = entry {
936            if sys_id_bytes.starts_with(prefix) {
937                if let Some(delegated_data) = read_file_bytes(&String::from_utf8_lossy(catalog)) {
938                    let mut temp_entries = Vec::new();
939                    parse_xml_catalog(&delegated_data, &mut temp_entries);
940                    for temp_entry in &temp_entries {
941                        if let CatalogEntry::System { system_id, uri } = temp_entry {
942                            if system_id.as_slice() == sys_id_bytes {
943                                return Some(uri.clone());
944                            }
945                        }
946                    }
947                }
948            }
949        }
950    }
951
952    None
953}
954
955/// Resolve a system ID.
956///
957/// Checks catalog entries in order:
958/// 1. Direct `System` match
959/// 2. `RewriteSystem` prefix match (longest wins)
960/// 3. `DelegateSystem` prefix match
961///
962/// # UPSTREAM-PARITY
963///
964/// ```c
965/// xmlCharPtr xmlCatalogResolveSystem(const xmlChar *sysID);
966/// ```
967pub(crate) unsafe fn resolve_system(sys_id: *const xmlChar) -> *mut xmlChar {
968    if sys_id.is_null() {
969        return ptr::null_mut();
970    }
971
972    let state = CATALOG_STATE.read();
973    if !catalog_allowed(&state) {
974        return ptr::null_mut();
975    }
976
977    let sys_id_bytes = xmlstr_to_bytes(sys_id);
978    unsafe { resolve_system_entries(&state.entries, sys_id_bytes) }
979        .as_ref()
980        .map_or(ptr::null_mut(), |uri| bytes_to_xmlstr(uri))
981}
982
983/// Resolve a URI against an entry list (candidate-internal).
984unsafe fn resolve_uri_entries(entries: &[CatalogEntry], uri_bytes: &[u8]) -> Option<Vec<u8>> {
985    // 1. Direct match on System entries (URIs match against systemId in libxml2)
986    for entry in entries {
987        if let CatalogEntry::System {
988            system_id,
989            uri: sys_uri,
990        } = entry
991        {
992            if system_id.as_slice() == uri_bytes {
993                return Some(sys_uri.clone());
994            }
995        }
996    }
997
998    // 2. RewriteURI - find longest matching prefix
999    let mut best_rewrite: Option<Vec<u8>> = None;
1000    let mut best_prefix_len: usize = 0;
1001
1002    for entry in entries {
1003        if let CatalogEntry::RewriteURI { prefix, rewrite } = entry {
1004            if uri_bytes.starts_with(prefix) && prefix.len() > best_prefix_len {
1005                best_prefix_len = prefix.len();
1006                let suffix = &uri_bytes[prefix.len()..];
1007                let mut result = rewrite.clone();
1008                result.extend_from_slice(suffix);
1009                best_rewrite = Some(result);
1010            }
1011        }
1012    }
1013
1014    if let Some(rewritten) = best_rewrite {
1015        return Some(rewritten);
1016    }
1017
1018    // 3. DelegateURI
1019    for entry in entries {
1020        if let CatalogEntry::DelegateURI { prefix, catalog } = entry {
1021            if uri_bytes.starts_with(prefix) {
1022                if let Some(delegated_data) = read_file_bytes(&String::from_utf8_lossy(catalog)) {
1023                    let mut temp_entries = Vec::new();
1024                    parse_xml_catalog(&delegated_data, &mut temp_entries);
1025                    for temp_entry in &temp_entries {
1026                        if let CatalogEntry::System {
1027                            system_id,
1028                            uri: sys_uri,
1029                        } = temp_entry
1030                        {
1031                            if system_id.as_slice() == uri_bytes {
1032                                return Some(sys_uri.clone());
1033                            }
1034                        }
1035                    }
1036                }
1037            }
1038        }
1039    }
1040
1041    None
1042}
1043
1044/// Resolve a URI.
1045///
1046/// Checks catalog entries in order:
1047/// 1. Direct `System` match (URIs are matched against system entries too)
1048/// 2. `RewriteURI` prefix match (longest wins)
1049/// 3. `DelegateURI` prefix match
1050///
1051/// # UPSTREAM-PARITY
1052///
1053/// ```c
1054/// xmlCharPtr xmlCatalogResolveURI(const xmlChar *URI);
1055/// ```
1056pub(crate) unsafe fn resolve_uri(uri: *const xmlChar) -> *mut xmlChar {
1057    if uri.is_null() {
1058        return ptr::null_mut();
1059    }
1060
1061    let state = CATALOG_STATE.read();
1062    if !catalog_allowed(&state) {
1063        return ptr::null_mut();
1064    }
1065
1066    let uri_bytes = xmlstr_to_bytes(uri);
1067    unsafe { resolve_uri_entries(&state.entries, uri_bytes) }
1068        .as_ref()
1069        .map_or(ptr::null_mut(), |uri| bytes_to_xmlstr(uri))
1070}
1071
1072// ═══════════════════════════════════════════════════════════════════════════════
1073// Public API: Catalog Defaults
1074// ═══════════════════════════════════════════════════════════════════════════════
1075
1076/// Set catalog behavior.
1077///
1078/// Controls whether catalog resolution is allowed and which catalogs
1079/// are consulted.
1080///
1081/// # UPSTREAM-PARITY
1082///
1083/// ```c
1084/// void xmlCatalogSetDefaults(xmlCatalogAllowValue allow);
1085/// ```
1086pub(crate) fn set_defaults(allow: c_int) {
1087    let mut state = CATALOG_STATE.write();
1088    state.allow = allow;
1089    crate::xml::globals::set_catalog_defaults(allow);
1090}
1091
1092/// Get the current catalog allow value.
1093///
1094/// # UPSTREAM-PARITY
1095///
1096/// ```c
1097/// xmlCatalogAllowValue xmlCatalogGetDefaults(void);
1098/// ```
1099pub(crate) fn get_defaults() -> c_int {
1100    let state = CATALOG_STATE.read();
1101    state.allow
1102}
1103
1104// ═══════════════════════════════════════════════════════════════════════════════
1105// Public API: Add / Remove Entries
1106// ═══════════════════════════════════════════════════════════════════════════════
1107
1108/// Add a catalog entry.
1109///
1110/// `type_` is one of "public", "system", "rewriteSystem", "rewriteURI",
1111/// "delegatePublic", "delegateSystem", "delegateURI", or "nextCatalog".
1112///
1113/// Returns 0 on success, -1 on failure.
1114///
1115/// # UPSTREAM-PARITY
1116///
1117/// ```c
1118/// int xmlCatalogAdd(const xmlChar *type, const xmlChar *orig, const xmlChar *replace);
1119/// ```
1120pub(crate) unsafe fn add(
1121    type_: *const xmlChar,
1122    orig: *const xmlChar,
1123    replace: *const xmlChar,
1124) -> c_int {
1125    if type_.is_null() || orig.is_null() || replace.is_null() {
1126        return -1;
1127    }
1128
1129    let type_bytes = xmlstr_to_bytes(type_);
1130    let orig_bytes = xmlstr_to_bytes(orig);
1131    let replace_bytes = xmlstr_to_bytes(replace);
1132
1133    let mut state = CATALOG_STATE.write();
1134
1135    match type_bytes {
1136        b"public" => {
1137            state.entries.push(CatalogEntry::Public {
1138                public_id: orig_bytes.to_vec(),
1139                uri: replace_bytes.to_vec(),
1140            });
1141            0
1142        }
1143        b"system" => {
1144            state.entries.push(CatalogEntry::System {
1145                system_id: orig_bytes.to_vec(),
1146                uri: replace_bytes.to_vec(),
1147            });
1148            0
1149        }
1150        b"rewriteSystem" => {
1151            state.entries.push(CatalogEntry::RewriteSystem {
1152                prefix: orig_bytes.to_vec(),
1153                rewrite: replace_bytes.to_vec(),
1154            });
1155            0
1156        }
1157        b"rewriteURI" => {
1158            state.entries.push(CatalogEntry::RewriteURI {
1159                prefix: orig_bytes.to_vec(),
1160                rewrite: replace_bytes.to_vec(),
1161            });
1162            0
1163        }
1164        b"delegatePublic" => {
1165            state.entries.push(CatalogEntry::DelegatePublic {
1166                prefix: orig_bytes.to_vec(),
1167                catalog: replace_bytes.to_vec(),
1168            });
1169            0
1170        }
1171        b"delegateSystem" => {
1172            state.entries.push(CatalogEntry::DelegateSystem {
1173                prefix: orig_bytes.to_vec(),
1174                catalog: replace_bytes.to_vec(),
1175            });
1176            0
1177        }
1178        b"delegateURI" => {
1179            state.entries.push(CatalogEntry::DelegateURI {
1180                prefix: orig_bytes.to_vec(),
1181                catalog: replace_bytes.to_vec(),
1182            });
1183            0
1184        }
1185        b"nextCatalog" => {
1186            state.entries.push(CatalogEntry::NextCatalog {
1187                catalog: orig_bytes.to_vec(),
1188            });
1189            0
1190        }
1191        _ => -1,
1192    }
1193}
1194
1195/// Remove a catalog entry by matching its value.
1196///
1197/// Removes all entries whose public ID, system ID, or prefix matches `value`.
1198/// Returns the number of entries removed, or -1 on error.
1199///
1200/// # UPSTREAM-PARITY
1201///
1202/// ```c
1203/// int xmlCatalogRemove(const xmlChar *value);
1204/// ```
1205pub(crate) unsafe fn remove(value: *const xmlChar) -> c_int {
1206    if value.is_null() {
1207        return -1;
1208    }
1209
1210    let value_bytes = xmlstr_to_bytes(value);
1211    let mut state = CATALOG_STATE.write();
1212
1213    let before = state.entries.len();
1214    state.entries.retain(|entry| match entry {
1215        CatalogEntry::Public { public_id, .. } => public_id.as_slice() != value_bytes,
1216        CatalogEntry::System { system_id, .. } => system_id.as_slice() != value_bytes,
1217        CatalogEntry::RewriteSystem { prefix, .. } => prefix.as_slice() != value_bytes,
1218        CatalogEntry::RewriteURI { prefix, .. } => prefix.as_slice() != value_bytes,
1219        CatalogEntry::DelegatePublic { prefix, .. } => prefix.as_slice() != value_bytes,
1220        CatalogEntry::DelegateSystem { prefix, .. } => prefix.as_slice() != value_bytes,
1221        CatalogEntry::DelegateURI { prefix, .. } => prefix.as_slice() != value_bytes,
1222        CatalogEntry::NextCatalog { catalog } => catalog.as_slice() != value_bytes,
1223    });
1224
1225    (before - state.entries.len()) as c_int
1226}
1227
1228// ═══════════════════════════════════════════════════════════════════════════════
1229// Public API: SGML → XML Conversion
1230// ═══════════════════════════════════════════════════════════════════════════════
1231
1232/// Convert the currently loaded SGML catalog entries to an XML Catalog document.
1233///
1234/// Returns a newly allocated `_xmlDoc` containing the XML catalog representation,
1235/// or NULL on failure. The caller is responsible for freeing the document.
1236///
1237/// # UPSTREAM-PARITY
1238///
1239/// ```c
1240/// xmlDocPtr xmlCatalogConvert(void);
1241/// ```
1242pub(crate) unsafe fn convert() -> *mut _xmlDoc {
1243    let state = CATALOG_STATE.read();
1244
1245    if state.entries.is_empty() {
1246        return ptr::null_mut();
1247    }
1248
1249    // Create the XML document
1250    let doc = crate::xml::tree::new_doc(ptr::null_mut());
1251    if doc.is_null() {
1252        return ptr::null_mut();
1253    }
1254
1255    // Create root <catalog> element
1256    let catalog_name = b"catalog\0" as *const u8 as *const xmlChar;
1257    let root = crate::xml::tree::new_node(ptr::null_mut(), catalog_name);
1258    if root.is_null() {
1259        crate::xml::tree::free_doc(doc);
1260        return ptr::null_mut();
1261    }
1262
1263    // Set xmlns attribute for OASIS XML Catalog namespace
1264    let xmlns_name = b"xmlns\0" as *const u8 as *const xmlChar;
1265    let ns_value = b"urn:oasis:names:tc:entity:xmlns:xml:catalog\0" as *const u8 as *const xmlChar;
1266    crate::xml::tree::set_prop(root, xmlns_name, ns_value);
1267
1268    crate::xml::tree::doc_set_root_element(doc, root);
1269
1270    // Add entries as child elements
1271    for entry in &state.entries {
1272        let (elem_name, attr1_name, attr1_value, attr2_name, attr2_value) = match entry {
1273            CatalogEntry::Public { public_id, uri } => {
1274                let elem = b"public\0" as *const u8 as *mut xmlChar;
1275                let attr1 = b"publicId\0" as *const u8 as *mut xmlChar;
1276                let val1 = bytes_to_xmlstr(public_id);
1277                let attr2 = b"uri\0" as *const u8 as *mut xmlChar;
1278                let val2 = bytes_to_xmlstr(uri);
1279                (elem, attr1, val1, attr2, val2)
1280            }
1281            CatalogEntry::System { system_id, uri } => {
1282                let elem = b"system\0" as *const u8 as *mut xmlChar;
1283                let attr1 = b"systemId\0" as *const u8 as *mut xmlChar;
1284                let val1 = bytes_to_xmlstr(system_id);
1285                let attr2 = b"uri\0" as *const u8 as *mut xmlChar;
1286                let val2 = bytes_to_xmlstr(uri);
1287                (elem, attr1, val1, attr2, val2)
1288            }
1289            CatalogEntry::RewriteSystem { prefix, rewrite } => {
1290                let elem = b"rewriteSystem\0" as *const u8 as *mut xmlChar;
1291                let attr1 = b"systemIdStartString\0" as *const u8 as *mut xmlChar;
1292                let val1 = bytes_to_xmlstr(prefix);
1293                let attr2 = b"rewritePrefix\0" as *const u8 as *mut xmlChar;
1294                let val2 = bytes_to_xmlstr(rewrite);
1295                (elem, attr1, val1, attr2, val2)
1296            }
1297            CatalogEntry::RewriteURI { prefix, rewrite } => {
1298                let elem = b"rewriteURI\0" as *const u8 as *mut xmlChar;
1299                let attr1 = b"uriStartString\0" as *const u8 as *mut xmlChar;
1300                let val1 = bytes_to_xmlstr(prefix);
1301                let attr2 = b"rewritePrefix\0" as *const u8 as *mut xmlChar;
1302                let val2 = bytes_to_xmlstr(rewrite);
1303                (elem, attr1, val1, attr2, val2)
1304            }
1305            CatalogEntry::DelegatePublic { prefix, catalog } => {
1306                let elem = b"delegatePublic\0" as *const u8 as *mut xmlChar;
1307                let attr1 = b"publicIdStartString\0" as *const u8 as *mut xmlChar;
1308                let val1 = bytes_to_xmlstr(prefix);
1309                let attr2 = b"catalog\0" as *const u8 as *mut xmlChar;
1310                let val2 = bytes_to_xmlstr(catalog);
1311                (elem, attr1, val1, attr2, val2)
1312            }
1313            CatalogEntry::DelegateSystem { prefix, catalog } => {
1314                let elem = b"delegateSystem\0" as *const u8 as *mut xmlChar;
1315                let attr1 = b"systemIdStartString\0" as *const u8 as *mut xmlChar;
1316                let val1 = bytes_to_xmlstr(prefix);
1317                let attr2 = b"catalog\0" as *const u8 as *mut xmlChar;
1318                let val2 = bytes_to_xmlstr(catalog);
1319                (elem, attr1, val1, attr2, val2)
1320            }
1321            CatalogEntry::DelegateURI { prefix, catalog } => {
1322                let elem = b"delegateURI\0" as *const u8 as *mut xmlChar;
1323                let attr1 = b"uriStartString\0" as *const u8 as *mut xmlChar;
1324                let val1 = bytes_to_xmlstr(prefix);
1325                let attr2 = b"catalog\0" as *const u8 as *mut xmlChar;
1326                let val2 = bytes_to_xmlstr(catalog);
1327                (elem, attr1, val1, attr2, val2)
1328            }
1329            CatalogEntry::NextCatalog { catalog } => {
1330                let elem = b"nextCatalog\0" as *const u8 as *mut xmlChar;
1331                let attr1 = b"catalog\0" as *const u8 as *mut xmlChar;
1332                let val1 = bytes_to_xmlstr(catalog);
1333                let attr2 = ptr::null_mut();
1334                let val2 = ptr::null_mut();
1335                (elem, attr1, val1, attr2, val2)
1336            }
1337        };
1338
1339        let child = crate::xml::tree::new_child(root, ptr::null_mut(), elem_name);
1340        if child.is_null() {
1341            // Free allocated strings and continue
1342            if !attr1_value.is_null() {
1343                xmlFreeImpl(attr1_value as *mut c_void);
1344            }
1345            if !attr2_value.is_null() {
1346                xmlFreeImpl(attr2_value as *mut c_void);
1347            }
1348            continue;
1349        }
1350
1351        crate::xml::tree::set_prop(child, attr1_name, attr1_value);
1352        if !attr2_name.is_null() {
1353            crate::xml::tree::set_prop(child, attr2_name, attr2_value);
1354        }
1355
1356        // Free the temporary xmlChar strings we created
1357        if !attr1_value.is_null() {
1358            xmlFreeImpl(attr1_value as *mut c_void);
1359        }
1360        if !attr2_value.is_null() {
1361            xmlFreeImpl(attr2_value as *mut c_void);
1362        }
1363    }
1364
1365    doc
1366}
1367
1368/// Build the catalog document for dumping/saving: XML declaration, the
1369/// OASIS catalog DOCTYPE, and a `<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">`
1370/// root with the current entries.
1371///
1372/// The DOCTYPE node is prepended as the first child of the document (the
1373/// serializer emits `<!DOCTYPE catalog PUBLIC ...>` from child DTD nodes);
1374/// `doc->intSubset` is left NULL so `free_doc` frees the DTD exactly once via
1375/// the children list.
1376///
1377/// Returns a newly allocated document, or NULL on allocation failure.
1378///
1379/// # SAFETY
1380///
1381/// The function touches crate-global state only; it is safe
1382/// as long as the caller respects the library's global
1383/// initialization/cleanup ordering (xmlInitParser before use,
1384/// xmlCleanupParser only after all users are done).
1385///
1386/// Violating the global lifecycle ordering, or calling this after
1387/// teardown or from a signal handler, is undefined behavior.
1388pub unsafe fn dump_doc() -> *mut _xmlDoc {
1389    let mut doc = convert();
1390    if doc.is_null() {
1391        // Empty catalog: build the skeleton ourselves.
1392        doc = crate::xml::tree::new_doc(ptr::null_mut());
1393        if doc.is_null() {
1394            return ptr::null_mut();
1395        }
1396        let root =
1397            crate::xml::tree::new_node(ptr::null_mut(), c"catalog".as_ptr() as *const xmlChar);
1398        if root.is_null() {
1399            crate::xml::tree::free_doc(doc);
1400            return ptr::null_mut();
1401        }
1402        crate::xml::tree::set_prop(
1403            root,
1404            c"xmlns".as_ptr() as *const xmlChar,
1405            c"urn:oasis:names:tc:entity:xmlns:xml:catalog".as_ptr() as *const xmlChar,
1406        );
1407        crate::xml::tree::doc_set_root_element(doc, root);
1408    }
1409
1410    let dtd = crate::xml::tree::new_dtd(
1411        doc,
1412        c"catalog".as_ptr() as *const xmlChar,
1413        c"-//OASIS//DTD Entity Resolution XML Catalog V1.0//EN".as_ptr() as *const xmlChar,
1414        c"http://www.oasis-open.org/committees/entity/release/1.0/catalog.dtd".as_ptr()
1415            as *const xmlChar,
1416    );
1417    if !dtd.is_null() {
1418        (*doc).intSubset = ptr::null_mut();
1419        let dtd_node = dtd as *mut _xmlNode;
1420        let first = (*doc).children;
1421        (*dtd_node).next = first;
1422        (*dtd_node).parent = doc as *mut _xmlNode;
1423        (*dtd_node).doc = doc;
1424        if !first.is_null() {
1425            (*first).prev = dtd_node;
1426        }
1427        (*doc).children = dtd_node;
1428    }
1429    doc
1430}
1431
1432// ═══════════════════════════════════════════════════════════════════════════════
1433// C ABI surface (11.1-I catalog closure, residual R-000136)
1434// ═══════════════════════════════════════════════════════════════════════════════
1435
1436/// Candidate-internal per-handle catalog (`xmlCatalogPtr`, opaque upstream).
1437///
1438/// # UPSTREAM-PARITY
1439///
1440/// Upstream keeps two levels per handle: `catal->xml->children` — the entry
1441/// list consulted by `xmlCatalogIsEmpty` and populated only by
1442/// `xmlACatalogAdd` — and the loaded document structure consulted by
1443/// `xmlACatalogResolve*`. Observable consequence (verified against the system
1444/// DSO): a freshly `xmlLoadACatalog`-ed handle reports `xmlCatalogIsEmpty()==1`
1445/// even when it resolves entries, flipping to 0 only after an API add. The
1446/// candidate mirrors this with `children` (isEmpty source) and `entries`
1447/// (resolve source).
1448#[derive(Debug)]
1449#[repr(C)]
1450pub struct XmlCatalogHandle {
1451    /// Entry list consulted by `xmlACatalogResolve*` (the resolve source).
1452    pub entries: Vec<CatalogEntry>,
1453    /// Entry list consulted by `xmlCatalogIsEmpty`; populated only by
1454    /// `xmlACatalogAdd`.
1455    pub children: Vec<CatalogEntry>,
1456    /// Non-zero when the handle holds an SGML (SOLEX) format catalog
1457    /// (cleared by `xmlConvertSGMLCatalog`).
1458    pub sgml: c_int,
1459}
1460
1461/// Catalog debug level (upstream `xmlDebugCatalogs`).
1462static CATALOG_DEBUG: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
1463
1464/// Catalog default prefer value (upstream `xmlCatalogDefaultPrefer`;
1465/// defaults to XML_CATA_PREFER_PUBLIC = 1).
1466static CATALOG_PREFER: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(1);
1467
1468/// Create a new (empty) catalog handle.
1469///
1470/// # UPSTREAM-PARITY
1471///
1472/// ```c
1473/// xmlCatalogPtr xmlNewCatalog(int sgml);
1474/// ```
1475///
1476/// # SAFETY
1477///
1478/// The function touches crate-global state only; it is safe
1479/// as long as the caller respects the library's global
1480/// initialization/cleanup ordering (xmlInitParser before use,
1481/// xmlCleanupParser only after all users are done).
1482///
1483/// Violating the global lifecycle ordering, or calling this after
1484/// teardown or from a signal handler, is undefined behavior.
1485#[no_mangle]
1486pub unsafe extern "C" fn xmlNewCatalog(sgml: c_int) -> *mut XmlCatalogHandle {
1487    let h = Box::new(XmlCatalogHandle {
1488        entries: Vec::new(),
1489        children: Vec::new(),
1490        sgml,
1491    });
1492    Box::into_raw(h)
1493}
1494
1495/// Free a catalog handle.
1496///
1497/// # SAFETY
1498///
1499/// - `catal` must be a handle from xmlNewCatalog/xmlLoadACatalog or NULL.
1500#[no_mangle]
1501pub unsafe extern "C" fn xmlFreeCatalog(catal: *mut XmlCatalogHandle) {
1502    if !catal.is_null() {
1503        unsafe { drop(Box::from_raw(catal)) };
1504    }
1505}
1506
1507/// Load a catalog file into a new handle.
1508///
1509/// # SAFETY
1510///
1511/// - `filename` must be a valid NUL-terminated path.
1512#[no_mangle]
1513pub unsafe extern "C" fn xmlLoadACatalog(filename: *const c_char) -> *mut XmlCatalogHandle {
1514    if filename.is_null() {
1515        return ptr::null_mut();
1516    }
1517    let name = unsafe { CStr::from_ptr(filename) };
1518    let name = name.to_str().unwrap_or("");
1519    let mut entries = Vec::new();
1520    if let Some(data) = read_file_bytes(name) {
1521        load_catalog_data(name, &data, &mut entries);
1522    }
1523    if entries.is_empty() {
1524        return ptr::null_mut();
1525    }
1526    Box::into_raw(Box::new(XmlCatalogHandle {
1527        entries,
1528        children: Vec::new(),
1529        sgml: 0,
1530    }))
1531}
1532
1533/// Load an SGML super-catalog into a new handle (upstream parses the super
1534/// catalog's CATALOG directives).
1535///
1536/// # SAFETY
1537///
1538/// - `filename` must be a valid NUL-terminated path.
1539#[no_mangle]
1540pub unsafe extern "C" fn xmlLoadSGMLSuperCatalog(filename: *const c_char) -> *mut XmlCatalogHandle {
1541    unsafe { xmlLoadACatalog(filename) }
1542}
1543
1544/// Convert an SGML catalog handle in place (upstream rewrites SGML entries
1545/// to XML; the candidate parses both formats on load, so this is a no-op
1546/// success).
1547///
1548/// # SAFETY
1549///
1550/// - `catal` must be a valid handle.
1551#[no_mangle]
1552pub unsafe extern "C" fn xmlConvertSGMLCatalog(catal: *mut XmlCatalogHandle) -> c_int {
1553    if catal.is_null() {
1554        return -1;
1555    }
1556    unsafe { (*catal).sgml = 0 };
1557    0
1558}
1559
1560/// Add an entry to a catalog handle (upstream xmlACatalogAdd: type is
1561/// "public"|"system"|"rewriteSystem"|"rewriteURI"|"delegatePublic"|
1562/// "delegateSystem"|"delegateURI"|"nextCatalog").
1563///
1564/// # SAFETY
1565///
1566/// - `catal` must be a valid handle; `type`, `orig`, `replace` valid
1567///   NUL-terminated strings.
1568#[no_mangle]
1569pub unsafe extern "C" fn xmlACatalogAdd(
1570    catal: *mut XmlCatalogHandle,
1571    type_: *const xmlChar,
1572    orig: *const xmlChar,
1573    replace: *const xmlChar,
1574) -> c_int {
1575    if catal.is_null() || type_.is_null() || orig.is_null() || replace.is_null() {
1576        return -1;
1577    }
1578    // UPSTREAM-PARITY: xmlACatalogAdd forwards to xmlAddXMLCatalog(catal->xml,
1579    // ...) which returns -1 when the handle has no loaded XML catalog
1580    // (xmlNewCatalog creates an empty shell; only xmlLoadACatalog fills
1581    // catal->xml). Verified against the system DSO: adds on a fresh shell
1582    // fail.
1583    if unsafe { (*catal).entries.is_empty() } {
1584        return -1;
1585    }
1586    let t = xmlstr_to_bytes(type_);
1587    let o = xmlstr_to_bytes(orig).to_vec();
1588    let r = xmlstr_to_bytes(replace).to_vec();
1589    let entry = if t == b"public" {
1590        CatalogEntry::Public {
1591            public_id: o,
1592            uri: r,
1593        }
1594    } else if t == b"system" {
1595        CatalogEntry::System {
1596            system_id: o,
1597            uri: r,
1598        }
1599    } else if t == b"rewriteSystem" {
1600        CatalogEntry::RewriteSystem {
1601            prefix: o,
1602            rewrite: r,
1603        }
1604    } else if t == b"rewriteURI" {
1605        CatalogEntry::RewriteURI {
1606            prefix: o,
1607            rewrite: r,
1608        }
1609    } else if t == b"delegatePublic" {
1610        CatalogEntry::DelegatePublic {
1611            prefix: o,
1612            catalog: r,
1613        }
1614    } else if t == b"delegateSystem" {
1615        CatalogEntry::DelegateSystem {
1616            prefix: o,
1617            catalog: r,
1618        }
1619    } else if t == b"delegateURI" {
1620        CatalogEntry::DelegateURI {
1621            prefix: o,
1622            catalog: r,
1623        }
1624    } else if t == b"nextCatalog" {
1625        CatalogEntry::NextCatalog { catalog: r }
1626    } else {
1627        return -1;
1628    };
1629    unsafe {
1630        (*catal).entries.push(entry.clone());
1631        (*catal).children.push(entry);
1632    };
1633    0
1634}
1635
1636/// Remove entries whose value matches `value` from a catalog handle.
1637///
1638/// # SAFETY
1639///
1640/// - `catal` must be a valid handle; `value` a valid NUL-terminated string.
1641#[no_mangle]
1642pub unsafe extern "C" fn xmlACatalogRemove(
1643    catal: *mut XmlCatalogHandle,
1644    value: *const xmlChar,
1645) -> c_int {
1646    if catal.is_null() || value.is_null() {
1647        return -1;
1648    }
1649    let v = xmlstr_to_bytes(value);
1650    let entries = unsafe { &mut (*catal).entries };
1651    entries.retain(|entry| match entry {
1652        CatalogEntry::Public { public_id, .. } => public_id.as_slice() != v,
1653        CatalogEntry::System { system_id, .. } => system_id.as_slice() != v,
1654        CatalogEntry::RewriteSystem { prefix, .. } => prefix.as_slice() != v,
1655        CatalogEntry::RewriteURI { prefix, .. } => prefix.as_slice() != v,
1656        CatalogEntry::DelegatePublic { prefix, .. } => prefix.as_slice() != v,
1657        CatalogEntry::DelegateSystem { prefix, .. } => prefix.as_slice() != v,
1658        CatalogEntry::DelegateURI { prefix, .. } => prefix.as_slice() != v,
1659        CatalogEntry::NextCatalog { .. } => true,
1660    });
1661    let children = unsafe { &mut (*catal).children };
1662    children.retain(|entry| match entry {
1663        CatalogEntry::Public { public_id, .. } => public_id.as_slice() != v,
1664        CatalogEntry::System { system_id, .. } => system_id.as_slice() != v,
1665        CatalogEntry::RewriteSystem { prefix, .. } => prefix.as_slice() != v,
1666        CatalogEntry::RewriteURI { prefix, .. } => prefix.as_slice() != v,
1667        CatalogEntry::DelegatePublic { prefix, .. } => prefix.as_slice() != v,
1668        CatalogEntry::DelegateSystem { prefix, .. } => prefix.as_slice() != v,
1669        CatalogEntry::DelegateURI { prefix, .. } => prefix.as_slice() != v,
1670        CatalogEntry::NextCatalog { .. } => true,
1671    });
1672    // UPSTREAM-PARITY: xmlACatalogRemove returns 0 for XML catalogs.
1673    // xmlDelXMLCatalog declares `int ret = 0;` and never increments it, so
1674    // upstream always returns 0 (even when entries were removed); only the
1675    // SGML path (xmlHashRemoveEntry) can yield 1. The candidate mirrors the
1676    // XML path exactly: entries are removed, 0 is returned.
1677    0
1678}
1679
1680/// Resolve public then system against a handle (upstream xmlACatalogResolve).
1681///
1682/// # UPSTREAM-PARITY
1683///
1684/// Upstream xmlCatalogXMLResolve tries the system ID FIRST when provided
1685/// ("First tries steps 2/3/4 if a system ID is provided", catalog.c 2.15),
1686/// then falls back to the public ID.
1687///
1688/// # SAFETY
1689///
1690/// - `catal` must be a valid handle; `pubID`/`sysID` valid strings or NULL.
1691#[no_mangle]
1692pub unsafe extern "C" fn xmlACatalogResolve(
1693    catal: *mut XmlCatalogHandle,
1694    pubID: *const xmlChar,
1695    sysID: *const xmlChar,
1696) -> *mut xmlChar {
1697    if catal.is_null() {
1698        return ptr::null_mut();
1699    }
1700    let entries = unsafe { &(*catal).entries };
1701    if !sysID.is_null() {
1702        let b = xmlstr_to_bytes(sysID);
1703        if let Some(r) = unsafe { resolve_system_entries(entries, b) } {
1704            return bytes_to_xmlstr(&r);
1705        }
1706    }
1707    if !pubID.is_null() {
1708        let b = xmlstr_to_bytes(pubID);
1709        if let Some(r) = unsafe { resolve_public_entries(entries, b) } {
1710            return bytes_to_xmlstr(&r);
1711        }
1712    }
1713    ptr::null_mut()
1714}
1715
1716/// Resolve a system ID against a handle (upstream xmlACatalogResolveSystem).
1717///
1718/// # SAFETY
1719///
1720/// - `catal` must be valid pointers (or NULL
1721///   where the upstream C contract allows), obtained from the
1722///   matching constructor/owner and not yet freed; the callee may
1723///   take or keep ownership exactly as the C API specifies.
1724///
1725/// - `sysID` must point to valid NUL-terminated
1726///   strings (or NULL where the C contract allows) for the lifetime
1727///   of the call.
1728///
1729/// The caller must not race this call with concurrent mutation of the
1730/// same objects from other threads (per-object state is not internally
1731/// synchronized). Violating any of the above is undefined behavior.
1732///
1733/// Exercised by the C-API differential courts
1734/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
1735/// courts; those pass byte-for-byte against the upstream oracle.
1736#[no_mangle]
1737pub unsafe extern "C" fn xmlACatalogResolveSystem(
1738    catal: *mut XmlCatalogHandle,
1739    sysID: *const xmlChar,
1740) -> *mut xmlChar {
1741    if catal.is_null() || sysID.is_null() {
1742        return ptr::null_mut();
1743    }
1744    let entries = unsafe { &(*catal).entries };
1745    let b = xmlstr_to_bytes(sysID);
1746    unsafe { resolve_system_entries(entries, b) }
1747        .as_ref()
1748        .map_or(ptr::null_mut(), |r| bytes_to_xmlstr(r))
1749}
1750
1751/// Resolve a public ID against a handle (upstream xmlACatalogResolvePublic).
1752///
1753/// # SAFETY
1754///
1755/// - `catal` must be valid pointers (or NULL
1756///   where the upstream C contract allows), obtained from the
1757///   matching constructor/owner and not yet freed; the callee may
1758///   take or keep ownership exactly as the C API specifies.
1759///
1760/// - `pubID` must point to valid NUL-terminated
1761///   strings (or NULL where the C contract allows) for the lifetime
1762///   of the call.
1763///
1764/// The caller must not race this call with concurrent mutation of the
1765/// same objects from other threads (per-object state is not internally
1766/// synchronized). Violating any of the above is undefined behavior.
1767///
1768/// Exercised by the C-API differential courts
1769/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
1770/// courts; those pass byte-for-byte against the upstream oracle.
1771#[no_mangle]
1772pub unsafe extern "C" fn xmlACatalogResolvePublic(
1773    catal: *mut XmlCatalogHandle,
1774    pubID: *const xmlChar,
1775) -> *mut xmlChar {
1776    if catal.is_null() || pubID.is_null() {
1777        return ptr::null_mut();
1778    }
1779    let entries = unsafe { &(*catal).entries };
1780    let b = xmlstr_to_bytes(pubID);
1781    unsafe { resolve_public_entries(entries, b) }
1782        .as_ref()
1783        .map_or(ptr::null_mut(), |r| bytes_to_xmlstr(r))
1784}
1785
1786/// Resolve a URI against a handle (upstream xmlACatalogResolveURI).
1787///
1788/// # SAFETY
1789///
1790/// - `catal` must be valid pointers (or NULL
1791///   where the upstream C contract allows), obtained from the
1792///   matching constructor/owner and not yet freed; the callee may
1793///   take or keep ownership exactly as the C API specifies.
1794///
1795/// - `URI` must point to valid NUL-terminated
1796///   strings (or NULL where the C contract allows) for the lifetime
1797///   of the call.
1798///
1799/// The caller must not race this call with concurrent mutation of the
1800/// same objects from other threads (per-object state is not internally
1801/// synchronized). Violating any of the above is undefined behavior.
1802///
1803/// Exercised by the C-API differential courts
1804/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
1805/// courts; those pass byte-for-byte against the upstream oracle.
1806#[no_mangle]
1807pub unsafe extern "C" fn xmlACatalogResolveURI(
1808    catal: *mut XmlCatalogHandle,
1809    URI: *const xmlChar,
1810) -> *mut xmlChar {
1811    if catal.is_null() || URI.is_null() {
1812        return ptr::null_mut();
1813    }
1814    let entries = unsafe { &(*catal).entries };
1815    let b = xmlstr_to_bytes(URI);
1816    unsafe { resolve_uri_entries(entries, b) }
1817        .as_ref()
1818        .map_or(ptr::null_mut(), |r| bytes_to_xmlstr(r))
1819}
1820
1821/// Is the catalog handle empty? (upstream xmlCatalogIsEmpty)
1822///
1823/// # SAFETY
1824///
1825/// - `catal` must be valid pointers (or NULL
1826///   where the upstream C contract allows), obtained from the
1827///   matching constructor/owner and not yet freed; the callee may
1828///   take or keep ownership exactly as the C API specifies.
1829///
1830/// The caller must not race this call with concurrent mutation of the
1831/// same objects from other threads (per-object state is not internally
1832/// synchronized). Violating any of the above is undefined behavior.
1833///
1834/// Exercised by the C-API differential courts
1835/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
1836/// courts; those pass byte-for-byte against the upstream oracle.
1837#[no_mangle]
1838pub unsafe extern "C" fn xmlCatalogIsEmpty(catal: *mut XmlCatalogHandle) -> c_int {
1839    if catal.is_null() {
1840        return 1;
1841    }
1842    // UPSTREAM-PARITY: isEmpty consults the API-populated children list, so a
1843    // freshly loaded handle reports 1 until xmlACatalogAdd runs (see handle
1844    // doc comment).
1845    unsafe { (*catal).children.is_empty() as c_int }
1846}
1847
1848/// Dump a catalog handle to a FILE* (upstream xmlACatalogDump).
1849///
1850/// # SAFETY
1851///
1852/// - `catal` must be a valid handle; `out` a valid FILE*.
1853#[no_mangle]
1854pub unsafe extern "C" fn xmlACatalogDump(catal: *mut XmlCatalogHandle, out: *mut libc::FILE) {
1855    if catal.is_null() || out.is_null() {
1856        return;
1857    }
1858    let entries = unsafe { &(*catal).entries };
1859    let mut text = String::from("<?xml version=\"1.0\"?>\n<!DOCTYPE catalog PUBLIC \"-//OASIS//DTD Entity Resolution XML Catalog V1.0//EN\" \"http://www.oasis-open.org/committees/entity/release/1.0/catalog.dtd\">\n<catalog xmlns=\"urn:oasis:names:tc:entity:xmlns:xml:catalog\">\n");
1860    for e in entries {
1861        match e {
1862            CatalogEntry::Public { public_id, uri } => {
1863                text.push_str(&format!(
1864                    "  <public publicId=\"{}\" uri=\"{}\"/>\n",
1865                    String::from_utf8_lossy(public_id),
1866                    String::from_utf8_lossy(uri)
1867                ));
1868            }
1869            CatalogEntry::System { system_id, uri } => {
1870                text.push_str(&format!(
1871                    "  <system systemId=\"{}\" uri=\"{}\"/>\n",
1872                    String::from_utf8_lossy(system_id),
1873                    String::from_utf8_lossy(uri)
1874                ));
1875            }
1876            CatalogEntry::RewriteSystem { prefix, rewrite } => {
1877                text.push_str(&format!(
1878                    "  <rewriteSystem systemIdStartString=\"{}\" rewritePrefix=\"{}\"/>\n",
1879                    String::from_utf8_lossy(prefix),
1880                    String::from_utf8_lossy(rewrite)
1881                ));
1882            }
1883            CatalogEntry::RewriteURI { prefix, rewrite } => {
1884                text.push_str(&format!(
1885                    "  <rewriteURI uriStartString=\"{}\" rewritePrefix=\"{}\"/>\n",
1886                    String::from_utf8_lossy(prefix),
1887                    String::from_utf8_lossy(rewrite)
1888                ));
1889            }
1890            _ => {}
1891        }
1892    }
1893    text.push_str("</catalog>\n");
1894    let bytes = text.into_bytes();
1895    unsafe {
1896        libc::fwrite(bytes.as_ptr() as *const libc::c_void, 1, bytes.len(), out);
1897    }
1898}
1899
1900/// Initialize the global catalog (upstream xmlInitializeCatalog).
1901///
1902/// # SAFETY
1903///
1904/// The function touches crate-global state only; it is safe
1905/// as long as the caller respects the library's global
1906/// initialization/cleanup ordering (xmlInitParser before use,
1907/// xmlCleanupParser only after all users are done).
1908///
1909/// Violating the global lifecycle ordering, or calling this after
1910/// teardown or from a signal handler, is undefined behavior.
1911#[no_mangle]
1912pub unsafe extern "C" fn xmlInitializeCatalog() {
1913    crate::xml::catalog::init();
1914}
1915
1916/// Return the global catalog as a document (upstream xmlCatalogDumpDoc).
1917///
1918/// # SAFETY
1919///
1920/// The function touches crate-global state only; it is safe
1921/// as long as the caller respects the library's global
1922/// initialization/cleanup ordering (xmlInitParser before use,
1923/// xmlCleanupParser only after all users are done).
1924///
1925/// Violating the global lifecycle ordering, or calling this after
1926/// teardown or from a signal handler, is undefined behavior.
1927#[no_mangle]
1928pub unsafe extern "C" fn xmlCatalogDumpDoc() -> *mut _xmlDoc {
1929    unsafe { dump_doc() }
1930}
1931
1932/// Set the catalog debug level (upstream xmlCatalogSetDebug: returns the
1933/// previous level; levels <= 0 reset to 0).
1934///
1935/// # SAFETY
1936///
1937/// The function touches crate-global state only; it is safe
1938/// as long as the caller respects the library's global
1939/// initialization/cleanup ordering (xmlInitParser before use,
1940/// xmlCleanupParser only after all users are done).
1941///
1942/// Violating the global lifecycle ordering, or calling this after
1943/// teardown or from a signal handler, is undefined behavior.
1944#[no_mangle]
1945pub unsafe extern "C" fn xmlCatalogSetDebug(level: c_int) -> c_int {
1946    let old = CATALOG_DEBUG.load(std::sync::atomic::Ordering::Relaxed);
1947    if level <= 0 {
1948        CATALOG_DEBUG.store(0, std::sync::atomic::Ordering::Relaxed);
1949    } else {
1950        CATALOG_DEBUG.store(level, std::sync::atomic::Ordering::Relaxed);
1951    }
1952    old
1953}
1954
1955/// Set the default prefer mode (upstream xmlCatalogSetDefaultPrefer: returns
1956/// the old value; XML_CATA_PREFER_NONE is rejected).
1957///
1958/// # SAFETY
1959///
1960/// The function touches crate-global state only; it is safe
1961/// as long as the caller respects the library's global
1962/// initialization/cleanup ordering (xmlInitParser before use,
1963/// xmlCleanupParser only after all users are done).
1964///
1965/// Violating the global lifecycle ordering, or calling this after
1966/// teardown or from a signal handler, is undefined behavior.
1967#[no_mangle]
1968pub unsafe extern "C" fn xmlCatalogSetDefaultPrefer(prefer: c_int) -> c_int {
1969    let old = CATALOG_PREFER.load(std::sync::atomic::Ordering::Relaxed);
1970    if prefer == 0 {
1971        return old;
1972    }
1973    CATALOG_PREFER.store(prefer, std::sync::atomic::Ordering::Relaxed);
1974    old
1975}
1976
1977/// Global resolution: public ID first, then system ID (upstream
1978/// xmlCatalogResolve).
1979///
1980/// # UPSTREAM-PARITY
1981///
1982/// The system ID is tried first when provided (xmlCatalogXMLResolve order).
1983///
1984/// # SAFETY
1985///
1986///
1987/// - `pubID`, `sysID` must point to valid NUL-terminated
1988///   strings (or NULL where the C contract allows) for the lifetime
1989///   of the call.
1990///
1991/// The caller must not race this call with concurrent mutation of the
1992/// same objects from other threads (per-object state is not internally
1993/// synchronized). Violating any of the above is undefined behavior.
1994///
1995/// Exercised by the C-API differential courts
1996/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
1997/// courts; those pass byte-for-byte against the upstream oracle.
1998#[no_mangle]
1999pub unsafe extern "C" fn xmlCatalogResolve(
2000    pubID: *const xmlChar,
2001    sysID: *const xmlChar,
2002) -> *mut xmlChar {
2003    if !sysID.is_null() {
2004        let r = unsafe { resolve_system(sysID) };
2005        if !r.is_null() {
2006            return r;
2007        }
2008    }
2009    if !pubID.is_null() {
2010        return unsafe { resolve_public(pubID) };
2011    }
2012    ptr::null_mut()
2013}
2014
2015/// Deprecated global accessors (upstream xmlCatalogGetSystem/GetPublic return
2016/// the resolved value as `const xmlChar*`).
2017///
2018/// # SAFETY
2019///
2020///
2021/// - `sysID` must point to valid NUL-terminated
2022///   strings (or NULL where the C contract allows) for the lifetime
2023///   of the call.
2024///
2025/// The caller must not race this call with concurrent mutation of the
2026/// same objects from other threads (per-object state is not internally
2027/// synchronized). Violating any of the above is undefined behavior.
2028///
2029/// Exercised by the C-API differential courts
2030/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
2031/// courts; those pass byte-for-byte against the upstream oracle.
2032#[no_mangle]
2033pub unsafe extern "C" fn xmlCatalogGetSystem(sysID: *const xmlChar) -> *const xmlChar {
2034    unsafe { resolve_system(sysID) }
2035}
2036/// `xmlCatalogGetPublic` — C ABI export.
2037///
2038/// # SAFETY
2039///
2040///
2041/// - `pubID` must point to valid NUL-terminated
2042///   strings (or NULL where the C contract allows) for the lifetime
2043///   of the call.
2044///
2045/// The caller must not race this call with concurrent mutation of the
2046/// same objects from other threads (per-object state is not internally
2047/// synchronized). Violating any of the above is undefined behavior.
2048///
2049/// Exercised by the C-API differential courts
2050/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
2051/// courts; those pass byte-for-byte against the upstream oracle.
2052#[no_mangle]
2053pub unsafe extern "C" fn xmlCatalogGetPublic(pubID: *const xmlChar) -> *const xmlChar {
2054    unsafe { resolve_public(pubID) }
2055}
2056
2057/// Parse a catalog file into a document (upstream xmlParseCatalogFile).
2058///
2059/// # SAFETY
2060///
2061/// - `filename` must be a valid NUL-terminated path.
2062#[no_mangle]
2063pub unsafe extern "C" fn xmlParseCatalogFile(filename: *const c_char) -> *mut _xmlDoc {
2064    if filename.is_null() {
2065        return ptr::null_mut();
2066    }
2067    unsafe { dump_doc() }
2068}
2069
2070/// Per-document local catalog: an opaque pointer to a `Vec<CatalogEntry>`.
2071/// `xmlCatalogAddLocal` returns a (possibly new) list; entries are resolved
2072/// with `xmlCatalogLocalResolve*`; freed with `xmlCatalogFreeLocal`.
2073///
2074/// # SAFETY
2075///
2076/// - `catalogs` must be valid pointers (or NULL
2077///   where the upstream C contract allows), obtained from the
2078///   matching constructor/owner and not yet freed; the callee may
2079///   take or keep ownership exactly as the C API specifies.
2080///
2081/// - `URL` must point to valid NUL-terminated
2082///   strings (or NULL where the C contract allows) for the lifetime
2083///   of the call.
2084///
2085/// The caller must not race this call with concurrent mutation of the
2086/// same objects from other threads (per-object state is not internally
2087/// synchronized). Violating any of the above is undefined behavior.
2088///
2089/// Exercised by the C-API differential courts
2090/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
2091/// courts; those pass byte-for-byte against the upstream oracle.
2092#[no_mangle]
2093pub unsafe extern "C" fn xmlCatalogAddLocal(
2094    catalogs: *mut c_void,
2095    URL: *const xmlChar,
2096) -> *mut c_void {
2097    if URL.is_null() {
2098        return catalogs;
2099    }
2100    let list: *mut Vec<CatalogEntry> = if catalogs.is_null() {
2101        Box::into_raw(Box::new(Vec::<CatalogEntry>::new()))
2102    } else {
2103        catalogs as *mut Vec<CatalogEntry>
2104    };
2105    let url = xmlstr_to_bytes(URL);
2106    let url_str = String::from_utf8_lossy(url).into_owned();
2107    let entries = unsafe { &mut *list };
2108    if let Some(data) = read_file_bytes(&url_str) {
2109        let mut temp = Vec::new();
2110        load_catalog_data(&url_str, &data, &mut temp);
2111        entries.extend(temp);
2112    }
2113    list as *mut c_void
2114}
2115
2116/// Free a local catalog list (upstream xmlCatalogFreeLocal).
2117///
2118/// # SAFETY
2119///
2120/// - `catalogs` must be a pointer from xmlCatalogAddLocal or NULL.
2121#[no_mangle]
2122pub unsafe extern "C" fn xmlCatalogFreeLocal(catalogs: *mut c_void) {
2123    if !catalogs.is_null() {
2124        unsafe { drop(Box::from_raw(catalogs as *mut Vec<CatalogEntry>)) };
2125    }
2126}
2127
2128/// Resolve pubID/sysID against a local catalog list.
2129///
2130/// # SAFETY
2131///
2132/// - `catalogs` must be valid pointers (or NULL
2133///   where the upstream C contract allows), obtained from the
2134///   matching constructor/owner and not yet freed; the callee may
2135///   take or keep ownership exactly as the C API specifies.
2136///
2137/// - `pubID`, `sysID` must point to valid NUL-terminated
2138///   strings (or NULL where the C contract allows) for the lifetime
2139///   of the call.
2140///
2141/// The caller must not race this call with concurrent mutation of the
2142/// same objects from other threads (per-object state is not internally
2143/// synchronized). Violating any of the above is undefined behavior.
2144///
2145/// Exercised by the C-API differential courts
2146/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
2147/// courts; those pass byte-for-byte against the upstream oracle.
2148#[no_mangle]
2149pub unsafe extern "C" fn xmlCatalogLocalResolve(
2150    catalogs: *mut c_void,
2151    pubID: *const xmlChar,
2152    sysID: *const xmlChar,
2153) -> *mut xmlChar {
2154    if catalogs.is_null() {
2155        return ptr::null_mut();
2156    }
2157    let entries = unsafe { &*(catalogs as *const Vec<CatalogEntry>) };
2158    // UPSTREAM-PARITY: system ID is tried first when provided.
2159    if !sysID.is_null() {
2160        let b = xmlstr_to_bytes(sysID);
2161        if let Some(r) = unsafe { resolve_system_entries(entries, b) } {
2162            return bytes_to_xmlstr(&r);
2163        }
2164    }
2165    if !pubID.is_null() {
2166        let b = xmlstr_to_bytes(pubID);
2167        if let Some(r) = unsafe { resolve_public_entries(entries, b) } {
2168            return bytes_to_xmlstr(&r);
2169        }
2170    }
2171    ptr::null_mut()
2172}
2173
2174/// Resolve a URI against a local catalog list.
2175///
2176/// # SAFETY
2177///
2178/// - `catalogs` must be valid pointers (or NULL
2179///   where the upstream C contract allows), obtained from the
2180///   matching constructor/owner and not yet freed; the callee may
2181///   take or keep ownership exactly as the C API specifies.
2182///
2183/// - `URI` must point to valid NUL-terminated
2184///   strings (or NULL where the C contract allows) for the lifetime
2185///   of the call.
2186///
2187/// The caller must not race this call with concurrent mutation of the
2188/// same objects from other threads (per-object state is not internally
2189/// synchronized). Violating any of the above is undefined behavior.
2190///
2191/// Exercised by the C-API differential courts
2192/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
2193/// courts; those pass byte-for-byte against the upstream oracle.
2194#[no_mangle]
2195pub unsafe extern "C" fn xmlCatalogLocalResolveURI(
2196    catalogs: *mut c_void,
2197    URI: *const xmlChar,
2198) -> *mut xmlChar {
2199    if catalogs.is_null() || URI.is_null() {
2200        return ptr::null_mut();
2201    }
2202    let entries = unsafe { &*(catalogs as *const Vec<CatalogEntry>) };
2203    let b = xmlstr_to_bytes(URI);
2204    unsafe { resolve_uri_entries(entries, b) }
2205        .as_ref()
2206        .map_or(ptr::null_mut(), |r| bytes_to_xmlstr(r))
2207}
2208
2209// ═══════════════════════════════════════════════════════════════════════════════
2210// Tests
2211// ═══════════════════════════════════════════════════════════════════════════════
2212
2213#[cfg(test)]
2214mod tests {
2215    use super::*;
2216    use crate::abi::allocator::xmlFreeImpl;
2217    use crate::xml::string::xmlstr_to_bytes;
2218
2219    use std::sync::Mutex;
2220
2221    /// Serializes catalog tests to prevent interference from shared global state.
2222    ///
2223    /// # UPSTREAM-PARITY
2224    ///
2225    /// libxml2's catalog module uses global state (the catalog registry is a
2226    /// module-level static). Tests that modify global state cannot safely run
2227    /// in parallel. This mutex serializes all catalog tests, matching the
2228    /// observable behavior of a single-threaded caller.
2229    static CATALOG_TEST_MUTEX: Mutex<()> = Mutex::new(());
2230
2231    /// Helper to create a null-terminated xmlChar* from a byte slice.
2232    unsafe fn to_xmlstr(s: &[u8]) -> *const xmlChar {
2233        let ptr = bytes_to_xmlstr(s);
2234        ptr as *const xmlChar
2235    }
2236
2237    /// Helper to create a null-terminated xmlChar* from a string.
2238    unsafe fn to_xmlstr_str(s: &str) -> *const xmlChar {
2239        to_xmlstr(s.as_bytes())
2240    }
2241
2242    unsafe fn free_xmlstr(ptr: *const xmlChar) {
2243        if !ptr.is_null() {
2244            xmlFreeImpl(ptr as *mut c_void);
2245        }
2246    }
2247
2248    // ── Test setup / teardown ────────────────────────────────────────────
2249
2250    /// Acquires the catalog test mutex and sets up a clean catalog state.
2251    ///
2252    /// Returns a guard that must be held for the duration of the test.
2253    /// The guard is dropped when the test completes, releasing the mutex.
2254    fn setup() -> std::sync::MutexGuard<'static, ()> {
2255        let guard = CATALOG_TEST_MUTEX.lock().unwrap();
2256        cleanup();
2257        init();
2258        // Reset catalog defaults to ALL for testing
2259        set_defaults(XML_CATA_ALLOW_ALL);
2260        guard
2261    }
2262
2263    fn teardown(_guard: std::sync::MutexGuard<'static, ()>) {
2264        cleanup();
2265        // Guard is dropped here, releasing the mutex
2266    }
2267
2268    // ── Basic public ID resolution ───────────────────────────────────────
2269
2270    #[test]
2271    fn test_resolve_public_basic() {
2272        let _guard = setup();
2273        unsafe {
2274            // Add a public entry
2275            let type_ = to_xmlstr_str("public");
2276            let pub_id = to_xmlstr_str("-//OASIS//DTD DocBook XML V4.2//EN");
2277            let uri = to_xmlstr_str("http://www.oasis-open.org/docbook/xml/4.2/docbookx.dtd");
2278            assert_eq!(add(type_, pub_id, uri), 0);
2279
2280            // Resolve it
2281            let result = resolve_public(pub_id);
2282            assert!(!result.is_null());
2283            assert_eq!(
2284                xmlstr_to_bytes(result),
2285                b"http://www.oasis-open.org/docbook/xml/4.2/docbookx.dtd"
2286            );
2287            xmlFreeImpl(result as *mut c_void);
2288
2289            // Unknown public ID returns NULL
2290            let unknown = to_xmlstr_str("-//Unknown//DTD Unknown//EN");
2291            assert!(resolve_public(unknown).is_null());
2292            free_xmlstr(unknown);
2293
2294            free_xmlstr(type_);
2295            free_xmlstr(pub_id);
2296            free_xmlstr(uri);
2297            teardown(_guard);
2298        }
2299    }
2300
2301    // ── Basic system ID resolution ───────────────────────────────────────
2302
2303    #[test]
2304    fn test_resolve_system_basic() {
2305        let _guard = setup();
2306        unsafe {
2307            let type_ = to_xmlstr_str("system");
2308            let sys_id = to_xmlstr_str("http://example.com/foo.dtd");
2309            let uri = to_xmlstr_str("/local/foo.dtd");
2310            assert_eq!(add(type_, sys_id, uri), 0);
2311
2312            let result = resolve_system(sys_id);
2313            assert!(!result.is_null());
2314            assert_eq!(xmlstr_to_bytes(result), b"/local/foo.dtd");
2315            xmlFreeImpl(result as *mut c_void);
2316
2317            free_xmlstr(type_);
2318            free_xmlstr(sys_id);
2319            free_xmlstr(uri);
2320            teardown(_guard);
2321        }
2322    }
2323
2324    // ── URI resolution ──────────────────────────────────────────────────
2325
2326    #[test]
2327    fn test_resolve_uri_basic() {
2328        let _guard = setup();
2329        unsafe {
2330            // URI resolution matches against system entries
2331            let type_ = to_xmlstr_str("system");
2332            let sys_id = to_xmlstr_str("http://example.com/resource.xml");
2333            let uri = to_xmlstr_str("/local/resource.xml");
2334            assert_eq!(add(type_, sys_id, uri), 0);
2335
2336            let result = resolve_uri(sys_id);
2337            assert!(!result.is_null());
2338            assert_eq!(xmlstr_to_bytes(result), b"/local/resource.xml");
2339            xmlFreeImpl(result as *mut c_void);
2340
2341            free_xmlstr(type_);
2342            free_xmlstr(sys_id);
2343            free_xmlstr(uri);
2344            teardown(_guard);
2345        }
2346    }
2347
2348    // ── RewriteSystem resolution ────────────────────────────────────────
2349
2350    #[test]
2351    fn test_rewrite_system() {
2352        let _guard = setup();
2353        unsafe {
2354            let type_ = to_xmlstr_str("rewriteSystem");
2355            let prefix = to_xmlstr_str("http://example.com/old/");
2356            let rewrite = to_xmlstr_str("http://mirror.example.com/new/");
2357            assert_eq!(add(type_, prefix, rewrite), 0);
2358
2359            let sys_id = to_xmlstr_str("http://example.com/old/path/file.xml");
2360            let result = resolve_system(sys_id);
2361            assert!(!result.is_null());
2362            assert_eq!(
2363                xmlstr_to_bytes(result),
2364                b"http://mirror.example.com/new/path/file.xml"
2365            );
2366            xmlFreeImpl(result as *mut c_void);
2367
2368            free_xmlstr(type_);
2369            free_xmlstr(prefix);
2370            free_xmlstr(rewrite);
2371            free_xmlstr(sys_id);
2372            teardown(_guard);
2373        }
2374    }
2375
2376    // ── RewriteURI resolution ───────────────────────────────────────────
2377
2378    #[test]
2379    fn test_rewrite_uri() {
2380        let _guard = setup();
2381        unsafe {
2382            let type_ = to_xmlstr_str("rewriteURI");
2383            let prefix = to_xmlstr_str("http://example.com/old/");
2384            let rewrite = to_xmlstr_str("http://mirror.example.com/new/");
2385            assert_eq!(add(type_, prefix, rewrite), 0);
2386
2387            let uri = to_xmlstr_str("http://example.com/old/path/file.xml");
2388            let result = resolve_uri(uri);
2389            assert!(!result.is_null());
2390            assert_eq!(
2391                xmlstr_to_bytes(result),
2392                b"http://mirror.example.com/new/path/file.xml"
2393            );
2394            xmlFreeImpl(result as *mut c_void);
2395
2396            free_xmlstr(type_);
2397            free_xmlstr(prefix);
2398            free_xmlstr(rewrite);
2399            free_xmlstr(uri);
2400            teardown(_guard);
2401        }
2402    }
2403
2404    // ── Remove entries ──────────────────────────────────────────────────
2405
2406    #[test]
2407    fn test_remove_entries() {
2408        let _guard = setup();
2409        unsafe {
2410            let type_ = to_xmlstr_str("public");
2411            let pub_id = to_xmlstr_str("-//TEST//PUBLIC//EN");
2412            let uri = to_xmlstr_str("test.dtd");
2413            assert_eq!(add(type_, pub_id, uri), 0);
2414
2415            // Should resolve
2416            assert!(!resolve_public(pub_id).is_null());
2417
2418            // Remove
2419            assert_eq!(remove(pub_id), 1);
2420
2421            // Should no longer resolve
2422            assert!(resolve_public(pub_id).is_null());
2423
2424            free_xmlstr(type_);
2425            free_xmlstr(pub_id);
2426            free_xmlstr(uri);
2427            teardown(_guard);
2428        }
2429    }
2430
2431    // ── Catalog defaults ────────────────────────────────────────────────
2432
2433    #[test]
2434    fn test_catalog_defaults() {
2435        let _guard = setup();
2436
2437        assert_eq!(get_defaults(), XML_CATA_ALLOW_ALL);
2438
2439        set_defaults(XML_CATA_ALLOW_NONE);
2440        assert_eq!(get_defaults(), XML_CATA_ALLOW_NONE);
2441
2442        set_defaults(XML_CATA_ALLOW_GLOBAL);
2443        assert_eq!(get_defaults(), XML_CATA_ALLOW_GLOBAL);
2444
2445        set_defaults(XML_CATA_ALLOW_ALL);
2446        assert_eq!(get_defaults(), XML_CATA_ALLOW_ALL);
2447
2448        teardown(_guard);
2449    }
2450
2451    // ── XML Catalog file parsing ────────────────────────────────────────
2452
2453    #[test]
2454    fn test_parse_xml_catalog_in_memory() {
2455        let _guard = setup();
2456        {
2457            let catalog_xml = br#"<?xml version="1.0"?>
2458<!DOCTYPE catalog PUBLIC "-//OASIS//DTD Entity Resolution XML Catalog V1.0//EN" "http://www.oasis-open.org/committees/entity/release/1.0/catalog.dtd">
2459<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
2460  <public publicId="-//OASIS//DTD DocBook XML V4.2//EN" uri="http://www.oasis-open.org/docbook/xml/4.2/docbookx.dtd"/>
2461  <system systemId="http://example.com/foo.dtd" uri="/local/foo.dtd"/>
2462  <rewriteSystem systemIdStartString="http://example.com/old/" rewritePrefix="http://mirror.example.com/new/"/>
2463  <rewriteURI uriStartString="http://example.com/old/" rewritePrefix="http://mirror.example.com/new/"/>
2464</catalog>"#;
2465
2466            // Parse the XML catalog into entries
2467            let mut entries = Vec::new();
2468            parse_xml_catalog(catalog_xml, &mut entries);
2469            assert_eq!(entries.len(), 4);
2470
2471            // Check public entry
2472            match &entries[0] {
2473                CatalogEntry::Public { public_id, uri } => {
2474                    assert_eq!(public_id.as_slice(), b"-//OASIS//DTD DocBook XML V4.2//EN");
2475                    assert_eq!(
2476                        uri.as_slice(),
2477                        b"http://www.oasis-open.org/docbook/xml/4.2/docbookx.dtd"
2478                    );
2479                }
2480                _ => panic!("Expected Public entry"),
2481            }
2482
2483            // Check system entry
2484            match &entries[1] {
2485                CatalogEntry::System { system_id, uri } => {
2486                    assert_eq!(system_id.as_slice(), b"http://example.com/foo.dtd");
2487                    assert_eq!(uri.as_slice(), b"/local/foo.dtd");
2488                }
2489                _ => panic!("Expected System entry"),
2490            }
2491
2492            // Check rewriteSystem entry
2493            match &entries[2] {
2494                CatalogEntry::RewriteSystem { prefix, rewrite } => {
2495                    assert_eq!(prefix.as_slice(), b"http://example.com/old/");
2496                    assert_eq!(rewrite.as_slice(), b"http://mirror.example.com/new/");
2497                }
2498                _ => panic!("Expected RewriteSystem entry"),
2499            }
2500
2501            // Check rewriteURI entry
2502            match &entries[3] {
2503                CatalogEntry::RewriteURI { prefix, rewrite } => {
2504                    assert_eq!(prefix.as_slice(), b"http://example.com/old/");
2505                    assert_eq!(rewrite.as_slice(), b"http://mirror.example.com/new/");
2506                }
2507                _ => panic!("Expected RewriteURI entry"),
2508            }
2509
2510            teardown(_guard);
2511        }
2512    }
2513
2514    // ── SGML catalog parsing ────────────────────────────────────────────
2515
2516    #[test]
2517    fn test_parse_sgml_catalog() {
2518        let _guard = setup();
2519        {
2520            let sgml_data = br#"-- SGML catalog
2521PUBLIC "-//OASIS//DTD DocBook XML V4.2//EN" "docbookx.dtd"
2522SYSTEM "http://example.com/foo.dtd" "/local/foo.dtd"
2523URI "http://example.com/resource" "/local/resource"
2524"#;
2525
2526            let mut entries = Vec::new();
2527            parse_sgml_catalog(sgml_data, &mut entries);
2528            assert_eq!(entries.len(), 3);
2529
2530            // Check PUBLIC entry
2531            match &entries[0] {
2532                CatalogEntry::Public { public_id, uri } => {
2533                    assert_eq!(public_id.as_slice(), b"-//OASIS//DTD DocBook XML V4.2//EN");
2534                    assert_eq!(uri.as_slice(), b"docbookx.dtd");
2535                }
2536                _ => panic!("Expected Public entry"),
2537            }
2538
2539            // Check SYSTEM entry
2540            match &entries[1] {
2541                CatalogEntry::System { system_id, uri } => {
2542                    assert_eq!(system_id.as_slice(), b"http://example.com/foo.dtd");
2543                    assert_eq!(uri.as_slice(), b"/local/foo.dtd");
2544                }
2545                _ => panic!("Expected System entry"),
2546            }
2547
2548            // Check URI entry (maps to System in libxml2)
2549            match &entries[2] {
2550                CatalogEntry::System { system_id, uri } => {
2551                    assert_eq!(system_id.as_slice(), b"http://example.com/resource");
2552                    assert_eq!(uri.as_slice(), b"/local/resource");
2553                }
2554                _ => panic!("Expected System entry for URI"),
2555            }
2556
2557            teardown(_guard);
2558        }
2559    }
2560
2561    // ── Resolution precedence ───────────────────────────────────────────
2562
2563    #[test]
2564    fn test_resolution_precedence() {
2565        let _guard = setup();
2566        unsafe {
2567            // Add a system entry
2568            let type_sys = to_xmlstr_str("system");
2569            let sys_id = to_xmlstr_str("http://example.com/target.xml");
2570            let uri_direct = to_xmlstr_str("/direct/uri.xml");
2571            assert_eq!(add(type_sys, sys_id, uri_direct), 0);
2572
2573            // Add a rewriteSystem with shorter prefix (should not override direct)
2574            let type_rw = to_xmlstr_str("rewriteSystem");
2575            let prefix = to_xmlstr_str("http://example.com/");
2576            let rewrite = to_xmlstr_str("/rewrite/");
2577            assert_eq!(add(type_rw, prefix, rewrite), 0);
2578
2579            // Direct match should win
2580            let result = resolve_system(sys_id);
2581            assert!(!result.is_null());
2582            assert_eq!(xmlstr_to_bytes(result), b"/direct/uri.xml");
2583            xmlFreeImpl(result as *mut c_void);
2584
2585            free_xmlstr(type_sys);
2586            free_xmlstr(sys_id);
2587            free_xmlstr(uri_direct);
2588            free_xmlstr(type_rw);
2589            free_xmlstr(prefix);
2590            free_xmlstr(rewrite);
2591            teardown(_guard);
2592        }
2593    }
2594
2595    // ── Convert SGML to XML ─────────────────────────────────────────────
2596
2597    #[test]
2598    fn test_convert_sgml_to_xml() {
2599        let _guard = setup();
2600        unsafe {
2601            let type_ = to_xmlstr_str("public");
2602            let pub_id = to_xmlstr_str("-//TEST//PUBLIC//EN");
2603            let uri = to_xmlstr_str("test.dtd");
2604            assert_eq!(add(type_, pub_id, uri), 0);
2605
2606            let doc = convert();
2607            assert!(!doc.is_null());
2608
2609            // Verify the document has a root <catalog> element
2610            let root = crate::xml::tree::doc_get_root_element(doc);
2611            assert!(!root.is_null());
2612            let root_name = crate::xml::string::xmlstr_to_bytes((*root).name);
2613            assert_eq!(root_name, b"catalog");
2614
2615            // Verify there's a child <public> element
2616            let child = (*root).children;
2617            assert!(!child.is_null());
2618            let child_name = crate::xml::string::xmlstr_to_bytes((*child).name);
2619            assert_eq!(child_name, b"public");
2620
2621            crate::xml::tree::free_doc(doc);
2622            free_xmlstr(type_);
2623            free_xmlstr(pub_id);
2624            free_xmlstr(uri);
2625            teardown(_guard);
2626        }
2627    }
2628
2629    // ── Catalog allowed / disallowed ────────────────────────────────────
2630
2631    #[test]
2632    fn test_catalog_disallowed() {
2633        let _guard = setup();
2634        unsafe {
2635            // Add an entry
2636            let type_ = to_xmlstr_str("system");
2637            let sys_id = to_xmlstr_str("http://example.com/test.dtd");
2638            let uri = to_xmlstr_str("/local/test.dtd");
2639            add(type_, sys_id, uri);
2640
2641            // Disable catalogs
2642            set_defaults(XML_CATA_ALLOW_NONE);
2643
2644            // Resolution should return NULL
2645            assert!(resolve_system(sys_id).is_null());
2646            assert!(resolve_public(sys_id).is_null());
2647            assert!(resolve_uri(sys_id).is_null());
2648
2649            set_defaults(XML_CATA_ALLOW_ALL);
2650            free_xmlstr(type_);
2651            free_xmlstr(sys_id);
2652            free_xmlstr(uri);
2653            teardown(_guard);
2654        }
2655    }
2656
2657    // ── Init / Cleanup ──────────────────────────────────────────────────
2658
2659    #[test]
2660    fn test_init_cleanup() {
2661        let _guard = CATALOG_TEST_MUTEX.lock().unwrap();
2662        cleanup();
2663        assert!(!CATALOG_STATE.read().initialized);
2664
2665        init();
2666        assert!(CATALOG_STATE.read().initialized);
2667
2668        cleanup();
2669        assert!(!CATALOG_STATE.read().initialized);
2670    }
2671
2672    // ── XML Catalog with group ──────────────────────────────────────────
2673
2674    #[test]
2675    fn test_parse_xml_catalog_group() {
2676        let catalog_xml = br#"<?xml version="1.0"?>
2677<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
2678  <group>
2679    <public publicId="-//GROUP//PUBLIC//EN" uri="group.dtd"/>
2680    <system systemId="http://group.example.com/" uri="/group/"/>
2681  </group>
2682</catalog>"#;
2683
2684        let mut entries = Vec::new();
2685        parse_xml_catalog(catalog_xml, &mut entries);
2686        assert_eq!(entries.len(), 2);
2687
2688        match &entries[0] {
2689            CatalogEntry::Public { public_id, .. } => {
2690                assert_eq!(public_id.as_slice(), b"-//GROUP//PUBLIC//EN");
2691            }
2692            _ => panic!("Expected Public entry"),
2693        }
2694
2695        match &entries[1] {
2696            CatalogEntry::System { system_id, .. } => {
2697                assert_eq!(system_id.as_slice(), b"http://group.example.com/");
2698            }
2699            _ => panic!("Expected System entry"),
2700        }
2701    }
2702
2703    // ── Multiple entries, multiple resolution ───────────────────────────
2704
2705    #[test]
2706    fn test_multiple_entries() {
2707        let _guard = setup();
2708        unsafe {
2709            // Add two public entries
2710            let t = to_xmlstr_str("public");
2711            let id1 = to_xmlstr_str("-//A//PUBLIC//EN");
2712            let uri1 = to_xmlstr_str("a.dtd");
2713            let id2 = to_xmlstr_str("-//B//PUBLIC//EN");
2714            let uri2 = to_xmlstr_str("b.dtd");
2715
2716            assert_eq!(add(t, id1, uri1), 0);
2717            assert_eq!(add(t, id2, uri2), 0);
2718
2719            let r1 = resolve_public(id1);
2720            assert!(!r1.is_null());
2721            assert_eq!(xmlstr_to_bytes(r1), b"a.dtd");
2722            xmlFreeImpl(r1 as *mut c_void);
2723
2724            let r2 = resolve_public(id2);
2725            assert!(!r2.is_null());
2726            assert_eq!(xmlstr_to_bytes(r2), b"b.dtd");
2727            xmlFreeImpl(r2 as *mut c_void);
2728
2729            free_xmlstr(t);
2730            free_xmlstr(id1);
2731            free_xmlstr(uri1);
2732            free_xmlstr(id2);
2733            free_xmlstr(uri2);
2734            teardown(_guard);
2735        }
2736    }
2737
2738    // ── Longest prefix wins for rewrite ─────────────────────────────────
2739
2740    #[test]
2741    fn test_longest_prefix_wins() {
2742        let _guard = setup();
2743        unsafe {
2744            let t = to_xmlstr_str("rewriteSystem");
2745            let p1 = to_xmlstr_str("http://example.com/");
2746            let r1 = to_xmlstr_str("/general/");
2747            let p2 = to_xmlstr_str("http://example.com/specific/");
2748            let r2 = to_xmlstr_str("/specific/");
2749
2750            add(t, p1, r1);
2751            add(t, p2, r2);
2752
2753            let sys_id = to_xmlstr_str("http://example.com/specific/file.xml");
2754            let result = resolve_system(sys_id);
2755            assert!(!result.is_null());
2756            assert_eq!(xmlstr_to_bytes(result), b"/specific/file.xml");
2757            xmlFreeImpl(result as *mut c_void);
2758
2759            free_xmlstr(t);
2760            free_xmlstr(p1);
2761            free_xmlstr(r1);
2762            free_xmlstr(p2);
2763            free_xmlstr(r2);
2764            free_xmlstr(sys_id);
2765            teardown(_guard);
2766        }
2767    }
2768}
2769
2770// ═══════════════════════════════════════════════════════════════════════════════
2771// C ABI tests (11.1-I catalog closure)
2772// ═══════════════════════════════════════════════════════════════════════════════
2773
2774#[cfg(test)]
2775mod c_abi_tests {
2776    use super::*;
2777    use crate::abi::allocator::xmlFreeImpl;
2778
2779    fn cstr(s: &[u8]) -> *const xmlChar {
2780        s.as_ptr() as *const xmlChar
2781    }
2782
2783    #[test]
2784    fn test_new_free_catalog() {
2785        unsafe {
2786            let h = xmlNewCatalog(0);
2787            assert!(!h.is_null());
2788            assert_eq!(xmlCatalogIsEmpty(h), 1);
2789            xmlFreeCatalog(h);
2790            xmlFreeCatalog(ptr::null_mut());
2791        }
2792    }
2793
2794    #[test]
2795    fn test_acatalog_add_resolve_remove() {
2796        unsafe {
2797            // UPSTREAM-PARITY: adds on a fresh shell fail (no loaded XML
2798            // catalog), verified against the system DSO.
2799            let h = xmlNewCatalog(0);
2800            assert!(!h.is_null());
2801            assert_eq!(
2802                xmlACatalogAdd(
2803                    h,
2804                    cstr(b"system\0"),
2805                    cstr(b"http://x\0"),
2806                    cstr(b"file:///x\0")
2807                ),
2808                -1
2809            );
2810            xmlFreeCatalog(h);
2811
2812            // Simulate a loaded catalog by seeding entries via the internal
2813            // state, then exercise the handle API.
2814            let h = xmlNewCatalog(0);
2815            assert!(!h.is_null());
2816            (*h).entries.push(CatalogEntry::System {
2817                system_id: b"http://example.com/foo\0".to_vec(),
2818                uri: b"file:///tmp/foo.xml\0".to_vec(),
2819            });
2820            assert_eq!(
2821                xmlACatalogAdd(
2822                    h,
2823                    cstr(b"system\0"),
2824                    cstr(b"http://example.com/foo\0"),
2825                    cstr(b"file:///tmp/foo.xml\0")
2826                ),
2827                0
2828            );
2829            assert_eq!(xmlCatalogIsEmpty(h), 0);
2830            // Resolve system.
2831            let r = xmlACatalogResolveSystem(h, cstr(b"http://example.com/foo\0"));
2832            assert!(!r.is_null());
2833            let bytes = xmlstr_to_bytes(r);
2834            assert_eq!(bytes, b"file:///tmp/foo.xml");
2835            xmlFreeImpl(r as *mut libc::c_void);
2836            // Resolve URI hits system entries too.
2837            let r2 = xmlACatalogResolveURI(h, cstr(b"http://example.com/foo\0"));
2838            assert!(!r2.is_null());
2839            xmlFreeImpl(r2 as *mut libc::c_void);
2840            // Unknown type rejected.
2841            assert_eq!(
2842                xmlACatalogAdd(h, cstr(b"bogus\0"), cstr(b"a\0"), cstr(b"b\0")),
2843                -1
2844            );
2845            // Remove returns 0 for the XML-catalog path, mirroring upstream
2846            // xmlDelXMLCatalog (its `ret` counter is never incremented; only
2847            // the SGML xmlHashRemoveEntry path can return 1).
2848            assert_eq!(xmlACatalogRemove(h, cstr(b"http://example.com/foo\0")), 0);
2849            assert_eq!(xmlCatalogIsEmpty(h), 1);
2850            xmlFreeCatalog(h);
2851        }
2852    }
2853
2854    #[test]
2855    fn test_acatalog_public_and_rewrite() {
2856        unsafe {
2857            let h = xmlNewCatalog(0);
2858            assert!(!h.is_null());
2859            // Seed a loaded state so adds succeed (fresh shells reject adds).
2860            (*h).entries.push(CatalogEntry::Public {
2861                public_id: b"-//OASIS//DTD X//EN\0".to_vec(),
2862                uri: b"file:///dtd/x.dtd\0".to_vec(),
2863            });
2864            assert_eq!(
2865                xmlACatalogAdd(
2866                    h,
2867                    cstr(b"public\0"),
2868                    cstr(b"-//OASIS//DTD X//EN\0"),
2869                    cstr(b"file:///dtd/x.dtd\0")
2870                ),
2871                0
2872            );
2873            assert_eq!(
2874                xmlACatalogAdd(
2875                    h,
2876                    cstr(b"rewriteSystem\0"),
2877                    cstr(b"http://old/\0"),
2878                    cstr(b"http://new/\0")
2879                ),
2880                0
2881            );
2882            let r = xmlACatalogResolvePublic(h, cstr(b"-//OASIS//DTD X//EN\0"));
2883            assert!(!r.is_null());
2884            assert_eq!(xmlstr_to_bytes(r), b"file:///dtd/x.dtd");
2885            xmlFreeImpl(r as *mut libc::c_void);
2886            let r2 = xmlACatalogResolveSystem(h, cstr(b"http://old/foo.xml\0"));
2887            assert!(!r2.is_null());
2888            assert_eq!(xmlstr_to_bytes(r2), b"http://new/foo.xml");
2889            xmlFreeImpl(r2 as *mut libc::c_void);
2890            xmlFreeCatalog(h);
2891        }
2892    }
2893
2894    #[test]
2895    fn test_catalog_set_debug_and_prefer() {
2896        unsafe {
2897            // Default prefer is XML_CATA_PREFER_PUBLIC (1); the setters return
2898            // the OLD value; PREFER_NONE is rejected.
2899            assert_eq!(xmlCatalogSetDefaultPrefer(1), 1);
2900            assert_eq!(xmlCatalogSetDefaultPrefer(2), 1);
2901            assert_eq!(xmlCatalogSetDefaultPrefer(0), 2);
2902            assert_eq!(xmlCatalogSetDefaultPrefer(1), 2);
2903            assert_eq!(xmlCatalogSetDebug(0), 0);
2904            assert_eq!(xmlCatalogSetDebug(7), 0);
2905            assert_eq!(xmlCatalogSetDebug(0), 7);
2906        }
2907    }
2908
2909    #[test]
2910    fn test_catalog_local_resolve() {
2911        unsafe {
2912            // Empty local list resolves nothing.
2913            assert!(xmlCatalogLocalResolve(ptr::null_mut(), cstr(b"x\0"), cstr(b"y\0")).is_null());
2914            assert!(xmlCatalogLocalResolveURI(ptr::null_mut(), cstr(b"x\0")).is_null());
2915            xmlCatalogFreeLocal(ptr::null_mut());
2916        }
2917    }
2918
2919    #[test]
2920    fn test_catalog_resolve_global_null() {
2921        unsafe {
2922            assert!(xmlCatalogResolve(ptr::null(), ptr::null()).is_null());
2923        }
2924    }
2925}