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
7use core::ffi::c_void;
8use core::ptr;
9use std::os::raw::{c_char, c_int, c_uint, c_ulong};
10
11use crate::abi::allocator;
12use crate::abi::structs::*;
13use crate::abi::types::xmlElementType::*;
14use crate::abi::types::xmlEntityType::*;
15use crate::abi::types::*;
16use crate::xml::hash;
17use crate::xml::string;
18
19// ═══════════════════════════════════════════════════════════════════════════════
20// Constants
21// ═══════════════════════════════════════════════════════════════════════════════
22
23/// Maximum entity recursion depth.
24///
25/// # UPSTREAM-PARITY
26///
27/// libxml2 uses XML_ENTITY_CONTENT_DEPTH_MAX (default 32).
28pub const XML_ENTITY_CONTENT_DEPTH_MAX: c_int = 32;
29
30/// Maximum entity expansion size (in bytes).
31///
32/// # UPSTREAM-PARITY
33///
34/// libxml2 uses XML_ENTITY_CONTENT_EXPANSION_MAX (default 1,000,000).
35pub const XML_ENTITY_CONTENT_EXPANSION_MAX: c_uint = 1_000_000;
36
37// ═══════════════════════════════════════════════════════════════════════════════
38// Entity Declaration Functions
39// ═══════════════════════════════════════════════════════════════════════════════
40
41/// Add an entity declaration to a DTD.
42///
43/// # UPSTREAM-PARITY
44///
45/// ```c
46/// xmlEntityPtr xmlAddEntity(xmlDtdPtr dtd, const xmlChar *name, int type,
47///                           const xmlChar *ExternalID, const xmlChar *SystemID,
48///                           const xmlChar *content);
49/// ```
50///
51/// Adds an entity to the appropriate hash table in the DTD based on the
52/// entity type (general entities go to `entities`, parameter entities to
53/// `pentities`).
54///
55/// If an entity with the same name already exists, the existing entity
56/// is returned and no new entity is created.
57///
58/// # SAFETY
59///
60/// - `dtd` must be a valid pointer to an _xmlDtd, or NULL.
61/// - `name` must be a valid null-terminated string.
62/// - `ExternalID`, `SystemID`, `content` may be NULL.
63pub unsafe fn add_entity(
64    dtd: *mut _xmlDtd,
65    name: *const xmlChar,
66    etype: c_int,
67    ExternalID: *const xmlChar,
68    SystemID: *const xmlChar,
69    content: *const xmlChar,
70) -> *mut _xmlEntity {
71    if dtd.is_null() || name.is_null() {
72        return ptr::null_mut();
73    }
74
75    unsafe {
76        let d = &*dtd;
77
78        // Determine which hash table to use
79        let is_param = is_parameter_entity(etype);
80        // UPSTREAM-PARITY (entities.c xmlAddEntity): the table is created
81        // lazily on first use.
82        if is_param {
83            if (*dtd).pentities.is_null() {
84                (*dtd).pentities = hash::hash_create(8) as *mut c_void;
85            }
86        } else if (*dtd).entities.is_null() {
87            (*dtd).entities = hash::hash_create(8) as *mut c_void;
88        }
89        let hash_table = if is_param {
90            d.pentities as *mut hash::HashTable
91        } else {
92            d.entities as *mut hash::HashTable
93        };
94
95        // Check if entity already exists
96        let existing = hash::hash_lookup(hash_table, name);
97        if !existing.is_null() {
98            return existing as *mut _xmlEntity;
99        }
100
101        // SAFETY: Allocate zero-initialized memory for the entity.
102        let entity = allocator::xmlMallocZero(size_of::<_xmlEntity>() as usize) as *mut _xmlEntity;
103        if entity.is_null() {
104            return ptr::null_mut();
105        }
106
107        (*entity).type_ = XML_ENTITY_DECL as c_int;
108        (*entity).name = string::xml_strdup(name);
109        (*entity).etype = etype;
110        (*entity).ExternalID = string::xml_strdup(ExternalID);
111        (*entity).SystemID = string::xml_strdup(SystemID);
112        (*entity).content = string::xml_strdup(content);
113        (*entity).orig = ptr::null_mut();
114        (*entity).length = if content.is_null() {
115            0
116        } else {
117            string::xml_strlen(content) as c_int
118        };
119        (*entity).flags = 0;
120        (*entity).expandedSize = 0;
121        (*entity).URI = ptr::null_mut();
122        (*entity).parent = dtd;
123        (*entity).doc = d.doc;
124        (*entity).nexte = ptr::null_mut();
125        (*entity).owner = 0;
126
127        // Add to hash table
128        let ret = hash::hash_add_entry(hash_table, name, entity as *mut c_void);
129        if ret != 0 {
130            // Failed to add
131            free_entity_internal(entity, false);
132            return ptr::null_mut();
133        }
134
135        // UPSTREAM-PARITY (entities.c xmlAddDocEntity/xmlAddDtdEntity
136        // "Link it to the DTD"): the entity decl is a child node of the DTD.
137        if (*dtd).last.is_null() {
138            (*dtd).children = entity as *mut _xmlNode;
139            (*dtd).last = entity as *mut _xmlNode;
140        } else {
141            (*(*dtd).last).next = entity as *mut _xmlNode;
142            (*entity).prev = (*dtd).last;
143            (*dtd).last = entity as *mut _xmlNode;
144        }
145
146        entity
147    }
148}
149
150/// Get a general entity by name.
151///
152/// # UPSTREAM-PARITY
153///
154/// ```c
155/// xmlEntityPtr xmlGetEntity(xmlDocPtr doc, const xmlChar *name);
156/// ```
157///
158/// Searches the document's DTD for a general entity with the given name.
159/// Checks both internal and external subsets.
160///
161/// # SAFETY
162///
163/// - `doc` must be a valid pointer to an _xmlDoc, or NULL.
164/// - `name` must be a valid null-terminated string.
165pub unsafe fn get_entity(doc: *mut _xmlDoc, name: *const xmlChar) -> *mut _xmlEntity {
166    if doc.is_null() || name.is_null() {
167        return ptr::null_mut();
168    }
169
170    unsafe {
171        let d = &*doc;
172
173        // Check internal subset
174        if !d.intSubset.is_null() {
175            let entities = (*d.intSubset).entities as *mut hash::HashTable;
176            let found = hash::hash_lookup(entities, name);
177            if !found.is_null() {
178                return found as *mut _xmlEntity;
179            }
180        }
181
182        // Check external subset
183        if !d.extSubset.is_null() {
184            let entities = (*d.extSubset).entities as *mut hash::HashTable;
185            let found = hash::hash_lookup(entities, name);
186            if !found.is_null() {
187                return found as *mut _xmlEntity;
188            }
189        }
190
191        ptr::null_mut()
192    }
193}
194
195/// Look up an entity in a DTD's entity hash table (upstream
196/// `xmlGetDtdEntity` core).
197///
198/// # SAFETY
199///
200/// - `dtd` must be a valid DTD pointer or NULL.
201/// - `name` must be a valid null-terminated string.
202pub unsafe fn get_entity_from_dtd(dtd: *mut _xmlDtd, name: *const xmlChar) -> *mut _xmlEntity {
203    if dtd.is_null() || name.is_null() {
204        return ptr::null_mut();
205    }
206    unsafe {
207        let entities = (*dtd).entities as *mut hash::HashTable;
208        let found = hash::hash_lookup(entities, name);
209        if found.is_null() {
210            ptr::null_mut()
211        } else {
212            found as *mut _xmlEntity
213        }
214    }
215}
216
217/// Get a parameter entity by name.
218///
219/// # UPSTREAM-PARITY
220///
221/// ```c
222/// xmlEntityPtr xmlGetParameterEntity(xmlDocPtr doc, const xmlChar *name);
223/// ```
224///
225/// Searches the document's DTD for a parameter entity with the given name.
226///
227/// # SAFETY
228///
229/// - `doc` must be a valid pointer to an _xmlDoc, or NULL.
230/// - `name` must be a valid null-terminated string.
231pub unsafe fn get_parameter_entity(doc: *mut _xmlDoc, name: *const xmlChar) -> *mut _xmlEntity {
232    if doc.is_null() || name.is_null() {
233        return ptr::null_mut();
234    }
235
236    unsafe {
237        let d = &*doc;
238
239        // Check internal subset
240        if !d.intSubset.is_null() {
241            let pentities = (*d.intSubset).pentities as *mut hash::HashTable;
242            let found = hash::hash_lookup(pentities, name);
243            if !found.is_null() {
244                return found as *mut _xmlEntity;
245            }
246        }
247
248        // Check external subset
249        if !d.extSubset.is_null() {
250            let pentities = (*d.extSubset).pentities as *mut hash::HashTable;
251            let found = hash::hash_lookup(pentities, name);
252            if !found.is_null() {
253                return found as *mut _xmlEntity;
254            }
255        }
256
257        ptr::null_mut()
258    }
259}
260
261/// Deep copy an entity declaration.
262///
263/// # UPSTREAM-PARITY
264///
265/// ```c
266/// xmlEntityPtr xmlCopyEntity(xmlEntityPtr entity);
267/// ```
268///
269/// # SAFETY
270///
271/// - `entity` must be a valid pointer to an _xmlEntity, or NULL.
272pub unsafe fn copy_entity(entity: *mut _xmlEntity) -> *mut _xmlEntity {
273    if entity.is_null() {
274        return ptr::null_mut();
275    }
276
277    unsafe {
278        let e = &*entity;
279
280        // SAFETY: Allocate zero-initialized memory for the copy.
281        let copy = allocator::xmlMallocZero(size_of::<_xmlEntity>() as usize) as *mut _xmlEntity;
282        if copy.is_null() {
283            return ptr::null_mut();
284        }
285
286        (*copy).type_ = XML_ENTITY_DECL as c_int;
287        (*copy).name = string::xml_strdup(e.name);
288        (*copy).etype = e.etype;
289        (*copy).ExternalID = string::xml_strdup(e.ExternalID);
290        (*copy).SystemID = string::xml_strdup(e.SystemID);
291        (*copy).content = string::xml_strdup(e.content);
292        (*copy).orig = string::xml_strdup(e.orig);
293        (*copy).length = e.length;
294        (*copy).flags = e.flags;
295        (*copy).expandedSize = e.expandedSize;
296        (*copy).URI = string::xml_strdup(e.URI);
297        (*copy).parent = e.parent;
298        (*copy).doc = e.doc;
299        (*copy).nexte = ptr::null_mut();
300        (*copy).owner = e.owner;
301
302        copy
303    }
304}
305
306/// Free an entity declaration.
307///
308/// # UPSTREAM-PARITY
309///
310/// ```c
311/// void xmlFreeEntity(xmlEntityPtr entity);
312/// ```
313///
314/// # SAFETY
315///
316/// - `entity` must be a valid pointer to an _xmlEntity, or NULL.
317pub unsafe fn free_entity(entity: *mut _xmlEntity) {
318    free_entity_internal(entity, true);
319}
320
321/// Internal entity free function.
322///
323/// If `free_children` is true, also frees the entity's children tree nodes.
324/// This is separated because the hash deallocator should not free children
325/// (they are owned by the document tree), but `xmlFreeEntity` from user code
326/// should free everything.
327unsafe fn free_entity_internal(entity: *mut _xmlEntity, free_children: bool) {
328    if entity.is_null() {
329        return;
330    }
331
332    unsafe {
333        let e = &*entity;
334
335        if !e.name.is_null() {
336            allocator::xmlFreeImpl(e.name as *mut c_void);
337        }
338        if !e.content.is_null() {
339            allocator::xmlFreeImpl(e.content as *mut c_void);
340        }
341        if !e.orig.is_null() {
342            allocator::xmlFreeImpl(e.orig as *mut c_void);
343        }
344        if !e.ExternalID.is_null() {
345            allocator::xmlFreeImpl(e.ExternalID as *mut c_void);
346        }
347        if !e.SystemID.is_null() {
348            allocator::xmlFreeImpl(e.SystemID as *mut c_void);
349        }
350        if !e.URI.is_null() {
351            allocator::xmlFreeImpl(e.URI as *mut c_void);
352        }
353
354        // Free children tree nodes if requested
355        if free_children && !e.children.is_null() {
356            // In a full implementation, we'd recursively free the node tree.
357            // For now, we simply note that children should be freed.
358            // The tree module's free functions would be called here.
359        }
360
361        allocator::xmlFreeImpl(entity as *mut c_void);
362    }
363}
364
365// ═══════════════════════════════════════════════════════════════════════════════
366// Entity Type Helpers
367// ═══════════════════════════════════════════════════════════════════════════════
368
369/// Check if an entity type is a parameter entity.
370#[inline]
371pub fn is_parameter_entity(etype: c_int) -> bool {
372    etype == XML_INTERNAL_PARAMETER_ENTITY as c_int
373        || etype == XML_EXTERNAL_PARAMETER_ENTITY as c_int
374}
375
376/// Check if an entity type is an external entity.
377#[inline]
378pub fn is_external_entity(etype: c_int) -> bool {
379    etype == XML_EXTERNAL_GENERAL_PARSED_ENTITY as c_int
380        || etype == XML_EXTERNAL_GENERAL_UNPARSED_ENTITY as c_int
381        || etype == XML_EXTERNAL_PARAMETER_ENTITY as c_int
382}
383
384/// Check if an entity type is a predefined entity.
385#[inline]
386pub fn is_predefined_entity(etype: c_int) -> bool {
387    etype == XML_INTERNAL_PREDEFINED_ENTITY as c_int
388}
389
390// ═══════════════════════════════════════════════════════════════════════════════
391// Entity Substitution & Encoding
392// ═══════════════════════════════════════════════════════════════════════════════
393
394/// Encode special XML characters in a string for output.
395///
396/// # UPSTREAM-PARITY
397///
398/// ```c
399/// xmlChar *xmlEncodeEntitiesReentrant(xmlDocPtr doc, const xmlChar *input);
400/// ```
401///
402/// Encodes `<`, `>`, `&`, `"`, `'` as their corresponding XML entities.
403/// The returned string must be freed with `xmlFree`.
404///
405/// # SAFETY
406///
407/// - `input` must be a valid null-terminated xmlChar string, or NULL.
408pub unsafe fn encode_entities_reentrant(_doc: *mut _xmlDoc, input: *const xmlChar) -> *mut xmlChar {
409    if input.is_null() {
410        return ptr::null_mut();
411    }
412
413    unsafe {
414        // First pass: calculate output size
415        let len = string::xml_strlen(input);
416        let mut out_len: usize = 0;
417
418        for i in 0..len {
419            match *input.add(i) {
420                b'<' => out_len += 4,  // &lt;
421                b'>' => out_len += 4,  // &gt;
422                b'&' => out_len += 5,  // &amp;
423                b'"' => out_len += 6,  // &quot;
424                b'\'' => out_len += 6, // &apos;
425                c => out_len += 1,
426            }
427        }
428
429        // Allocate output buffer
430        let output = allocator::xmlMallocImpl(out_len + 1) as *mut xmlChar;
431        if output.is_null() {
432            return ptr::null_mut();
433        }
434
435        // Second pass: encode
436        let mut j: usize = 0;
437        for i in 0..len {
438            let c = *input.add(i);
439            match c {
440                b'<' => {
441                    ptr::copy_nonoverlapping(b"&lt;" as *const u8, output.add(j), 4);
442                    j += 4;
443                }
444                b'>' => {
445                    ptr::copy_nonoverlapping(b"&gt;" as *const u8, output.add(j), 4);
446                    j += 4;
447                }
448                b'&' => {
449                    ptr::copy_nonoverlapping(b"&amp;" as *const u8, output.add(j), 5);
450                    j += 5;
451                }
452                b'"' => {
453                    ptr::copy_nonoverlapping(b"&quot;" as *const u8, output.add(j), 6);
454                    j += 6;
455                }
456                b'\'' => {
457                    ptr::copy_nonoverlapping(b"&apos;" as *const u8, output.add(j), 6);
458                    j += 6;
459                }
460                _ => {
461                    *output.add(j) = c;
462                    j += 1;
463                }
464            }
465        }
466
467        *output.add(out_len) = 0; // null-terminate
468        output
469    }
470}
471
472/// Decode entity references in a string.
473///
474/// # UPSTREAM-PARITY
475///
476/// ```c
477/// xmlChar *xmlStringDecodeEntities(xmlDocPtr doc, const xmlChar *input,
478///                                  int what, int end, int end2, int end3);
479/// ```
480///
481/// Replaces entity references (`&name;`) with their content from the
482/// document's entity declarations. Also handles numeric character
483/// references (`&#NNN;` and `&#xHHH;`).
484///
485/// The `what` parameter specifies which entities to substitute:
486/// - 0: substitute all
487/// - 1: substitute only predefined
488/// - 2: substitute only general
489///
490/// `end`, `end2`, `end3` specify terminating characters (0 if none).
491///
492/// Returns the decoded string (must be freed with `xmlFree`), or NULL on error.
493///
494/// # SAFETY
495///
496/// - `doc` may be NULL (no entity lookup, only numeric refs).
497/// - `input` must be a valid null-terminated xmlChar string, or NULL.
498pub unsafe fn string_decode_entities(
499    doc: *mut _xmlDoc,
500    input: *const xmlChar,
501    what: c_int,
502    end: xmlChar,
503    end2: xmlChar,
504    end3: xmlChar,
505) -> *mut xmlChar {
506    if input.is_null() {
507        return ptr::null_mut();
508    }
509
510    unsafe {
511        let len = string::xml_strlen(input);
512        if len == 0 {
513            // Return empty string
514            let empty = allocator::xmlMallocImpl(1) as *mut xmlChar;
515            if !empty.is_null() {
516                *empty = 0;
517            }
518            return empty;
519        }
520
521        // Allocate a generous output buffer (input length + expansion)
522        let max_out = len * 4 + 1; // Allow for some expansion
523        let output = allocator::xmlMallocImpl(max_out) as *mut xmlChar;
524        if output.is_null() {
525            return ptr::null_mut();
526        }
527
528        let mut out_pos: usize = 0;
529        let mut i: usize = 0;
530
531        while i < len {
532            let c = *input.add(i);
533
534            // Check for terminating characters
535            if (end != 0 && c == end) || (end2 != 0 && c == end2) || (end3 != 0 && c == end3) {
536                break;
537            }
538
539            if c == b'&' {
540                // Entity reference
541                i += 1;
542
543                // Check for numeric character reference: &#NNN; or &#xHHH;
544                if i < len && *input.add(i) == b'#' {
545                    i += 1;
546                    let (decoded_char, _consumed) = decode_numeric_ref(input, &mut i, len);
547                    if decoded_char != 0 {
548                        // Encode the decoded character as UTF-8
549                        if decoded_char < 0x80 {
550                            if out_pos < max_out - 1 {
551                                *output.add(out_pos) = decoded_char as u8;
552                                out_pos += 1;
553                            }
554                        } else if decoded_char < 0x800 {
555                            if out_pos < max_out - 2 {
556                                *output.add(out_pos) = 0xC0 | ((decoded_char >> 6) as u8);
557                                *output.add(out_pos + 1) = 0x80 | ((decoded_char & 0x3F) as u8);
558                                out_pos += 2;
559                            }
560                        } else {
561                            if out_pos < max_out - 3 {
562                                *output.add(out_pos) = 0xE0 | ((decoded_char >> 12) as u8);
563                                *output.add(out_pos + 1) =
564                                    0x80 | (((decoded_char >> 6) & 0x3F) as u8);
565                                *output.add(out_pos + 2) = 0x80 | ((decoded_char & 0x3F) as u8);
566                                out_pos += 3;
567                            }
568                        }
569                    }
570                    // decode_numeric_ref already advanced i past ';'
571                    // The continue skips the i += 1 at the bottom of the loop
572                    continue;
573                }
574
575                // General entity reference: &name;
576                let mut entity_name_start = i;
577                while i < len
578                    && *input.add(i) != b';'
579                    && *input.add(i) != b'&'
580                    && *input.add(i) != 0
581                {
582                    i += 1;
583                }
584
585                if i < len && *input.add(i) == b';' {
586                    // We have a complete entity reference
587                    let name_len = i - entity_name_start;
588                    if name_len > 0 {
589                        // Create a null-terminated name
590                        let name_buf = allocator::xmlMallocImpl(name_len + 1) as *mut xmlChar;
591                        if !name_buf.is_null() {
592                            ptr::copy_nonoverlapping(
593                                input.add(entity_name_start),
594                                name_buf,
595                                name_len,
596                            );
597                            *name_buf.add(name_len) = 0;
598
599                            // Try to find the entity
600                            let mut entity: *mut _xmlEntity = ptr::null_mut();
601                            if what != 1 {
602                                // Not just predefined
603                                if !doc.is_null() {
604                                    entity = get_entity(doc, name_buf as *const xmlChar);
605                                }
606                            }
607
608                            if entity.is_null() {
609                                // Try predefined entities
610                                entity = lookup_predefined_entity(name_buf as *const xmlChar);
611                            }
612
613                            if !entity.is_null() && !(*entity).content.is_null() {
614                                // Copy entity content to output
615                                let content = (*entity).content;
616                                let content_len = string::xml_strlen(content);
617                                for j in 0..content_len {
618                                    if out_pos < max_out - 1 {
619                                        *output.add(out_pos) = *content.add(j);
620                                        out_pos += 1;
621                                    }
622                                }
623                            } else {
624                                // Entity not found — output the reference as-is
625                                if out_pos < max_out - 2 {
626                                    *output.add(out_pos) = b'&';
627                                    out_pos += 1;
628                                }
629                                for j in 0..name_len {
630                                    if out_pos < max_out - 2 {
631                                        *output.add(out_pos) = *input.add(entity_name_start + j);
632                                        out_pos += 1;
633                                    }
634                                }
635                                if out_pos < max_out - 1 {
636                                    *output.add(out_pos) = b';';
637                                    out_pos += 1;
638                                }
639                            }
640
641                            allocator::xmlFreeImpl(name_buf as *mut c_void);
642                        }
643                    }
644                    // Skip past the semicolon
645                    // i is already at the semicolon, loop increment will skip it
646                } else {
647                    // Malformed reference — output as-is
648                    if out_pos < max_out - 1 {
649                        *output.add(out_pos) = b'&';
650                        out_pos += 1;
651                    }
652                    // Back up to include all characters we scanned
653                    // The loop increment will advance i
654                }
655            } else {
656                // Regular character
657                if out_pos < max_out - 1 {
658                    *output.add(out_pos) = c;
659                    out_pos += 1;
660                }
661            }
662
663            i += 1;
664        }
665
666        *output.add(out_pos) = 0; // null-terminate
667        output
668    }
669}
670
671/// Decode a numeric character reference (`&#NNN;` or `&#xHHH;`).
672///
673/// Returns the decoded character and the number of additional characters consumed.
674unsafe fn decode_numeric_ref(input: *const xmlChar, pos: &mut usize, len: usize) -> (u32, usize) {
675    unsafe {
676        let mut consumed: usize = 0;
677
678        if *pos >= len {
679            return (0, 0);
680        }
681
682        if *input.add(*pos) == b'x' || *input.add(*pos) == b'X' {
683            // Hexadecimal: &#xHHH;
684            *pos += 1;
685            consumed += 1;
686
687            let mut value: u32 = 0;
688            while *pos < len {
689                let c = *input.add(*pos);
690                if c == b';' {
691                    *pos += 1;
692                    consumed += 1;
693                    return (value, consumed);
694                }
695                let digit = match c {
696                    b'0'..=b'9' => c - b'0',
697                    b'a'..=b'f' => c - b'a' + 10,
698                    b'A'..=b'F' => c - b'A' + 10,
699                    _ => break,
700                };
701                value = value.wrapping_mul(16).wrapping_add(digit as u32);
702                *pos += 1;
703                consumed += 1;
704            }
705            (value, consumed)
706        } else {
707            // Decimal: &#NNN;
708            let mut value: u32 = 0;
709            while *pos < len {
710                let c = *input.add(*pos);
711                if c == b';' {
712                    *pos += 1;
713                    consumed += 1;
714                    return (value, consumed);
715                }
716                if !c.is_ascii_digit() {
717                    break;
718                }
719                value = value.wrapping_mul(10).wrapping_add((c - b'0') as u32);
720                *pos += 1;
721                consumed += 1;
722            }
723            (value, consumed)
724        }
725    }
726}
727
728/// A wrapper for static data that implements Sync for raw pointer types.
729struct SyncPtr<T>(pub T);
730unsafe impl<T> Sync for SyncPtr<T> {}
731
732/// Look up a predefined XML entity by name.
733///
734/// Returns the entity pointer (to a static entity) or NULL.
735unsafe fn lookup_predefined_entity(name: *const xmlChar) -> *mut _xmlEntity {
736    if name.is_null() {
737        return ptr::null_mut();
738    }
739
740    // UPSTREAM-PARITY: Predefined entities are: lt, gt, amp, quot, apos
741    static PREDEFINED_ENTITIES: SyncPtr<[_xmlEntity; 5]> = SyncPtr([
742        _xmlEntity {
743            _private: ptr::null_mut(),
744            type_: XML_ENTITY_DECL as c_int,
745            name: b"lt\0" as *const u8 as *const xmlChar,
746            children: ptr::null_mut(),
747            last: ptr::null_mut(),
748            parent: ptr::null_mut(),
749            next: ptr::null_mut(),
750            prev: ptr::null_mut(),
751            doc: ptr::null_mut(),
752            orig: ptr::null_mut(),
753            content: b"<\0" as *const u8 as *mut xmlChar,
754            length: 1,
755            etype: XML_INTERNAL_PREDEFINED_ENTITY as c_int,
756            ExternalID: ptr::null(),
757            SystemID: ptr::null(),
758            nexte: ptr::null_mut(),
759            URI: ptr::null(),
760            owner: 0,
761            flags: 0,
762            expandedSize: 0,
763        },
764        _xmlEntity {
765            _private: ptr::null_mut(),
766            type_: XML_ENTITY_DECL as c_int,
767            name: b"gt\0" as *const u8 as *const xmlChar,
768            children: ptr::null_mut(),
769            last: ptr::null_mut(),
770            parent: ptr::null_mut(),
771            next: ptr::null_mut(),
772            prev: ptr::null_mut(),
773            doc: ptr::null_mut(),
774            orig: ptr::null_mut(),
775            content: b">\0" as *const u8 as *mut xmlChar,
776            length: 1,
777            etype: XML_INTERNAL_PREDEFINED_ENTITY as c_int,
778            ExternalID: ptr::null(),
779            SystemID: ptr::null(),
780            nexte: ptr::null_mut(),
781            URI: ptr::null(),
782            owner: 0,
783            flags: 0,
784            expandedSize: 0,
785        },
786        _xmlEntity {
787            _private: ptr::null_mut(),
788            type_: XML_ENTITY_DECL as c_int,
789            name: b"amp\0" as *const u8 as *const xmlChar,
790            children: ptr::null_mut(),
791            last: ptr::null_mut(),
792            parent: ptr::null_mut(),
793            next: ptr::null_mut(),
794            prev: ptr::null_mut(),
795            doc: ptr::null_mut(),
796            orig: ptr::null_mut(),
797            content: b"&\0" as *const u8 as *mut xmlChar,
798            length: 1,
799            etype: XML_INTERNAL_PREDEFINED_ENTITY as c_int,
800            ExternalID: ptr::null(),
801            SystemID: ptr::null(),
802            nexte: ptr::null_mut(),
803            URI: ptr::null(),
804            owner: 0,
805            flags: 0,
806            expandedSize: 0,
807        },
808        _xmlEntity {
809            _private: ptr::null_mut(),
810            type_: XML_ENTITY_DECL as c_int,
811            name: b"quot\0" as *const u8 as *const xmlChar,
812            children: ptr::null_mut(),
813            last: ptr::null_mut(),
814            parent: ptr::null_mut(),
815            next: ptr::null_mut(),
816            prev: ptr::null_mut(),
817            doc: ptr::null_mut(),
818            orig: ptr::null_mut(),
819            content: b"\"\0" as *const u8 as *mut xmlChar,
820            length: 1,
821            etype: XML_INTERNAL_PREDEFINED_ENTITY as c_int,
822            ExternalID: ptr::null(),
823            SystemID: ptr::null(),
824            nexte: ptr::null_mut(),
825            URI: ptr::null(),
826            owner: 0,
827            flags: 0,
828            expandedSize: 0,
829        },
830        _xmlEntity {
831            _private: ptr::null_mut(),
832            type_: XML_ENTITY_DECL as c_int,
833            name: b"apos\0" as *const u8 as *const xmlChar,
834            children: ptr::null_mut(),
835            last: ptr::null_mut(),
836            parent: ptr::null_mut(),
837            next: ptr::null_mut(),
838            prev: ptr::null_mut(),
839            doc: ptr::null_mut(),
840            orig: ptr::null_mut(),
841            content: b"'\0" as *const u8 as *mut xmlChar,
842            length: 1,
843            etype: XML_INTERNAL_PREDEFINED_ENTITY as c_int,
844            ExternalID: ptr::null(),
845            SystemID: ptr::null(),
846            nexte: ptr::null_mut(),
847            URI: ptr::null(),
848            owner: 0,
849            flags: 0,
850            expandedSize: 0,
851        },
852    ]);
853
854    unsafe {
855        // Match which entity
856        let idx = match *name.add(0) as char {
857            'l' => {
858                if string::xml_strcmp(name, b"lt\0" as *const u8 as *const xmlChar) == 0 {
859                    0
860                } else {
861                    return ptr::null_mut();
862                }
863            }
864            'g' => {
865                if string::xml_strcmp(name, b"gt\0" as *const u8 as *const xmlChar) == 0 {
866                    1
867                } else {
868                    return ptr::null_mut();
869                }
870            }
871            'a' => {
872                if string::xml_strcmp(name, b"amp\0" as *const u8 as *const xmlChar) == 0 {
873                    2
874                } else if string::xml_strcmp(name, b"apos\0" as *const u8 as *const xmlChar) == 0 {
875                    4
876                } else {
877                    return ptr::null_mut();
878                }
879            }
880            'q' => {
881                if string::xml_strcmp(name, b"quot\0" as *const u8 as *const xmlChar) == 0 {
882                    3
883                } else {
884                    return ptr::null_mut();
885                }
886            }
887            _ => return ptr::null_mut(),
888        };
889
890        &PREDEFINED_ENTITIES.0[idx] as *const _xmlEntity as *mut _xmlEntity
891    }
892}
893
894// ═══════════════════════════════════════════════════════════════════════════════
895// Entity Content Retrieval
896// ═══════════════════════════════════════════════════════════════════════════════
897
898/// Get the content of an entity as a string.
899///
900/// # SAFETY
901///
902/// - `entity` must be a valid pointer to an _xmlEntity, or NULL.
903pub unsafe fn get_entity_content(entity: *mut _xmlEntity) -> *mut xmlChar {
904    if entity.is_null() {
905        return ptr::null_mut();
906    }
907
908    unsafe {
909        let e = &*entity;
910        if e.content.is_null() {
911            return ptr::null_mut();
912        }
913        string::xml_strdup(e.content)
914    }
915}
916
917// ═══════════════════════════════════════════════════════════════════════════════
918// Security/Limit Functions
919// ═══════════════════════════════════════════════════════════════════════════════
920
921/// Check if entity expansion exceeds limits.
922///
923/// Returns 0 if within limits, -1 if exceeded.
924pub fn check_entity_expansion_limit(expanded_size: c_ulong) -> c_int {
925    if expanded_size > XML_ENTITY_CONTENT_EXPANSION_MAX as c_ulong {
926        -1
927    } else {
928        0
929    }
930}
931
932/// Check if entity recursion depth exceeds limits.
933///
934/// Returns 0 if within limits, -1 if exceeded.
935pub fn check_entity_recursion_depth(depth: c_int) -> c_int {
936    if depth > XML_ENTITY_CONTENT_DEPTH_MAX {
937        -1
938    } else {
939        0
940    }
941}
942
943// ═══════════════════════════════════════════════════════════════════════════════
944// Tests
945// ═══════════════════════════════════════════════════════════════════════════════
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950    use crate::abi::allocator::xmlFreeImpl;
951    use core::ffi::c_void;
952    use core::ptr;
953
954    unsafe fn c_str(s: &[u8]) -> *const xmlChar {
955        let len = s.len();
956        let buf = allocator::xmlMallocImpl(len + 1) as *mut xmlChar;
957        assert!(!buf.is_null());
958        ptr::copy_nonoverlapping(s.as_ptr(), buf, len);
959        *buf.add(len) = 0;
960        buf as *const xmlChar
961    }
962
963    unsafe fn make_doc_and_dtd() -> (*mut _xmlDoc, *mut _xmlDtd) {
964        let doc = allocator::xmlMallocZero(size_of::<_xmlDoc>() as usize) as *mut _xmlDoc;
965        assert!(!doc.is_null());
966        (*doc).type_ = XML_DOCUMENT_NODE as c_int;
967        (*doc).doc = doc;
968        let dtd = allocator::xmlMallocZero(size_of::<_xmlDtd>() as usize) as *mut _xmlDtd;
969        assert!(!dtd.is_null());
970        (*dtd).type_ = XML_DTD_NODE as c_int;
971        (*dtd).parent = doc;
972        (*dtd).doc = doc;
973        (*dtd).entities = hash::hash_create(8) as *mut c_void;
974        (*dtd).pentities = hash::hash_create(8) as *mut c_void;
975        (*doc).intSubset = dtd;
976        (doc, dtd)
977    }
978
979    // ── Entity Declaration Tests ────────────────────────────────────────
980
981    #[test]
982    fn test_add_entity_general() {
983        unsafe {
984            let (doc, dtd) = make_doc_and_dtd();
985            let name = c_str(b"myEntity");
986            let content = c_str(b"Hello, World!");
987
988            let entity = add_entity(
989                dtd,
990                name,
991                XML_INTERNAL_GENERAL_ENTITY as c_int,
992                ptr::null(),
993                ptr::null(),
994                content,
995            );
996            assert!(!entity.is_null());
997            assert_eq!((*entity).etype, XML_INTERNAL_GENERAL_ENTITY as c_int);
998            assert_eq!((*entity).length, 13);
999
1000            // Lookup
1001            let found = get_entity(doc, name);
1002            assert_eq!(found, entity);
1003
1004            // Cleanup
1005            // We need to free the hash tables manually since we don't have free_dtd available
1006            hash::hash_free(
1007                (*dtd).entities as *mut hash::HashTable,
1008                Some(entity_deallocator),
1009            );
1010            hash::hash_free(
1011                (*dtd).pentities as *mut hash::HashTable,
1012                Some(entity_deallocator),
1013            );
1014            allocator::xmlFreeImpl(dtd as *mut c_void);
1015            allocator::xmlFreeImpl(doc as *mut c_void);
1016        }
1017    }
1018
1019    unsafe extern "C" fn entity_deallocator(payload: *mut c_void, _name: *mut u8) {
1020        if !payload.is_null() {
1021            free_entity(payload as *mut _xmlEntity);
1022        }
1023    }
1024
1025    #[test]
1026    fn test_add_entity_parameter() {
1027        unsafe {
1028            let (doc, dtd) = make_doc_and_dtd();
1029            let name = c_str(b"myParam");
1030            let content = c_str(b"parameter content");
1031
1032            let entity = add_entity(
1033                dtd,
1034                name,
1035                XML_INTERNAL_PARAMETER_ENTITY as c_int,
1036                ptr::null(),
1037                ptr::null(),
1038                content,
1039            );
1040            assert!(!entity.is_null());
1041            assert_eq!((*entity).etype, XML_INTERNAL_PARAMETER_ENTITY as c_int);
1042
1043            // Parameter entity should be in pentities
1044            let found = hash::hash_lookup((*dtd).pentities as *mut hash::HashTable, name);
1045            assert_eq!(found, entity as *mut c_void);
1046
1047            // General entity lookup should NOT find it
1048            let not_found = hash::hash_lookup((*dtd).entities as *mut hash::HashTable, name);
1049            assert!(not_found.is_null());
1050
1051            hash::hash_free(
1052                (*dtd).entities as *mut hash::HashTable,
1053                Some(entity_deallocator),
1054            );
1055            hash::hash_free(
1056                (*dtd).pentities as *mut hash::HashTable,
1057                Some(entity_deallocator),
1058            );
1059            allocator::xmlFreeImpl(dtd as *mut c_void);
1060            allocator::xmlFreeImpl(doc as *mut c_void);
1061        }
1062    }
1063
1064    #[test]
1065    fn test_add_entity_null_dtd() {
1066        unsafe {
1067            let entity = add_entity(
1068                ptr::null_mut(),
1069                c_str(b"test"),
1070                XML_INTERNAL_GENERAL_ENTITY as c_int,
1071                ptr::null(),
1072                ptr::null(),
1073                ptr::null(),
1074            );
1075            assert!(entity.is_null());
1076        }
1077    }
1078
1079    #[test]
1080    fn test_add_entity_duplicate() {
1081        unsafe {
1082            let (doc, dtd) = make_doc_and_dtd();
1083            let name = c_str(b"dup");
1084            let content = c_str(b"original");
1085
1086            let e1 = add_entity(
1087                dtd,
1088                name,
1089                XML_INTERNAL_GENERAL_ENTITY as c_int,
1090                ptr::null(),
1091                ptr::null(),
1092                content,
1093            );
1094            assert!(!e1.is_null());
1095
1096            let e2 = add_entity(
1097                dtd,
1098                name,
1099                XML_INTERNAL_GENERAL_ENTITY as c_int,
1100                ptr::null(),
1101                ptr::null(),
1102                c_str(b"replacement"),
1103            );
1104            assert_eq!(e1, e2); // Same pointer
1105
1106            hash::hash_free(
1107                (*dtd).entities as *mut hash::HashTable,
1108                Some(entity_deallocator),
1109            );
1110            hash::hash_free(
1111                (*dtd).pentities as *mut hash::HashTable,
1112                Some(entity_deallocator),
1113            );
1114            allocator::xmlFreeImpl(dtd as *mut c_void);
1115            allocator::xmlFreeImpl(doc as *mut c_void);
1116        }
1117    }
1118
1119    #[test]
1120    fn test_add_entity_external() {
1121        unsafe {
1122            let (doc, dtd) = make_doc_and_dtd();
1123            let name = c_str(b"extEntity");
1124            let sysid = c_str(b"http://example.com/entity.xml");
1125
1126            let entity = add_entity(
1127                dtd,
1128                name,
1129                XML_EXTERNAL_GENERAL_PARSED_ENTITY as c_int,
1130                ptr::null(),
1131                sysid,
1132                ptr::null(),
1133            );
1134            assert!(!entity.is_null());
1135            assert_eq!((*entity).etype, XML_EXTERNAL_GENERAL_PARSED_ENTITY as c_int);
1136            assert_eq!(string::xml_strcmp((*entity).SystemID, sysid), 0);
1137
1138            hash::hash_free(
1139                (*dtd).entities as *mut hash::HashTable,
1140                Some(entity_deallocator),
1141            );
1142            hash::hash_free(
1143                (*dtd).pentities as *mut hash::HashTable,
1144                Some(entity_deallocator),
1145            );
1146            allocator::xmlFreeImpl(dtd as *mut c_void);
1147            allocator::xmlFreeImpl(doc as *mut c_void);
1148        }
1149    }
1150
1151    #[test]
1152    fn test_get_entity_null_doc() {
1153        unsafe {
1154            let found = get_entity(ptr::null_mut(), c_str(b"test"));
1155            assert!(found.is_null());
1156        }
1157    }
1158
1159    #[test]
1160    fn test_get_entity_not_found() {
1161        unsafe {
1162            let (doc, dtd) = make_doc_and_dtd();
1163            let found = get_entity(doc, c_str(b"nonexistent"));
1164            assert!(found.is_null());
1165
1166            hash::hash_free(
1167                (*dtd).entities as *mut hash::HashTable,
1168                Some(entity_deallocator),
1169            );
1170            hash::hash_free(
1171                (*dtd).pentities as *mut hash::HashTable,
1172                Some(entity_deallocator),
1173            );
1174            allocator::xmlFreeImpl(dtd as *mut c_void);
1175            allocator::xmlFreeImpl(doc as *mut c_void);
1176        }
1177    }
1178
1179    #[test]
1180    fn test_get_parameter_entity() {
1181        unsafe {
1182            let (doc, dtd) = make_doc_and_dtd();
1183            let name = c_str(b"param1");
1184            let content = c_str(b"param content");
1185
1186            let entity = add_entity(
1187                dtd,
1188                name,
1189                XML_INTERNAL_PARAMETER_ENTITY as c_int,
1190                ptr::null(),
1191                ptr::null(),
1192                content,
1193            );
1194            assert!(!entity.is_null());
1195
1196            let found = get_parameter_entity(doc, name);
1197            assert_eq!(found, entity);
1198
1199            hash::hash_free(
1200                (*dtd).entities as *mut hash::HashTable,
1201                Some(entity_deallocator),
1202            );
1203            hash::hash_free(
1204                (*dtd).pentities as *mut hash::HashTable,
1205                Some(entity_deallocator),
1206            );
1207            allocator::xmlFreeImpl(dtd as *mut c_void);
1208            allocator::xmlFreeImpl(doc as *mut c_void);
1209        }
1210    }
1211
1212    #[test]
1213    fn test_copy_entity() {
1214        unsafe {
1215            let name = c_str(b"srcEntity");
1216            let content = c_str(b"source content");
1217
1218            // Create an entity without a DTD
1219            let entity =
1220                allocator::xmlMallocZero(size_of::<_xmlEntity>() as usize) as *mut _xmlEntity;
1221            assert!(!entity.is_null());
1222            (*entity).type_ = XML_ENTITY_DECL as c_int;
1223            (*entity).name = string::xml_strdup(name);
1224            (*entity).content = string::xml_strdup(content);
1225            (*entity).length = 14;
1226            (*entity).etype = XML_INTERNAL_GENERAL_ENTITY as c_int;
1227
1228            let copy = copy_entity(entity);
1229            assert!(!copy.is_null());
1230            assert_ne!(copy, entity);
1231            assert_eq!((*copy).etype, XML_INTERNAL_GENERAL_ENTITY as c_int);
1232            assert_eq!((*copy).length, 14);
1233            assert_eq!(string::xml_strcmp((*copy).name, name), 0);
1234            assert_eq!(string::xml_strcmp((*copy).content, content), 0);
1235
1236            free_entity(copy);
1237            free_entity(entity);
1238        }
1239    }
1240
1241    #[test]
1242    fn test_copy_entity_null() {
1243        unsafe {
1244            assert!(copy_entity(ptr::null_mut()).is_null());
1245        }
1246    }
1247
1248    #[test]
1249    fn test_free_entity_null() {
1250        unsafe {
1251            free_entity(ptr::null_mut()); // Should not crash
1252        }
1253    }
1254
1255    // ── Entity Type Tests ───────────────────────────────────────────────
1256
1257    #[test]
1258    fn test_is_parameter_entity() {
1259        assert!(is_parameter_entity(XML_INTERNAL_PARAMETER_ENTITY as c_int));
1260        assert!(is_parameter_entity(XML_EXTERNAL_PARAMETER_ENTITY as c_int));
1261        assert!(!is_parameter_entity(XML_INTERNAL_GENERAL_ENTITY as c_int));
1262        assert!(!is_parameter_entity(
1263            XML_EXTERNAL_GENERAL_PARSED_ENTITY as c_int
1264        ));
1265    }
1266
1267    #[test]
1268    fn test_is_external_entity() {
1269        assert!(is_external_entity(
1270            XML_EXTERNAL_GENERAL_PARSED_ENTITY as c_int
1271        ));
1272        assert!(is_external_entity(
1273            XML_EXTERNAL_GENERAL_UNPARSED_ENTITY as c_int
1274        ));
1275        assert!(is_external_entity(XML_EXTERNAL_PARAMETER_ENTITY as c_int));
1276        assert!(!is_external_entity(XML_INTERNAL_GENERAL_ENTITY as c_int));
1277        assert!(!is_external_entity(XML_INTERNAL_PARAMETER_ENTITY as c_int));
1278    }
1279
1280    #[test]
1281    fn test_is_predefined_entity() {
1282        assert!(is_predefined_entity(
1283            XML_INTERNAL_PREDEFINED_ENTITY as c_int
1284        ));
1285        assert!(!is_predefined_entity(XML_INTERNAL_GENERAL_ENTITY as c_int));
1286    }
1287
1288    // ── Entity Encoding Tests ───────────────────────────────────────────
1289
1290    #[test]
1291    fn test_encode_entities_reentrant_null() {
1292        unsafe {
1293            let result = encode_entities_reentrant(ptr::null_mut(), ptr::null());
1294            assert!(result.is_null());
1295        }
1296    }
1297
1298    #[test]
1299    fn test_encode_entities_reentrant_no_special() {
1300        unsafe {
1301            let input = c_str(b"Hello, World!");
1302            let result = encode_entities_reentrant(ptr::null_mut(), input);
1303            assert!(!result.is_null());
1304            assert_eq!(string::xml_strcmp(result, input), 0);
1305            allocator::xmlFreeImpl(result as *mut c_void);
1306        }
1307    }
1308
1309    #[test]
1310    fn test_encode_entities_reentrant_lt_gt() {
1311        unsafe {
1312            let input = c_str(b"a < b > c");
1313            let result = encode_entities_reentrant(ptr::null_mut(), input);
1314            assert!(!result.is_null());
1315            let expected = c_str(b"a &lt; b &gt; c");
1316            assert_eq!(string::xml_strcmp(result, expected), 0);
1317            allocator::xmlFreeImpl(result as *mut c_void);
1318        }
1319    }
1320
1321    #[test]
1322    fn test_encode_entities_reentrant_amp() {
1323        unsafe {
1324            let input = c_str(b"a & b");
1325            let result = encode_entities_reentrant(ptr::null_mut(), input);
1326            assert!(!result.is_null());
1327            let expected = c_str(b"a &amp; b");
1328            assert_eq!(string::xml_strcmp(result, expected), 0);
1329            allocator::xmlFreeImpl(result as *mut c_void);
1330        }
1331    }
1332
1333    #[test]
1334    fn test_encode_entities_reentrant_quotes() {
1335        unsafe {
1336            let input = c_str(b"\"hello\" 'world'");
1337            let result = encode_entities_reentrant(ptr::null_mut(), input);
1338            assert!(!result.is_null());
1339            let expected = c_str(b"&quot;hello&quot; &apos;world&apos;");
1340            assert_eq!(string::xml_strcmp(result, expected), 0);
1341            allocator::xmlFreeImpl(result as *mut c_void);
1342        }
1343    }
1344
1345    #[test]
1346    fn test_encode_entities_reentrant_all() {
1347        unsafe {
1348            let input = c_str(b"<tag attr=\"value\">&'more'</tag>");
1349            let result = encode_entities_reentrant(ptr::null_mut(), input);
1350            assert!(!result.is_null());
1351            let expected =
1352                c_str(b"&lt;tag attr=&quot;value&quot;&gt;&amp;&apos;more&apos;&lt;/tag&gt;");
1353            assert_eq!(string::xml_strcmp(result, expected), 0);
1354            allocator::xmlFreeImpl(result as *mut c_void);
1355        }
1356    }
1357
1358    // ── Entity Decoding Tests ───────────────────────────────────────────
1359
1360    #[test]
1361    fn test_decode_entities_null_input() {
1362        unsafe {
1363            let result = string_decode_entities(ptr::null_mut(), ptr::null(), 0, 0, 0, 0);
1364            assert!(result.is_null());
1365        }
1366    }
1367
1368    #[test]
1369    fn test_decode_entities_empty() {
1370        unsafe {
1371            let input = c_str(b"");
1372            let result = string_decode_entities(ptr::null_mut(), input, 0, 0, 0, 0);
1373            assert!(!result.is_null());
1374            assert_eq!(*result, 0);
1375            allocator::xmlFreeImpl(result as *mut c_void);
1376        }
1377    }
1378
1379    #[test]
1380    fn test_decode_entities_no_refs() {
1381        unsafe {
1382            let input = c_str(b"Hello, World!");
1383            let result = string_decode_entities(ptr::null_mut(), input, 0, 0, 0, 0);
1384            assert!(!result.is_null());
1385            assert_eq!(string::xml_strcmp(result, input), 0);
1386            allocator::xmlFreeImpl(result as *mut c_void);
1387        }
1388    }
1389
1390    #[test]
1391    fn test_decode_numeric_decimal() {
1392        unsafe {
1393            // &#65; = 'A'
1394            let input = c_str(b"&#65;");
1395            let result = string_decode_entities(ptr::null_mut(), input, 0, 0, 0, 0);
1396            assert!(!result.is_null());
1397            let expected = c_str(b"A");
1398            assert_eq!(string::xml_strcmp(result, expected), 0);
1399            allocator::xmlFreeImpl(result as *mut c_void);
1400        }
1401    }
1402
1403    #[test]
1404    fn test_decode_numeric_hex() {
1405        unsafe {
1406            // &#x41; = 'A'
1407            let input = c_str(b"&#x41;");
1408            let result = string_decode_entities(ptr::null_mut(), input, 0, 0, 0, 0);
1409            assert!(!result.is_null());
1410            let expected = c_str(b"A");
1411            assert_eq!(string::xml_strcmp(result, expected), 0);
1412            allocator::xmlFreeImpl(result as *mut c_void);
1413        }
1414    }
1415
1416    #[test]
1417    fn test_decode_numeric_mixed() {
1418        unsafe {
1419            let input = c_str(b"Hello &#x57;&#111;rld!"); // Hello World!
1420            let result = string_decode_entities(ptr::null_mut(), input, 0, 0, 0, 0);
1421            assert!(!result.is_null());
1422            let expected = c_str(b"Hello World!");
1423            assert_eq!(string::xml_strcmp(result, expected), 0);
1424            allocator::xmlFreeImpl(result as *mut c_void);
1425        }
1426    }
1427
1428    #[test]
1429    fn test_decode_predefined_entities() {
1430        unsafe {
1431            let (doc, dtd) = make_doc_and_dtd();
1432            let input = c_str(b"a &lt; b &gt; c &amp; d");
1433
1434            // Add the entities to the DTD so get_entity can find them
1435            add_entity(
1436                dtd,
1437                c_str(b"lt"),
1438                XML_INTERNAL_GENERAL_ENTITY as c_int,
1439                ptr::null(),
1440                ptr::null(),
1441                c_str(b"<"),
1442            );
1443            add_entity(
1444                dtd,
1445                c_str(b"gt"),
1446                XML_INTERNAL_GENERAL_ENTITY as c_int,
1447                ptr::null(),
1448                ptr::null(),
1449                c_str(b">"),
1450            );
1451            add_entity(
1452                dtd,
1453                c_str(b"amp"),
1454                XML_INTERNAL_GENERAL_ENTITY as c_int,
1455                ptr::null(),
1456                ptr::null(),
1457                c_str(b"&"),
1458            );
1459
1460            let result = string_decode_entities(doc, input, 0, 0, 0, 0);
1461            assert!(!result.is_null());
1462            let expected = c_str(b"a < b > c & d");
1463            assert_eq!(string::xml_strcmp(result, expected), 0);
1464
1465            allocator::xmlFreeImpl(result as *mut c_void);
1466
1467            hash::hash_free(
1468                (*dtd).entities as *mut hash::HashTable,
1469                Some(entity_deallocator),
1470            );
1471            hash::hash_free(
1472                (*dtd).pentities as *mut hash::HashTable,
1473                Some(entity_deallocator),
1474            );
1475            allocator::xmlFreeImpl(dtd as *mut c_void);
1476            allocator::xmlFreeImpl(doc as *mut c_void);
1477        }
1478    }
1479
1480    // ── Security/Limit Tests ────────────────────────────────────────────
1481
1482    #[test]
1483    fn test_check_entity_expansion_limit() {
1484        assert_eq!(check_entity_expansion_limit(100), 0);
1485        assert_eq!(check_entity_expansion_limit(1_000_000), 0);
1486        assert_eq!(check_entity_expansion_limit(1_000_001), -1);
1487    }
1488
1489    #[test]
1490    fn test_check_entity_recursion_depth() {
1491        assert_eq!(check_entity_recursion_depth(10), 0);
1492        assert_eq!(check_entity_recursion_depth(32), 0);
1493        assert_eq!(check_entity_recursion_depth(33), -1);
1494    }
1495
1496    // ── Entity Content Tests ────────────────────────────────────────────
1497
1498    #[test]
1499    fn test_get_entity_content() {
1500        unsafe {
1501            let content = c_str(b"entity content");
1502            let entity =
1503                allocator::xmlMallocZero(size_of::<_xmlEntity>() as usize) as *mut _xmlEntity;
1504            assert!(!entity.is_null());
1505            (*entity).content = string::xml_strdup(content);
1506
1507            let retrieved = get_entity_content(entity);
1508            assert!(!retrieved.is_null());
1509            assert_eq!(string::xml_strcmp(retrieved, content), 0);
1510
1511            allocator::xmlFreeImpl(retrieved as *mut c_void);
1512            free_entity(entity);
1513        }
1514    }
1515
1516    #[test]
1517    fn test_get_entity_content_null() {
1518        unsafe {
1519            assert!(get_entity_content(ptr::null_mut()).is_null());
1520        }
1521    }
1522}