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