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