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