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