Skip to main content

libxml_rs/xml/entities/
mod.rs

1//! Entity handling (§24, §85 Phase 6).
2//!
3//! General entities, parameter entities, external entities, entity
4//! substitution, entity references, security limits, recursive entities,
5//! expansion limits.
6//!
7//! # Upstream contract
8//!
9//! Mirrors upstream entities.c and the entity paths of parser.c
10//! (SRC-LIBXML2-2.15.0, oracle tree `oracle/historical/src/libxml2-2.15.0/`):
11//! xmlAddEntity, xmlGetEntity, xmlGetPredefinedEntity, xmlNewReference,
12//! entity content caching and the expansion limits. Parity target: the system
13//! libxml2 2.15.3 oracle.
14//!
15//! # Conceptual behavior
16//!
17//! General entities, parameter entities, external entities, entity
18//! substitution, entity references, security limits, recursive entities and
19//! expansion limits. The entity model parses a referenced entity content once
20//! into ent->children (XML_ENT_PARSED / XML_ENT_EXPANDING flags) and reuses
21//! it — structural re-expansion is impossible by construction.
22//!
23//! # Ownership & safety invariants
24//!
25//! Ownership: entity declarations are owned by the DTD hash tables
26//! (entities/pentities); ent->children nodes are owned by the declaration and
27//! freed with the DTD; entity-ref nodes share content with the entity
28//! (xmlNewReference semantics) and must never be freed separately (R-000164).
29//! SAFETY: the expansion guards make entity processing loop-free.
30//!
31//! # Historical quirks & epochs
32//!
33//! Security epochs: CVE-2014-3660 (billion laughs) fix be2a7eda and its
34//! regression fix 72a46a51 (SEC-0006) bounded expansion; CVE-2013-2877
35//! (SEC-0004) added loop detection; the 2015 batch (SEC-0008: 69030714,
36//! f1063fdb) fixed entity-boundary bugs; the recursion-depth increments came
37//! from commit 8f30bdff (2016, SEC-0009). XML_ENTITY_CONTENT_DEPTH_MAX = 32
38//! and XML_ENTITY_CONTENT_EXPANSION_MAX = 1,000,000 follow upstream.
39//!
40//! # Deliberate oddities
41//!
42//! Deliberate oddities: the amplification guard fires unconditionally with no
43//! XML_PARSE_HUGE bypass; unloadable external entities fail silently
44//! (xmlCtxtParseEntity) rather than raising undeclared-entity errors;
45//! predefined entities (amp/lt/gt/quot/apos) substitute unconditionally
46//! regardless of XML_PARSE_NOENT.
47//!
48//! # Proving courts
49//!
50//! PARSER-ENTITY-* court family, SECURITY-LIMITS probe (amplification sweep
51//! L4..L9 x 10 matches the oracle on every boundary), TREE-001, CLI-XMLLINT-
52//! 0033/0034 and `cargo test --lib`.
53//!
54//! # Tempting simplifications that would break parity
55//!
56//! Not caching entity content would re-parse per reference (exponential
57//! blowup — the vulnerable pre-CVE behavior); skipping the XML_ENT_EXPANDING
58//! re-entry check would loop on a self-referential entity (CVE-2013-2877). Do
59//! not drop the silent-failure path for unloadable external entities — the
60//! NONET oracle behavior depends on it (SD-004).
61//!
62//! # Safety
63//!
64//! - The module-level `unsafe impl Sync for SyncPtr` is only instantiated in
65//!   this module as the `static PREDEFINED_ENTITIES` array of `_xmlEntity`
66//!   values. That static and the byte-string literals it points to are
67//!   immutable after initialization and are only ever read (never mutated or
68//!   freed), so sharing references to it across threads cannot cause data
69//!   races or use-after-free.
70
71use core::ffi::c_void;
72use core::mem::size_of;
73use core::ptr;
74use std::os::raw::{c_char, c_int, c_uint, c_ulong};
75
76use crate::abi::allocator;
77use crate::abi::structs::*;
78use crate::abi::types::xmlElementType::*;
79use crate::abi::types::xmlEntityType::*;
80use crate::abi::types::*;
81use crate::xml::hash;
82use crate::xml::string;
83
84// ═══════════════════════════════════════════════════════════════════════════════
85// Constants
86// ═══════════════════════════════════════════════════════════════════════════════
87
88/// Maximum entity recursion depth.
89///
90/// # UPSTREAM-PARITY
91///
92/// libxml2 uses XML_ENTITY_CONTENT_DEPTH_MAX (default 32).
93pub const XML_ENTITY_CONTENT_DEPTH_MAX: c_int = 32;
94
95/// Maximum entity expansion size (in bytes).
96///
97/// # UPSTREAM-PARITY
98///
99/// libxml2 uses XML_ENTITY_CONTENT_EXPANSION_MAX (default 1,000,000).
100pub const XML_ENTITY_CONTENT_EXPANSION_MAX: c_uint = 1_000_000;
101
102// ═══════════════════════════════════════════════════════════════════════════════
103// Entity Declaration Functions
104// ═══════════════════════════════════════════════════════════════════════════════
105
106/// Add an entity declaration to a DTD.
107///
108/// # UPSTREAM-PARITY
109///
110/// Add an entity declaration to a DTD's hash table (DTD-level helper).
111///
112/// # UPSTREAM-PARITY
113///
114/// This is the historical tree.c `xmlAddEntity` core (dtd-level add) and
115/// backs the candidate's `xmlAddDocEntity`/`xmlAddDtdEntity` and the parser
116/// entity-declaration path. The exported `xmlAddEntity` (entities.h, 2.15)
117/// is the document-level `add_entity_doc` with the int/error-code contract
118/// (R-000176).
119///
120/// Adds an entity to the appropriate hash table in the DTD based on the
121/// entity type (general entities go to `entities`, parameter entities to
122/// `pentities`).
123///
124/// If an entity with the same name already exists, the existing entity
125/// is returned and no new entity is created.
126///
127/// # SAFETY
128///
129/// - `dtd` must be a valid pointer to an _xmlDtd, or NULL.
130/// - `name` must be a valid null-terminated string.
131/// - `ExternalID`, `SystemID`, `content` may be NULL.
132pub unsafe fn add_entity(
133    dtd: *mut _xmlDtd,
134    name: *const xmlChar,
135    etype: c_int,
136    ExternalID: *const xmlChar,
137    SystemID: *const xmlChar,
138    content: *const xmlChar,
139) -> *mut _xmlEntity {
140    unsafe { add_entity_impl(dtd, name, etype, ExternalID, SystemID, content, ptr::null()) }
141}
142
143/// Like `add_entity`, but also records the ORIGINAL declaration text on the
144/// entity (`orig`). UPSTREAM-PARITY (parser.c xmlParseEntityDecl): internal
145/// entities parsed from a DTD keep the raw source text of their value in
146/// `ent->orig`, and xmlsave.c `xmlBufDumpEntityDecl` prints `orig` verbatim
147/// (only `"` escaped) when present — falling back to the content path (which
148/// additionally escapes `%` to `%`) only for entities without `orig`
149/// (php bug67081: `<!ENTITY % attrs "%coreattrs;">` must round-trip with a
150/// raw `%`).
151///
152/// # SAFETY
153///
154/// - Same as `add_entity`; `orig` may be NULL.
155pub unsafe fn add_entity_with_orig(
156    dtd: *mut _xmlDtd,
157    name: *const xmlChar,
158    etype: c_int,
159    ExternalID: *const xmlChar,
160    SystemID: *const xmlChar,
161    content: *const xmlChar,
162    orig: *const xmlChar,
163) -> *mut _xmlEntity {
164    unsafe { add_entity_impl(dtd, name, etype, ExternalID, SystemID, content, orig) }
165}
166
167#[allow(clippy::too_many_arguments)]
168unsafe fn add_entity_impl(
169    dtd: *mut _xmlDtd,
170    name: *const xmlChar,
171    etype: c_int,
172    ExternalID: *const xmlChar,
173    SystemID: *const xmlChar,
174    content: *const xmlChar,
175    orig: *const xmlChar,
176) -> *mut _xmlEntity {
177    if dtd.is_null() || name.is_null() {
178        return ptr::null_mut();
179    }
180
181    unsafe {
182        let d = &*dtd;
183
184        // Determine which hash table to use
185        let is_param = is_parameter_entity(etype);
186        // UPSTREAM-PARITY (entities.c xmlAddEntity): the table is created
187        // lazily on first use.
188        if is_param {
189            if (*dtd).pentities.is_null() {
190                (*dtd).pentities = hash::hash_create(8) as *mut c_void;
191            }
192        } else if (*dtd).entities.is_null() {
193            (*dtd).entities = hash::hash_create(8) as *mut c_void;
194        }
195        let hash_table = if is_param {
196            d.pentities as *mut hash::HashTable
197        } else {
198            d.entities as *mut hash::HashTable
199        };
200
201        // Check if entity already exists
202        let existing = hash::hash_lookup(hash_table, name);
203        if !existing.is_null() {
204            return existing as *mut _xmlEntity;
205        }
206
207        // SAFETY: Allocate zero-initialized memory for the entity.
208        let entity = allocator::xmlMallocZero(size_of::<_xmlEntity>() as usize) as *mut _xmlEntity;
209        if entity.is_null() {
210            return ptr::null_mut();
211        }
212
213        (*entity).type_ = XML_ENTITY_DECL as c_int;
214        (*entity).name = string::xml_strdup(name);
215        (*entity).etype = etype;
216        (*entity).ExternalID = string::xml_strdup(ExternalID);
217        (*entity).SystemID = string::xml_strdup(SystemID);
218        (*entity).content = string::xml_strdup(content);
219        (*entity).orig = if orig.is_null() {
220            ptr::null_mut()
221        } else {
222            string::xml_strdup(orig)
223        };
224        (*entity).length = if content.is_null() {
225            0
226        } else {
227            string::xml_strlen(content) as c_int
228        };
229        (*entity).flags = 0;
230        (*entity).expandedSize = 0;
231        (*entity).URI = ptr::null_mut();
232        (*entity).parent = dtd;
233        (*entity).doc = d.doc;
234        (*entity).nexte = ptr::null_mut();
235        (*entity).owner = 0;
236
237        // Add to hash table
238        let ret = hash::hash_add_entry(hash_table, name, entity as *mut c_void);
239        if ret != 0 {
240            // Failed to add
241            free_entity_internal(entity, false);
242            return ptr::null_mut();
243        }
244
245        // UPSTREAM-PARITY (entities.c xmlAddDocEntity/xmlAddDtdEntity
246        // "Link it to the DTD"): the entity decl is a child node of the DTD.
247        if (*dtd).last.is_null() {
248            (*dtd).children = entity as *mut _xmlNode;
249            (*dtd).last = entity as *mut _xmlNode;
250        } else {
251            (*(*dtd).last).next = entity as *mut _xmlNode;
252            (*entity).prev = (*dtd).last;
253            (*dtd).last = entity as *mut _xmlNode;
254        }
255
256        entity
257    }
258}
259
260/// Add an entity to a document's DTD (upstream entities.c `xmlAddEntity`, 2.15).
261///
262/// # UPSTREAM-PARITY
263///
264/// ```c
265/// int xmlAddEntity(xmlDoc *doc, int extSubset, const xmlChar *name, int type,
266///                  const xmlChar *publicId, const xmlChar *systemId,
267///                  const xmlChar *content, xmlEntity **out);
268/// ```
269///
270/// Mirrors the upstream control flow exactly (entities.c 2.15.0):
271///
272/// - `*out` is set to NULL on entry (upstream line: `if (out != NULL)
273///   *out = NULL;`);
274/// - NULL `doc`/`name` → `XML_ERR_ARGUMENT`;
275/// - missing DTD (extSubset selects the external subset, else internal) →
276///   `XML_DTD_NO_DTD`;
277/// - a predefined entity (lt/gt/amp/apos/quot) may only be redeclared with
278///   the exact replacement-content form upstream accepts (XML 1.0 §4.6),
279///   otherwise → `XML_ERR_REDECL_PREDEF_ENTITY`;
280/// - an unknown entity type → `XML_ERR_ARGUMENT`;
281/// - allocation failure → `XML_ERR_NO_MEMORY`;
282/// - a name already present in the selected table → `XML_WAR_ENTITY_REDEFINED`
283///   (the freshly created entity is freed);
284/// - success → `*out = entity` and returns 0.
285///
286/// The entity is created without going through the exported hooks, exactly
287/// as upstream `xmlCreateEntity` allocates directly with `xmlMalloc`.
288///
289/// # SAFETY
290///
291/// - `doc` must be a valid `_xmlDoc` pointer (or NULL), `name` a valid
292///   NUL-terminated string, `publicId`/`systemId`/`content` NUL-terminated
293///   or NULL, and `out` a writable `xmlEntity*` slot or NULL.
294#[allow(clippy::too_many_arguments)]
295pub unsafe fn add_entity_doc(
296    doc: *mut _xmlDoc,
297    ext_subset: c_int,
298    name: *const xmlChar,
299    etype: c_int,
300    public_id: *const xmlChar,
301    system_id: *const xmlChar,
302    content: *const xmlChar,
303    out: *mut *mut _xmlEntity,
304) -> c_int {
305    use crate::abi::types::{
306        XML_DTD_NO_DTD, XML_ERR_ARGUMENT, XML_ERR_NO_MEMORY, XML_ERR_REDECL_PREDEF_ENTITY,
307        XML_WAR_ENTITY_REDEFINED,
308    };
309    unsafe {
310        if !out.is_null() {
311            *out = ptr::null_mut();
312        }
313        if doc.is_null() || name.is_null() {
314            return XML_ERR_ARGUMENT;
315        }
316        let dtd = if ext_subset != 0 {
317            (*doc).extSubset
318        } else {
319            (*doc).intSubset
320        };
321        if dtd.is_null() {
322            return XML_DTD_NO_DTD;
323        }
324
325        // Select the table by entity type (upstream switch), creating it
326        // lazily with the candidate's dictionary-backed hash (hash_create).
327        let general = etype == XML_INTERNAL_GENERAL_ENTITY as c_int
328            || etype == XML_EXTERNAL_GENERAL_PARSED_ENTITY as c_int
329            || etype == XML_EXTERNAL_GENERAL_UNPARSED_ENTITY as c_int;
330        let parameter = etype == XML_INTERNAL_PARAMETER_ENTITY as c_int
331            || etype == XML_EXTERNAL_PARAMETER_ENTITY as c_int;
332        let table_field: *mut hash::HashTable = if general {
333            // XML 1.0 §4.6 predefined-entity redeclaration check.
334            let predef = lookup_predefined_entity(name);
335            if !predef.is_null() {
336                let mut valid = 0;
337                if etype == XML_INTERNAL_GENERAL_ENTITY as c_int && !content.is_null() {
338                    let c = *(*predef).content;
339                    if (*content == c)
340                        && (*(content.add(1)) == 0)
341                        && (c == b'>' || c == b'\'' || c == b'"')
342                    {
343                        valid = 1;
344                    } else if (*content == b'&') && (*(content.add(1)) == b'#') {
345                        if *(content.add(2)) == b'x' as xmlChar {
346                            let hex = b"0123456789ABCDEF";
347                            let mut ref_: [u8; 3] = [0, 0, b';'];
348                            ref_[0] = hex[(c / 16 % 16) as usize];
349                            ref_[1] = hex[(c % 16) as usize];
350                            if libc::strcasecmp(
351                                content.add(3) as *const c_char,
352                                ref_.as_ptr() as *const c_char,
353                            ) == 0
354                            {
355                                valid = 1;
356                            }
357                        } else {
358                            let mut ref_: [u8; 3] = [0, 0, b';'];
359                            ref_[0] = b'0' + c / 10 % 10;
360                            ref_[1] = b'0' + c % 10;
361                            if libc::strcmp(
362                                content.add(2) as *const c_char,
363                                ref_.as_ptr() as *const c_char,
364                            ) == 0
365                            {
366                                valid = 1;
367                            }
368                        }
369                    }
370                }
371                if valid == 0 {
372                    return XML_ERR_REDECL_PREDEF_ENTITY;
373                }
374            }
375            if (*dtd).entities.is_null() {
376                (*dtd).entities = hash::hash_create(8) as *mut c_void;
377            }
378            (*dtd).entities as *mut hash::HashTable
379        } else if parameter {
380            if (*dtd).pentities.is_null() {
381                (*dtd).pentities = hash::hash_create(8) as *mut c_void;
382            }
383            (*dtd).pentities as *mut hash::HashTable
384        } else {
385            return XML_ERR_ARGUMENT;
386        };
387
388        // Upstream xmlCreateEntity: allocate zeroed, fill fields, strdup.
389        let entity = allocator::xmlMallocZero(size_of::<_xmlEntity>() as usize) as *mut _xmlEntity;
390        if entity.is_null() {
391            return XML_ERR_NO_MEMORY;
392        }
393        (*entity).doc = doc;
394        (*entity).type_ = XML_ENTITY_DECL as c_int;
395        (*entity).etype = etype;
396        (*entity).name = string::xml_strdup(name);
397        if (*entity).name.is_null() {
398            free_entity_internal(entity, false);
399            return XML_ERR_NO_MEMORY;
400        }
401        if !public_id.is_null() {
402            (*entity).ExternalID = string::xml_strdup(public_id);
403            if (*entity).ExternalID.is_null() {
404                free_entity_internal(entity, false);
405                return XML_ERR_NO_MEMORY;
406            }
407        }
408        if !system_id.is_null() {
409            (*entity).SystemID = string::xml_strdup(system_id);
410            if (*entity).SystemID.is_null() {
411                free_entity_internal(entity, false);
412                return XML_ERR_NO_MEMORY;
413            }
414        }
415        if !content.is_null() {
416            (*entity).length = string::xml_strlen(content) as c_int;
417            (*entity).content = string::xml_strdup(content);
418            if (*entity).content.is_null() {
419                free_entity_internal(entity, false);
420                return XML_ERR_NO_MEMORY;
421            }
422        } else {
423            (*entity).length = 0;
424            (*entity).content = ptr::null_mut();
425        }
426        (*entity).URI = ptr::null();
427        (*entity).orig = ptr::null_mut();
428        (*entity).flags = 0;
429        (*entity).expandedSize = 0;
430        (*entity).nexte = ptr::null_mut();
431        (*entity).owner = 0;
432
433        // Upstream xmlHashAdd on the selected table: a name already present
434        // frees the fresh entity and reports the redefinition warning; any
435        // other add failure is treated as OOM (upstream res < 0).
436        let existing = hash::hash_lookup(table_field, name);
437        if !existing.is_null() {
438            free_entity_internal(entity, false);
439            return XML_WAR_ENTITY_REDEFINED;
440        }
441        let res = hash::hash_add_entry(table_field, name, entity as *mut c_void);
442        if res != 0 {
443            free_entity_internal(entity, false);
444            return XML_ERR_NO_MEMORY;
445        }
446
447        // Link it to the DTD (upstream "Link it to the DTD" block).
448        (*entity).parent = dtd;
449        (*entity).doc = (*dtd).doc;
450        if (*dtd).last.is_null() {
451            (*dtd).children = entity as *mut _xmlNode;
452            (*dtd).last = entity as *mut _xmlNode;
453        } else {
454            (*(*dtd).last).next = entity as *mut _xmlNode;
455            (*entity).prev = (*dtd).last;
456            (*dtd).last = entity as *mut _xmlNode;
457        }
458
459        if !out.is_null() {
460            *out = entity;
461        }
462        0
463    }
464}
465
466/// Get a general entity by name.
467///
468/// # UPSTREAM-PARITY
469///
470/// ```c
471/// xmlEntityPtr xmlGetEntity(xmlDocPtr doc, const xmlChar *name);
472/// ```
473///
474/// Searches the document's DTD for a general entity with the given name.
475/// Checks both internal and external subsets.
476///
477/// # SAFETY
478///
479/// - `doc` must be a valid pointer to an _xmlDoc, or NULL.
480/// - `name` must be a valid null-terminated string.
481pub unsafe fn get_entity(doc: *mut _xmlDoc, name: *const xmlChar) -> *mut _xmlEntity {
482    // UPSTREAM-PARITY (entities.c xmlGetDocEntity): a NULL document resolves
483    // through the PREDEFINED entities only — a doc-less
484    // `new DOMEntityReference("amp")` (xmlNewReference with doc NULL) still
485    // binds the predefined declaration as its children/last/content, and
486    // php's `dom_entity_reference_fetch_and_sync_declaration` re-syncs the
487    // same way via xmlGetDocEntity(NULL, ...).
488    if doc.is_null() {
489        return unsafe { crate::abi::exports_misc::xmlGetPredefinedEntity(name) };
490    }
491    if name.is_null() {
492        return ptr::null_mut();
493    }
494
495    unsafe {
496        let d = &*doc;
497
498        // Check internal subset
499        if !d.intSubset.is_null() {
500            let entities = (*d.intSubset).entities as *mut hash::HashTable;
501            let found = hash::hash_lookup(entities, name);
502            if !found.is_null() {
503                return found as *mut _xmlEntity;
504            }
505        }
506
507        // Check external subset
508        if !d.extSubset.is_null() {
509            let entities = (*d.extSubset).entities as *mut hash::HashTable;
510            let found = hash::hash_lookup(entities, name);
511            if !found.is_null() {
512                return found as *mut _xmlEntity;
513            }
514        }
515
516        // UPSTREAM-PARITY (entities.c xmlGetDocEntity): an unregistered name
517        // still resolves to the predefined entity (amp/lt/gt/quot/apos).
518        unsafe { crate::abi::exports_misc::xmlGetPredefinedEntity(name) }
519    }
520}
521
522/// Look up an entity in a DTD's entity hash table (upstream
523/// `xmlGetDtdEntity` core).
524///
525/// # SAFETY
526///
527/// - `dtd` must be a valid DTD pointer or NULL.
528/// - `name` must be a valid null-terminated string.
529pub unsafe fn get_entity_from_dtd(dtd: *mut _xmlDtd, name: *const xmlChar) -> *mut _xmlEntity {
530    if dtd.is_null() || name.is_null() {
531        return ptr::null_mut();
532    }
533    unsafe {
534        let entities = (*dtd).entities as *mut hash::HashTable;
535        let found = hash::hash_lookup(entities, name);
536        if found.is_null() {
537            ptr::null_mut()
538        } else {
539            found as *mut _xmlEntity
540        }
541    }
542}
543
544/// Get a parameter entity by name.
545///
546/// # UPSTREAM-PARITY
547///
548/// ```c
549/// xmlEntityPtr xmlGetParameterEntity(xmlDocPtr doc, const xmlChar *name);
550/// ```
551///
552/// Searches the document's DTD for a parameter entity with the given name.
553///
554/// # SAFETY
555///
556/// - `doc` must be a valid pointer to an _xmlDoc, or NULL.
557/// - `name` must be a valid null-terminated string.
558pub unsafe fn get_parameter_entity(doc: *mut _xmlDoc, name: *const xmlChar) -> *mut _xmlEntity {
559    if doc.is_null() || name.is_null() {
560        return ptr::null_mut();
561    }
562
563    unsafe {
564        let d = &*doc;
565
566        // Check internal subset
567        if !d.intSubset.is_null() {
568            let pentities = (*d.intSubset).pentities as *mut hash::HashTable;
569            let found = hash::hash_lookup(pentities, name);
570            if !found.is_null() {
571                return found as *mut _xmlEntity;
572            }
573        }
574
575        // Check external subset
576        if !d.extSubset.is_null() {
577            let pentities = (*d.extSubset).pentities as *mut hash::HashTable;
578            let found = hash::hash_lookup(pentities, name);
579            if !found.is_null() {
580                return found as *mut _xmlEntity;
581            }
582        }
583
584        ptr::null_mut()
585    }
586}
587
588/// Deep copy an entity declaration.
589///
590/// # UPSTREAM-PARITY
591///
592/// ```c
593/// xmlEntityPtr xmlCopyEntity(xmlEntityPtr entity);
594/// ```
595///
596/// # SAFETY
597///
598/// - `entity` must be a valid pointer to an _xmlEntity, or NULL.
599pub unsafe fn copy_entity(entity: *mut _xmlEntity) -> *mut _xmlEntity {
600    if entity.is_null() {
601        return ptr::null_mut();
602    }
603
604    unsafe {
605        let e = &*entity;
606
607        // SAFETY: Allocate zero-initialized memory for the copy.
608        let copy = allocator::xmlMallocZero(size_of::<_xmlEntity>() as usize) as *mut _xmlEntity;
609        if copy.is_null() {
610            return ptr::null_mut();
611        }
612
613        (*copy).type_ = XML_ENTITY_DECL as c_int;
614        (*copy).name = string::xml_strdup(e.name);
615        (*copy).etype = e.etype;
616        (*copy).ExternalID = string::xml_strdup(e.ExternalID);
617        (*copy).SystemID = string::xml_strdup(e.SystemID);
618        (*copy).content = string::xml_strdup(e.content);
619        (*copy).orig = string::xml_strdup(e.orig);
620        (*copy).length = e.length;
621        (*copy).flags = e.flags;
622        (*copy).expandedSize = e.expandedSize;
623        (*copy).URI = string::xml_strdup(e.URI);
624        (*copy).parent = e.parent;
625        (*copy).doc = e.doc;
626        (*copy).nexte = ptr::null_mut();
627        (*copy).owner = e.owner;
628
629        copy
630    }
631}
632
633/// Free an entity declaration.
634///
635/// # UPSTREAM-PARITY
636///
637/// ```c
638/// void xmlFreeEntity(xmlEntityPtr entity);
639/// ```
640///
641/// # SAFETY
642///
643/// - `entity` must be a valid pointer to an _xmlEntity, or NULL.
644pub unsafe fn free_entity(entity: *mut _xmlEntity) {
645    free_entity_internal(entity, true);
646}
647
648/// Internal entity free function.
649///
650/// If `free_children` is true, also frees the entity's children tree nodes.
651/// This is separated because the hash deallocator should not free children
652/// (they are owned by the document tree), but `xmlFreeEntity` from user code
653/// should free everything.
654///
655/// # Safety
656///
657/// - `entity` must be NULL or a pointer to a heap-allocated `_xmlEntity` whose
658///   `name`, `content`, `orig`, `ExternalID`, `SystemID`, and `URI` fields are
659///   each either NULL or pointers to allocator-owned allocations. The call
660///   frees every non-NULL field, then frees the entity itself, so afterwards
661///   neither the entity nor any of its fields may be dereferenced or freed
662///   again by the caller.
663unsafe fn free_entity_internal(entity: *mut _xmlEntity, free_children: bool) {
664    if entity.is_null() {
665        return;
666    }
667
668    unsafe {
669        let e = &*entity;
670
671        if !e.name.is_null() {
672            allocator::xmlFreeImpl(e.name as *mut c_void);
673        }
674        if !e.content.is_null() {
675            allocator::xmlFreeImpl(e.content as *mut c_void);
676        }
677        if !e.orig.is_null() {
678            allocator::xmlFreeImpl(e.orig as *mut c_void);
679        }
680        if !e.ExternalID.is_null() {
681            allocator::xmlFreeImpl(e.ExternalID as *mut c_void);
682        }
683        if !e.SystemID.is_null() {
684            allocator::xmlFreeImpl(e.SystemID as *mut c_void);
685        }
686        if !e.URI.is_null() {
687            allocator::xmlFreeImpl(e.URI as *mut c_void);
688        }
689
690        // Free children tree nodes if requested. The materialized replacement
691        // tree (upstream xmlNodeParseAttValue fills `ent->children` from
692        // `ent->content` on first reference) is owned by the declaration and
693        // freed with it (text/cdata nodes and entity-REF nodes; the REFs' own
694        // `children` point at declarations, which free_node never descends
695        // into).
696        if free_children && !e.children.is_null() {
697            crate::xml::tree::free_node_list(e.children);
698        }
699
700        allocator::xmlFreeImpl(entity as *mut c_void);
701    }
702}
703
704// ═══════════════════════════════════════════════════════════════════════════════
705// Entity Type Helpers
706// ═══════════════════════════════════════════════════════════════════════════════
707
708/// Check if an entity type is a parameter entity.
709#[inline]
710pub const fn is_parameter_entity(etype: c_int) -> bool {
711    etype == XML_INTERNAL_PARAMETER_ENTITY as c_int
712        || etype == XML_EXTERNAL_PARAMETER_ENTITY as c_int
713}
714
715/// Check if an entity type is an external entity.
716#[inline]
717pub const fn is_external_entity(etype: c_int) -> bool {
718    etype == XML_EXTERNAL_GENERAL_PARSED_ENTITY as c_int
719        || etype == XML_EXTERNAL_GENERAL_UNPARSED_ENTITY as c_int
720        || etype == XML_EXTERNAL_PARAMETER_ENTITY as c_int
721}
722
723/// Check if an entity type is a predefined entity.
724#[inline]
725pub const fn is_predefined_entity(etype: c_int) -> bool {
726    etype == XML_INTERNAL_PREDEFINED_ENTITY as c_int
727}
728
729// ═══════════════════════════════════════════════════════════════════════════════
730// Entity Substitution & Encoding
731// ═══════════════════════════════════════════════════════════════════════════════
732
733/// Encode special XML characters in a string for output.
734///
735/// # UPSTREAM-PARITY
736///
737/// ```c
738/// xmlChar *xmlEncodeEntitiesReentrant(xmlDocPtr doc, const xmlChar *input);
739/// ```
740///
741/// Encodes `<`, `>`, `&`, `"`, `'` as their corresponding XML entities.
742/// The returned string must be freed with `xmlFree`.
743///
744/// # SAFETY
745///
746/// - `input` must be a valid null-terminated xmlChar string, or NULL.
747pub unsafe fn encode_entities_reentrant(_doc: *mut _xmlDoc, input: *const xmlChar) -> *mut xmlChar {
748    if input.is_null() {
749        return ptr::null_mut();
750    }
751
752    unsafe {
753        // First pass: calculate output size
754        let len = string::xml_strlen(input);
755        let mut out_len: usize = 0;
756
757        for i in 0..len {
758            match *input.add(i) {
759                b'<' => out_len += 4,  // &lt;
760                b'>' => out_len += 4,  // &gt;
761                b'&' => out_len += 5,  // &amp;
762                b'"' => out_len += 6,  // &quot;
763                b'\'' => out_len += 6, // &apos;
764                _c => out_len += 1,
765            }
766        }
767
768        // Allocate output buffer
769        let output = allocator::xmlMallocImpl(out_len + 1) as *mut xmlChar;
770        if output.is_null() {
771            return ptr::null_mut();
772        }
773
774        // Second pass: encode
775        let mut j: usize = 0;
776        for i in 0..len {
777            let c = *input.add(i);
778            match c {
779                b'<' => {
780                    ptr::copy_nonoverlapping(b"&lt;" as *const u8, output.add(j), 4);
781                    j += 4;
782                }
783                b'>' => {
784                    ptr::copy_nonoverlapping(b"&gt;" as *const u8, output.add(j), 4);
785                    j += 4;
786                }
787                b'&' => {
788                    ptr::copy_nonoverlapping(b"&amp;" as *const u8, output.add(j), 5);
789                    j += 5;
790                }
791                b'"' => {
792                    ptr::copy_nonoverlapping(b"&quot;" as *const u8, output.add(j), 6);
793                    j += 6;
794                }
795                b'\'' => {
796                    ptr::copy_nonoverlapping(b"&apos;" as *const u8, output.add(j), 6);
797                    j += 6;
798                }
799                _ => {
800                    *output.add(j) = c;
801                    j += 1;
802                }
803            }
804        }
805
806        *output.add(out_len) = 0; // null-terminate
807        output
808    }
809}
810
811/// Decode entity references in a string.
812///
813/// # UPSTREAM-PARITY
814///
815/// ```c
816/// xmlChar *xmlStringDecodeEntities(xmlDocPtr doc, const xmlChar *input,
817///                                  int what, int end, int end2, int end3);
818/// ```
819///
820/// Replaces entity references (`&name;`) with their content from the
821/// document's entity declarations. Also handles numeric character
822/// references (`&#NNN;` and `&#xHHH;`).
823///
824/// The `what` parameter specifies which entities to substitute:
825/// - 0: substitute all
826/// - 1: substitute only predefined
827/// - 2: substitute only general
828///
829/// `end`, `end2`, `end3` specify terminating characters (0 if none).
830///
831/// Returns the decoded string (must be freed with `xmlFree`), or NULL on error.
832///
833/// # SAFETY
834///
835/// - `doc` may be NULL (no entity lookup, only numeric refs).
836/// - `input` must be a valid null-terminated xmlChar string, or NULL.
837pub unsafe fn string_decode_entities(
838    doc: *mut _xmlDoc,
839    input: *const xmlChar,
840    what: c_int,
841    end: xmlChar,
842    end2: xmlChar,
843    end3: xmlChar,
844) -> *mut xmlChar {
845    if input.is_null() {
846        return ptr::null_mut();
847    }
848
849    unsafe {
850        let len = string::xml_strlen(input);
851        if len == 0 {
852            // Return empty string
853            let empty = allocator::xmlMallocImpl(1) as *mut xmlChar;
854            if !empty.is_null() {
855                *empty = 0;
856            }
857            return empty;
858        }
859
860        // Allocate a generous output buffer (input length + expansion)
861        let max_out = len * 4 + 1; // Allow for some expansion
862        let output = allocator::xmlMallocImpl(max_out) as *mut xmlChar;
863        if output.is_null() {
864            return ptr::null_mut();
865        }
866
867        let mut out_pos: usize = 0;
868        let mut i: usize = 0;
869
870        while i < len {
871            let c = *input.add(i);
872
873            // Check for terminating characters
874            if (end != 0 && c == end) || (end2 != 0 && c == end2) || (end3 != 0 && c == end3) {
875                break;
876            }
877
878            if c == b'&' {
879                // Entity reference
880                i += 1;
881
882                // Check for numeric character reference: &#NNN; or &#xHHH;
883                if i < len && *input.add(i) == b'#' {
884                    i += 1;
885                    let (decoded_char, _consumed) = decode_numeric_ref(input, &mut i, len);
886                    if decoded_char != 0 {
887                        // Encode the decoded character as UTF-8
888                        if decoded_char < 0x80 {
889                            if out_pos < max_out - 1 {
890                                *output.add(out_pos) = decoded_char as u8;
891                                out_pos += 1;
892                            }
893                        } else if decoded_char < 0x800 {
894                            if out_pos < max_out - 2 {
895                                *output.add(out_pos) = 0xC0 | ((decoded_char >> 6) as u8);
896                                *output.add(out_pos + 1) = 0x80 | ((decoded_char & 0x3F) as u8);
897                                out_pos += 2;
898                            }
899                        } else {
900                            if out_pos < max_out - 3 {
901                                *output.add(out_pos) = 0xE0 | ((decoded_char >> 12) as u8);
902                                *output.add(out_pos + 1) =
903                                    0x80 | (((decoded_char >> 6) & 0x3F) as u8);
904                                *output.add(out_pos + 2) = 0x80 | ((decoded_char & 0x3F) as u8);
905                                out_pos += 3;
906                            }
907                        }
908                    }
909                    // decode_numeric_ref already advanced i past ';'
910                    // The continue skips the i += 1 at the bottom of the loop
911                    continue;
912                }
913
914                // General entity reference: &name;
915                let entity_name_start = i;
916                while i < len
917                    && *input.add(i) != b';'
918                    && *input.add(i) != b'&'
919                    && *input.add(i) != 0
920                {
921                    i += 1;
922                }
923
924                if i < len && *input.add(i) == b';' {
925                    // We have a complete entity reference
926                    let name_len = i - entity_name_start;
927                    if name_len > 0 {
928                        // Create a null-terminated name
929                        let name_buf = allocator::xmlMallocImpl(name_len + 1) as *mut xmlChar;
930                        if !name_buf.is_null() {
931                            ptr::copy_nonoverlapping(
932                                input.add(entity_name_start),
933                                name_buf,
934                                name_len,
935                            );
936                            *name_buf.add(name_len) = 0;
937
938                            // Try to find the entity
939                            let mut entity: *mut _xmlEntity = ptr::null_mut();
940                            if what != 1 {
941                                // Not just predefined
942                                if !doc.is_null() {
943                                    entity = get_entity(doc, name_buf as *const xmlChar);
944                                }
945                            }
946
947                            if entity.is_null() {
948                                // Try predefined entities
949                                entity = lookup_predefined_entity(name_buf as *const xmlChar);
950                            }
951
952                            if !entity.is_null() && !(*entity).content.is_null() {
953                                // Copy entity content to output
954                                let content = (*entity).content;
955                                let content_len = string::xml_strlen(content);
956                                for j in 0..content_len {
957                                    if out_pos < max_out - 1 {
958                                        *output.add(out_pos) = *content.add(j);
959                                        out_pos += 1;
960                                    }
961                                }
962                            } else {
963                                // Entity not found — output the reference as-is
964                                if out_pos < max_out - 2 {
965                                    *output.add(out_pos) = b'&';
966                                    out_pos += 1;
967                                }
968                                for j in 0..name_len {
969                                    if out_pos < max_out - 2 {
970                                        *output.add(out_pos) = *input.add(entity_name_start + j);
971                                        out_pos += 1;
972                                    }
973                                }
974                                if out_pos < max_out - 1 {
975                                    *output.add(out_pos) = b';';
976                                    out_pos += 1;
977                                }
978                            }
979
980                            allocator::xmlFreeImpl(name_buf as *mut c_void);
981                        }
982                    }
983                    // Skip past the semicolon
984                    // i is already at the semicolon, loop increment will skip it
985                } else {
986                    // Malformed reference — output as-is
987                    if out_pos < max_out - 1 {
988                        *output.add(out_pos) = b'&';
989                        out_pos += 1;
990                    }
991                    // Back up to include all characters we scanned
992                    // The loop increment will advance i
993                }
994            } else {
995                // Regular character
996                if out_pos < max_out - 1 {
997                    *output.add(out_pos) = c;
998                    out_pos += 1;
999                }
1000            }
1001
1002            i += 1;
1003        }
1004
1005        *output.add(out_pos) = 0; // null-terminate
1006        output
1007    }
1008}
1009
1010/// Decode a numeric character reference (`&#NNN;` or `&#xHHH;`).
1011///
1012/// Returns the decoded character and the number of additional characters consumed.
1013///
1014/// # Safety
1015///
1016/// - `input` must be a valid pointer to a buffer of at least `len` readable
1017///   bytes; `pos` must be a valid `&mut usize` in `0..=len` — the read at
1018///   `input[pos]` is bounds-checked against `len` before every access.
1019const unsafe fn decode_numeric_ref(
1020    input: *const xmlChar,
1021    pos: &mut usize,
1022    len: usize,
1023) -> (u32, usize) {
1024    unsafe {
1025        let mut consumed: usize = 0;
1026
1027        if *pos >= len {
1028            return (0, 0);
1029        }
1030
1031        if *input.add(*pos) == b'x' || *input.add(*pos) == b'X' {
1032            // Hexadecimal: &#xHHH;
1033            *pos += 1;
1034            consumed += 1;
1035
1036            let mut value: u32 = 0;
1037            while *pos < len {
1038                let c = *input.add(*pos);
1039                if c == b';' {
1040                    *pos += 1;
1041                    consumed += 1;
1042                    return (value, consumed);
1043                }
1044                let digit = match c {
1045                    b'0'..=b'9' => c - b'0',
1046                    b'a'..=b'f' => c - b'a' + 10,
1047                    b'A'..=b'F' => c - b'A' + 10,
1048                    _ => break,
1049                };
1050                value = value.wrapping_mul(16).wrapping_add(digit as u32);
1051                *pos += 1;
1052                consumed += 1;
1053            }
1054            (value, consumed)
1055        } else {
1056            // Decimal: &#NNN;
1057            let mut value: u32 = 0;
1058            while *pos < len {
1059                let c = *input.add(*pos);
1060                if c == b';' {
1061                    *pos += 1;
1062                    consumed += 1;
1063                    return (value, consumed);
1064                }
1065                if !c.is_ascii_digit() {
1066                    break;
1067                }
1068                value = value.wrapping_mul(10).wrapping_add((c - b'0') as u32);
1069                *pos += 1;
1070                consumed += 1;
1071            }
1072            (value, consumed)
1073        }
1074    }
1075}
1076
1077/// A wrapper for static data that implements Sync for raw pointer types.
1078struct SyncPtr<T>(pub T);
1079unsafe impl<T> Sync for SyncPtr<T> {}
1080
1081/// Look up a predefined XML entity by name.
1082///
1083/// Returns the entity pointer (to a static entity) or NULL.
1084///
1085/// # Safety
1086///
1087/// - `name` must be NULL or a pointer to a NUL-terminated `xmlChar` string
1088///   that stays readable for the duration of the call: the first byte is read
1089///   via `name.add(0)` and the full string is compared with
1090///   `string::xml_strcmp`. The returned pointer aliases the immutable static
1091///   `PREDEFINED_ENTITIES` array, lives for the whole program, and must not be
1092///   freed or mutated by the caller.
1093unsafe fn lookup_predefined_entity(name: *const xmlChar) -> *mut _xmlEntity {
1094    if name.is_null() {
1095        return ptr::null_mut();
1096    }
1097
1098    // UPSTREAM-PARITY: Predefined entities are: lt, gt, amp, quot, apos
1099    static PREDEFINED_ENTITIES: SyncPtr<[_xmlEntity; 5]> = SyncPtr([
1100        _xmlEntity {
1101            _private: ptr::null_mut(),
1102            type_: XML_ENTITY_DECL as c_int,
1103            name: b"lt\0" as *const u8 as *const xmlChar,
1104            children: ptr::null_mut(),
1105            last: ptr::null_mut(),
1106            parent: ptr::null_mut(),
1107            next: ptr::null_mut(),
1108            prev: ptr::null_mut(),
1109            doc: ptr::null_mut(),
1110            orig: ptr::null_mut(),
1111            content: b"<\0" as *const u8 as *mut xmlChar,
1112            length: 1,
1113            etype: XML_INTERNAL_PREDEFINED_ENTITY as c_int,
1114            ExternalID: ptr::null(),
1115            SystemID: ptr::null(),
1116            nexte: ptr::null_mut(),
1117            URI: ptr::null(),
1118            owner: 0,
1119            flags: 0,
1120            expandedSize: 0,
1121        },
1122        _xmlEntity {
1123            _private: ptr::null_mut(),
1124            type_: XML_ENTITY_DECL as c_int,
1125            name: b"gt\0" as *const u8 as *const xmlChar,
1126            children: ptr::null_mut(),
1127            last: ptr::null_mut(),
1128            parent: ptr::null_mut(),
1129            next: ptr::null_mut(),
1130            prev: ptr::null_mut(),
1131            doc: ptr::null_mut(),
1132            orig: ptr::null_mut(),
1133            content: b">\0" as *const u8 as *mut xmlChar,
1134            length: 1,
1135            etype: XML_INTERNAL_PREDEFINED_ENTITY as c_int,
1136            ExternalID: ptr::null(),
1137            SystemID: ptr::null(),
1138            nexte: ptr::null_mut(),
1139            URI: ptr::null(),
1140            owner: 0,
1141            flags: 0,
1142            expandedSize: 0,
1143        },
1144        _xmlEntity {
1145            _private: ptr::null_mut(),
1146            type_: XML_ENTITY_DECL as c_int,
1147            name: b"amp\0" as *const u8 as *const xmlChar,
1148            children: ptr::null_mut(),
1149            last: ptr::null_mut(),
1150            parent: ptr::null_mut(),
1151            next: ptr::null_mut(),
1152            prev: ptr::null_mut(),
1153            doc: ptr::null_mut(),
1154            orig: ptr::null_mut(),
1155            content: b"&\0" as *const u8 as *mut xmlChar,
1156            length: 1,
1157            etype: XML_INTERNAL_PREDEFINED_ENTITY as c_int,
1158            ExternalID: ptr::null(),
1159            SystemID: ptr::null(),
1160            nexte: ptr::null_mut(),
1161            URI: ptr::null(),
1162            owner: 0,
1163            flags: 0,
1164            expandedSize: 0,
1165        },
1166        _xmlEntity {
1167            _private: ptr::null_mut(),
1168            type_: XML_ENTITY_DECL as c_int,
1169            name: b"quot\0" as *const u8 as *const xmlChar,
1170            children: ptr::null_mut(),
1171            last: ptr::null_mut(),
1172            parent: ptr::null_mut(),
1173            next: ptr::null_mut(),
1174            prev: ptr::null_mut(),
1175            doc: ptr::null_mut(),
1176            orig: ptr::null_mut(),
1177            content: b"\"\0" as *const u8 as *mut xmlChar,
1178            length: 1,
1179            etype: XML_INTERNAL_PREDEFINED_ENTITY as c_int,
1180            ExternalID: ptr::null(),
1181            SystemID: ptr::null(),
1182            nexte: ptr::null_mut(),
1183            URI: ptr::null(),
1184            owner: 0,
1185            flags: 0,
1186            expandedSize: 0,
1187        },
1188        _xmlEntity {
1189            _private: ptr::null_mut(),
1190            type_: XML_ENTITY_DECL as c_int,
1191            name: b"apos\0" as *const u8 as *const xmlChar,
1192            children: ptr::null_mut(),
1193            last: ptr::null_mut(),
1194            parent: ptr::null_mut(),
1195            next: ptr::null_mut(),
1196            prev: ptr::null_mut(),
1197            doc: ptr::null_mut(),
1198            orig: ptr::null_mut(),
1199            content: b"'\0" as *const u8 as *mut xmlChar,
1200            length: 1,
1201            etype: XML_INTERNAL_PREDEFINED_ENTITY as c_int,
1202            ExternalID: ptr::null(),
1203            SystemID: ptr::null(),
1204            nexte: ptr::null_mut(),
1205            URI: ptr::null(),
1206            owner: 0,
1207            flags: 0,
1208            expandedSize: 0,
1209        },
1210    ]);
1211
1212    unsafe {
1213        // Match which entity
1214        let idx = match *name.add(0) as char {
1215            'l' => {
1216                if string::xml_strcmp(name, b"lt\0" as *const u8 as *const xmlChar) == 0 {
1217                    0
1218                } else {
1219                    return ptr::null_mut();
1220                }
1221            }
1222            'g' => {
1223                if string::xml_strcmp(name, b"gt\0" as *const u8 as *const xmlChar) == 0 {
1224                    1
1225                } else {
1226                    return ptr::null_mut();
1227                }
1228            }
1229            'a' => {
1230                if string::xml_strcmp(name, b"amp\0" as *const u8 as *const xmlChar) == 0 {
1231                    2
1232                } else if string::xml_strcmp(name, b"apos\0" as *const u8 as *const xmlChar) == 0 {
1233                    4
1234                } else {
1235                    return ptr::null_mut();
1236                }
1237            }
1238            'q' if string::xml_strcmp(name, b"quot\0" as *const u8 as *const xmlChar) == 0 => 3,
1239            _ => return ptr::null_mut(),
1240        };
1241
1242        &PREDEFINED_ENTITIES.0[idx] as *const _xmlEntity as *mut _xmlEntity
1243    }
1244}
1245
1246// ═══════════════════════════════════════════════════════════════════════════════
1247// Entity Content Retrieval
1248// ═══════════════════════════════════════════════════════════════════════════════
1249
1250/// Get the content of an entity as a string.
1251///
1252/// # SAFETY
1253///
1254/// - `entity` must be a valid pointer to an _xmlEntity, or NULL.
1255pub unsafe fn get_entity_content(entity: *mut _xmlEntity) -> *mut xmlChar {
1256    if entity.is_null() {
1257        return ptr::null_mut();
1258    }
1259
1260    unsafe {
1261        let e = &*entity;
1262        if e.content.is_null() {
1263            return ptr::null_mut();
1264        }
1265        string::xml_strdup(e.content)
1266    }
1267}
1268
1269// ═══════════════════════════════════════════════════════════════════════════════
1270// Security/Limit Functions
1271// ═══════════════════════════════════════════════════════════════════════════════
1272
1273/// Check if entity expansion exceeds limits.
1274///
1275/// Returns 0 if within limits, -1 if exceeded.
1276pub const fn check_entity_expansion_limit(expanded_size: c_ulong) -> c_int {
1277    if expanded_size > XML_ENTITY_CONTENT_EXPANSION_MAX as c_ulong {
1278        -1
1279    } else {
1280        0
1281    }
1282}
1283
1284/// Check if entity recursion depth exceeds limits.
1285///
1286/// Returns 0 if within limits, -1 if exceeded.
1287pub const fn check_entity_recursion_depth(depth: c_int) -> c_int {
1288    if depth > XML_ENTITY_CONTENT_DEPTH_MAX {
1289        -1
1290    } else {
1291        0
1292    }
1293}
1294
1295// ═══════════════════════════════════════════════════════════════════════════════
1296// Tests
1297// ═══════════════════════════════════════════════════════════════════════════════
1298
1299#[cfg(test)]
1300mod tests {
1301    use super::*;
1302
1303    use core::ffi::c_void;
1304    use core::ptr;
1305
1306    unsafe fn c_str(s: &[u8]) -> *const xmlChar {
1307        let len = s.len();
1308        let buf = allocator::xmlMallocImpl(len + 1) as *mut xmlChar;
1309        assert!(!buf.is_null());
1310        ptr::copy_nonoverlapping(s.as_ptr(), buf, len);
1311        *buf.add(len) = 0;
1312        buf as *const xmlChar
1313    }
1314
1315    unsafe fn make_doc_and_dtd() -> (*mut _xmlDoc, *mut _xmlDtd) {
1316        let doc = allocator::xmlMallocZero(size_of::<_xmlDoc>() as usize) as *mut _xmlDoc;
1317        assert!(!doc.is_null());
1318        (*doc).type_ = XML_DOCUMENT_NODE as c_int;
1319        (*doc).doc = doc;
1320        let dtd = allocator::xmlMallocZero(size_of::<_xmlDtd>() as usize) as *mut _xmlDtd;
1321        assert!(!dtd.is_null());
1322        (*dtd).type_ = XML_DTD_NODE as c_int;
1323        (*dtd).parent = doc;
1324        (*dtd).doc = doc;
1325        (*dtd).entities = hash::hash_create(8) as *mut c_void;
1326        (*dtd).pentities = hash::hash_create(8) as *mut c_void;
1327        (*doc).intSubset = dtd;
1328        (doc, dtd)
1329    }
1330
1331    // ── Entity Declaration Tests ────────────────────────────────────────
1332
1333    #[test]
1334    /// Tests that a general entity can be added to a DTD and looked back up.
1335    ///
1336    /// # Safety
1337    ///
1338    /// - The test dereferences only raw pointers it allocates itself: `doc`
1339    ///   and `dtd` are zero-initialized by `make_doc_and_dtd` and stay alive
1340    ///   until the final `xmlFreeImpl` calls; `name` and `content` are
1341    ///   NUL-terminated buffers from `c_str`; `entity` is asserted non-NULL
1342    ///   before `(*entity)` is read. The hash tables are freed with
1343    ///   `entity_deallocator` before `dtd` and `doc` are freed, so no access
1344    ///   touches freed memory.
1345    fn test_add_entity_general() {
1346        unsafe {
1347            let (doc, dtd) = make_doc_and_dtd();
1348            let name = c_str(b"myEntity");
1349            let content = c_str(b"Hello, World!");
1350
1351            let entity = add_entity(
1352                dtd,
1353                name,
1354                XML_INTERNAL_GENERAL_ENTITY as c_int,
1355                ptr::null(),
1356                ptr::null(),
1357                content,
1358            );
1359            assert!(!entity.is_null());
1360            assert_eq!((*entity).etype, XML_INTERNAL_GENERAL_ENTITY as c_int);
1361            assert_eq!((*entity).length, 13);
1362
1363            // Lookup
1364            let found = get_entity(doc, name);
1365            assert_eq!(found, entity);
1366
1367            // Cleanup
1368            // We need to free the hash tables manually since we don't have free_dtd available
1369            hash::hash_free(
1370                (*dtd).entities as *mut hash::HashTable,
1371                Some(entity_deallocator),
1372            );
1373            hash::hash_free(
1374                (*dtd).pentities as *mut hash::HashTable,
1375                Some(entity_deallocator),
1376            );
1377            allocator::xmlFreeImpl(dtd as *mut c_void);
1378            allocator::xmlFreeImpl(doc as *mut c_void);
1379        }
1380    }
1381
1382    /// Deallocator passed to `hash::hash_free` to release entity payloads.
1383    ///
1384    /// # Safety
1385    ///
1386    /// - `payload` must be NULL or a pointer to a heap-allocated `_xmlEntity`
1387    ///   that has not yet been freed, because it is forwarded to
1388    ///   `free_entity`; `_name` is ignored by this function.
1389    unsafe extern "C" fn entity_deallocator(payload: *mut c_void, _name: *mut u8) {
1390        if !payload.is_null() {
1391            free_entity(payload as *mut _xmlEntity);
1392        }
1393    }
1394
1395    #[test]
1396    /// Tests that a parameter entity is stored in the `pentities` table.
1397    ///
1398    /// # Safety
1399    ///
1400    /// - `doc` and `dtd` are zero-initialized by `make_doc_and_dtd` and remain
1401    ///   live until the trailing frees; `name` and `content` are NUL-terminated
1402    ///   `c_str` allocations valid for the whole test; the `entity` pointer is
1403    ///   asserted non-NULL before `(*entity)` is read, and the hash tables are
1404    ///   freed with `entity_deallocator` before `dtd` and `doc` are freed.
1405    fn test_add_entity_parameter() {
1406        unsafe {
1407            let (doc, dtd) = make_doc_and_dtd();
1408            let name = c_str(b"myParam");
1409            let content = c_str(b"parameter content");
1410
1411            let entity = add_entity(
1412                dtd,
1413                name,
1414                XML_INTERNAL_PARAMETER_ENTITY as c_int,
1415                ptr::null(),
1416                ptr::null(),
1417                content,
1418            );
1419            assert!(!entity.is_null());
1420            assert_eq!((*entity).etype, XML_INTERNAL_PARAMETER_ENTITY as c_int);
1421
1422            // Parameter entity should be in pentities
1423            let found = hash::hash_lookup((*dtd).pentities as *mut hash::HashTable, name);
1424            assert_eq!(found, entity as *mut c_void);
1425
1426            // General entity lookup should NOT find it
1427            let not_found = hash::hash_lookup((*dtd).entities as *mut hash::HashTable, name);
1428            assert!(not_found.is_null());
1429
1430            hash::hash_free(
1431                (*dtd).entities as *mut hash::HashTable,
1432                Some(entity_deallocator),
1433            );
1434            hash::hash_free(
1435                (*dtd).pentities as *mut hash::HashTable,
1436                Some(entity_deallocator),
1437            );
1438            allocator::xmlFreeImpl(dtd as *mut c_void);
1439            allocator::xmlFreeImpl(doc as *mut c_void);
1440        }
1441    }
1442
1443    #[test]
1444    /// Tests that `add_entity` returns NULL when the DTD is NULL.
1445    ///
1446    /// # Safety
1447    ///
1448    /// - `add_entity` checks `dtd` for NULL and returns early without
1449    ///   dereferencing it, and the `name` argument is a NUL-terminated `c_str`
1450    ///   allocation that stays alive for the duration of the call.
1451    fn test_add_entity_null_dtd() {
1452        unsafe {
1453            let entity = add_entity(
1454                ptr::null_mut(),
1455                c_str(b"test"),
1456                XML_INTERNAL_GENERAL_ENTITY as c_int,
1457                ptr::null(),
1458                ptr::null(),
1459                ptr::null(),
1460            );
1461            assert!(entity.is_null());
1462        }
1463    }
1464
1465    #[test]
1466    /// Tests that re-adding an entity with the same name reuses the existing
1467    /// declaration.
1468    ///
1469    /// # Safety
1470    ///
1471    /// - `doc` and `dtd` are zero-initialized by `make_doc_and_dtd` and outlive
1472    ///   every dereference; `name` and `content` are NUL-terminated `c_str`
1473    ///   allocations; `e1` is asserted non-NULL before `(*e1)` is read, and
1474    ///   the hash tables are freed with `entity_deallocator` before `dtd` and
1475    ///   `doc` are freed.
1476    fn test_add_entity_duplicate() {
1477        unsafe {
1478            let (doc, dtd) = make_doc_and_dtd();
1479            let name = c_str(b"dup");
1480            let content = c_str(b"original");
1481
1482            let e1 = add_entity(
1483                dtd,
1484                name,
1485                XML_INTERNAL_GENERAL_ENTITY as c_int,
1486                ptr::null(),
1487                ptr::null(),
1488                content,
1489            );
1490            assert!(!e1.is_null());
1491
1492            let e2 = add_entity(
1493                dtd,
1494                name,
1495                XML_INTERNAL_GENERAL_ENTITY as c_int,
1496                ptr::null(),
1497                ptr::null(),
1498                c_str(b"replacement"),
1499            );
1500            assert_eq!(e1, e2); // Same pointer
1501
1502            hash::hash_free(
1503                (*dtd).entities as *mut hash::HashTable,
1504                Some(entity_deallocator),
1505            );
1506            hash::hash_free(
1507                (*dtd).pentities as *mut hash::HashTable,
1508                Some(entity_deallocator),
1509            );
1510            allocator::xmlFreeImpl(dtd as *mut c_void);
1511            allocator::xmlFreeImpl(doc as *mut c_void);
1512        }
1513    }
1514
1515    #[test]
1516    /// Tests that an external parsed entity keeps its SystemID.
1517    ///
1518    /// # Safety
1519    ///
1520    /// - `doc` and `dtd` come from `make_doc_and_dtd` and stay live until the
1521    ///   trailing frees; `name` and `sysid` are NUL-terminated `c_str`
1522    ///   allocations; `entity` is asserted non-NULL before `(*entity).SystemID`
1523    ///   is passed to `string::xml_strcmp`, and the hash tables are freed with
1524    ///   `entity_deallocator` before `dtd` and `doc` are freed.
1525    fn test_add_entity_external() {
1526        unsafe {
1527            let (doc, dtd) = make_doc_and_dtd();
1528            let name = c_str(b"extEntity");
1529            let sysid = c_str(b"http://example.com/entity.xml");
1530
1531            let entity = add_entity(
1532                dtd,
1533                name,
1534                XML_EXTERNAL_GENERAL_PARSED_ENTITY as c_int,
1535                ptr::null(),
1536                sysid,
1537                ptr::null(),
1538            );
1539            assert!(!entity.is_null());
1540            assert_eq!((*entity).etype, XML_EXTERNAL_GENERAL_PARSED_ENTITY as c_int);
1541            assert_eq!(string::xml_strcmp((*entity).SystemID, sysid), 0);
1542
1543            hash::hash_free(
1544                (*dtd).entities as *mut hash::HashTable,
1545                Some(entity_deallocator),
1546            );
1547            hash::hash_free(
1548                (*dtd).pentities as *mut hash::HashTable,
1549                Some(entity_deallocator),
1550            );
1551            allocator::xmlFreeImpl(dtd as *mut c_void);
1552            allocator::xmlFreeImpl(doc as *mut c_void);
1553        }
1554    }
1555
1556    #[test]
1557    /// Tests that `get_entity` returns NULL when the document is NULL.
1558    ///
1559    /// # Safety
1560    ///
1561    /// - `get_entity` checks `doc` for NULL before dereferencing it, and the
1562    ///   `name` argument is a NUL-terminated `c_str` allocation that stays
1563    ///   alive for the duration of the call.
1564    fn test_get_entity_null_doc() {
1565        unsafe {
1566            let found = get_entity(ptr::null_mut(), c_str(b"test"));
1567            assert!(found.is_null());
1568        }
1569    }
1570
1571    #[test]
1572    /// Tests that `get_entity` returns NULL for an undeclared name.
1573    ///
1574    /// # Safety
1575    ///
1576    /// - `doc` and `dtd` are zero-initialized by `make_doc_and_dtd` and stay
1577    ///   live until the trailing frees; the lookup name is a NUL-terminated
1578    ///   `c_str` allocation; the hash tables are freed with
1579    ///   `entity_deallocator` before `dtd` and `doc` are freed.
1580    fn test_get_entity_not_found() {
1581        unsafe {
1582            let (doc, dtd) = make_doc_and_dtd();
1583            let found = get_entity(doc, c_str(b"nonexistent"));
1584            assert!(found.is_null());
1585
1586            hash::hash_free(
1587                (*dtd).entities as *mut hash::HashTable,
1588                Some(entity_deallocator),
1589            );
1590            hash::hash_free(
1591                (*dtd).pentities as *mut hash::HashTable,
1592                Some(entity_deallocator),
1593            );
1594            allocator::xmlFreeImpl(dtd as *mut c_void);
1595            allocator::xmlFreeImpl(doc as *mut c_void);
1596        }
1597    }
1598
1599    #[test]
1600    /// Tests that `get_parameter_entity` finds a stored parameter entity.
1601    ///
1602    /// # Safety
1603    ///
1604    /// - `doc` and `dtd` come from `make_doc_and_dtd` and stay live until the
1605    ///   trailing frees; `name` and `content` are NUL-terminated `c_str`
1606    ///   allocations; `entity` is asserted non-NULL before the equality check,
1607    ///   and the hash tables are freed with `entity_deallocator` before `dtd`
1608    ///   and `doc` are freed.
1609    fn test_get_parameter_entity() {
1610        unsafe {
1611            let (doc, dtd) = make_doc_and_dtd();
1612            let name = c_str(b"param1");
1613            let content = c_str(b"param content");
1614
1615            let entity = add_entity(
1616                dtd,
1617                name,
1618                XML_INTERNAL_PARAMETER_ENTITY as c_int,
1619                ptr::null(),
1620                ptr::null(),
1621                content,
1622            );
1623            assert!(!entity.is_null());
1624
1625            let found = get_parameter_entity(doc, name);
1626            assert_eq!(found, entity);
1627
1628            hash::hash_free(
1629                (*dtd).entities as *mut hash::HashTable,
1630                Some(entity_deallocator),
1631            );
1632            hash::hash_free(
1633                (*dtd).pentities as *mut hash::HashTable,
1634                Some(entity_deallocator),
1635            );
1636            allocator::xmlFreeImpl(dtd as *mut c_void);
1637            allocator::xmlFreeImpl(doc as *mut c_void);
1638        }
1639    }
1640
1641    #[test]
1642    /// Tests that `copy_entity` produces an independent deep copy.
1643    ///
1644    /// # Safety
1645    ///
1646    /// - `entity` is a zero-initialized `xmlMallocZero` allocation whose
1647    ///   `name` and `content` fields are fresh `xml_strdup` allocations; the
1648    ///   original and the copy are both freed with `free_entity` before the
1649    ///   test returns, and every dereference of these raw pointers happens
1650    ///   while the allocations are still live.
1651    fn test_copy_entity() {
1652        unsafe {
1653            let name = c_str(b"srcEntity");
1654            let content = c_str(b"source content");
1655
1656            // Create an entity without a DTD
1657            let entity =
1658                allocator::xmlMallocZero(size_of::<_xmlEntity>() as usize) as *mut _xmlEntity;
1659            assert!(!entity.is_null());
1660            (*entity).type_ = XML_ENTITY_DECL as c_int;
1661            (*entity).name = string::xml_strdup(name);
1662            (*entity).content = string::xml_strdup(content);
1663            (*entity).length = 14;
1664            (*entity).etype = XML_INTERNAL_GENERAL_ENTITY as c_int;
1665
1666            let copy = copy_entity(entity);
1667            assert!(!copy.is_null());
1668            assert_ne!(copy, entity);
1669            assert_eq!((*copy).etype, XML_INTERNAL_GENERAL_ENTITY as c_int);
1670            assert_eq!((*copy).length, 14);
1671            assert_eq!(string::xml_strcmp((*copy).name, name), 0);
1672            assert_eq!(string::xml_strcmp((*copy).content, content), 0);
1673
1674            free_entity(copy);
1675            free_entity(entity);
1676        }
1677    }
1678
1679    #[test]
1680    /// Tests that `copy_entity` returns NULL for a NULL input.
1681    ///
1682    /// # Safety
1683    ///
1684    /// - `copy_entity` checks its argument for NULL and returns early without
1685    ///   dereferencing it, so passing `ptr::null_mut()` is sound.
1686    fn test_copy_entity_null() {
1687        unsafe {
1688            assert!(copy_entity(ptr::null_mut()).is_null());
1689        }
1690    }
1691
1692    #[test]
1693    /// Tests that `free_entity` tolerates a NULL pointer without crashing.
1694    ///
1695    /// # Safety
1696    ///
1697    /// - `free_entity` returns early when the pointer is NULL and never
1698    ///   dereferences it, so the call is sound.
1699    fn test_free_entity_null() {
1700        unsafe {
1701            free_entity(ptr::null_mut()); // Should not crash
1702        }
1703    }
1704
1705    // ── Entity Type Tests ───────────────────────────────────────────────
1706
1707    #[test]
1708    fn test_is_parameter_entity() {
1709        assert!(is_parameter_entity(XML_INTERNAL_PARAMETER_ENTITY as c_int));
1710        assert!(is_parameter_entity(XML_EXTERNAL_PARAMETER_ENTITY as c_int));
1711        assert!(!is_parameter_entity(XML_INTERNAL_GENERAL_ENTITY as c_int));
1712        assert!(!is_parameter_entity(
1713            XML_EXTERNAL_GENERAL_PARSED_ENTITY as c_int
1714        ));
1715    }
1716
1717    #[test]
1718    fn test_is_external_entity() {
1719        assert!(is_external_entity(
1720            XML_EXTERNAL_GENERAL_PARSED_ENTITY as c_int
1721        ));
1722        assert!(is_external_entity(
1723            XML_EXTERNAL_GENERAL_UNPARSED_ENTITY as c_int
1724        ));
1725        assert!(is_external_entity(XML_EXTERNAL_PARAMETER_ENTITY as c_int));
1726        assert!(!is_external_entity(XML_INTERNAL_GENERAL_ENTITY as c_int));
1727        assert!(!is_external_entity(XML_INTERNAL_PARAMETER_ENTITY as c_int));
1728    }
1729
1730    #[test]
1731    fn test_is_predefined_entity() {
1732        assert!(is_predefined_entity(
1733            XML_INTERNAL_PREDEFINED_ENTITY as c_int
1734        ));
1735        assert!(!is_predefined_entity(XML_INTERNAL_GENERAL_ENTITY as c_int));
1736    }
1737
1738    // ── Entity Encoding Tests ───────────────────────────────────────────
1739
1740    #[test]
1741    /// Tests that `encode_entities_reentrant` returns NULL for NULL input.
1742    ///
1743    /// # Safety
1744    ///
1745    /// - `encode_entities_reentrant` checks `input` for NULL and returns
1746    ///   without dereferencing it, so passing null pointers is sound.
1747    fn test_encode_entities_reentrant_null() {
1748        unsafe {
1749            let result = encode_entities_reentrant(ptr::null_mut(), ptr::null());
1750            assert!(result.is_null());
1751        }
1752    }
1753
1754    #[test]
1755    /// Tests that text without special characters is encoded unchanged.
1756    ///
1757    /// # Safety
1758    ///
1759    /// - `input` is a NUL-terminated `c_str` allocation that stays alive until
1760    ///   the comparison; `result` is the freshly allocated output of
1761    ///   `encode_entities_reentrant`, asserted non-NULL, and is freed with
1762    ///   `xmlFreeImpl` after the comparison.
1763    fn test_encode_entities_reentrant_no_special() {
1764        unsafe {
1765            let input = c_str(b"Hello, World!");
1766            let result = encode_entities_reentrant(ptr::null_mut(), input);
1767            assert!(!result.is_null());
1768            assert_eq!(string::xml_strcmp(result, input), 0);
1769            allocator::xmlFreeImpl(result as *mut c_void);
1770        }
1771    }
1772
1773    #[test]
1774    /// Tests that the `lt` and `gt` characters are encoded to entities.
1775    ///
1776    /// # Safety
1777    ///
1778    /// - `input` and `expected` are NUL-terminated `c_str` allocations that
1779    ///   stay alive until the comparison; `result` is the freshly allocated
1780    ///   output of `encode_entities_reentrant`, asserted non-NULL, and is
1781    ///   freed with `xmlFreeImpl` after the comparison.
1782    fn test_encode_entities_reentrant_lt_gt() {
1783        unsafe {
1784            let input = c_str(b"a < b > c");
1785            let result = encode_entities_reentrant(ptr::null_mut(), input);
1786            assert!(!result.is_null());
1787            let expected = c_str(b"a &lt; b &gt; c");
1788            assert_eq!(string::xml_strcmp(result, expected), 0);
1789            allocator::xmlFreeImpl(result as *mut c_void);
1790        }
1791    }
1792
1793    #[test]
1794    /// Tests that the `amp` character is encoded to an entity.
1795    ///
1796    /// # Safety
1797    ///
1798    /// - `input` and `expected` are NUL-terminated `c_str` allocations that
1799    ///   stay alive until the comparison; `result` is the freshly allocated
1800    ///   output of `encode_entities_reentrant`, asserted non-NULL, and is
1801    ///   freed with `xmlFreeImpl` after the comparison.
1802    fn test_encode_entities_reentrant_amp() {
1803        unsafe {
1804            let input = c_str(b"a & b");
1805            let result = encode_entities_reentrant(ptr::null_mut(), input);
1806            assert!(!result.is_null());
1807            let expected = c_str(b"a &amp; b");
1808            assert_eq!(string::xml_strcmp(result, expected), 0);
1809            allocator::xmlFreeImpl(result as *mut c_void);
1810        }
1811    }
1812
1813    #[test]
1814    /// Tests that quotes and apostrophes are encoded to entities.
1815    ///
1816    /// # Safety
1817    ///
1818    /// - `input` and `expected` are NUL-terminated `c_str` allocations that
1819    ///   stay alive until the comparison; `result` is the freshly allocated
1820    ///   output of `encode_entities_reentrant`, asserted non-NULL, and is
1821    ///   freed with `xmlFreeImpl` after the comparison.
1822    fn test_encode_entities_reentrant_quotes() {
1823        unsafe {
1824            let input = c_str(b"\"hello\" 'world'");
1825            let result = encode_entities_reentrant(ptr::null_mut(), input);
1826            assert!(!result.is_null());
1827            let expected = c_str(b"&quot;hello&quot; &apos;world&apos;");
1828            assert_eq!(string::xml_strcmp(result, expected), 0);
1829            allocator::xmlFreeImpl(result as *mut c_void);
1830        }
1831    }
1832
1833    #[test]
1834    /// Tests that a mixed string is fully encoded in one pass.
1835    ///
1836    /// # Safety
1837    ///
1838    /// - `input` and `expected` are NUL-terminated `c_str` allocations that
1839    ///   stay alive until the comparison; `result` is the freshly allocated
1840    ///   output of `encode_entities_reentrant`, asserted non-NULL, and is
1841    ///   freed with `xmlFreeImpl` after the comparison.
1842    fn test_encode_entities_reentrant_all() {
1843        unsafe {
1844            let input = c_str(b"<tag attr=\"value\">&'more'</tag>");
1845            let result = encode_entities_reentrant(ptr::null_mut(), input);
1846            assert!(!result.is_null());
1847            let expected =
1848                c_str(b"&lt;tag attr=&quot;value&quot;&gt;&amp;&apos;more&apos;&lt;/tag&gt;");
1849            assert_eq!(string::xml_strcmp(result, expected), 0);
1850            allocator::xmlFreeImpl(result as *mut c_void);
1851        }
1852    }
1853
1854    // ── Entity Decoding Tests ───────────────────────────────────────────
1855
1856    #[test]
1857    /// Tests that `string_decode_entities` returns NULL for a NULL input.
1858    ///
1859    /// # Safety
1860    ///
1861    /// - `string_decode_entities` returns early when `input` is NULL, and the
1862    ///   `doc` argument is only dereferenced for entity lookup during
1863    ///   decoding, so passing null pointers here is sound.
1864    fn test_decode_entities_null_input() {
1865        unsafe {
1866            let result = string_decode_entities(ptr::null_mut(), ptr::null(), 0, 0, 0, 0);
1867            assert!(result.is_null());
1868        }
1869    }
1870
1871    #[test]
1872    /// Tests that decoding an empty string yields an empty NUL-terminated
1873    /// result.
1874    ///
1875    /// # Safety
1876    ///
1877    /// - `input` is a NUL-terminated `c_str` allocation valid for the call;
1878    ///   `result` is asserted non-NULL before the `*result` dereference and is
1879    ///   freed with `xmlFreeImpl` before the test ends.
1880    fn test_decode_entities_empty() {
1881        unsafe {
1882            let input = c_str(b"");
1883            let result = string_decode_entities(ptr::null_mut(), input, 0, 0, 0, 0);
1884            assert!(!result.is_null());
1885            assert_eq!(*result, 0);
1886            allocator::xmlFreeImpl(result as *mut c_void);
1887        }
1888    }
1889
1890    #[test]
1891    /// Tests that input without references is decoded unchanged.
1892    ///
1893    /// # Safety
1894    ///
1895    /// - `input` is a NUL-terminated `c_str` allocation that stays alive until
1896    ///   the comparison; `result` is asserted non-NULL before being passed to
1897    ///   `string::xml_strcmp` and is freed with `xmlFreeImpl` afterwards.
1898    fn test_decode_entities_no_refs() {
1899        unsafe {
1900            let input = c_str(b"Hello, World!");
1901            let result = string_decode_entities(ptr::null_mut(), input, 0, 0, 0, 0);
1902            assert!(!result.is_null());
1903            assert_eq!(string::xml_strcmp(result, input), 0);
1904            allocator::xmlFreeImpl(result as *mut c_void);
1905        }
1906    }
1907
1908    #[test]
1909    /// Tests that a decimal numeric character reference is decoded.
1910    ///
1911    /// # Safety
1912    ///
1913    /// - `input` and `expected` are NUL-terminated `c_str` allocations that
1914    ///   stay alive until the comparison; `result` is asserted non-NULL before
1915    ///   being passed to `string::xml_strcmp` and is freed with `xmlFreeImpl`
1916    ///   afterwards.
1917    fn test_decode_numeric_decimal() {
1918        unsafe {
1919            // &#65; = 'A'
1920            let input = c_str(b"&#65;");
1921            let result = string_decode_entities(ptr::null_mut(), input, 0, 0, 0, 0);
1922            assert!(!result.is_null());
1923            let expected = c_str(b"A");
1924            assert_eq!(string::xml_strcmp(result, expected), 0);
1925            allocator::xmlFreeImpl(result as *mut c_void);
1926        }
1927    }
1928
1929    #[test]
1930    /// Tests that a hexadecimal numeric character reference is decoded.
1931    ///
1932    /// # Safety
1933    ///
1934    /// - `input` and `expected` are NUL-terminated `c_str` allocations that
1935    ///   stay alive until the comparison; `result` is asserted non-NULL before
1936    ///   being passed to `string::xml_strcmp` and is freed with `xmlFreeImpl`
1937    ///   afterwards.
1938    fn test_decode_numeric_hex() {
1939        unsafe {
1940            // &#x41; = 'A'
1941            let input = c_str(b"&#x41;");
1942            let result = string_decode_entities(ptr::null_mut(), input, 0, 0, 0, 0);
1943            assert!(!result.is_null());
1944            let expected = c_str(b"A");
1945            assert_eq!(string::xml_strcmp(result, expected), 0);
1946            allocator::xmlFreeImpl(result as *mut c_void);
1947        }
1948    }
1949
1950    #[test]
1951    /// Tests that mixed decimal and hexadecimal references are decoded.
1952    ///
1953    /// # Safety
1954    ///
1955    /// - `input` and `expected` are NUL-terminated `c_str` allocations that
1956    ///   stay alive until the comparison; `result` is asserted non-NULL before
1957    ///   being passed to `string::xml_strcmp` and is freed with `xmlFreeImpl`
1958    ///   afterwards.
1959    fn test_decode_numeric_mixed() {
1960        unsafe {
1961            let input = c_str(b"Hello &#x57;&#111;rld!"); // Hello World!
1962            let result = string_decode_entities(ptr::null_mut(), input, 0, 0, 0, 0);
1963            assert!(!result.is_null());
1964            let expected = c_str(b"Hello World!");
1965            assert_eq!(string::xml_strcmp(result, expected), 0);
1966            allocator::xmlFreeImpl(result as *mut c_void);
1967        }
1968    }
1969
1970    #[test]
1971    /// Tests that predefined general entities in the DTD are substituted.
1972    ///
1973    /// # Safety
1974    ///
1975    /// - `doc` and `dtd` are zero-initialized by `make_doc_and_dtd` and stay
1976    ///   live until the trailing frees; the `input` and `expected` buffers are
1977    ///   NUL-terminated `c_str` allocations; `result` is asserted non-NULL
1978    ///   before the comparison and freed with `xmlFreeImpl`; the hash tables
1979    ///   are freed with `entity_deallocator` before `dtd` and `doc` are freed.
1980    fn test_decode_predefined_entities() {
1981        unsafe {
1982            let (doc, dtd) = make_doc_and_dtd();
1983            let input = c_str(b"a &lt; b &gt; c &amp; d");
1984
1985            // Add the entities to the DTD so get_entity can find them
1986            add_entity(
1987                dtd,
1988                c_str(b"lt"),
1989                XML_INTERNAL_GENERAL_ENTITY as c_int,
1990                ptr::null(),
1991                ptr::null(),
1992                c_str(b"<"),
1993            );
1994            add_entity(
1995                dtd,
1996                c_str(b"gt"),
1997                XML_INTERNAL_GENERAL_ENTITY as c_int,
1998                ptr::null(),
1999                ptr::null(),
2000                c_str(b">"),
2001            );
2002            add_entity(
2003                dtd,
2004                c_str(b"amp"),
2005                XML_INTERNAL_GENERAL_ENTITY as c_int,
2006                ptr::null(),
2007                ptr::null(),
2008                c_str(b"&"),
2009            );
2010
2011            let result = string_decode_entities(doc, input, 0, 0, 0, 0);
2012            assert!(!result.is_null());
2013            let expected = c_str(b"a < b > c & d");
2014            assert_eq!(string::xml_strcmp(result, expected), 0);
2015
2016            allocator::xmlFreeImpl(result as *mut c_void);
2017
2018            hash::hash_free(
2019                (*dtd).entities as *mut hash::HashTable,
2020                Some(entity_deallocator),
2021            );
2022            hash::hash_free(
2023                (*dtd).pentities as *mut hash::HashTable,
2024                Some(entity_deallocator),
2025            );
2026            allocator::xmlFreeImpl(dtd as *mut c_void);
2027            allocator::xmlFreeImpl(doc as *mut c_void);
2028        }
2029    }
2030
2031    // ── Security/Limit Tests ────────────────────────────────────────────
2032
2033    #[test]
2034    fn test_check_entity_expansion_limit() {
2035        assert_eq!(check_entity_expansion_limit(100), 0);
2036        assert_eq!(check_entity_expansion_limit(1_000_000), 0);
2037        assert_eq!(check_entity_expansion_limit(1_000_001), -1);
2038    }
2039
2040    #[test]
2041    fn test_check_entity_recursion_depth() {
2042        assert_eq!(check_entity_recursion_depth(10), 0);
2043        assert_eq!(check_entity_recursion_depth(32), 0);
2044        assert_eq!(check_entity_recursion_depth(33), -1);
2045    }
2046
2047    // ── Entity Content Tests ────────────────────────────────────────────
2048
2049    #[test]
2050    /// Tests that `get_entity_content` returns a copy of the entity content.
2051    ///
2052    /// # Safety
2053    ///
2054    /// - `entity` is a zero-initialized `xmlMallocZero` allocation whose
2055    ///   `content` field is a fresh `xml_strdup` allocation; `retrieved` is
2056    ///   asserted non-NULL before the comparison and freed with `xmlFreeImpl`,
2057    ///   and `entity` is freed with `free_entity` before the test returns.
2058    fn test_get_entity_content() {
2059        unsafe {
2060            let content = c_str(b"entity content");
2061            let entity =
2062                allocator::xmlMallocZero(size_of::<_xmlEntity>() as usize) as *mut _xmlEntity;
2063            assert!(!entity.is_null());
2064            (*entity).content = string::xml_strdup(content);
2065
2066            let retrieved = get_entity_content(entity);
2067            assert!(!retrieved.is_null());
2068            assert_eq!(string::xml_strcmp(retrieved, content), 0);
2069
2070            allocator::xmlFreeImpl(retrieved as *mut c_void);
2071            free_entity(entity);
2072        }
2073    }
2074
2075    #[test]
2076    /// Tests that `get_entity_content` returns NULL for a NULL entity.
2077    ///
2078    /// # Safety
2079    ///
2080    /// - `get_entity_content` checks its argument for NULL and returns early
2081    ///   without dereferencing it, so passing `ptr::null_mut()` is sound.
2082    fn test_get_entity_content_null() {
2083        unsafe {
2084            assert!(get_entity_content(ptr::null_mut()).is_null());
2085        }
2086    }
2087}