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