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/// ```
793///
794/// # Safety
795///
796/// - `catalogs` must be NULL or a valid pointer to a NUL-terminated C string
797///   that stays alive for the duration of the call (`CStr::from_ptr` reads it
798///   to the terminator).
799/// - The returned handle is an opaque non-NULL marker derived from the
800///   catalog count, not a dereferenceable pointer; NULL means nothing was
801///   loaded.
802/// - The global catalog state is shared: callers must not race `init` or
803///   `cleanup` with concurrent resolution, though the internal `RwLock`
804///   serializes ordinary readers and writers.
805pub(crate) fn load_catalog(catalogs: *const c_char) -> *mut c_void {
806    if catalogs.is_null() {
807        return ptr::null_mut();
808    }
809
810    let catalogs_str = unsafe { CStr::from_ptr(catalogs) };
811    let catalogs_str = catalogs_str.to_str().unwrap_or("");
812
813    let mut state = CATALOG_STATE.write();
814
815    // Ensure initialized
816    if !state.initialized {
817        drop(state);
818        init();
819        state = CATALOG_STATE.write();
820    }
821
822    let count_before = state.catalogs.len();
823    load_catalog_list(catalogs_str, &mut state);
824
825    if state.catalogs.len() > count_before {
826        // Return a non-null handle (the number of loaded catalogs as a magic pointer)
827        (state.catalogs.len() as isize) as *mut c_void
828    } else {
829        ptr::null_mut()
830    }
831}
832
833// ═══════════════════════════════════════════════════════════════════════════════
834// Public API: Resolution Functions
835// ═══════════════════════════════════════════════════════════════════════════════
836
837/// Check whether catalog resolution is allowed based on the current `allow` value.
838const fn catalog_allowed(state: &CatalogState) -> bool {
839    let allow = state.allow;
840    match allow {
841        XML_CATA_ALLOW_NONE => false,
842        XML_CATA_ALLOW_GLOBAL | XML_CATA_ALLOW_DOCUMENT | XML_CATA_ALLOW_ALL => true,
843        _ => false,
844    }
845}
846
847/// Resolve a public ID against an entry list (candidate-internal; the
848/// global/public wrappers check the allow flag).
849unsafe fn resolve_public_entries(entries: &[CatalogEntry], pub_id_bytes: &[u8]) -> Option<Vec<u8>> {
850    // 1. Direct match on Public entries
851    for entry in entries {
852        if let CatalogEntry::Public { public_id, uri } = entry {
853            if public_id.as_slice() == pub_id_bytes {
854                return Some(uri.clone());
855            }
856        }
857    }
858
859    // 2. DelegatePublic - find longest matching prefix
860    let mut best_match: Option<Vec<u8>> = None;
861    let mut best_prefix_len: usize = 0;
862
863    for entry in entries {
864        if let CatalogEntry::DelegatePublic { prefix, catalog } = entry {
865            if pub_id_bytes.starts_with(prefix) && prefix.len() > best_prefix_len {
866                best_prefix_len = prefix.len();
867                // Try to load the delegated catalog and resolve
868                if let Some(delegated_data) = read_file_bytes(&String::from_utf8_lossy(catalog)) {
869                    let mut temp_entries = Vec::new();
870                    parse_xml_catalog(&delegated_data, &mut temp_entries);
871                    // Check for public match in delegated catalog
872                    for temp_entry in &temp_entries {
873                        if let CatalogEntry::Public { public_id: dp, uri } = temp_entry {
874                            if dp.as_slice() == pub_id_bytes {
875                                best_match = Some(uri.clone());
876                            }
877                        }
878                    }
879                }
880            }
881        }
882    }
883
884    best_match
885}
886
887/// Resolve a public ID to a system/URI.
888///
889/// Checks catalog entries in order, first matching `Public` entries,
890/// then falls through to delegation.
891///
892/// # UPSTREAM-PARITY
893///
894/// ```c
895/// xmlCharPtr xmlCatalogResolvePublic(const xmlChar *pubID);
896/// ```
897///
898/// # Safety
899///
900/// - `pub_id` must be NULL or a valid pointer to a NUL-terminated `xmlChar`
901///   string readable through its full length for the duration of the call
902///   (`xmlstr_to_bytes` scans to the terminator).
903/// - A non-NULL return value is a freshly allocated NUL-terminated string
904///   that the caller owns and must free exactly once with `xmlFree`.
905/// - The catalog state is read under the internal lock; callers must not
906///   race `init`/`cleanup` with this call.
907pub(crate) unsafe fn resolve_public(pub_id: *const xmlChar) -> *mut xmlChar {
908    if pub_id.is_null() {
909        return ptr::null_mut();
910    }
911
912    let state = CATALOG_STATE.read();
913    if !catalog_allowed(&state) {
914        return ptr::null_mut();
915    }
916
917    let pub_id_bytes = xmlstr_to_bytes(pub_id);
918    unsafe { resolve_public_entries(&state.entries, pub_id_bytes) }
919        .as_ref()
920        .map_or(ptr::null_mut(), |uri| bytes_to_xmlstr(uri))
921}
922
923/// Resolve a system ID against an entry list (candidate-internal).
924unsafe fn resolve_system_entries(entries: &[CatalogEntry], sys_id_bytes: &[u8]) -> Option<Vec<u8>> {
925    // 1. Direct match on System entries
926    for entry in entries {
927        if let CatalogEntry::System { system_id, uri } = entry {
928            if system_id.as_slice() == sys_id_bytes {
929                return Some(uri.clone());
930            }
931        }
932    }
933
934    // 2. RewriteSystem - find longest matching prefix
935    let mut best_rewrite: Option<Vec<u8>> = None;
936    let mut best_prefix_len: usize = 0;
937
938    for entry in entries {
939        if let CatalogEntry::RewriteSystem { prefix, rewrite } = entry {
940            if sys_id_bytes.starts_with(prefix) && prefix.len() > best_prefix_len {
941                best_prefix_len = prefix.len();
942                // Replace the prefix with the rewrite prefix
943                let suffix = &sys_id_bytes[prefix.len()..];
944                let mut result = rewrite.clone();
945                result.extend_from_slice(suffix);
946                best_rewrite = Some(result);
947            }
948        }
949    }
950
951    if let Some(rewritten) = best_rewrite {
952        return Some(rewritten);
953    }
954
955    // 3. DelegateSystem
956    for entry in entries {
957        if let CatalogEntry::DelegateSystem { prefix, catalog } = entry {
958            if sys_id_bytes.starts_with(prefix) {
959                if let Some(delegated_data) = read_file_bytes(&String::from_utf8_lossy(catalog)) {
960                    let mut temp_entries = Vec::new();
961                    parse_xml_catalog(&delegated_data, &mut temp_entries);
962                    for temp_entry in &temp_entries {
963                        if let CatalogEntry::System { system_id, uri } = temp_entry {
964                            if system_id.as_slice() == sys_id_bytes {
965                                return Some(uri.clone());
966                            }
967                        }
968                    }
969                }
970            }
971        }
972    }
973
974    None
975}
976
977/// Resolve a system ID.
978///
979/// Checks catalog entries in order:
980/// 1. Direct `System` match
981/// 2. `RewriteSystem` prefix match (longest wins)
982/// 3. `DelegateSystem` prefix match
983///
984/// # UPSTREAM-PARITY
985///
986/// ```c
987/// xmlCharPtr xmlCatalogResolveSystem(const xmlChar *sysID);
988/// ```
989///
990/// # Safety
991///
992/// - `sys_id` must be NULL or a valid pointer to a NUL-terminated `xmlChar`
993///   string readable through its full length for the duration of the call
994///   (`xmlstr_to_bytes` scans to the terminator).
995/// - A non-NULL return value is a freshly allocated NUL-terminated string
996///   that the caller owns and must free exactly once with `xmlFree`.
997/// - The catalog state is read under the internal lock; callers must not
998///   race `init`/`cleanup` with this call.
999pub(crate) unsafe fn resolve_system(sys_id: *const xmlChar) -> *mut xmlChar {
1000    if sys_id.is_null() {
1001        return ptr::null_mut();
1002    }
1003
1004    let state = CATALOG_STATE.read();
1005    if !catalog_allowed(&state) {
1006        return ptr::null_mut();
1007    }
1008
1009    let sys_id_bytes = xmlstr_to_bytes(sys_id);
1010    unsafe { resolve_system_entries(&state.entries, sys_id_bytes) }
1011        .as_ref()
1012        .map_or(ptr::null_mut(), |uri| bytes_to_xmlstr(uri))
1013}
1014
1015/// Resolve a URI against an entry list (candidate-internal).
1016unsafe fn resolve_uri_entries(entries: &[CatalogEntry], uri_bytes: &[u8]) -> Option<Vec<u8>> {
1017    // 1. Direct match on System entries (URIs match against systemId in libxml2)
1018    for entry in entries {
1019        if let CatalogEntry::System {
1020            system_id,
1021            uri: sys_uri,
1022        } = entry
1023        {
1024            if system_id.as_slice() == uri_bytes {
1025                return Some(sys_uri.clone());
1026            }
1027        }
1028    }
1029
1030    // 2. RewriteURI - find longest matching prefix
1031    let mut best_rewrite: Option<Vec<u8>> = None;
1032    let mut best_prefix_len: usize = 0;
1033
1034    for entry in entries {
1035        if let CatalogEntry::RewriteURI { prefix, rewrite } = entry {
1036            if uri_bytes.starts_with(prefix) && prefix.len() > best_prefix_len {
1037                best_prefix_len = prefix.len();
1038                let suffix = &uri_bytes[prefix.len()..];
1039                let mut result = rewrite.clone();
1040                result.extend_from_slice(suffix);
1041                best_rewrite = Some(result);
1042            }
1043        }
1044    }
1045
1046    if let Some(rewritten) = best_rewrite {
1047        return Some(rewritten);
1048    }
1049
1050    // 3. DelegateURI
1051    for entry in entries {
1052        if let CatalogEntry::DelegateURI { prefix, catalog } = entry {
1053            if uri_bytes.starts_with(prefix) {
1054                if let Some(delegated_data) = read_file_bytes(&String::from_utf8_lossy(catalog)) {
1055                    let mut temp_entries = Vec::new();
1056                    parse_xml_catalog(&delegated_data, &mut temp_entries);
1057                    for temp_entry in &temp_entries {
1058                        if let CatalogEntry::System {
1059                            system_id,
1060                            uri: sys_uri,
1061                        } = temp_entry
1062                        {
1063                            if system_id.as_slice() == uri_bytes {
1064                                return Some(sys_uri.clone());
1065                            }
1066                        }
1067                    }
1068                }
1069            }
1070        }
1071    }
1072
1073    None
1074}
1075
1076/// Resolve a URI.
1077///
1078/// Checks catalog entries in order:
1079/// 1. Direct `System` match (URIs are matched against system entries too)
1080/// 2. `RewriteURI` prefix match (longest wins)
1081/// 3. `DelegateURI` prefix match
1082///
1083/// # UPSTREAM-PARITY
1084///
1085/// ```c
1086/// xmlCharPtr xmlCatalogResolveURI(const xmlChar *URI);
1087/// ```
1088///
1089/// # Safety
1090///
1091/// - `uri` must be NULL or a valid pointer to a NUL-terminated `xmlChar`
1092///   string readable through its full length for the duration of the call
1093///   (`xmlstr_to_bytes` scans to the terminator).
1094/// - A non-NULL return value is a freshly allocated NUL-terminated string
1095///   that the caller owns and must free exactly once with `xmlFree`.
1096/// - The catalog state is read under the internal lock; callers must not
1097///   race `init`/`cleanup` with this call.
1098pub(crate) unsafe fn resolve_uri(uri: *const xmlChar) -> *mut xmlChar {
1099    if uri.is_null() {
1100        return ptr::null_mut();
1101    }
1102
1103    let state = CATALOG_STATE.read();
1104    if !catalog_allowed(&state) {
1105        return ptr::null_mut();
1106    }
1107
1108    let uri_bytes = xmlstr_to_bytes(uri);
1109    unsafe { resolve_uri_entries(&state.entries, uri_bytes) }
1110        .as_ref()
1111        .map_or(ptr::null_mut(), |uri| bytes_to_xmlstr(uri))
1112}
1113
1114// ═══════════════════════════════════════════════════════════════════════════════
1115// Public API: Catalog Defaults
1116// ═══════════════════════════════════════════════════════════════════════════════
1117
1118/// Set catalog behavior.
1119///
1120/// Controls whether catalog resolution is allowed and which catalogs
1121/// are consulted.
1122///
1123/// # UPSTREAM-PARITY
1124///
1125/// ```c
1126/// void xmlCatalogSetDefaults(xmlCatalogAllowValue allow);
1127/// ```
1128pub(crate) fn set_defaults(allow: c_int) {
1129    let mut state = CATALOG_STATE.write();
1130    state.allow = allow;
1131    crate::xml::globals::set_catalog_defaults(allow);
1132}
1133
1134/// Get the current catalog allow value.
1135///
1136/// # UPSTREAM-PARITY
1137///
1138/// ```c
1139/// xmlCatalogAllowValue xmlCatalogGetDefaults(void);
1140/// ```
1141pub(crate) fn get_defaults() -> c_int {
1142    let state = CATALOG_STATE.read();
1143    state.allow
1144}
1145
1146// ═══════════════════════════════════════════════════════════════════════════════
1147// Public API: Add / Remove Entries
1148// ═══════════════════════════════════════════════════════════════════════════════
1149
1150/// Add a catalog entry.
1151///
1152/// `type_` is one of "public", "system", "rewriteSystem", "rewriteURI",
1153/// "delegatePublic", "delegateSystem", "delegateURI", or "nextCatalog".
1154///
1155/// Returns 0 on success, -1 on failure.
1156///
1157/// # UPSTREAM-PARITY
1158///
1159/// ```c
1160/// int xmlCatalogAdd(const xmlChar *type, const xmlChar *orig, const xmlChar *replace);
1161/// ```
1162pub(crate) unsafe fn add(
1163    type_: *const xmlChar,
1164    orig: *const xmlChar,
1165    replace: *const xmlChar,
1166) -> c_int {
1167    if type_.is_null() || orig.is_null() || replace.is_null() {
1168        return -1;
1169    }
1170
1171    let type_bytes = xmlstr_to_bytes(type_);
1172    let orig_bytes = xmlstr_to_bytes(orig);
1173    let replace_bytes = xmlstr_to_bytes(replace);
1174
1175    let mut state = CATALOG_STATE.write();
1176
1177    match type_bytes {
1178        b"public" => {
1179            state.entries.push(CatalogEntry::Public {
1180                public_id: orig_bytes.to_vec(),
1181                uri: replace_bytes.to_vec(),
1182            });
1183            0
1184        }
1185        b"system" => {
1186            state.entries.push(CatalogEntry::System {
1187                system_id: orig_bytes.to_vec(),
1188                uri: replace_bytes.to_vec(),
1189            });
1190            0
1191        }
1192        b"rewriteSystem" => {
1193            state.entries.push(CatalogEntry::RewriteSystem {
1194                prefix: orig_bytes.to_vec(),
1195                rewrite: replace_bytes.to_vec(),
1196            });
1197            0
1198        }
1199        b"rewriteURI" => {
1200            state.entries.push(CatalogEntry::RewriteURI {
1201                prefix: orig_bytes.to_vec(),
1202                rewrite: replace_bytes.to_vec(),
1203            });
1204            0
1205        }
1206        b"delegatePublic" => {
1207            state.entries.push(CatalogEntry::DelegatePublic {
1208                prefix: orig_bytes.to_vec(),
1209                catalog: replace_bytes.to_vec(),
1210            });
1211            0
1212        }
1213        b"delegateSystem" => {
1214            state.entries.push(CatalogEntry::DelegateSystem {
1215                prefix: orig_bytes.to_vec(),
1216                catalog: replace_bytes.to_vec(),
1217            });
1218            0
1219        }
1220        b"delegateURI" => {
1221            state.entries.push(CatalogEntry::DelegateURI {
1222                prefix: orig_bytes.to_vec(),
1223                catalog: replace_bytes.to_vec(),
1224            });
1225            0
1226        }
1227        b"nextCatalog" => {
1228            state.entries.push(CatalogEntry::NextCatalog {
1229                catalog: orig_bytes.to_vec(),
1230            });
1231            0
1232        }
1233        _ => -1,
1234    }
1235}
1236
1237/// Remove a catalog entry by matching its value.
1238///
1239/// Removes all entries whose public ID, system ID, or prefix matches `value`.
1240/// Returns the number of entries removed, or -1 on error.
1241///
1242/// # UPSTREAM-PARITY
1243///
1244/// ```c
1245/// int xmlCatalogRemove(const xmlChar *value);
1246/// ```
1247pub(crate) unsafe fn remove(value: *const xmlChar) -> c_int {
1248    if value.is_null() {
1249        return -1;
1250    }
1251
1252    let value_bytes = xmlstr_to_bytes(value);
1253    let mut state = CATALOG_STATE.write();
1254
1255    let before = state.entries.len();
1256    state.entries.retain(|entry| match entry {
1257        CatalogEntry::Public { public_id, .. } => public_id.as_slice() != value_bytes,
1258        CatalogEntry::System { system_id, .. } => system_id.as_slice() != value_bytes,
1259        CatalogEntry::RewriteSystem { prefix, .. } => prefix.as_slice() != value_bytes,
1260        CatalogEntry::RewriteURI { prefix, .. } => prefix.as_slice() != value_bytes,
1261        CatalogEntry::DelegatePublic { prefix, .. } => prefix.as_slice() != value_bytes,
1262        CatalogEntry::DelegateSystem { prefix, .. } => prefix.as_slice() != value_bytes,
1263        CatalogEntry::DelegateURI { prefix, .. } => prefix.as_slice() != value_bytes,
1264        CatalogEntry::NextCatalog { catalog } => catalog.as_slice() != value_bytes,
1265    });
1266
1267    (before - state.entries.len()) as c_int
1268}
1269
1270// ═══════════════════════════════════════════════════════════════════════════════
1271// Public API: SGML → XML Conversion
1272// ═══════════════════════════════════════════════════════════════════════════════
1273
1274/// Convert the currently loaded SGML catalog entries to an XML Catalog document.
1275///
1276/// Returns a newly allocated `_xmlDoc` containing the XML catalog representation,
1277/// or NULL on failure. The caller is responsible for freeing the document.
1278///
1279/// # UPSTREAM-PARITY
1280///
1281/// ```c
1282/// xmlDocPtr xmlCatalogConvert(void);
1283/// ```
1284pub(crate) unsafe fn convert() -> *mut _xmlDoc {
1285    let state = CATALOG_STATE.read();
1286
1287    if state.entries.is_empty() {
1288        return ptr::null_mut();
1289    }
1290
1291    // Create the XML document
1292    let doc = crate::xml::tree::new_doc(ptr::null_mut());
1293    if doc.is_null() {
1294        return ptr::null_mut();
1295    }
1296
1297    // Create root <catalog> element
1298    let catalog_name = b"catalog\0" as *const u8 as *const xmlChar;
1299    let root = crate::xml::tree::new_node(ptr::null_mut(), catalog_name);
1300    if root.is_null() {
1301        crate::xml::tree::free_doc(doc);
1302        return ptr::null_mut();
1303    }
1304
1305    // Set xmlns attribute for OASIS XML Catalog namespace
1306    let xmlns_name = b"xmlns\0" as *const u8 as *const xmlChar;
1307    let ns_value = b"urn:oasis:names:tc:entity:xmlns:xml:catalog\0" as *const u8 as *const xmlChar;
1308    crate::xml::tree::set_prop(root, xmlns_name, ns_value);
1309
1310    crate::xml::tree::doc_set_root_element(doc, root);
1311
1312    // Add entries as child elements
1313    for entry in &state.entries {
1314        let (elem_name, attr1_name, attr1_value, attr2_name, attr2_value) = match entry {
1315            CatalogEntry::Public { public_id, uri } => {
1316                let elem = b"public\0" as *const u8 as *mut xmlChar;
1317                let attr1 = b"publicId\0" as *const u8 as *mut xmlChar;
1318                let val1 = bytes_to_xmlstr(public_id);
1319                let attr2 = b"uri\0" as *const u8 as *mut xmlChar;
1320                let val2 = bytes_to_xmlstr(uri);
1321                (elem, attr1, val1, attr2, val2)
1322            }
1323            CatalogEntry::System { system_id, uri } => {
1324                let elem = b"system\0" as *const u8 as *mut xmlChar;
1325                let attr1 = b"systemId\0" as *const u8 as *mut xmlChar;
1326                let val1 = bytes_to_xmlstr(system_id);
1327                let attr2 = b"uri\0" as *const u8 as *mut xmlChar;
1328                let val2 = bytes_to_xmlstr(uri);
1329                (elem, attr1, val1, attr2, val2)
1330            }
1331            CatalogEntry::RewriteSystem { prefix, rewrite } => {
1332                let elem = b"rewriteSystem\0" as *const u8 as *mut xmlChar;
1333                let attr1 = b"systemIdStartString\0" as *const u8 as *mut xmlChar;
1334                let val1 = bytes_to_xmlstr(prefix);
1335                let attr2 = b"rewritePrefix\0" as *const u8 as *mut xmlChar;
1336                let val2 = bytes_to_xmlstr(rewrite);
1337                (elem, attr1, val1, attr2, val2)
1338            }
1339            CatalogEntry::RewriteURI { prefix, rewrite } => {
1340                let elem = b"rewriteURI\0" as *const u8 as *mut xmlChar;
1341                let attr1 = b"uriStartString\0" as *const u8 as *mut xmlChar;
1342                let val1 = bytes_to_xmlstr(prefix);
1343                let attr2 = b"rewritePrefix\0" as *const u8 as *mut xmlChar;
1344                let val2 = bytes_to_xmlstr(rewrite);
1345                (elem, attr1, val1, attr2, val2)
1346            }
1347            CatalogEntry::DelegatePublic { prefix, catalog } => {
1348                let elem = b"delegatePublic\0" as *const u8 as *mut xmlChar;
1349                let attr1 = b"publicIdStartString\0" as *const u8 as *mut xmlChar;
1350                let val1 = bytes_to_xmlstr(prefix);
1351                let attr2 = b"catalog\0" as *const u8 as *mut xmlChar;
1352                let val2 = bytes_to_xmlstr(catalog);
1353                (elem, attr1, val1, attr2, val2)
1354            }
1355            CatalogEntry::DelegateSystem { prefix, catalog } => {
1356                let elem = b"delegateSystem\0" as *const u8 as *mut xmlChar;
1357                let attr1 = b"systemIdStartString\0" as *const u8 as *mut xmlChar;
1358                let val1 = bytes_to_xmlstr(prefix);
1359                let attr2 = b"catalog\0" as *const u8 as *mut xmlChar;
1360                let val2 = bytes_to_xmlstr(catalog);
1361                (elem, attr1, val1, attr2, val2)
1362            }
1363            CatalogEntry::DelegateURI { prefix, catalog } => {
1364                let elem = b"delegateURI\0" as *const u8 as *mut xmlChar;
1365                let attr1 = b"uriStartString\0" as *const u8 as *mut xmlChar;
1366                let val1 = bytes_to_xmlstr(prefix);
1367                let attr2 = b"catalog\0" as *const u8 as *mut xmlChar;
1368                let val2 = bytes_to_xmlstr(catalog);
1369                (elem, attr1, val1, attr2, val2)
1370            }
1371            CatalogEntry::NextCatalog { catalog } => {
1372                let elem = b"nextCatalog\0" as *const u8 as *mut xmlChar;
1373                let attr1 = b"catalog\0" as *const u8 as *mut xmlChar;
1374                let val1 = bytes_to_xmlstr(catalog);
1375                let attr2 = ptr::null_mut();
1376                let val2 = ptr::null_mut();
1377                (elem, attr1, val1, attr2, val2)
1378            }
1379        };
1380
1381        let child = crate::xml::tree::new_child(root, ptr::null_mut(), elem_name);
1382        if child.is_null() {
1383            // Free allocated strings and continue
1384            if !attr1_value.is_null() {
1385                xmlFreeImpl(attr1_value as *mut c_void);
1386            }
1387            if !attr2_value.is_null() {
1388                xmlFreeImpl(attr2_value as *mut c_void);
1389            }
1390            continue;
1391        }
1392
1393        crate::xml::tree::set_prop(child, attr1_name, attr1_value);
1394        if !attr2_name.is_null() {
1395            crate::xml::tree::set_prop(child, attr2_name, attr2_value);
1396        }
1397
1398        // Free the temporary xmlChar strings we created
1399        if !attr1_value.is_null() {
1400            xmlFreeImpl(attr1_value as *mut c_void);
1401        }
1402        if !attr2_value.is_null() {
1403            xmlFreeImpl(attr2_value as *mut c_void);
1404        }
1405    }
1406
1407    doc
1408}
1409
1410/// Build the catalog document for dumping/saving: XML declaration, the
1411/// OASIS catalog DOCTYPE, and a `<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">`
1412/// root with the current entries.
1413///
1414/// The DOCTYPE node is prepended as the first child of the document (the
1415/// serializer emits `<!DOCTYPE catalog PUBLIC ...>` from child DTD nodes);
1416/// `doc->intSubset` is left NULL so `free_doc` frees the DTD exactly once via
1417/// the children list.
1418///
1419/// Returns a newly allocated document, or NULL on allocation failure.
1420///
1421/// # SAFETY
1422///
1423/// The function touches crate-global state only; it is safe
1424/// as long as the caller respects the library's global
1425/// initialization/cleanup ordering (xmlInitParser before use,
1426/// xmlCleanupParser only after all users are done).
1427///
1428/// Violating the global lifecycle ordering, or calling this after
1429/// teardown or from a signal handler, is undefined behavior.
1430pub unsafe fn dump_doc() -> *mut _xmlDoc {
1431    let mut doc = convert();
1432    if doc.is_null() {
1433        // Empty catalog: build the skeleton ourselves.
1434        doc = crate::xml::tree::new_doc(ptr::null_mut());
1435        if doc.is_null() {
1436            return ptr::null_mut();
1437        }
1438        let root =
1439            crate::xml::tree::new_node(ptr::null_mut(), c"catalog".as_ptr() as *const xmlChar);
1440        if root.is_null() {
1441            crate::xml::tree::free_doc(doc);
1442            return ptr::null_mut();
1443        }
1444        crate::xml::tree::set_prop(
1445            root,
1446            c"xmlns".as_ptr() as *const xmlChar,
1447            c"urn:oasis:names:tc:entity:xmlns:xml:catalog".as_ptr() as *const xmlChar,
1448        );
1449        crate::xml::tree::doc_set_root_element(doc, root);
1450    }
1451
1452    let dtd = crate::xml::tree::new_dtd(
1453        doc,
1454        c"catalog".as_ptr() as *const xmlChar,
1455        c"-//OASIS//DTD Entity Resolution XML Catalog V1.0//EN".as_ptr() as *const xmlChar,
1456        c"http://www.oasis-open.org/committees/entity/release/1.0/catalog.dtd".as_ptr()
1457            as *const xmlChar,
1458    );
1459    if !dtd.is_null() {
1460        (*doc).intSubset = ptr::null_mut();
1461        let dtd_node = dtd as *mut _xmlNode;
1462        let first = (*doc).children;
1463        (*dtd_node).next = first;
1464        (*dtd_node).parent = doc as *mut _xmlNode;
1465        (*dtd_node).doc = doc;
1466        if !first.is_null() {
1467            (*first).prev = dtd_node;
1468        }
1469        (*doc).children = dtd_node;
1470    }
1471    doc
1472}
1473
1474// ═══════════════════════════════════════════════════════════════════════════════
1475// C ABI surface (11.1-I catalog closure, residual R-000136)
1476// ═══════════════════════════════════════════════════════════════════════════════
1477
1478/// Candidate-internal per-handle catalog (`xmlCatalogPtr`, opaque upstream).
1479///
1480/// # UPSTREAM-PARITY
1481///
1482/// Upstream keeps two levels per handle: `catal->xml->children` — the entry
1483/// list consulted by `xmlCatalogIsEmpty` and populated only by
1484/// `xmlACatalogAdd` — and the loaded document structure consulted by
1485/// `xmlACatalogResolve*`. Observable consequence (verified against the system
1486/// DSO): a freshly `xmlLoadACatalog`-ed handle reports `xmlCatalogIsEmpty()==1`
1487/// even when it resolves entries, flipping to 0 only after an API add. The
1488/// candidate mirrors this with `children` (isEmpty source) and `entries`
1489/// (resolve source).
1490#[derive(Debug)]
1491#[repr(C)]
1492pub struct XmlCatalogHandle {
1493    /// Entry list consulted by `xmlACatalogResolve*` (the resolve source).
1494    pub entries: Vec<CatalogEntry>,
1495    /// Entry list consulted by `xmlCatalogIsEmpty`; populated only by
1496    /// `xmlACatalogAdd`.
1497    pub children: Vec<CatalogEntry>,
1498    /// Non-zero when the handle holds an SGML (SOLEX) format catalog
1499    /// (cleared by `xmlConvertSGMLCatalog`).
1500    pub sgml: c_int,
1501}
1502
1503/// Catalog debug level (upstream `xmlDebugCatalogs`).
1504static CATALOG_DEBUG: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
1505
1506/// Catalog default prefer value (upstream `xmlCatalogDefaultPrefer`;
1507/// defaults to XML_CATA_PREFER_PUBLIC = 1).
1508static CATALOG_PREFER: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(1);
1509
1510/// Create a new (empty) catalog handle.
1511///
1512/// # UPSTREAM-PARITY
1513///
1514/// ```c
1515/// xmlCatalogPtr xmlNewCatalog(int sgml);
1516/// ```
1517///
1518/// # SAFETY
1519///
1520/// The function touches crate-global state only; it is safe
1521/// as long as the caller respects the library's global
1522/// initialization/cleanup ordering (xmlInitParser before use,
1523/// xmlCleanupParser only after all users are done).
1524///
1525/// Violating the global lifecycle ordering, or calling this after
1526/// teardown or from a signal handler, is undefined behavior.
1527#[no_mangle]
1528pub unsafe extern "C" fn xmlNewCatalog(sgml: c_int) -> *mut XmlCatalogHandle {
1529    let h = Box::new(XmlCatalogHandle {
1530        entries: Vec::new(),
1531        children: Vec::new(),
1532        sgml,
1533    });
1534    Box::into_raw(h)
1535}
1536
1537/// Free a catalog handle.
1538///
1539/// # SAFETY
1540///
1541/// - `catal` must be a handle from xmlNewCatalog/xmlLoadACatalog or NULL.
1542#[no_mangle]
1543pub unsafe extern "C" fn xmlFreeCatalog(catal: *mut XmlCatalogHandle) {
1544    if !catal.is_null() {
1545        unsafe { drop(Box::from_raw(catal)) };
1546    }
1547}
1548
1549/// Load a catalog file into a new handle.
1550///
1551/// # SAFETY
1552///
1553/// - `filename` must be a valid NUL-terminated path.
1554#[no_mangle]
1555pub unsafe extern "C" fn xmlLoadACatalog(filename: *const c_char) -> *mut XmlCatalogHandle {
1556    if filename.is_null() {
1557        return ptr::null_mut();
1558    }
1559    let name = unsafe { CStr::from_ptr(filename) };
1560    let name = name.to_str().unwrap_or("");
1561    let mut entries = Vec::new();
1562    if let Some(data) = read_file_bytes(name) {
1563        load_catalog_data(name, &data, &mut entries);
1564    }
1565    if entries.is_empty() {
1566        return ptr::null_mut();
1567    }
1568    Box::into_raw(Box::new(XmlCatalogHandle {
1569        entries,
1570        children: Vec::new(),
1571        sgml: 0,
1572    }))
1573}
1574
1575/// Load an SGML super-catalog into a new handle (upstream parses the super
1576/// catalog's CATALOG directives).
1577///
1578/// # SAFETY
1579///
1580/// - `filename` must be a valid NUL-terminated path.
1581#[no_mangle]
1582pub unsafe extern "C" fn xmlLoadSGMLSuperCatalog(filename: *const c_char) -> *mut XmlCatalogHandle {
1583    unsafe { xmlLoadACatalog(filename) }
1584}
1585
1586/// Convert an SGML catalog handle in place (upstream rewrites SGML entries
1587/// to XML; the candidate parses both formats on load, so this is a no-op
1588/// success).
1589///
1590/// # SAFETY
1591///
1592/// - `catal` must be a valid handle.
1593#[no_mangle]
1594pub unsafe extern "C" fn xmlConvertSGMLCatalog(catal: *mut XmlCatalogHandle) -> c_int {
1595    if catal.is_null() {
1596        return -1;
1597    }
1598    unsafe { (*catal).sgml = 0 };
1599    0
1600}
1601
1602/// Add an entry to a catalog handle (upstream xmlACatalogAdd: type is
1603/// "public"|"system"|"rewriteSystem"|"rewriteURI"|"delegatePublic"|
1604/// "delegateSystem"|"delegateURI"|"nextCatalog").
1605///
1606/// # SAFETY
1607///
1608/// - `catal` must be a valid handle; `type`, `orig`, `replace` valid
1609///   NUL-terminated strings.
1610#[no_mangle]
1611pub unsafe extern "C" fn xmlACatalogAdd(
1612    catal: *mut XmlCatalogHandle,
1613    type_: *const xmlChar,
1614    orig: *const xmlChar,
1615    replace: *const xmlChar,
1616) -> c_int {
1617    if catal.is_null() || type_.is_null() || orig.is_null() || replace.is_null() {
1618        return -1;
1619    }
1620    // UPSTREAM-PARITY: xmlACatalogAdd forwards to xmlAddXMLCatalog(catal->xml,
1621    // ...) which returns -1 when the handle has no loaded XML catalog
1622    // (xmlNewCatalog creates an empty shell; only xmlLoadACatalog fills
1623    // catal->xml). Verified against the system DSO: adds on a fresh shell
1624    // fail.
1625    if unsafe { (*catal).entries.is_empty() } {
1626        return -1;
1627    }
1628    let t = xmlstr_to_bytes(type_);
1629    let o = xmlstr_to_bytes(orig).to_vec();
1630    let r = xmlstr_to_bytes(replace).to_vec();
1631    let entry = if t == b"public" {
1632        CatalogEntry::Public {
1633            public_id: o,
1634            uri: r,
1635        }
1636    } else if t == b"system" {
1637        CatalogEntry::System {
1638            system_id: o,
1639            uri: r,
1640        }
1641    } else if t == b"rewriteSystem" {
1642        CatalogEntry::RewriteSystem {
1643            prefix: o,
1644            rewrite: r,
1645        }
1646    } else if t == b"rewriteURI" {
1647        CatalogEntry::RewriteURI {
1648            prefix: o,
1649            rewrite: r,
1650        }
1651    } else if t == b"delegatePublic" {
1652        CatalogEntry::DelegatePublic {
1653            prefix: o,
1654            catalog: r,
1655        }
1656    } else if t == b"delegateSystem" {
1657        CatalogEntry::DelegateSystem {
1658            prefix: o,
1659            catalog: r,
1660        }
1661    } else if t == b"delegateURI" {
1662        CatalogEntry::DelegateURI {
1663            prefix: o,
1664            catalog: r,
1665        }
1666    } else if t == b"nextCatalog" {
1667        CatalogEntry::NextCatalog { catalog: r }
1668    } else {
1669        return -1;
1670    };
1671    unsafe {
1672        (*catal).entries.push(entry.clone());
1673        (*catal).children.push(entry);
1674    };
1675    0
1676}
1677
1678/// Remove entries whose value matches `value` from a catalog handle.
1679///
1680/// # SAFETY
1681///
1682/// - `catal` must be a valid handle; `value` a valid NUL-terminated string.
1683#[no_mangle]
1684pub unsafe extern "C" fn xmlACatalogRemove(
1685    catal: *mut XmlCatalogHandle,
1686    value: *const xmlChar,
1687) -> c_int {
1688    if catal.is_null() || value.is_null() {
1689        return -1;
1690    }
1691    let v = xmlstr_to_bytes(value);
1692    let entries = unsafe { &mut (*catal).entries };
1693    entries.retain(|entry| match entry {
1694        CatalogEntry::Public { public_id, .. } => public_id.as_slice() != v,
1695        CatalogEntry::System { system_id, .. } => system_id.as_slice() != v,
1696        CatalogEntry::RewriteSystem { prefix, .. } => prefix.as_slice() != v,
1697        CatalogEntry::RewriteURI { prefix, .. } => prefix.as_slice() != v,
1698        CatalogEntry::DelegatePublic { prefix, .. } => prefix.as_slice() != v,
1699        CatalogEntry::DelegateSystem { prefix, .. } => prefix.as_slice() != v,
1700        CatalogEntry::DelegateURI { prefix, .. } => prefix.as_slice() != v,
1701        CatalogEntry::NextCatalog { .. } => true,
1702    });
1703    let children = unsafe { &mut (*catal).children };
1704    children.retain(|entry| match entry {
1705        CatalogEntry::Public { public_id, .. } => public_id.as_slice() != v,
1706        CatalogEntry::System { system_id, .. } => system_id.as_slice() != v,
1707        CatalogEntry::RewriteSystem { prefix, .. } => prefix.as_slice() != v,
1708        CatalogEntry::RewriteURI { prefix, .. } => prefix.as_slice() != v,
1709        CatalogEntry::DelegatePublic { prefix, .. } => prefix.as_slice() != v,
1710        CatalogEntry::DelegateSystem { prefix, .. } => prefix.as_slice() != v,
1711        CatalogEntry::DelegateURI { prefix, .. } => prefix.as_slice() != v,
1712        CatalogEntry::NextCatalog { .. } => true,
1713    });
1714    // UPSTREAM-PARITY: xmlACatalogRemove returns 0 for XML catalogs.
1715    // xmlDelXMLCatalog declares `int ret = 0;` and never increments it, so
1716    // upstream always returns 0 (even when entries were removed); only the
1717    // SGML path (xmlHashRemoveEntry) can yield 1. The candidate mirrors the
1718    // XML path exactly: entries are removed, 0 is returned.
1719    0
1720}
1721
1722/// Resolve public then system against a handle (upstream xmlACatalogResolve).
1723///
1724/// # UPSTREAM-PARITY
1725///
1726/// Upstream xmlCatalogXMLResolve tries the system ID FIRST when provided
1727/// ("First tries steps 2/3/4 if a system ID is provided", catalog.c 2.15),
1728/// then falls back to the public ID.
1729///
1730/// # SAFETY
1731///
1732/// - `catal` must be a valid handle; `pubID`/`sysID` valid strings or NULL.
1733#[no_mangle]
1734pub unsafe extern "C" fn xmlACatalogResolve(
1735    catal: *mut XmlCatalogHandle,
1736    pubID: *const xmlChar,
1737    sysID: *const xmlChar,
1738) -> *mut xmlChar {
1739    if catal.is_null() {
1740        return ptr::null_mut();
1741    }
1742    let entries = unsafe { &(*catal).entries };
1743    if !sysID.is_null() {
1744        let b = xmlstr_to_bytes(sysID);
1745        if let Some(r) = unsafe { resolve_system_entries(entries, b) } {
1746            return bytes_to_xmlstr(&r);
1747        }
1748    }
1749    if !pubID.is_null() {
1750        let b = xmlstr_to_bytes(pubID);
1751        if let Some(r) = unsafe { resolve_public_entries(entries, b) } {
1752            return bytes_to_xmlstr(&r);
1753        }
1754    }
1755    ptr::null_mut()
1756}
1757
1758/// Resolve a system ID against a handle (upstream xmlACatalogResolveSystem).
1759///
1760/// # SAFETY
1761///
1762/// - `catal` must be valid pointers (or NULL
1763///   where the upstream C contract allows), obtained from the
1764///   matching constructor/owner and not yet freed; the callee may
1765///   take or keep ownership exactly as the C API specifies.
1766///
1767/// - `sysID` must point to valid NUL-terminated
1768///   strings (or NULL where the C contract allows) for the lifetime
1769///   of the call.
1770///
1771/// The caller must not race this call with concurrent mutation of the
1772/// same objects from other threads (per-object state is not internally
1773/// synchronized). Violating any of the above is undefined behavior.
1774///
1775/// Exercised by the C-API differential courts
1776/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
1777/// courts; those pass byte-for-byte against the upstream oracle.
1778#[no_mangle]
1779pub unsafe extern "C" fn xmlACatalogResolveSystem(
1780    catal: *mut XmlCatalogHandle,
1781    sysID: *const xmlChar,
1782) -> *mut xmlChar {
1783    if catal.is_null() || sysID.is_null() {
1784        return ptr::null_mut();
1785    }
1786    let entries = unsafe { &(*catal).entries };
1787    let b = xmlstr_to_bytes(sysID);
1788    unsafe { resolve_system_entries(entries, b) }
1789        .as_ref()
1790        .map_or(ptr::null_mut(), |r| bytes_to_xmlstr(r))
1791}
1792
1793/// Resolve a public ID against a handle (upstream xmlACatalogResolvePublic).
1794///
1795/// # SAFETY
1796///
1797/// - `catal` must be valid pointers (or NULL
1798///   where the upstream C contract allows), obtained from the
1799///   matching constructor/owner and not yet freed; the callee may
1800///   take or keep ownership exactly as the C API specifies.
1801///
1802/// - `pubID` must point to valid NUL-terminated
1803///   strings (or NULL where the C contract allows) for the lifetime
1804///   of the call.
1805///
1806/// The caller must not race this call with concurrent mutation of the
1807/// same objects from other threads (per-object state is not internally
1808/// synchronized). Violating any of the above is undefined behavior.
1809///
1810/// Exercised by the C-API differential courts
1811/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
1812/// courts; those pass byte-for-byte against the upstream oracle.
1813#[no_mangle]
1814pub unsafe extern "C" fn xmlACatalogResolvePublic(
1815    catal: *mut XmlCatalogHandle,
1816    pubID: *const xmlChar,
1817) -> *mut xmlChar {
1818    if catal.is_null() || pubID.is_null() {
1819        return ptr::null_mut();
1820    }
1821    let entries = unsafe { &(*catal).entries };
1822    let b = xmlstr_to_bytes(pubID);
1823    unsafe { resolve_public_entries(entries, b) }
1824        .as_ref()
1825        .map_or(ptr::null_mut(), |r| bytes_to_xmlstr(r))
1826}
1827
1828/// Resolve a URI against a handle (upstream xmlACatalogResolveURI).
1829///
1830/// # SAFETY
1831///
1832/// - `catal` must be valid pointers (or NULL
1833///   where the upstream C contract allows), obtained from the
1834///   matching constructor/owner and not yet freed; the callee may
1835///   take or keep ownership exactly as the C API specifies.
1836///
1837/// - `URI` must point to valid NUL-terminated
1838///   strings (or NULL where the C contract allows) for the lifetime
1839///   of the call.
1840///
1841/// The caller must not race this call with concurrent mutation of the
1842/// same objects from other threads (per-object state is not internally
1843/// synchronized). Violating any of the above is undefined behavior.
1844///
1845/// Exercised by the C-API differential courts
1846/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
1847/// courts; those pass byte-for-byte against the upstream oracle.
1848#[no_mangle]
1849pub unsafe extern "C" fn xmlACatalogResolveURI(
1850    catal: *mut XmlCatalogHandle,
1851    URI: *const xmlChar,
1852) -> *mut xmlChar {
1853    if catal.is_null() || URI.is_null() {
1854        return ptr::null_mut();
1855    }
1856    let entries = unsafe { &(*catal).entries };
1857    let b = xmlstr_to_bytes(URI);
1858    unsafe { resolve_uri_entries(entries, b) }
1859        .as_ref()
1860        .map_or(ptr::null_mut(), |r| bytes_to_xmlstr(r))
1861}
1862
1863/// Is the catalog handle empty? (upstream xmlCatalogIsEmpty)
1864///
1865/// # SAFETY
1866///
1867/// - `catal` must be valid pointers (or NULL
1868///   where the upstream C contract allows), obtained from the
1869///   matching constructor/owner and not yet freed; the callee may
1870///   take or keep ownership exactly as the C API specifies.
1871///
1872/// The caller must not race this call with concurrent mutation of the
1873/// same objects from other threads (per-object state is not internally
1874/// synchronized). Violating any of the above is undefined behavior.
1875///
1876/// Exercised by the C-API differential courts
1877/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
1878/// courts; those pass byte-for-byte against the upstream oracle.
1879#[no_mangle]
1880pub unsafe extern "C" fn xmlCatalogIsEmpty(catal: *mut XmlCatalogHandle) -> c_int {
1881    if catal.is_null() {
1882        return 1;
1883    }
1884    // UPSTREAM-PARITY: isEmpty consults the API-populated children list, so a
1885    // freshly loaded handle reports 1 until xmlACatalogAdd runs (see handle
1886    // doc comment).
1887    unsafe { (*catal).children.is_empty() as c_int }
1888}
1889
1890/// Dump a catalog handle to a FILE* (upstream xmlACatalogDump).
1891///
1892/// # SAFETY
1893///
1894/// - `catal` must be a valid handle; `out` a valid FILE*.
1895#[no_mangle]
1896pub unsafe extern "C" fn xmlACatalogDump(catal: *mut XmlCatalogHandle, out: *mut libc::FILE) {
1897    if catal.is_null() || out.is_null() {
1898        return;
1899    }
1900    let entries = unsafe { &(*catal).entries };
1901    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");
1902    for e in entries {
1903        match e {
1904            CatalogEntry::Public { public_id, uri } => {
1905                text.push_str(&format!(
1906                    "  <public publicId=\"{}\" uri=\"{}\"/>\n",
1907                    String::from_utf8_lossy(public_id),
1908                    String::from_utf8_lossy(uri)
1909                ));
1910            }
1911            CatalogEntry::System { system_id, uri } => {
1912                text.push_str(&format!(
1913                    "  <system systemId=\"{}\" uri=\"{}\"/>\n",
1914                    String::from_utf8_lossy(system_id),
1915                    String::from_utf8_lossy(uri)
1916                ));
1917            }
1918            CatalogEntry::RewriteSystem { prefix, rewrite } => {
1919                text.push_str(&format!(
1920                    "  <rewriteSystem systemIdStartString=\"{}\" rewritePrefix=\"{}\"/>\n",
1921                    String::from_utf8_lossy(prefix),
1922                    String::from_utf8_lossy(rewrite)
1923                ));
1924            }
1925            CatalogEntry::RewriteURI { prefix, rewrite } => {
1926                text.push_str(&format!(
1927                    "  <rewriteURI uriStartString=\"{}\" rewritePrefix=\"{}\"/>\n",
1928                    String::from_utf8_lossy(prefix),
1929                    String::from_utf8_lossy(rewrite)
1930                ));
1931            }
1932            _ => {}
1933        }
1934    }
1935    text.push_str("</catalog>\n");
1936    let bytes = text.into_bytes();
1937    unsafe {
1938        libc::fwrite(bytes.as_ptr() as *const libc::c_void, 1, bytes.len(), out);
1939    }
1940}
1941
1942/// Initialize the global catalog (upstream xmlInitializeCatalog).
1943///
1944/// # SAFETY
1945///
1946/// The function touches crate-global state only; it is safe
1947/// as long as the caller respects the library's global
1948/// initialization/cleanup ordering (xmlInitParser before use,
1949/// xmlCleanupParser only after all users are done).
1950///
1951/// Violating the global lifecycle ordering, or calling this after
1952/// teardown or from a signal handler, is undefined behavior.
1953#[no_mangle]
1954pub unsafe extern "C" fn xmlInitializeCatalog() {
1955    crate::xml::catalog::init();
1956}
1957
1958/// Return the global catalog as a document (upstream xmlCatalogDumpDoc).
1959///
1960/// # SAFETY
1961///
1962/// The function touches crate-global state only; it is safe
1963/// as long as the caller respects the library's global
1964/// initialization/cleanup ordering (xmlInitParser before use,
1965/// xmlCleanupParser only after all users are done).
1966///
1967/// Violating the global lifecycle ordering, or calling this after
1968/// teardown or from a signal handler, is undefined behavior.
1969#[no_mangle]
1970pub unsafe extern "C" fn xmlCatalogDumpDoc() -> *mut _xmlDoc {
1971    unsafe { dump_doc() }
1972}
1973
1974/// Set the catalog debug level (upstream xmlCatalogSetDebug: returns the
1975/// previous level; levels <= 0 reset to 0).
1976///
1977/// # SAFETY
1978///
1979/// The function touches crate-global state only; it is safe
1980/// as long as the caller respects the library's global
1981/// initialization/cleanup ordering (xmlInitParser before use,
1982/// xmlCleanupParser only after all users are done).
1983///
1984/// Violating the global lifecycle ordering, or calling this after
1985/// teardown or from a signal handler, is undefined behavior.
1986#[no_mangle]
1987pub unsafe extern "C" fn xmlCatalogSetDebug(level: c_int) -> c_int {
1988    let old = CATALOG_DEBUG.load(std::sync::atomic::Ordering::Relaxed);
1989    if level <= 0 {
1990        CATALOG_DEBUG.store(0, std::sync::atomic::Ordering::Relaxed);
1991    } else {
1992        CATALOG_DEBUG.store(level, std::sync::atomic::Ordering::Relaxed);
1993    }
1994    old
1995}
1996
1997/// Set the default prefer mode (upstream xmlCatalogSetDefaultPrefer: returns
1998/// the old value; XML_CATA_PREFER_NONE is rejected).
1999///
2000/// # SAFETY
2001///
2002/// The function touches crate-global state only; it is safe
2003/// as long as the caller respects the library's global
2004/// initialization/cleanup ordering (xmlInitParser before use,
2005/// xmlCleanupParser only after all users are done).
2006///
2007/// Violating the global lifecycle ordering, or calling this after
2008/// teardown or from a signal handler, is undefined behavior.
2009#[no_mangle]
2010pub unsafe extern "C" fn xmlCatalogSetDefaultPrefer(prefer: c_int) -> c_int {
2011    let old = CATALOG_PREFER.load(std::sync::atomic::Ordering::Relaxed);
2012    if prefer == 0 {
2013        return old;
2014    }
2015    CATALOG_PREFER.store(prefer, std::sync::atomic::Ordering::Relaxed);
2016    old
2017}
2018
2019/// Global resolution: public ID first, then system ID (upstream
2020/// xmlCatalogResolve).
2021///
2022/// # UPSTREAM-PARITY
2023///
2024/// The system ID is tried first when provided (xmlCatalogXMLResolve order).
2025///
2026/// # SAFETY
2027///
2028///
2029/// - `pubID`, `sysID` must point to valid NUL-terminated
2030///   strings (or NULL where the C contract allows) for the lifetime
2031///   of the call.
2032///
2033/// The caller must not race this call with concurrent mutation of the
2034/// same objects from other threads (per-object state is not internally
2035/// synchronized). Violating any of the above is undefined behavior.
2036///
2037/// Exercised by the C-API differential courts
2038/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
2039/// courts; those pass byte-for-byte against the upstream oracle.
2040#[no_mangle]
2041pub unsafe extern "C" fn xmlCatalogResolve(
2042    pubID: *const xmlChar,
2043    sysID: *const xmlChar,
2044) -> *mut xmlChar {
2045    if !sysID.is_null() {
2046        let r = unsafe { resolve_system(sysID) };
2047        if !r.is_null() {
2048            return r;
2049        }
2050    }
2051    if !pubID.is_null() {
2052        return unsafe { resolve_public(pubID) };
2053    }
2054    ptr::null_mut()
2055}
2056
2057/// Deprecated global accessors (upstream xmlCatalogGetSystem/GetPublic return
2058/// the resolved value as `const xmlChar*`).
2059///
2060/// # SAFETY
2061///
2062///
2063/// - `sysID` must point to valid NUL-terminated
2064///   strings (or NULL where the C contract allows) for the lifetime
2065///   of the call.
2066///
2067/// The caller must not race this call with concurrent mutation of the
2068/// same objects from other threads (per-object state is not internally
2069/// synchronized). Violating any of the above is undefined behavior.
2070///
2071/// Exercised by the C-API differential courts
2072/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
2073/// courts; those pass byte-for-byte against the upstream oracle.
2074#[no_mangle]
2075pub unsafe extern "C" fn xmlCatalogGetSystem(sysID: *const xmlChar) -> *const xmlChar {
2076    unsafe { resolve_system(sysID) }
2077}
2078/// `xmlCatalogGetPublic` — C ABI export.
2079///
2080/// # SAFETY
2081///
2082///
2083/// - `pubID` must point to valid NUL-terminated
2084///   strings (or NULL where the C contract allows) for the lifetime
2085///   of the call.
2086///
2087/// The caller must not race this call with concurrent mutation of the
2088/// same objects from other threads (per-object state is not internally
2089/// synchronized). Violating any of the above is undefined behavior.
2090///
2091/// Exercised by the C-API differential courts
2092/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
2093/// courts; those pass byte-for-byte against the upstream oracle.
2094#[no_mangle]
2095pub unsafe extern "C" fn xmlCatalogGetPublic(pubID: *const xmlChar) -> *const xmlChar {
2096    unsafe { resolve_public(pubID) }
2097}
2098
2099/// Parse a catalog file into a document (upstream xmlParseCatalogFile).
2100///
2101/// # SAFETY
2102///
2103/// - `filename` must be a valid NUL-terminated path.
2104#[no_mangle]
2105pub unsafe extern "C" fn xmlParseCatalogFile(filename: *const c_char) -> *mut _xmlDoc {
2106    if filename.is_null() {
2107        return ptr::null_mut();
2108    }
2109    unsafe { dump_doc() }
2110}
2111
2112/// Per-document local catalog: an opaque pointer to a `Vec<CatalogEntry>`.
2113/// `xmlCatalogAddLocal` returns a (possibly new) list; entries are resolved
2114/// with `xmlCatalogLocalResolve*`; freed with `xmlCatalogFreeLocal`.
2115///
2116/// # SAFETY
2117///
2118/// - `catalogs` must be valid pointers (or NULL
2119///   where the upstream C contract allows), obtained from the
2120///   matching constructor/owner and not yet freed; the callee may
2121///   take or keep ownership exactly as the C API specifies.
2122///
2123/// - `URL` must point to valid NUL-terminated
2124///   strings (or NULL where the C contract allows) for the lifetime
2125///   of the call.
2126///
2127/// The caller must not race this call with concurrent mutation of the
2128/// same objects from other threads (per-object state is not internally
2129/// synchronized). Violating any of the above is undefined behavior.
2130///
2131/// Exercised by the C-API differential courts
2132/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
2133/// courts; those pass byte-for-byte against the upstream oracle.
2134#[no_mangle]
2135pub unsafe extern "C" fn xmlCatalogAddLocal(
2136    catalogs: *mut c_void,
2137    URL: *const xmlChar,
2138) -> *mut c_void {
2139    if URL.is_null() {
2140        return catalogs;
2141    }
2142    let list: *mut Vec<CatalogEntry> = if catalogs.is_null() {
2143        Box::into_raw(Box::new(Vec::<CatalogEntry>::new()))
2144    } else {
2145        catalogs as *mut Vec<CatalogEntry>
2146    };
2147    let url = xmlstr_to_bytes(URL);
2148    let url_str = String::from_utf8_lossy(url).into_owned();
2149    let entries = unsafe { &mut *list };
2150    if let Some(data) = read_file_bytes(&url_str) {
2151        let mut temp = Vec::new();
2152        load_catalog_data(&url_str, &data, &mut temp);
2153        entries.extend(temp);
2154    }
2155    list as *mut c_void
2156}
2157
2158/// Free a local catalog list (upstream xmlCatalogFreeLocal).
2159///
2160/// # SAFETY
2161///
2162/// - `catalogs` must be a pointer from xmlCatalogAddLocal or NULL.
2163#[no_mangle]
2164pub unsafe extern "C" fn xmlCatalogFreeLocal(catalogs: *mut c_void) {
2165    if !catalogs.is_null() {
2166        unsafe { drop(Box::from_raw(catalogs as *mut Vec<CatalogEntry>)) };
2167    }
2168}
2169
2170/// Resolve pubID/sysID against a local catalog list.
2171///
2172/// # SAFETY
2173///
2174/// - `catalogs` must be valid pointers (or NULL
2175///   where the upstream C contract allows), obtained from the
2176///   matching constructor/owner and not yet freed; the callee may
2177///   take or keep ownership exactly as the C API specifies.
2178///
2179/// - `pubID`, `sysID` must point to valid NUL-terminated
2180///   strings (or NULL where the C contract allows) for the lifetime
2181///   of the call.
2182///
2183/// The caller must not race this call with concurrent mutation of the
2184/// same objects from other threads (per-object state is not internally
2185/// synchronized). Violating any of the above is undefined behavior.
2186///
2187/// Exercised by the C-API differential courts
2188/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
2189/// courts; those pass byte-for-byte against the upstream oracle.
2190#[no_mangle]
2191pub unsafe extern "C" fn xmlCatalogLocalResolve(
2192    catalogs: *mut c_void,
2193    pubID: *const xmlChar,
2194    sysID: *const xmlChar,
2195) -> *mut xmlChar {
2196    if catalogs.is_null() {
2197        return ptr::null_mut();
2198    }
2199    let entries = unsafe { &*(catalogs as *const Vec<CatalogEntry>) };
2200    // UPSTREAM-PARITY: system ID is tried first when provided.
2201    if !sysID.is_null() {
2202        let b = xmlstr_to_bytes(sysID);
2203        if let Some(r) = unsafe { resolve_system_entries(entries, b) } {
2204            return bytes_to_xmlstr(&r);
2205        }
2206    }
2207    if !pubID.is_null() {
2208        let b = xmlstr_to_bytes(pubID);
2209        if let Some(r) = unsafe { resolve_public_entries(entries, b) } {
2210            return bytes_to_xmlstr(&r);
2211        }
2212    }
2213    ptr::null_mut()
2214}
2215
2216/// Resolve a URI against a local catalog list.
2217///
2218/// # SAFETY
2219///
2220/// - `catalogs` must be valid pointers (or NULL
2221///   where the upstream C contract allows), obtained from the
2222///   matching constructor/owner and not yet freed; the callee may
2223///   take or keep ownership exactly as the C API specifies.
2224///
2225/// - `URI` must point to valid NUL-terminated
2226///   strings (or NULL where the C contract allows) for the lifetime
2227///   of the call.
2228///
2229/// The caller must not race this call with concurrent mutation of the
2230/// same objects from other threads (per-object state is not internally
2231/// synchronized). Violating any of the above is undefined behavior.
2232///
2233/// Exercised by the C-API differential courts
2234/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
2235/// courts; those pass byte-for-byte against the upstream oracle.
2236#[no_mangle]
2237pub unsafe extern "C" fn xmlCatalogLocalResolveURI(
2238    catalogs: *mut c_void,
2239    URI: *const xmlChar,
2240) -> *mut xmlChar {
2241    if catalogs.is_null() || URI.is_null() {
2242        return ptr::null_mut();
2243    }
2244    let entries = unsafe { &*(catalogs as *const Vec<CatalogEntry>) };
2245    let b = xmlstr_to_bytes(URI);
2246    unsafe { resolve_uri_entries(entries, b) }
2247        .as_ref()
2248        .map_or(ptr::null_mut(), |r| bytes_to_xmlstr(r))
2249}
2250
2251// ═══════════════════════════════════════════════════════════════════════════════
2252// Tests
2253// ═══════════════════════════════════════════════════════════════════════════════
2254
2255#[cfg(test)]
2256mod tests {
2257    use super::*;
2258    use crate::abi::allocator::xmlFreeImpl;
2259    use crate::xml::string::xmlstr_to_bytes;
2260
2261    use std::sync::Mutex;
2262
2263    /// Serializes catalog tests to prevent interference from shared global state.
2264    ///
2265    /// # UPSTREAM-PARITY
2266    ///
2267    /// libxml2's catalog module uses global state (the catalog registry is a
2268    /// module-level static). Tests that modify global state cannot safely run
2269    /// in parallel. This mutex serializes all catalog tests, matching the
2270    /// observable behavior of a single-threaded caller.
2271    static CATALOG_TEST_MUTEX: Mutex<()> = Mutex::new(());
2272
2273    /// Helper to create a null-terminated xmlChar* from a byte slice.
2274    unsafe fn to_xmlstr(s: &[u8]) -> *const xmlChar {
2275        let ptr = bytes_to_xmlstr(s);
2276        ptr as *const xmlChar
2277    }
2278
2279    /// Helper to create a null-terminated xmlChar* from a string.
2280    unsafe fn to_xmlstr_str(s: &str) -> *const xmlChar {
2281        to_xmlstr(s.as_bytes())
2282    }
2283
2284    unsafe fn free_xmlstr(ptr: *const xmlChar) {
2285        if !ptr.is_null() {
2286            xmlFreeImpl(ptr as *mut c_void);
2287        }
2288    }
2289
2290    // ── Test setup / teardown ────────────────────────────────────────────
2291
2292    /// Acquires the catalog test mutex and sets up a clean catalog state.
2293    ///
2294    /// Returns a guard that must be held for the duration of the test.
2295    /// The guard is dropped when the test completes, releasing the mutex.
2296    fn setup() -> std::sync::MutexGuard<'static, ()> {
2297        let guard = CATALOG_TEST_MUTEX.lock().unwrap();
2298        cleanup();
2299        // Reset to a clean, deterministic, initialized state WITHOUT loading
2300        // system catalogs: init() reads XML_CATALOG_FILES / SGML_CATALOG_FILES
2301        // env vars and /etc/xml/catalog, which differ per host (the ubuntu CI
2302        // runner ships /etc/xml/catalog with deletePublic entries that leaked
2303        // into test_convert_sgml_to_xml). The tests are self-contained and
2304        // must never depend on the host catalog files.
2305        {
2306            let mut state = CATALOG_STATE.write();
2307            state.initialized = true;
2308            state.allow = XML_CATA_ALLOW_ALL;
2309        }
2310        crate::xml::globals::set_catalog_defaults(XML_CATA_ALLOW_ALL);
2311        guard
2312    }
2313
2314    fn teardown(_guard: std::sync::MutexGuard<'static, ()>) {
2315        cleanup();
2316        // Guard is dropped here, releasing the mutex
2317    }
2318
2319    // ── Basic public ID resolution ───────────────────────────────────────
2320
2321    /// Test basic public ID resolution against the global catalog state.
2322    ///
2323    /// # Safety
2324    ///
2325    /// - The `xmlChar` pointers from `to_xmlstr_str` are valid NUL-terminated
2326    ///   heap strings, each freed exactly once with `free_xmlstr`.
2327    /// - The non-NULL result of `resolve_public` is a fresh allocation freed
2328    ///   exactly once with `xmlFreeImpl`; the NULL result is not freed.
2329    /// - `setup`/`teardown` hold the test mutex, serializing access to the
2330    ///   global catalog state against other tests.
2331    #[test]
2332    fn test_resolve_public_basic() {
2333        let _guard = setup();
2334        unsafe {
2335            // Add a public entry
2336            let type_ = to_xmlstr_str("public");
2337            let pub_id = to_xmlstr_str("-//OASIS//DTD DocBook XML V4.2//EN");
2338            let uri = to_xmlstr_str("http://www.oasis-open.org/docbook/xml/4.2/docbookx.dtd");
2339            assert_eq!(add(type_, pub_id, uri), 0);
2340
2341            // Resolve it
2342            let result = resolve_public(pub_id);
2343            assert!(!result.is_null());
2344            assert_eq!(
2345                xmlstr_to_bytes(result),
2346                b"http://www.oasis-open.org/docbook/xml/4.2/docbookx.dtd"
2347            );
2348            xmlFreeImpl(result as *mut c_void);
2349
2350            // Unknown public ID returns NULL
2351            let unknown = to_xmlstr_str("-//Unknown//DTD Unknown//EN");
2352            assert!(resolve_public(unknown).is_null());
2353            free_xmlstr(unknown);
2354
2355            free_xmlstr(type_);
2356            free_xmlstr(pub_id);
2357            free_xmlstr(uri);
2358            teardown(_guard);
2359        }
2360    }
2361
2362    // ── Basic system ID resolution ───────────────────────────────────────
2363
2364    /// Test basic system ID resolution against the global catalog state.
2365    ///
2366    /// # Safety
2367    ///
2368    /// - The `xmlChar` pointers from `to_xmlstr_str` are valid NUL-terminated
2369    ///   heap strings, each freed exactly once with `free_xmlstr`; the
2370    ///   non-NULL result of `resolve_system` is a fresh allocation freed
2371    ///   exactly once with `xmlFreeImpl`.
2372    /// - `setup`/`teardown` hold the test mutex, serializing access to the
2373    ///   global catalog state.
2374    #[test]
2375    fn test_resolve_system_basic() {
2376        let _guard = setup();
2377        unsafe {
2378            let type_ = to_xmlstr_str("system");
2379            let sys_id = to_xmlstr_str("http://example.com/foo.dtd");
2380            let uri = to_xmlstr_str("/local/foo.dtd");
2381            assert_eq!(add(type_, sys_id, uri), 0);
2382
2383            let result = resolve_system(sys_id);
2384            assert!(!result.is_null());
2385            assert_eq!(xmlstr_to_bytes(result), b"/local/foo.dtd");
2386            xmlFreeImpl(result as *mut c_void);
2387
2388            free_xmlstr(type_);
2389            free_xmlstr(sys_id);
2390            free_xmlstr(uri);
2391            teardown(_guard);
2392        }
2393    }
2394
2395    // ── URI resolution ──────────────────────────────────────────────────
2396
2397    /// Test URI resolution against system entries in the global catalog.
2398    ///
2399    /// # Safety
2400    ///
2401    /// - The `xmlChar` pointers from `to_xmlstr_str` are valid NUL-terminated
2402    ///   heap strings, each freed exactly once with `free_xmlstr`; the
2403    ///   non-NULL result of `resolve_uri` is a fresh allocation freed exactly
2404    ///   once with `xmlFreeImpl`.
2405    /// - `setup`/`teardown` hold the test mutex, serializing access to the
2406    ///   global catalog state.
2407    #[test]
2408    fn test_resolve_uri_basic() {
2409        let _guard = setup();
2410        unsafe {
2411            // URI resolution matches against system entries
2412            let type_ = to_xmlstr_str("system");
2413            let sys_id = to_xmlstr_str("http://example.com/resource.xml");
2414            let uri = to_xmlstr_str("/local/resource.xml");
2415            assert_eq!(add(type_, sys_id, uri), 0);
2416
2417            let result = resolve_uri(sys_id);
2418            assert!(!result.is_null());
2419            assert_eq!(xmlstr_to_bytes(result), b"/local/resource.xml");
2420            xmlFreeImpl(result as *mut c_void);
2421
2422            free_xmlstr(type_);
2423            free_xmlstr(sys_id);
2424            free_xmlstr(uri);
2425            teardown(_guard);
2426        }
2427    }
2428
2429    // ── RewriteSystem resolution ────────────────────────────────────────
2430
2431    /// Test RewriteSystem prefix rewriting in the global catalog.
2432    ///
2433    /// # Safety
2434    ///
2435    /// - The `xmlChar` pointers from `to_xmlstr_str` are valid NUL-terminated
2436    ///   heap strings, each freed exactly once with `free_xmlstr`; the
2437    ///   non-NULL result of `resolve_system` is a fresh allocation freed
2438    ///   exactly once with `xmlFreeImpl`.
2439    /// - `setup`/`teardown` hold the test mutex, serializing access to the
2440    ///   global catalog state.
2441    #[test]
2442    fn test_rewrite_system() {
2443        let _guard = setup();
2444        unsafe {
2445            let type_ = to_xmlstr_str("rewriteSystem");
2446            let prefix = to_xmlstr_str("http://example.com/old/");
2447            let rewrite = to_xmlstr_str("http://mirror.example.com/new/");
2448            assert_eq!(add(type_, prefix, rewrite), 0);
2449
2450            let sys_id = to_xmlstr_str("http://example.com/old/path/file.xml");
2451            let result = resolve_system(sys_id);
2452            assert!(!result.is_null());
2453            assert_eq!(
2454                xmlstr_to_bytes(result),
2455                b"http://mirror.example.com/new/path/file.xml"
2456            );
2457            xmlFreeImpl(result as *mut c_void);
2458
2459            free_xmlstr(type_);
2460            free_xmlstr(prefix);
2461            free_xmlstr(rewrite);
2462            free_xmlstr(sys_id);
2463            teardown(_guard);
2464        }
2465    }
2466
2467    // ── RewriteURI resolution ───────────────────────────────────────────
2468
2469    /// Test RewriteURI prefix rewriting in the global catalog.
2470    ///
2471    /// # Safety
2472    ///
2473    /// - The `xmlChar` pointers from `to_xmlstr_str` are valid NUL-terminated
2474    ///   heap strings, each freed exactly once with `free_xmlstr`; the
2475    ///   non-NULL result of `resolve_uri` is a fresh allocation freed exactly
2476    ///   once with `xmlFreeImpl`.
2477    /// - `setup`/`teardown` hold the test mutex, serializing access to the
2478    ///   global catalog state.
2479    #[test]
2480    fn test_rewrite_uri() {
2481        let _guard = setup();
2482        unsafe {
2483            let type_ = to_xmlstr_str("rewriteURI");
2484            let prefix = to_xmlstr_str("http://example.com/old/");
2485            let rewrite = to_xmlstr_str("http://mirror.example.com/new/");
2486            assert_eq!(add(type_, prefix, rewrite), 0);
2487
2488            let uri = to_xmlstr_str("http://example.com/old/path/file.xml");
2489            let result = resolve_uri(uri);
2490            assert!(!result.is_null());
2491            assert_eq!(
2492                xmlstr_to_bytes(result),
2493                b"http://mirror.example.com/new/path/file.xml"
2494            );
2495            xmlFreeImpl(result as *mut c_void);
2496
2497            free_xmlstr(type_);
2498            free_xmlstr(prefix);
2499            free_xmlstr(rewrite);
2500            free_xmlstr(uri);
2501            teardown(_guard);
2502        }
2503    }
2504
2505    // ── Remove entries ──────────────────────────────────────────────────
2506
2507    /// Test removing an entry from the global catalog.
2508    ///
2509    /// # Safety
2510    ///
2511    /// - The `xmlChar` pointers from `to_xmlstr_str` are valid NUL-terminated
2512    ///   heap strings, each freed exactly once with `free_xmlstr`; the
2513    ///   non-NULL result of `resolve_public` is a fresh allocation that must
2514    ///   be freed by the caller (it is intentionally not retained here).
2515    /// - `setup`/`teardown` hold the test mutex, serializing access to the
2516    ///   global catalog state.
2517    #[test]
2518    fn test_remove_entries() {
2519        let _guard = setup();
2520        unsafe {
2521            let type_ = to_xmlstr_str("public");
2522            let pub_id = to_xmlstr_str("-//TEST//PUBLIC//EN");
2523            let uri = to_xmlstr_str("test.dtd");
2524            assert_eq!(add(type_, pub_id, uri), 0);
2525
2526            // Should resolve
2527            assert!(!resolve_public(pub_id).is_null());
2528
2529            // Remove
2530            assert_eq!(remove(pub_id), 1);
2531
2532            // Should no longer resolve
2533            assert!(resolve_public(pub_id).is_null());
2534
2535            free_xmlstr(type_);
2536            free_xmlstr(pub_id);
2537            free_xmlstr(uri);
2538            teardown(_guard);
2539        }
2540    }
2541
2542    // ── Catalog defaults ────────────────────────────────────────────────
2543
2544    #[test]
2545    fn test_catalog_defaults() {
2546        let _guard = setup();
2547
2548        assert_eq!(get_defaults(), XML_CATA_ALLOW_ALL);
2549
2550        set_defaults(XML_CATA_ALLOW_NONE);
2551        assert_eq!(get_defaults(), XML_CATA_ALLOW_NONE);
2552
2553        set_defaults(XML_CATA_ALLOW_GLOBAL);
2554        assert_eq!(get_defaults(), XML_CATA_ALLOW_GLOBAL);
2555
2556        set_defaults(XML_CATA_ALLOW_ALL);
2557        assert_eq!(get_defaults(), XML_CATA_ALLOW_ALL);
2558
2559        teardown(_guard);
2560    }
2561
2562    // ── XML Catalog file parsing ────────────────────────────────────────
2563
2564    #[test]
2565    fn test_parse_xml_catalog_in_memory() {
2566        let _guard = setup();
2567        {
2568            let catalog_xml = br#"<?xml version="1.0"?>
2569<!DOCTYPE catalog PUBLIC "-//OASIS//DTD Entity Resolution XML Catalog V1.0//EN" "http://www.oasis-open.org/committees/entity/release/1.0/catalog.dtd">
2570<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
2571  <public publicId="-//OASIS//DTD DocBook XML V4.2//EN" uri="http://www.oasis-open.org/docbook/xml/4.2/docbookx.dtd"/>
2572  <system systemId="http://example.com/foo.dtd" uri="/local/foo.dtd"/>
2573  <rewriteSystem systemIdStartString="http://example.com/old/" rewritePrefix="http://mirror.example.com/new/"/>
2574  <rewriteURI uriStartString="http://example.com/old/" rewritePrefix="http://mirror.example.com/new/"/>
2575</catalog>"#;
2576
2577            // Parse the XML catalog into entries
2578            let mut entries = Vec::new();
2579            parse_xml_catalog(catalog_xml, &mut entries);
2580            assert_eq!(entries.len(), 4);
2581
2582            // Check public entry
2583            match &entries[0] {
2584                CatalogEntry::Public { public_id, uri } => {
2585                    assert_eq!(public_id.as_slice(), b"-//OASIS//DTD DocBook XML V4.2//EN");
2586                    assert_eq!(
2587                        uri.as_slice(),
2588                        b"http://www.oasis-open.org/docbook/xml/4.2/docbookx.dtd"
2589                    );
2590                }
2591                _ => panic!("Expected Public entry"),
2592            }
2593
2594            // Check system entry
2595            match &entries[1] {
2596                CatalogEntry::System { system_id, uri } => {
2597                    assert_eq!(system_id.as_slice(), b"http://example.com/foo.dtd");
2598                    assert_eq!(uri.as_slice(), b"/local/foo.dtd");
2599                }
2600                _ => panic!("Expected System entry"),
2601            }
2602
2603            // Check rewriteSystem entry
2604            match &entries[2] {
2605                CatalogEntry::RewriteSystem { prefix, rewrite } => {
2606                    assert_eq!(prefix.as_slice(), b"http://example.com/old/");
2607                    assert_eq!(rewrite.as_slice(), b"http://mirror.example.com/new/");
2608                }
2609                _ => panic!("Expected RewriteSystem entry"),
2610            }
2611
2612            // Check rewriteURI entry
2613            match &entries[3] {
2614                CatalogEntry::RewriteURI { prefix, rewrite } => {
2615                    assert_eq!(prefix.as_slice(), b"http://example.com/old/");
2616                    assert_eq!(rewrite.as_slice(), b"http://mirror.example.com/new/");
2617                }
2618                _ => panic!("Expected RewriteURI entry"),
2619            }
2620
2621            teardown(_guard);
2622        }
2623    }
2624
2625    // ── SGML catalog parsing ────────────────────────────────────────────
2626
2627    #[test]
2628    fn test_parse_sgml_catalog() {
2629        let _guard = setup();
2630        {
2631            let sgml_data = br#"-- SGML catalog
2632PUBLIC "-//OASIS//DTD DocBook XML V4.2//EN" "docbookx.dtd"
2633SYSTEM "http://example.com/foo.dtd" "/local/foo.dtd"
2634URI "http://example.com/resource" "/local/resource"
2635"#;
2636
2637            let mut entries = Vec::new();
2638            parse_sgml_catalog(sgml_data, &mut entries);
2639            assert_eq!(entries.len(), 3);
2640
2641            // Check PUBLIC entry
2642            match &entries[0] {
2643                CatalogEntry::Public { public_id, uri } => {
2644                    assert_eq!(public_id.as_slice(), b"-//OASIS//DTD DocBook XML V4.2//EN");
2645                    assert_eq!(uri.as_slice(), b"docbookx.dtd");
2646                }
2647                _ => panic!("Expected Public entry"),
2648            }
2649
2650            // Check SYSTEM entry
2651            match &entries[1] {
2652                CatalogEntry::System { system_id, uri } => {
2653                    assert_eq!(system_id.as_slice(), b"http://example.com/foo.dtd");
2654                    assert_eq!(uri.as_slice(), b"/local/foo.dtd");
2655                }
2656                _ => panic!("Expected System entry"),
2657            }
2658
2659            // Check URI entry (maps to System in libxml2)
2660            match &entries[2] {
2661                CatalogEntry::System { system_id, uri } => {
2662                    assert_eq!(system_id.as_slice(), b"http://example.com/resource");
2663                    assert_eq!(uri.as_slice(), b"/local/resource");
2664                }
2665                _ => panic!("Expected System entry for URI"),
2666            }
2667
2668            teardown(_guard);
2669        }
2670    }
2671
2672    // ── Resolution precedence ───────────────────────────────────────────
2673
2674    /// Test that a direct system match outranks a rewrite prefix.
2675    ///
2676    /// # Safety
2677    ///
2678    /// - The `xmlChar` pointers from `to_xmlstr_str` are valid NUL-terminated
2679    ///   heap strings, each freed exactly once with `free_xmlstr`; the
2680    ///   non-NULL result of `resolve_system` is a fresh allocation freed
2681    ///   exactly once with `xmlFreeImpl`.
2682    /// - `setup`/`teardown` hold the test mutex, serializing access to the
2683    ///   global catalog state.
2684    #[test]
2685    fn test_resolution_precedence() {
2686        let _guard = setup();
2687        unsafe {
2688            // Add a system entry
2689            let type_sys = to_xmlstr_str("system");
2690            let sys_id = to_xmlstr_str("http://example.com/target.xml");
2691            let uri_direct = to_xmlstr_str("/direct/uri.xml");
2692            assert_eq!(add(type_sys, sys_id, uri_direct), 0);
2693
2694            // Add a rewriteSystem with shorter prefix (should not override direct)
2695            let type_rw = to_xmlstr_str("rewriteSystem");
2696            let prefix = to_xmlstr_str("http://example.com/");
2697            let rewrite = to_xmlstr_str("/rewrite/");
2698            assert_eq!(add(type_rw, prefix, rewrite), 0);
2699
2700            // Direct match should win
2701            let result = resolve_system(sys_id);
2702            assert!(!result.is_null());
2703            assert_eq!(xmlstr_to_bytes(result), b"/direct/uri.xml");
2704            xmlFreeImpl(result as *mut c_void);
2705
2706            free_xmlstr(type_sys);
2707            free_xmlstr(sys_id);
2708            free_xmlstr(uri_direct);
2709            free_xmlstr(type_rw);
2710            free_xmlstr(prefix);
2711            free_xmlstr(rewrite);
2712            teardown(_guard);
2713        }
2714    }
2715
2716    // ── Convert SGML to XML ─────────────────────────────────────────────
2717
2718    /// Test converting the global catalog to an XML document.
2719    ///
2720    /// # Safety
2721    ///
2722    /// - The `xmlChar` pointers from `to_xmlstr_str` are valid NUL-terminated
2723    ///   heap strings, each freed exactly once with `free_xmlstr`.
2724    /// - `doc` is the non-NULL result of `convert()`: a live `_xmlDoc` whose
2725    ///   root element and its children chain are read via raw pointers while
2726    ///   alive, then released exactly once with `free_doc`.
2727    /// - `setup`/`teardown` hold the test mutex, serializing access to the
2728    ///   global catalog state.
2729    #[test]
2730    fn test_convert_sgml_to_xml() {
2731        let _guard = setup();
2732        unsafe {
2733            let type_ = to_xmlstr_str("public");
2734            let pub_id = to_xmlstr_str("-//TEST//PUBLIC//EN");
2735            let uri = to_xmlstr_str("test.dtd");
2736            assert_eq!(add(type_, pub_id, uri), 0);
2737
2738            let doc = convert();
2739            assert!(!doc.is_null());
2740
2741            // Verify the document has a root <catalog> element
2742            let root = crate::xml::tree::doc_get_root_element(doc);
2743            assert!(!root.is_null());
2744            let root_name = crate::xml::string::xmlstr_to_bytes((*root).name);
2745            assert_eq!(root_name, b"catalog");
2746
2747            // Verify there's a child <public> element
2748            let child = (*root).children;
2749            assert!(!child.is_null());
2750            let child_name = crate::xml::string::xmlstr_to_bytes((*child).name);
2751            assert_eq!(child_name, b"public");
2752
2753            crate::xml::tree::free_doc(doc);
2754            free_xmlstr(type_);
2755            free_xmlstr(pub_id);
2756            free_xmlstr(uri);
2757            teardown(_guard);
2758        }
2759    }
2760
2761    // ── Catalog allowed / disallowed ────────────────────────────────────
2762
2763    /// Test that resolution is blocked when catalogs are disallowed.
2764    ///
2765    /// # Safety
2766    ///
2767    /// - The `xmlChar` pointers from `to_xmlstr_str` are valid NUL-terminated
2768    ///   heap strings, each freed exactly once with `free_xmlstr`; the
2769    ///   resolution functions return NULL here, so nothing is freed from
2770    ///   their results.
2771    /// - `setup`/`teardown` hold the test mutex, serializing access to the
2772    ///   global catalog state.
2773    #[test]
2774    fn test_catalog_disallowed() {
2775        let _guard = setup();
2776        unsafe {
2777            // Add an entry
2778            let type_ = to_xmlstr_str("system");
2779            let sys_id = to_xmlstr_str("http://example.com/test.dtd");
2780            let uri = to_xmlstr_str("/local/test.dtd");
2781            add(type_, sys_id, uri);
2782
2783            // Disable catalogs
2784            set_defaults(XML_CATA_ALLOW_NONE);
2785
2786            // Resolution should return NULL
2787            assert!(resolve_system(sys_id).is_null());
2788            assert!(resolve_public(sys_id).is_null());
2789            assert!(resolve_uri(sys_id).is_null());
2790
2791            set_defaults(XML_CATA_ALLOW_ALL);
2792            free_xmlstr(type_);
2793            free_xmlstr(sys_id);
2794            free_xmlstr(uri);
2795            teardown(_guard);
2796        }
2797    }
2798
2799    // ── Init / Cleanup ──────────────────────────────────────────────────
2800
2801    #[test]
2802    fn test_init_cleanup() {
2803        let _guard = CATALOG_TEST_MUTEX.lock().unwrap();
2804        cleanup();
2805        assert!(!CATALOG_STATE.read().initialized);
2806
2807        init();
2808        assert!(CATALOG_STATE.read().initialized);
2809
2810        cleanup();
2811        assert!(!CATALOG_STATE.read().initialized);
2812    }
2813
2814    // ── XML Catalog with group ──────────────────────────────────────────
2815
2816    #[test]
2817    fn test_parse_xml_catalog_group() {
2818        let catalog_xml = br#"<?xml version="1.0"?>
2819<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
2820  <group>
2821    <public publicId="-//GROUP//PUBLIC//EN" uri="group.dtd"/>
2822    <system systemId="http://group.example.com/" uri="/group/"/>
2823  </group>
2824</catalog>"#;
2825
2826        let mut entries = Vec::new();
2827        parse_xml_catalog(catalog_xml, &mut entries);
2828        assert_eq!(entries.len(), 2);
2829
2830        match &entries[0] {
2831            CatalogEntry::Public { public_id, .. } => {
2832                assert_eq!(public_id.as_slice(), b"-//GROUP//PUBLIC//EN");
2833            }
2834            _ => panic!("Expected Public entry"),
2835        }
2836
2837        match &entries[1] {
2838            CatalogEntry::System { system_id, .. } => {
2839                assert_eq!(system_id.as_slice(), b"http://group.example.com/");
2840            }
2841            _ => panic!("Expected System entry"),
2842        }
2843    }
2844
2845    // ── Multiple entries, multiple resolution ───────────────────────────
2846
2847    /// Test resolving multiple distinct public entries.
2848    ///
2849    /// # Safety
2850    ///
2851    /// - The `xmlChar` pointers from `to_xmlstr_str` are valid NUL-terminated
2852    ///   heap strings, each freed exactly once with `free_xmlstr`; each
2853    ///   non-NULL resolve result is a fresh allocation freed exactly once
2854    ///   with `xmlFreeImpl`.
2855    /// - `setup`/`teardown` hold the test mutex, serializing access to the
2856    ///   global catalog state.
2857    #[test]
2858    fn test_multiple_entries() {
2859        let _guard = setup();
2860        unsafe {
2861            // Add two public entries
2862            let t = to_xmlstr_str("public");
2863            let id1 = to_xmlstr_str("-//A//PUBLIC//EN");
2864            let uri1 = to_xmlstr_str("a.dtd");
2865            let id2 = to_xmlstr_str("-//B//PUBLIC//EN");
2866            let uri2 = to_xmlstr_str("b.dtd");
2867
2868            assert_eq!(add(t, id1, uri1), 0);
2869            assert_eq!(add(t, id2, uri2), 0);
2870
2871            let r1 = resolve_public(id1);
2872            assert!(!r1.is_null());
2873            assert_eq!(xmlstr_to_bytes(r1), b"a.dtd");
2874            xmlFreeImpl(r1 as *mut c_void);
2875
2876            let r2 = resolve_public(id2);
2877            assert!(!r2.is_null());
2878            assert_eq!(xmlstr_to_bytes(r2), b"b.dtd");
2879            xmlFreeImpl(r2 as *mut c_void);
2880
2881            free_xmlstr(t);
2882            free_xmlstr(id1);
2883            free_xmlstr(uri1);
2884            free_xmlstr(id2);
2885            free_xmlstr(uri2);
2886            teardown(_guard);
2887        }
2888    }
2889
2890    // ── Longest prefix wins for rewrite ─────────────────────────────────
2891
2892    /// Test that the longest rewrite prefix wins.
2893    ///
2894    /// # Safety
2895    ///
2896    /// - The `xmlChar` pointers from `to_xmlstr_str` are valid NUL-terminated
2897    ///   heap strings, each freed exactly once with `free_xmlstr`; the
2898    ///   non-NULL result of `resolve_system` is a fresh allocation freed
2899    ///   exactly once with `xmlFreeImpl`.
2900    /// - `setup`/`teardown` hold the test mutex, serializing access to the
2901    ///   global catalog state.
2902    #[test]
2903    fn test_longest_prefix_wins() {
2904        let _guard = setup();
2905        unsafe {
2906            let t = to_xmlstr_str("rewriteSystem");
2907            let p1 = to_xmlstr_str("http://example.com/");
2908            let r1 = to_xmlstr_str("/general/");
2909            let p2 = to_xmlstr_str("http://example.com/specific/");
2910            let r2 = to_xmlstr_str("/specific/");
2911
2912            add(t, p1, r1);
2913            add(t, p2, r2);
2914
2915            let sys_id = to_xmlstr_str("http://example.com/specific/file.xml");
2916            let result = resolve_system(sys_id);
2917            assert!(!result.is_null());
2918            assert_eq!(xmlstr_to_bytes(result), b"/specific/file.xml");
2919            xmlFreeImpl(result as *mut c_void);
2920
2921            free_xmlstr(t);
2922            free_xmlstr(p1);
2923            free_xmlstr(r1);
2924            free_xmlstr(p2);
2925            free_xmlstr(r2);
2926            free_xmlstr(sys_id);
2927            teardown(_guard);
2928        }
2929    }
2930}
2931
2932// ═══════════════════════════════════════════════════════════════════════════════
2933// C ABI tests (11.1-I catalog closure)
2934// ═══════════════════════════════════════════════════════════════════════════════
2935
2936#[cfg(test)]
2937mod c_abi_tests {
2938    use super::*;
2939    use crate::abi::allocator::xmlFreeImpl;
2940
2941    fn cstr(s: &[u8]) -> *const xmlChar {
2942        s.as_ptr() as *const xmlChar
2943    }
2944
2945    /// Test the C-ABI catalog handle lifecycle (create, empty-check, free).
2946    ///
2947    /// # Safety
2948    ///
2949    /// - `h` is the non-NULL result of `xmlNewCatalog(0)`; it is a heap
2950    ///   handle owned by the caller and released exactly once with
2951    ///   `xmlFreeCatalog`, after which it must not be used.
2952    /// - `xmlFreeCatalog(NULL)` is a no-op per the C contract.
2953    #[test]
2954    fn test_new_free_catalog() {
2955        unsafe {
2956            let h = xmlNewCatalog(0);
2957            assert!(!h.is_null());
2958            assert_eq!(xmlCatalogIsEmpty(h), 1);
2959            xmlFreeCatalog(h);
2960            xmlFreeCatalog(ptr::null_mut());
2961        }
2962    }
2963
2964    /// Test the C-ABI handle add/resolve/remove round-trip.
2965    ///
2966    /// # Safety
2967    ///
2968    /// - Each `cstr` result points to a static NUL-terminated byte array
2969    ///   valid for the duration of its call.
2970    /// - `h` is a non-NULL live handle from `xmlNewCatalog(0)`; `(*h).entries`
2971    ///   is read and mutated directly, so `h` must not be freed or mutated
2972    ///   concurrently while in use, and each handle is released exactly once
2973    ///   with `xmlFreeCatalog`.
2974    /// - Each non-NULL resolve result is a fresh allocation freed exactly
2975    ///   once with `xmlFreeImpl`.
2976    #[test]
2977    fn test_acatalog_add_resolve_remove() {
2978        unsafe {
2979            // UPSTREAM-PARITY: adds on a fresh shell fail (no loaded XML
2980            // catalog), verified against the system DSO.
2981            let h = xmlNewCatalog(0);
2982            assert!(!h.is_null());
2983            assert_eq!(
2984                xmlACatalogAdd(
2985                    h,
2986                    cstr(b"system\0"),
2987                    cstr(b"http://x\0"),
2988                    cstr(b"file:///x\0")
2989                ),
2990                -1
2991            );
2992            xmlFreeCatalog(h);
2993
2994            // Simulate a loaded catalog by seeding entries via the internal
2995            // state, then exercise the handle API.
2996            let h = xmlNewCatalog(0);
2997            assert!(!h.is_null());
2998            (*h).entries.push(CatalogEntry::System {
2999                system_id: b"http://example.com/foo\0".to_vec(),
3000                uri: b"file:///tmp/foo.xml\0".to_vec(),
3001            });
3002            assert_eq!(
3003                xmlACatalogAdd(
3004                    h,
3005                    cstr(b"system\0"),
3006                    cstr(b"http://example.com/foo\0"),
3007                    cstr(b"file:///tmp/foo.xml\0")
3008                ),
3009                0
3010            );
3011            assert_eq!(xmlCatalogIsEmpty(h), 0);
3012            // Resolve system.
3013            let r = xmlACatalogResolveSystem(h, cstr(b"http://example.com/foo\0"));
3014            assert!(!r.is_null());
3015            let bytes = xmlstr_to_bytes(r);
3016            assert_eq!(bytes, b"file:///tmp/foo.xml");
3017            xmlFreeImpl(r as *mut libc::c_void);
3018            // Resolve URI hits system entries too.
3019            let r2 = xmlACatalogResolveURI(h, cstr(b"http://example.com/foo\0"));
3020            assert!(!r2.is_null());
3021            xmlFreeImpl(r2 as *mut libc::c_void);
3022            // Unknown type rejected.
3023            assert_eq!(
3024                xmlACatalogAdd(h, cstr(b"bogus\0"), cstr(b"a\0"), cstr(b"b\0")),
3025                -1
3026            );
3027            // Remove returns 0 for the XML-catalog path, mirroring upstream
3028            // xmlDelXMLCatalog (its `ret` counter is never incremented; only
3029            // the SGML xmlHashRemoveEntry path can return 1).
3030            assert_eq!(xmlACatalogRemove(h, cstr(b"http://example.com/foo\0")), 0);
3031            assert_eq!(xmlCatalogIsEmpty(h), 1);
3032            xmlFreeCatalog(h);
3033        }
3034    }
3035
3036    /// Test C-ABI public resolution and rewrite on a seeded handle.
3037    ///
3038    /// # Safety
3039    ///
3040    /// - Each `cstr` result points to a static NUL-terminated byte array
3041    ///   valid for the duration of its call.
3042    /// - `h` is a non-NULL live handle from `xmlNewCatalog(0)`; `(*h).entries`
3043    ///   is mutated directly, so `h` must not be freed or mutated
3044    ///   concurrently while in use, and is released exactly once with
3045    ///   `xmlFreeCatalog`.
3046    /// - Each non-NULL resolve result is a fresh allocation freed exactly
3047    ///   once with `xmlFreeImpl`.
3048    #[test]
3049    fn test_acatalog_public_and_rewrite() {
3050        unsafe {
3051            let h = xmlNewCatalog(0);
3052            assert!(!h.is_null());
3053            // Seed a loaded state so adds succeed (fresh shells reject adds).
3054            (*h).entries.push(CatalogEntry::Public {
3055                public_id: b"-//OASIS//DTD X//EN\0".to_vec(),
3056                uri: b"file:///dtd/x.dtd\0".to_vec(),
3057            });
3058            assert_eq!(
3059                xmlACatalogAdd(
3060                    h,
3061                    cstr(b"public\0"),
3062                    cstr(b"-//OASIS//DTD X//EN\0"),
3063                    cstr(b"file:///dtd/x.dtd\0")
3064                ),
3065                0
3066            );
3067            assert_eq!(
3068                xmlACatalogAdd(
3069                    h,
3070                    cstr(b"rewriteSystem\0"),
3071                    cstr(b"http://old/\0"),
3072                    cstr(b"http://new/\0")
3073                ),
3074                0
3075            );
3076            let r = xmlACatalogResolvePublic(h, cstr(b"-//OASIS//DTD X//EN\0"));
3077            assert!(!r.is_null());
3078            assert_eq!(xmlstr_to_bytes(r), b"file:///dtd/x.dtd");
3079            xmlFreeImpl(r as *mut libc::c_void);
3080            let r2 = xmlACatalogResolveSystem(h, cstr(b"http://old/foo.xml\0"));
3081            assert!(!r2.is_null());
3082            assert_eq!(xmlstr_to_bytes(r2), b"http://new/foo.xml");
3083            xmlFreeImpl(r2 as *mut libc::c_void);
3084            xmlFreeCatalog(h);
3085        }
3086    }
3087
3088    /// Test the global prefer/debug setters return the previous values.
3089    ///
3090    /// # Safety
3091    ///
3092    /// - The setters read and write crate-global catalog configuration; they
3093    ///   touch no raw pointers, and the calls are safe as long as the global
3094    ///   catalog state is not being torn down concurrently.
3095    #[test]
3096    fn test_catalog_set_debug_and_prefer() {
3097        unsafe {
3098            // Default prefer is XML_CATA_PREFER_PUBLIC (1); the setters return
3099            // the OLD value; PREFER_NONE is rejected.
3100            assert_eq!(xmlCatalogSetDefaultPrefer(1), 1);
3101            assert_eq!(xmlCatalogSetDefaultPrefer(2), 1);
3102            assert_eq!(xmlCatalogSetDefaultPrefer(0), 2);
3103            assert_eq!(xmlCatalogSetDefaultPrefer(1), 2);
3104            assert_eq!(xmlCatalogSetDebug(0), 0);
3105            assert_eq!(xmlCatalogSetDebug(7), 0);
3106            assert_eq!(xmlCatalogSetDebug(0), 7);
3107        }
3108    }
3109
3110    /// Test the C-ABI local-resolution helpers with NULL and empty state.
3111    ///
3112    /// # Safety
3113    ///
3114    /// - Each `cstr` result points to a static NUL-terminated byte array
3115    ///   valid for the duration of its call; a NULL catalog handle is accepted
3116    ///   by the local helpers and yields NULL, and `xmlCatalogFreeLocal(NULL)`
3117    ///   is a no-op.
3118    #[test]
3119    fn test_catalog_local_resolve() {
3120        unsafe {
3121            // Empty local list resolves nothing.
3122            assert!(xmlCatalogLocalResolve(ptr::null_mut(), cstr(b"x\0"), cstr(b"y\0")).is_null());
3123            assert!(xmlCatalogLocalResolveURI(ptr::null_mut(), cstr(b"x\0")).is_null());
3124            xmlCatalogFreeLocal(ptr::null_mut());
3125        }
3126    }
3127
3128    /// Test that the C-ABI global resolver accepts NULL IDs.
3129    ///
3130    /// # Safety
3131    ///
3132    /// - Passing NULL for both public and system IDs to `xmlCatalogResolve`
3133    ///   is accepted per the C contract and returns NULL without
3134    ///   dereferencing the arguments.
3135    #[test]
3136    fn test_catalog_resolve_global_null() {
3137        unsafe {
3138            assert!(xmlCatalogResolve(ptr::null(), ptr::null()).is_null());
3139        }
3140    }
3141}