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::{xmlFree, xmlMalloc};
33use crate::abi::structs::_xmlDoc;
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 to a system/URI.
731///
732/// Checks catalog entries in order, first matching `Public` entries,
733/// then falls through to delegation.
734///
735/// # UPSTREAM-PARITY
736///
737/// ```c
738/// xmlCharPtr xmlCatalogResolvePublic(const xmlChar *pubID);
739/// ```
740pub(crate) unsafe fn resolve_public(pub_id: *const xmlChar) -> *mut xmlChar {
741    if pub_id.is_null() {
742        return ptr::null_mut();
743    }
744
745    let state = CATALOG_STATE.read();
746    if !catalog_allowed(&state) {
747        return ptr::null_mut();
748    }
749
750    let pub_id_bytes = xmlstr_to_bytes(pub_id);
751
752    // 1. Direct match on Public entries
753    for entry in &state.entries {
754        if let CatalogEntry::Public { public_id, uri } = entry {
755            if public_id.as_slice() == pub_id_bytes {
756                return bytes_to_xmlstr(uri);
757            }
758        }
759    }
760
761    // 2. DelegatePublic - find longest matching prefix
762    let mut best_match: Option<Vec<u8>> = None;
763    let mut best_prefix_len: usize = 0;
764
765    for entry in &state.entries {
766        if let CatalogEntry::DelegatePublic { prefix, catalog } = entry {
767            if pub_id_bytes.starts_with(prefix) && prefix.len() > best_prefix_len {
768                best_prefix_len = prefix.len();
769                // Try to load the delegated catalog and resolve
770                if let Some(delegated_data) = read_file_bytes(&String::from_utf8_lossy(catalog)) {
771                    let mut temp_entries = Vec::new();
772                    parse_xml_catalog(&delegated_data, &mut temp_entries);
773                    // Check for public match in delegated catalog
774                    for temp_entry in &temp_entries {
775                        if let CatalogEntry::Public { public_id: dp, uri } = temp_entry {
776                            if dp.as_slice() == pub_id_bytes {
777                                best_match = Some(uri.clone());
778                            }
779                        }
780                    }
781                }
782            }
783        }
784    }
785
786    best_match
787        .as_ref()
788        .map_or(ptr::null_mut(), |uri| bytes_to_xmlstr(uri))
789}
790
791/// Resolve a system ID.
792///
793/// Checks catalog entries in order:
794/// 1. Direct `System` match
795/// 2. `RewriteSystem` prefix match (longest wins)
796/// 3. `DelegateSystem` prefix match
797///
798/// # UPSTREAM-PARITY
799///
800/// ```c
801/// xmlCharPtr xmlCatalogResolveSystem(const xmlChar *sysID);
802/// ```
803pub(crate) unsafe fn resolve_system(sys_id: *const xmlChar) -> *mut xmlChar {
804    if sys_id.is_null() {
805        return ptr::null_mut();
806    }
807
808    let state = CATALOG_STATE.read();
809    if !catalog_allowed(&state) {
810        return ptr::null_mut();
811    }
812
813    let sys_id_bytes = xmlstr_to_bytes(sys_id);
814
815    // 1. Direct match on System entries
816    for entry in &state.entries {
817        if let CatalogEntry::System { system_id, uri } = entry {
818            if system_id.as_slice() == sys_id_bytes {
819                return bytes_to_xmlstr(uri);
820            }
821        }
822    }
823
824    // 2. RewriteSystem - find longest matching prefix
825    let mut best_rewrite: Option<Vec<u8>> = None;
826    let mut best_prefix_len: usize = 0;
827
828    for entry in &state.entries {
829        if let CatalogEntry::RewriteSystem { prefix, rewrite } = entry {
830            if sys_id_bytes.starts_with(prefix) && prefix.len() > best_prefix_len {
831                best_prefix_len = prefix.len();
832                // Replace the prefix with the rewrite prefix
833                let suffix = &sys_id_bytes[prefix.len()..];
834                let mut result = rewrite.clone();
835                result.extend_from_slice(suffix);
836                best_rewrite = Some(result);
837            }
838        }
839    }
840
841    if let Some(rewritten) = best_rewrite {
842        return bytes_to_xmlstr(&rewritten);
843    }
844
845    // 3. DelegateSystem
846    for entry in &state.entries {
847        if let CatalogEntry::DelegateSystem { prefix, catalog } = entry {
848            if sys_id_bytes.starts_with(prefix) {
849                if let Some(delegated_data) = read_file_bytes(&String::from_utf8_lossy(catalog)) {
850                    let mut temp_entries = Vec::new();
851                    parse_xml_catalog(&delegated_data, &mut temp_entries);
852                    for temp_entry in &temp_entries {
853                        if let CatalogEntry::System { system_id, uri } = temp_entry {
854                            if system_id.as_slice() == sys_id_bytes {
855                                return bytes_to_xmlstr(uri);
856                            }
857                        }
858                    }
859                }
860            }
861        }
862    }
863
864    ptr::null_mut()
865}
866
867/// Resolve a URI.
868///
869/// Checks catalog entries in order:
870/// 1. Direct `System` match (URIs are matched against system entries too)
871/// 2. `RewriteURI` prefix match (longest wins)
872/// 3. `DelegateURI` prefix match
873///
874/// # UPSTREAM-PARITY
875///
876/// ```c
877/// xmlCharPtr xmlCatalogResolveURI(const xmlChar *URI);
878/// ```
879pub(crate) unsafe fn resolve_uri(uri: *const xmlChar) -> *mut xmlChar {
880    if uri.is_null() {
881        return ptr::null_mut();
882    }
883
884    let state = CATALOG_STATE.read();
885    if !catalog_allowed(&state) {
886        return ptr::null_mut();
887    }
888
889    let uri_bytes = xmlstr_to_bytes(uri);
890
891    // 1. Direct match on System entries (URIs match against systemId in libxml2)
892    for entry in &state.entries {
893        if let CatalogEntry::System {
894            system_id,
895            uri: sys_uri,
896        } = entry
897        {
898            if system_id.as_slice() == uri_bytes {
899                return bytes_to_xmlstr(sys_uri);
900            }
901        }
902    }
903
904    // 2. RewriteURI - find longest matching prefix
905    let mut best_rewrite: Option<Vec<u8>> = None;
906    let mut best_prefix_len: usize = 0;
907
908    for entry in &state.entries {
909        if let CatalogEntry::RewriteURI { prefix, rewrite } = entry {
910            if uri_bytes.starts_with(prefix) && prefix.len() > best_prefix_len {
911                best_prefix_len = prefix.len();
912                let suffix = &uri_bytes[prefix.len()..];
913                let mut result = rewrite.clone();
914                result.extend_from_slice(suffix);
915                best_rewrite = Some(result);
916            }
917        }
918    }
919
920    if let Some(rewritten) = best_rewrite {
921        return bytes_to_xmlstr(&rewritten);
922    }
923
924    // 3. DelegateURI
925    for entry in &state.entries {
926        if let CatalogEntry::DelegateURI { prefix, catalog } = entry {
927            if uri_bytes.starts_with(prefix) {
928                if let Some(delegated_data) = read_file_bytes(&String::from_utf8_lossy(catalog)) {
929                    let mut temp_entries = Vec::new();
930                    parse_xml_catalog(&delegated_data, &mut temp_entries);
931                    for temp_entry in &temp_entries {
932                        if let CatalogEntry::System {
933                            system_id,
934                            uri: sys_uri,
935                        } = temp_entry
936                        {
937                            if system_id.as_slice() == uri_bytes {
938                                return bytes_to_xmlstr(sys_uri);
939                            }
940                        }
941                    }
942                }
943            }
944        }
945    }
946
947    ptr::null_mut()
948}
949
950// ═══════════════════════════════════════════════════════════════════════════════
951// Public API: Catalog Defaults
952// ═══════════════════════════════════════════════════════════════════════════════
953
954/// Set catalog behavior.
955///
956/// Controls whether catalog resolution is allowed and which catalogs
957/// are consulted.
958///
959/// # UPSTREAM-PARITY
960///
961/// ```c
962/// void xmlCatalogSetDefaults(xmlCatalogAllowValue allow);
963/// ```
964pub(crate) fn set_defaults(allow: c_int) {
965    let mut state = CATALOG_STATE.write();
966    state.allow = allow;
967    crate::xml::globals::set_catalog_defaults(allow);
968}
969
970/// Get the current catalog allow value.
971///
972/// # UPSTREAM-PARITY
973///
974/// ```c
975/// xmlCatalogAllowValue xmlCatalogGetDefaults(void);
976/// ```
977pub(crate) fn get_defaults() -> c_int {
978    let state = CATALOG_STATE.read();
979    state.allow
980}
981
982// ═══════════════════════════════════════════════════════════════════════════════
983// Public API: Add / Remove Entries
984// ═══════════════════════════════════════════════════════════════════════════════
985
986/// Add a catalog entry.
987///
988/// `type_` is one of "public", "system", "rewriteSystem", "rewriteURI",
989/// "delegatePublic", "delegateSystem", "delegateURI", or "nextCatalog".
990///
991/// Returns 0 on success, -1 on failure.
992///
993/// # UPSTREAM-PARITY
994///
995/// ```c
996/// int xmlCatalogAdd(const xmlChar *type, const xmlChar *orig, const xmlChar *replace);
997/// ```
998pub(crate) unsafe fn add(
999    type_: *const xmlChar,
1000    orig: *const xmlChar,
1001    replace: *const xmlChar,
1002) -> c_int {
1003    if type_.is_null() || orig.is_null() || replace.is_null() {
1004        return -1;
1005    }
1006
1007    let type_bytes = xmlstr_to_bytes(type_);
1008    let orig_bytes = xmlstr_to_bytes(orig);
1009    let replace_bytes = xmlstr_to_bytes(replace);
1010
1011    let mut state = CATALOG_STATE.write();
1012
1013    match type_bytes {
1014        b"public" => {
1015            state.entries.push(CatalogEntry::Public {
1016                public_id: orig_bytes.to_vec(),
1017                uri: replace_bytes.to_vec(),
1018            });
1019            0
1020        }
1021        b"system" => {
1022            state.entries.push(CatalogEntry::System {
1023                system_id: orig_bytes.to_vec(),
1024                uri: replace_bytes.to_vec(),
1025            });
1026            0
1027        }
1028        b"rewriteSystem" => {
1029            state.entries.push(CatalogEntry::RewriteSystem {
1030                prefix: orig_bytes.to_vec(),
1031                rewrite: replace_bytes.to_vec(),
1032            });
1033            0
1034        }
1035        b"rewriteURI" => {
1036            state.entries.push(CatalogEntry::RewriteURI {
1037                prefix: orig_bytes.to_vec(),
1038                rewrite: replace_bytes.to_vec(),
1039            });
1040            0
1041        }
1042        b"delegatePublic" => {
1043            state.entries.push(CatalogEntry::DelegatePublic {
1044                prefix: orig_bytes.to_vec(),
1045                catalog: replace_bytes.to_vec(),
1046            });
1047            0
1048        }
1049        b"delegateSystem" => {
1050            state.entries.push(CatalogEntry::DelegateSystem {
1051                prefix: orig_bytes.to_vec(),
1052                catalog: replace_bytes.to_vec(),
1053            });
1054            0
1055        }
1056        b"delegateURI" => {
1057            state.entries.push(CatalogEntry::DelegateURI {
1058                prefix: orig_bytes.to_vec(),
1059                catalog: replace_bytes.to_vec(),
1060            });
1061            0
1062        }
1063        b"nextCatalog" => {
1064            state.entries.push(CatalogEntry::NextCatalog {
1065                catalog: orig_bytes.to_vec(),
1066            });
1067            0
1068        }
1069        _ => -1,
1070    }
1071}
1072
1073/// Remove a catalog entry by matching its value.
1074///
1075/// Removes all entries whose public ID, system ID, or prefix matches `value`.
1076/// Returns the number of entries removed, or -1 on error.
1077///
1078/// # UPSTREAM-PARITY
1079///
1080/// ```c
1081/// int xmlCatalogRemove(const xmlChar *value);
1082/// ```
1083pub(crate) unsafe fn remove(value: *const xmlChar) -> c_int {
1084    if value.is_null() {
1085        return -1;
1086    }
1087
1088    let value_bytes = xmlstr_to_bytes(value);
1089    let mut state = CATALOG_STATE.write();
1090
1091    let before = state.entries.len();
1092    state.entries.retain(|entry| match entry {
1093        CatalogEntry::Public { public_id, .. } => public_id.as_slice() != value_bytes,
1094        CatalogEntry::System { system_id, .. } => system_id.as_slice() != value_bytes,
1095        CatalogEntry::RewriteSystem { prefix, .. } => prefix.as_slice() != value_bytes,
1096        CatalogEntry::RewriteURI { prefix, .. } => prefix.as_slice() != value_bytes,
1097        CatalogEntry::DelegatePublic { prefix, .. } => prefix.as_slice() != value_bytes,
1098        CatalogEntry::DelegateSystem { prefix, .. } => prefix.as_slice() != value_bytes,
1099        CatalogEntry::DelegateURI { prefix, .. } => prefix.as_slice() != value_bytes,
1100        CatalogEntry::NextCatalog { catalog } => catalog.as_slice() != value_bytes,
1101    });
1102
1103    (before - state.entries.len()) as c_int
1104}
1105
1106// ═══════════════════════════════════════════════════════════════════════════════
1107// Public API: SGML → XML Conversion
1108// ═══════════════════════════════════════════════════════════════════════════════
1109
1110/// Convert the currently loaded SGML catalog entries to an XML Catalog document.
1111///
1112/// Returns a newly allocated `_xmlDoc` containing the XML catalog representation,
1113/// or NULL on failure. The caller is responsible for freeing the document.
1114///
1115/// # UPSTREAM-PARITY
1116///
1117/// ```c
1118/// xmlDocPtr xmlCatalogConvert(void);
1119/// ```
1120pub(crate) unsafe fn convert() -> *mut _xmlDoc {
1121    let state = CATALOG_STATE.read();
1122
1123    if state.entries.is_empty() {
1124        return ptr::null_mut();
1125    }
1126
1127    // Create the XML document
1128    let doc = crate::xml::tree::new_doc(ptr::null_mut());
1129    if doc.is_null() {
1130        return ptr::null_mut();
1131    }
1132
1133    // Create root <catalog> element
1134    let catalog_name = b"catalog\0" as *const u8 as *const xmlChar;
1135    let root = crate::xml::tree::new_node(ptr::null_mut(), catalog_name);
1136    if root.is_null() {
1137        crate::xml::tree::free_doc(doc);
1138        return ptr::null_mut();
1139    }
1140
1141    // Set xmlns attribute for OASIS XML Catalog namespace
1142    let xmlns_name = b"xmlns\0" as *const u8 as *const xmlChar;
1143    let ns_value = b"urn:oasis:names:tc:entity:xmlns:xml:catalog\0" as *const u8 as *const xmlChar;
1144    crate::xml::tree::set_prop(root, xmlns_name, ns_value);
1145
1146    crate::xml::tree::doc_set_root_element(doc, root);
1147
1148    // Add entries as child elements
1149    for entry in &state.entries {
1150        let (elem_name, attr1_name, attr1_value, attr2_name, attr2_value) = match entry {
1151            CatalogEntry::Public { public_id, uri } => {
1152                let elem = b"public\0" as *const u8 as *mut xmlChar;
1153                let attr1 = b"publicId\0" as *const u8 as *mut xmlChar;
1154                let val1 = bytes_to_xmlstr(public_id);
1155                let attr2 = b"uri\0" as *const u8 as *mut xmlChar;
1156                let val2 = bytes_to_xmlstr(uri);
1157                (elem, attr1, val1, attr2, val2)
1158            }
1159            CatalogEntry::System { system_id, uri } => {
1160                let elem = b"system\0" as *const u8 as *mut xmlChar;
1161                let attr1 = b"systemId\0" as *const u8 as *mut xmlChar;
1162                let val1 = bytes_to_xmlstr(system_id);
1163                let attr2 = b"uri\0" as *const u8 as *mut xmlChar;
1164                let val2 = bytes_to_xmlstr(uri);
1165                (elem, attr1, val1, attr2, val2)
1166            }
1167            CatalogEntry::RewriteSystem { prefix, rewrite } => {
1168                let elem = b"rewriteSystem\0" as *const u8 as *mut xmlChar;
1169                let attr1 = b"systemIdStartString\0" as *const u8 as *mut xmlChar;
1170                let val1 = bytes_to_xmlstr(prefix);
1171                let attr2 = b"rewritePrefix\0" as *const u8 as *mut xmlChar;
1172                let val2 = bytes_to_xmlstr(rewrite);
1173                (elem, attr1, val1, attr2, val2)
1174            }
1175            CatalogEntry::RewriteURI { prefix, rewrite } => {
1176                let elem = b"rewriteURI\0" as *const u8 as *mut xmlChar;
1177                let attr1 = b"uriStartString\0" as *const u8 as *mut xmlChar;
1178                let val1 = bytes_to_xmlstr(prefix);
1179                let attr2 = b"rewritePrefix\0" as *const u8 as *mut xmlChar;
1180                let val2 = bytes_to_xmlstr(rewrite);
1181                (elem, attr1, val1, attr2, val2)
1182            }
1183            CatalogEntry::DelegatePublic { prefix, catalog } => {
1184                let elem = b"delegatePublic\0" as *const u8 as *mut xmlChar;
1185                let attr1 = b"publicIdStartString\0" as *const u8 as *mut xmlChar;
1186                let val1 = bytes_to_xmlstr(prefix);
1187                let attr2 = b"catalog\0" as *const u8 as *mut xmlChar;
1188                let val2 = bytes_to_xmlstr(catalog);
1189                (elem, attr1, val1, attr2, val2)
1190            }
1191            CatalogEntry::DelegateSystem { prefix, catalog } => {
1192                let elem = b"delegateSystem\0" as *const u8 as *mut xmlChar;
1193                let attr1 = b"systemIdStartString\0" as *const u8 as *mut xmlChar;
1194                let val1 = bytes_to_xmlstr(prefix);
1195                let attr2 = b"catalog\0" as *const u8 as *mut xmlChar;
1196                let val2 = bytes_to_xmlstr(catalog);
1197                (elem, attr1, val1, attr2, val2)
1198            }
1199            CatalogEntry::DelegateURI { prefix, catalog } => {
1200                let elem = b"delegateURI\0" as *const u8 as *mut xmlChar;
1201                let attr1 = b"uriStartString\0" as *const u8 as *mut xmlChar;
1202                let val1 = bytes_to_xmlstr(prefix);
1203                let attr2 = b"catalog\0" as *const u8 as *mut xmlChar;
1204                let val2 = bytes_to_xmlstr(catalog);
1205                (elem, attr1, val1, attr2, val2)
1206            }
1207            CatalogEntry::NextCatalog { catalog } => {
1208                let elem = b"nextCatalog\0" as *const u8 as *mut xmlChar;
1209                let attr1 = b"catalog\0" as *const u8 as *mut xmlChar;
1210                let val1 = bytes_to_xmlstr(catalog);
1211                let attr2 = ptr::null_mut();
1212                let val2 = ptr::null_mut();
1213                (elem, attr1, val1, attr2, val2)
1214            }
1215        };
1216
1217        let child = crate::xml::tree::new_child(root, ptr::null_mut(), elem_name);
1218        if child.is_null() {
1219            // Free allocated strings and continue
1220            if !attr1_value.is_null() {
1221                xmlFree(attr1_value as *mut c_void);
1222            }
1223            if !attr2_value.is_null() {
1224                xmlFree(attr2_value as *mut c_void);
1225            }
1226            continue;
1227        }
1228
1229        crate::xml::tree::set_prop(child, attr1_name, attr1_value);
1230        if !attr2_name.is_null() {
1231            crate::xml::tree::set_prop(child, attr2_name, attr2_value);
1232        }
1233
1234        // Free the temporary xmlChar strings we created
1235        if !attr1_value.is_null() {
1236            xmlFree(attr1_value as *mut c_void);
1237        }
1238        if !attr2_value.is_null() {
1239            xmlFree(attr2_value as *mut c_void);
1240        }
1241    }
1242
1243    doc
1244}
1245
1246// ═══════════════════════════════════════════════════════════════════════════════
1247// Tests
1248// ═══════════════════════════════════════════════════════════════════════════════
1249
1250#[cfg(test)]
1251mod tests {
1252    use super::*;
1253    use crate::abi::allocator::xmlFree;
1254    use crate::xml::string::xmlstr_to_bytes;
1255    use std::ffi::CString;
1256    use std::sync::Mutex;
1257
1258    /// Serializes catalog tests to prevent interference from shared global state.
1259    ///
1260    /// # UPSTREAM-PARITY
1261    ///
1262    /// libxml2's catalog module uses global state (the catalog registry is a
1263    /// module-level static). Tests that modify global state cannot safely run
1264    /// in parallel. This mutex serializes all catalog tests, matching the
1265    /// observable behavior of a single-threaded caller.
1266    static CATALOG_TEST_MUTEX: Mutex<()> = Mutex::new(());
1267
1268    /// Helper to create a null-terminated xmlChar* from a byte slice.
1269    unsafe fn to_xmlstr(s: &[u8]) -> *const xmlChar {
1270        let ptr = bytes_to_xmlstr(s);
1271        ptr as *const xmlChar
1272    }
1273
1274    /// Helper to create a null-terminated xmlChar* from a string.
1275    unsafe fn to_xmlstr_str(s: &str) -> *const xmlChar {
1276        to_xmlstr(s.as_bytes())
1277    }
1278
1279    unsafe fn free_xmlstr(ptr: *const xmlChar) {
1280        if !ptr.is_null() {
1281            xmlFree(ptr as *mut c_void);
1282        }
1283    }
1284
1285    // ── Test setup / teardown ────────────────────────────────────────────
1286
1287    /// Acquires the catalog test mutex and sets up a clean catalog state.
1288    ///
1289    /// Returns a guard that must be held for the duration of the test.
1290    /// The guard is dropped when the test completes, releasing the mutex.
1291    fn setup() -> std::sync::MutexGuard<'static, ()> {
1292        let guard = CATALOG_TEST_MUTEX.lock().unwrap();
1293        cleanup();
1294        init();
1295        // Reset catalog defaults to ALL for testing
1296        set_defaults(XML_CATA_ALLOW_ALL);
1297        guard
1298    }
1299
1300    fn teardown(_guard: std::sync::MutexGuard<'static, ()>) {
1301        cleanup();
1302        // Guard is dropped here, releasing the mutex
1303    }
1304
1305    // ── Basic public ID resolution ───────────────────────────────────────
1306
1307    #[test]
1308    fn test_resolve_public_basic() {
1309        let _guard = setup();
1310        unsafe {
1311            // Add a public entry
1312            let type_ = to_xmlstr_str("public");
1313            let pub_id = to_xmlstr_str("-//OASIS//DTD DocBook XML V4.2//EN");
1314            let uri = to_xmlstr_str("http://www.oasis-open.org/docbook/xml/4.2/docbookx.dtd");
1315            assert_eq!(add(type_, pub_id, uri), 0);
1316
1317            // Resolve it
1318            let result = resolve_public(pub_id);
1319            assert!(!result.is_null());
1320            assert_eq!(
1321                xmlstr_to_bytes(result),
1322                b"http://www.oasis-open.org/docbook/xml/4.2/docbookx.dtd"
1323            );
1324            xmlFree(result as *mut c_void);
1325
1326            // Unknown public ID returns NULL
1327            let unknown = to_xmlstr_str("-//Unknown//DTD Unknown//EN");
1328            assert!(resolve_public(unknown).is_null());
1329            free_xmlstr(unknown);
1330
1331            free_xmlstr(type_);
1332            free_xmlstr(pub_id);
1333            free_xmlstr(uri);
1334            teardown(_guard);
1335        }
1336    }
1337
1338    // ── Basic system ID resolution ───────────────────────────────────────
1339
1340    #[test]
1341    fn test_resolve_system_basic() {
1342        let _guard = setup();
1343        unsafe {
1344            let type_ = to_xmlstr_str("system");
1345            let sys_id = to_xmlstr_str("http://example.com/foo.dtd");
1346            let uri = to_xmlstr_str("/local/foo.dtd");
1347            assert_eq!(add(type_, sys_id, uri), 0);
1348
1349            let result = resolve_system(sys_id);
1350            assert!(!result.is_null());
1351            assert_eq!(xmlstr_to_bytes(result), b"/local/foo.dtd");
1352            xmlFree(result as *mut c_void);
1353
1354            free_xmlstr(type_);
1355            free_xmlstr(sys_id);
1356            free_xmlstr(uri);
1357            teardown(_guard);
1358        }
1359    }
1360
1361    // ── URI resolution ──────────────────────────────────────────────────
1362
1363    #[test]
1364    fn test_resolve_uri_basic() {
1365        let _guard = setup();
1366        unsafe {
1367            // URI resolution matches against system entries
1368            let type_ = to_xmlstr_str("system");
1369            let sys_id = to_xmlstr_str("http://example.com/resource.xml");
1370            let uri = to_xmlstr_str("/local/resource.xml");
1371            assert_eq!(add(type_, sys_id, uri), 0);
1372
1373            let result = resolve_uri(sys_id);
1374            assert!(!result.is_null());
1375            assert_eq!(xmlstr_to_bytes(result), b"/local/resource.xml");
1376            xmlFree(result as *mut c_void);
1377
1378            free_xmlstr(type_);
1379            free_xmlstr(sys_id);
1380            free_xmlstr(uri);
1381            teardown(_guard);
1382        }
1383    }
1384
1385    // ── RewriteSystem resolution ────────────────────────────────────────
1386
1387    #[test]
1388    fn test_rewrite_system() {
1389        let _guard = setup();
1390        unsafe {
1391            let type_ = to_xmlstr_str("rewriteSystem");
1392            let prefix = to_xmlstr_str("http://example.com/old/");
1393            let rewrite = to_xmlstr_str("http://mirror.example.com/new/");
1394            assert_eq!(add(type_, prefix, rewrite), 0);
1395
1396            let sys_id = to_xmlstr_str("http://example.com/old/path/file.xml");
1397            let result = resolve_system(sys_id);
1398            assert!(!result.is_null());
1399            assert_eq!(
1400                xmlstr_to_bytes(result),
1401                b"http://mirror.example.com/new/path/file.xml"
1402            );
1403            xmlFree(result as *mut c_void);
1404
1405            free_xmlstr(type_);
1406            free_xmlstr(prefix);
1407            free_xmlstr(rewrite);
1408            free_xmlstr(sys_id);
1409            teardown(_guard);
1410        }
1411    }
1412
1413    // ── RewriteURI resolution ───────────────────────────────────────────
1414
1415    #[test]
1416    fn test_rewrite_uri() {
1417        let _guard = setup();
1418        unsafe {
1419            let type_ = to_xmlstr_str("rewriteURI");
1420            let prefix = to_xmlstr_str("http://example.com/old/");
1421            let rewrite = to_xmlstr_str("http://mirror.example.com/new/");
1422            assert_eq!(add(type_, prefix, rewrite), 0);
1423
1424            let uri = to_xmlstr_str("http://example.com/old/path/file.xml");
1425            let result = resolve_uri(uri);
1426            assert!(!result.is_null());
1427            assert_eq!(
1428                xmlstr_to_bytes(result),
1429                b"http://mirror.example.com/new/path/file.xml"
1430            );
1431            xmlFree(result as *mut c_void);
1432
1433            free_xmlstr(type_);
1434            free_xmlstr(prefix);
1435            free_xmlstr(rewrite);
1436            free_xmlstr(uri);
1437            teardown(_guard);
1438        }
1439    }
1440
1441    // ── Remove entries ──────────────────────────────────────────────────
1442
1443    #[test]
1444    fn test_remove_entries() {
1445        let _guard = setup();
1446        unsafe {
1447            let type_ = to_xmlstr_str("public");
1448            let pub_id = to_xmlstr_str("-//TEST//PUBLIC//EN");
1449            let uri = to_xmlstr_str("test.dtd");
1450            assert_eq!(add(type_, pub_id, uri), 0);
1451
1452            // Should resolve
1453            assert!(!resolve_public(pub_id).is_null());
1454
1455            // Remove
1456            assert_eq!(remove(pub_id), 1);
1457
1458            // Should no longer resolve
1459            assert!(resolve_public(pub_id).is_null());
1460
1461            free_xmlstr(type_);
1462            free_xmlstr(pub_id);
1463            free_xmlstr(uri);
1464            teardown(_guard);
1465        }
1466    }
1467
1468    // ── Catalog defaults ────────────────────────────────────────────────
1469
1470    #[test]
1471    fn test_catalog_defaults() {
1472        let _guard = setup();
1473
1474        assert_eq!(get_defaults(), XML_CATA_ALLOW_ALL);
1475
1476        set_defaults(XML_CATA_ALLOW_NONE);
1477        assert_eq!(get_defaults(), XML_CATA_ALLOW_NONE);
1478
1479        set_defaults(XML_CATA_ALLOW_GLOBAL);
1480        assert_eq!(get_defaults(), XML_CATA_ALLOW_GLOBAL);
1481
1482        set_defaults(XML_CATA_ALLOW_ALL);
1483        assert_eq!(get_defaults(), XML_CATA_ALLOW_ALL);
1484
1485        teardown(_guard);
1486    }
1487
1488    // ── XML Catalog file parsing ────────────────────────────────────────
1489
1490    #[test]
1491    fn test_parse_xml_catalog_in_memory() {
1492        let _guard = setup();
1493        unsafe {
1494            let catalog_xml = br#"<?xml version="1.0"?>
1495<!DOCTYPE catalog PUBLIC "-//OASIS//DTD Entity Resolution XML Catalog V1.0//EN" "http://www.oasis-open.org/committees/entity/release/1.0/catalog.dtd">
1496<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
1497  <public publicId="-//OASIS//DTD DocBook XML V4.2//EN" uri="http://www.oasis-open.org/docbook/xml/4.2/docbookx.dtd"/>
1498  <system systemId="http://example.com/foo.dtd" uri="/local/foo.dtd"/>
1499  <rewriteSystem systemIdStartString="http://example.com/old/" rewritePrefix="http://mirror.example.com/new/"/>
1500  <rewriteURI uriStartString="http://example.com/old/" rewritePrefix="http://mirror.example.com/new/"/>
1501</catalog>"#;
1502
1503            // Parse the XML catalog into entries
1504            let mut entries = Vec::new();
1505            parse_xml_catalog(catalog_xml, &mut entries);
1506            assert_eq!(entries.len(), 4);
1507
1508            // Check public entry
1509            match &entries[0] {
1510                CatalogEntry::Public { public_id, uri } => {
1511                    assert_eq!(public_id.as_slice(), b"-//OASIS//DTD DocBook XML V4.2//EN");
1512                    assert_eq!(
1513                        uri.as_slice(),
1514                        b"http://www.oasis-open.org/docbook/xml/4.2/docbookx.dtd"
1515                    );
1516                }
1517                _ => panic!("Expected Public entry"),
1518            }
1519
1520            // Check system entry
1521            match &entries[1] {
1522                CatalogEntry::System { system_id, uri } => {
1523                    assert_eq!(system_id.as_slice(), b"http://example.com/foo.dtd");
1524                    assert_eq!(uri.as_slice(), b"/local/foo.dtd");
1525                }
1526                _ => panic!("Expected System entry"),
1527            }
1528
1529            // Check rewriteSystem entry
1530            match &entries[2] {
1531                CatalogEntry::RewriteSystem { prefix, rewrite } => {
1532                    assert_eq!(prefix.as_slice(), b"http://example.com/old/");
1533                    assert_eq!(rewrite.as_slice(), b"http://mirror.example.com/new/");
1534                }
1535                _ => panic!("Expected RewriteSystem entry"),
1536            }
1537
1538            // Check rewriteURI entry
1539            match &entries[3] {
1540                CatalogEntry::RewriteURI { prefix, rewrite } => {
1541                    assert_eq!(prefix.as_slice(), b"http://example.com/old/");
1542                    assert_eq!(rewrite.as_slice(), b"http://mirror.example.com/new/");
1543                }
1544                _ => panic!("Expected RewriteURI entry"),
1545            }
1546
1547            teardown(_guard);
1548        }
1549    }
1550
1551    // ── SGML catalog parsing ────────────────────────────────────────────
1552
1553    #[test]
1554    fn test_parse_sgml_catalog() {
1555        let _guard = setup();
1556        unsafe {
1557            let sgml_data = br#"-- SGML catalog
1558PUBLIC "-//OASIS//DTD DocBook XML V4.2//EN" "docbookx.dtd"
1559SYSTEM "http://example.com/foo.dtd" "/local/foo.dtd"
1560URI "http://example.com/resource" "/local/resource"
1561"#;
1562
1563            let mut entries = Vec::new();
1564            parse_sgml_catalog(sgml_data, &mut entries);
1565            assert_eq!(entries.len(), 3);
1566
1567            // Check PUBLIC entry
1568            match &entries[0] {
1569                CatalogEntry::Public { public_id, uri } => {
1570                    assert_eq!(public_id.as_slice(), b"-//OASIS//DTD DocBook XML V4.2//EN");
1571                    assert_eq!(uri.as_slice(), b"docbookx.dtd");
1572                }
1573                _ => panic!("Expected Public entry"),
1574            }
1575
1576            // Check SYSTEM entry
1577            match &entries[1] {
1578                CatalogEntry::System { system_id, uri } => {
1579                    assert_eq!(system_id.as_slice(), b"http://example.com/foo.dtd");
1580                    assert_eq!(uri.as_slice(), b"/local/foo.dtd");
1581                }
1582                _ => panic!("Expected System entry"),
1583            }
1584
1585            // Check URI entry (maps to System in libxml2)
1586            match &entries[2] {
1587                CatalogEntry::System { system_id, uri } => {
1588                    assert_eq!(system_id.as_slice(), b"http://example.com/resource");
1589                    assert_eq!(uri.as_slice(), b"/local/resource");
1590                }
1591                _ => panic!("Expected System entry for URI"),
1592            }
1593
1594            teardown(_guard);
1595        }
1596    }
1597
1598    // ── Resolution precedence ───────────────────────────────────────────
1599
1600    #[test]
1601    fn test_resolution_precedence() {
1602        let _guard = setup();
1603        unsafe {
1604            // Add a system entry
1605            let type_sys = to_xmlstr_str("system");
1606            let sys_id = to_xmlstr_str("http://example.com/target.xml");
1607            let uri_direct = to_xmlstr_str("/direct/uri.xml");
1608            assert_eq!(add(type_sys, sys_id, uri_direct), 0);
1609
1610            // Add a rewriteSystem with shorter prefix (should not override direct)
1611            let type_rw = to_xmlstr_str("rewriteSystem");
1612            let prefix = to_xmlstr_str("http://example.com/");
1613            let rewrite = to_xmlstr_str("/rewrite/");
1614            assert_eq!(add(type_rw, prefix, rewrite), 0);
1615
1616            // Direct match should win
1617            let result = resolve_system(sys_id);
1618            assert!(!result.is_null());
1619            assert_eq!(xmlstr_to_bytes(result), b"/direct/uri.xml");
1620            xmlFree(result as *mut c_void);
1621
1622            free_xmlstr(type_sys);
1623            free_xmlstr(sys_id);
1624            free_xmlstr(uri_direct);
1625            free_xmlstr(type_rw);
1626            free_xmlstr(prefix);
1627            free_xmlstr(rewrite);
1628            teardown(_guard);
1629        }
1630    }
1631
1632    // ── Convert SGML to XML ─────────────────────────────────────────────
1633
1634    #[test]
1635    fn test_convert_sgml_to_xml() {
1636        let _guard = setup();
1637        unsafe {
1638            let type_ = to_xmlstr_str("public");
1639            let pub_id = to_xmlstr_str("-//TEST//PUBLIC//EN");
1640            let uri = to_xmlstr_str("test.dtd");
1641            assert_eq!(add(type_, pub_id, uri), 0);
1642
1643            let doc = convert();
1644            assert!(!doc.is_null());
1645
1646            // Verify the document has a root <catalog> element
1647            let root = crate::xml::tree::doc_get_root_element(doc);
1648            assert!(!root.is_null());
1649            let root_name = crate::xml::string::xmlstr_to_bytes((*root).name);
1650            assert_eq!(root_name, b"catalog");
1651
1652            // Verify there's a child <public> element
1653            let child = (*root).children;
1654            assert!(!child.is_null());
1655            let child_name = crate::xml::string::xmlstr_to_bytes((*child).name);
1656            assert_eq!(child_name, b"public");
1657
1658            crate::xml::tree::free_doc(doc);
1659            free_xmlstr(type_);
1660            free_xmlstr(pub_id);
1661            free_xmlstr(uri);
1662            teardown(_guard);
1663        }
1664    }
1665
1666    // ── Catalog allowed / disallowed ────────────────────────────────────
1667
1668    #[test]
1669    fn test_catalog_disallowed() {
1670        let _guard = setup();
1671        unsafe {
1672            // Add an entry
1673            let type_ = to_xmlstr_str("system");
1674            let sys_id = to_xmlstr_str("http://example.com/test.dtd");
1675            let uri = to_xmlstr_str("/local/test.dtd");
1676            add(type_, sys_id, uri);
1677
1678            // Disable catalogs
1679            set_defaults(XML_CATA_ALLOW_NONE);
1680
1681            // Resolution should return NULL
1682            assert!(resolve_system(sys_id).is_null());
1683            assert!(resolve_public(sys_id).is_null());
1684            assert!(resolve_uri(sys_id).is_null());
1685
1686            set_defaults(XML_CATA_ALLOW_ALL);
1687            free_xmlstr(type_);
1688            free_xmlstr(sys_id);
1689            free_xmlstr(uri);
1690            teardown(_guard);
1691        }
1692    }
1693
1694    // ── Init / Cleanup ──────────────────────────────────────────────────
1695
1696    #[test]
1697    fn test_init_cleanup() {
1698        let _guard = CATALOG_TEST_MUTEX.lock().unwrap();
1699        cleanup();
1700        assert_eq!(CATALOG_STATE.read().initialized, false);
1701
1702        init();
1703        assert_eq!(CATALOG_STATE.read().initialized, true);
1704
1705        cleanup();
1706        assert_eq!(CATALOG_STATE.read().initialized, false);
1707    }
1708
1709    // ── XML Catalog with group ──────────────────────────────────────────
1710
1711    #[test]
1712    fn test_parse_xml_catalog_group() {
1713        let catalog_xml = br#"<?xml version="1.0"?>
1714<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
1715  <group>
1716    <public publicId="-//GROUP//PUBLIC//EN" uri="group.dtd"/>
1717    <system systemId="http://group.example.com/" uri="/group/"/>
1718  </group>
1719</catalog>"#;
1720
1721        let mut entries = Vec::new();
1722        parse_xml_catalog(catalog_xml, &mut entries);
1723        assert_eq!(entries.len(), 2);
1724
1725        match &entries[0] {
1726            CatalogEntry::Public { public_id, .. } => {
1727                assert_eq!(public_id.as_slice(), b"-//GROUP//PUBLIC//EN");
1728            }
1729            _ => panic!("Expected Public entry"),
1730        }
1731
1732        match &entries[1] {
1733            CatalogEntry::System { system_id, .. } => {
1734                assert_eq!(system_id.as_slice(), b"http://group.example.com/");
1735            }
1736            _ => panic!("Expected System entry"),
1737        }
1738    }
1739
1740    // ── Multiple entries, multiple resolution ───────────────────────────
1741
1742    #[test]
1743    fn test_multiple_entries() {
1744        let _guard = setup();
1745        unsafe {
1746            // Add two public entries
1747            let t = to_xmlstr_str("public");
1748            let id1 = to_xmlstr_str("-//A//PUBLIC//EN");
1749            let uri1 = to_xmlstr_str("a.dtd");
1750            let id2 = to_xmlstr_str("-//B//PUBLIC//EN");
1751            let uri2 = to_xmlstr_str("b.dtd");
1752
1753            assert_eq!(add(t, id1, uri1), 0);
1754            assert_eq!(add(t, id2, uri2), 0);
1755
1756            let r1 = resolve_public(id1);
1757            assert!(!r1.is_null());
1758            assert_eq!(xmlstr_to_bytes(r1), b"a.dtd");
1759            xmlFree(r1 as *mut c_void);
1760
1761            let r2 = resolve_public(id2);
1762            assert!(!r2.is_null());
1763            assert_eq!(xmlstr_to_bytes(r2), b"b.dtd");
1764            xmlFree(r2 as *mut c_void);
1765
1766            free_xmlstr(t);
1767            free_xmlstr(id1);
1768            free_xmlstr(uri1);
1769            free_xmlstr(id2);
1770            free_xmlstr(uri2);
1771            teardown(_guard);
1772        }
1773    }
1774
1775    // ── Longest prefix wins for rewrite ─────────────────────────────────
1776
1777    #[test]
1778    fn test_longest_prefix_wins() {
1779        let _guard = setup();
1780        unsafe {
1781            let t = to_xmlstr_str("rewriteSystem");
1782            let p1 = to_xmlstr_str("http://example.com/");
1783            let r1 = to_xmlstr_str("/general/");
1784            let p2 = to_xmlstr_str("http://example.com/specific/");
1785            let r2 = to_xmlstr_str("/specific/");
1786
1787            add(t, p1, r1);
1788            add(t, p2, r2);
1789
1790            let sys_id = to_xmlstr_str("http://example.com/specific/file.xml");
1791            let result = resolve_system(sys_id);
1792            assert!(!result.is_null());
1793            assert_eq!(xmlstr_to_bytes(result), b"/specific/file.xml");
1794            xmlFree(result as *mut c_void);
1795
1796            free_xmlstr(t);
1797            free_xmlstr(p1);
1798            free_xmlstr(r1);
1799            free_xmlstr(p2);
1800            free_xmlstr(r2);
1801            free_xmlstr(sys_id);
1802            teardown(_guard);
1803        }
1804    }
1805}