Skip to main content

libxml_rs/xml/dictionary/
mod.rs

1//! Dictionary — string interning (§85 Phase 1).
2//!
3//! Implements `xmlDict`, the libxml2 string interning mechanism for efficient
4//! string comparison and memory sharing.
5//!
6//! # UPSTREAM-PARITY
7//!
8//! The libxml2 `xmlDict` is a hash-table-backed string interning dictionary.
9//! Key properties:
10//!
11//! - Strings are interned (stored once, reused by reference)
12//! - Interned strings are reference-counted
13//! - Sub-dictionaries share the parent string table but have their own
14//!   reference counting
15//! - Dictionary limits prevent denial-of-service via excessive unique strings
16//! - `xmlDictSetLimit` controls the maximum number of strings
17//! - `xmlDictGetUsage` returns the current number of strings
18//!
19//! # Thread safety
20//!
21//! xmlDict is NOT thread-safe for concurrent modification.
22//! However, concurrent reads are safe once the dictionary is populated.
23//! This matches upstream behavior.
24//!
25//! # Phase 1 status
26//!
27//! Complete — all dictionary functions are implemented.
28//! Uses `hashbrown::HashTable` for the underlying hash table.
29//!
30//! # Upstream contract
31//!
32//! Mirrors upstream `dict.c` / `dict.h` (`SRC-LIBXML2-2.15.0-DICT-C`, parity
33//! target libxml2 2.15.3 oracle): `xmlDictCreate` / `xmlDictCreateSub` /
34//! `xmlDictLookup` / `xmlDictQLookup` / `xmlDictSetLimit` / `xmlDictGetUsage`
35//! / `xmlDictReference` / `xmlDictFree`, with the FNV-1a key hashing of
36//! `xmlDictComputeFastQKey`.
37//!
38//! # Ownership & safety invariants
39//!
40//! Dict-owned strings are stable for the dict lifetime and invalidated by
41//! `xmlDictFree` — the dict must outlive every string interned from it and
42//! the docs that reference them (OWNERSHIP_ATLAS §3, §7.4). A sub-dict
43//! shares the parent table but keeps its own refcounts; `xmlDictReference`
44//! bumps the owning dict so parser/doc sharing stays safe. Concurrent
45//! reads are safe once populated; concurrent modification is NOT (matches
46//! upstream).
47//!
48//! # Historical quirks & epochs
49//!
50//! The default dictionary-size limit dates from the 2.9.0 hardening epoch
51//! (QUIRK-0001 / SEC-0001, commit 52d8ade7 2012-07-30): excessive unique
52//! strings fail unless `XML_PARSE_HUGE` lifts the limits. CVE-2015-7497
53//! (commit 6360a31a, "Avoid an heap buffer overflow in
54//! xmlDictComputeFastQKey") fixed the key-hash path the FNV hasher here
55//! reproduces (SEC-0008).
56//!
57//! # Deliberate oddities
58//!
59//! The FNV-1a hasher is deliberately NOT a general-purpose Rust hasher:
60//! it reproduces the upstream hash so interning behavior (and the
61//! `xmlDictQLookup` collision handling) matches the oracle.
62//!
63//! # Proving courts
64//!
65//! PARSER-LIMIT-* courts verify the default limits and the `XML_PARSE_HUGE`
66//! relaxation; DICT-* courts cover lookups and key hashing; the
67//! SECURITY-LIMITS court covers entity/amplification paths that consult the
68//! dict. All differential courts require byte-identical output vs the
69//! oracle DSO; cargo test runs the Rust unit suites.
70//!
71//! # Tempting simplifications that would break parity
72//!
73//! Do not replace the interning table with a plain Rust HashMap of Strings:
74//! callers observe stable `xmlDictLookup` pointers, refcount semantics,
75//! sub-dict sharing and `xmlDictSetLimit` accounting — all part of the C
76//! ABI. Do not change the hash: `xmlDictQLookup` collision order is
77//! observable, and the CVE-2015-7497 fix constrains the implementation.
78
79use core::ffi::c_void;
80use core::hash::Hasher;
81use core::ptr;
82use core::sync::atomic::AtomicU32;
83use std::os::raw::c_int;
84
85use crate::abi::allocator;
86use crate::abi::types::xmlChar;
87
88// ═══════════════════════════════════════════════════════════════════════════════
89// Constants
90// ═══════════════════════════════════════════════════════════════════════════════
91
92/// Default initial capacity for the dictionary.
93const DICT_INIT_SIZE: usize = 64;
94
95/// Maximum load factor numerator (upstream uses 0.75 ≈ 3/4).
96#[allow(dead_code)]
97const MAX_LOAD_NUM: usize = 3;
98#[allow(dead_code)]
99const MAX_LOAD_DEN: usize = 4;
100
101// ═══════════════════════════════════════════════════════════════════════════════
102// Internal Types
103// ═══════════════════════════════════════════════════════════════════════════════
104
105/// A reference-counted interned string.
106#[derive(Debug)]
107struct DictEntry {
108    /// Reference count. 0 means the entry is unused/freed.
109    ref_count: usize,
110    /// The string data (owned, allocated via xmlMalloc).
111    /// Stored as a null-terminated byte slice.
112    data: *mut u8,
113    /// Length of the string (excluding null terminator).
114    len: usize,
115    /// Hash of the string.
116    #[allow(dead_code)]
117    hash: u64,
118}
119
120/// Simple FNV-1a hasher for consistent hashing across platforms.
121/// This is NOT cryptographically secure; it is for hash table performance.
122///
123/// UPSTREAM-PARITY: reproduces `xmlDictComputeFastQKey` (dict.c) so interned
124/// keys hash identically to upstream; the CVE-2015-7497 fix (commit
125/// 6360a31a, SEC-0008) constrained this path, so it must not be swapped for
126/// a different hash.
127struct SimpleHasher(u64);
128
129impl Hasher for SimpleHasher {
130    fn finish(&self) -> u64 {
131        self.0
132    }
133
134    fn write(&mut self, bytes: &[u8]) {
135        // FNV-1a
136        for &b in bytes {
137            self.0 ^= b as u64;
138            self.0 = self.0.wrapping_mul(0x100000001b3);
139        }
140    }
141}
142
143/// The dictionary hash table uses hashbrown.
144type DictTable = hashbrown::HashTable<(u64, usize)>; // (hash, entry_index)
145
146/// The dictionary struct (opaque in the C ABI).
147///
148/// In the C ABI, `xmlDict` is an opaque type (defined as a struct forward
149/// declaration in the public header). Users only interact with it through
150/// pointer. Our `_xmlDict` is defined in `structs.rs` as opaque. Here we
151/// define the actual internal representation.
152#[derive(Debug)]
153pub struct Dict {
154    /// The hash table: maps hash values to entry indices.
155    table: DictTable,
156    /// The entries array.
157    entries: Vec<DictEntry>,
158    /// Number of active (non-zero refcount) entries.
159    active_count: usize,
160    /// Maximum number of entries allowed (0 = no limit).
161    limit: usize,
162    /// Parent dictionary (for sub-dictionaries).
163    parent: Option<DictRef>,
164    /// Whether this is a sub-dictionary.
165    is_sub: bool,
166    /// Reference count (upstream xmlDict `ref_counter`). Stored IN the
167    /// shared dict memory so cross-DSO operations observe one coherent
168    /// count — R-000177 (Phase 14: the pre-fix per-DSO side table
169    /// partitioned the count, so a dict created/referenced through
170    /// libxml2.so.16 was invisible to libxslt.so.1's xmlDictFree, which
171    /// freed it out from under live references).
172    pub(crate) ref_count: AtomicU32,
173}
174
175/// A reference-counted handle to a Dict.
176/// Used for parent references in sub-dictionaries.
177#[derive(Clone, Debug)]
178struct DictRef {
179    ptr: *mut Dict,
180}
181
182// SAFETY: Dict is only accessed through mutable references.
183unsafe impl Send for DictRef {}
184unsafe impl Sync for DictRef {}
185
186// ═══════════════════════════════════════════════════════════════════════════════
187// String Hashing
188// ═══════════════════════════════════════════════════════════════════════════════
189
190/// Compute the FNV-1a hash of a byte slice.
191fn fnv1a_hash(data: &[u8]) -> u64 {
192    let mut hasher = SimpleHasher(0xcbf29ce484222325);
193    hasher.write(data);
194    hasher.finish()
195}
196
197/// Compute the FNV-1a hash of a C string (null-terminated xmlChar*).
198fn hash_xml_str(s: *const xmlChar, len: usize) -> u64 {
199    if s.is_null() {
200        return 0;
201    }
202    // SAFETY: Caller guarantees s is valid for len bytes.
203    let slice = unsafe { core::slice::from_raw_parts(s, len) };
204    fnv1a_hash(slice)
205}
206
207// ═══════════════════════════════════════════════════════════════════════════════
208// Public API
209// ═══════════════════════════════════════════════════════════════════════════════
210
211/// Create a new dictionary.
212///
213/// # UPSTREAM-PARITY
214///
215/// ```c
216/// xmlDictPtr xmlDictCreate(void);
217/// ```
218///
219/// Returns a pointer to the newly created dictionary, or NULL on failure.
220pub fn dict_create() -> *mut Dict {
221    let dict = Box::new(Dict {
222        table: DictTable::new(),
223        entries: Vec::with_capacity(DICT_INIT_SIZE),
224        active_count: 0,
225        limit: 0,
226        parent: None,
227        is_sub: false,
228        ref_count: AtomicU32::new(1),
229    });
230
231    Box::into_raw(dict)
232}
233
234/// Create a sub-dictionary that shares strings with its parent.
235///
236/// # UPSTREAM-PARITY
237///
238/// ```c
239/// xmlDictPtr xmlDictCreateSub(xmlDictPtr parent);
240/// ```
241///
242/// A sub-dictionary uses the parent's string table but maintains its own
243/// reference counts. Strings looked up in the sub-dictionary that exist
244/// in the parent are shared (the sub-dictionary increments the refcount).
245///
246/// Returns a pointer to the newly created sub-dictionary, or NULL on failure.
247///
248/// # SAFETY
249///
250/// - `parent` must be a valid pointer to a Dict, or NULL.
251pub unsafe fn dict_create_sub(parent: *mut Dict) -> *mut Dict {
252    if parent.is_null() {
253        return dict_create();
254    }
255
256    let sub = Box::new(Dict {
257        table: DictTable::new(),
258        entries: Vec::with_capacity(DICT_INIT_SIZE),
259        active_count: 0,
260        limit: 0,
261        parent: Some(DictRef { ptr: parent }),
262        is_sub: true,
263        ref_count: AtomicU32::new(1),
264    });
265
266    Box::into_raw(sub)
267}
268
269/// Look up a string in the dictionary, adding it if not found.
270///
271/// # UPSTREAM-PARITY
272///
273/// ```c
274/// const xmlChar *xmlDictLookup(xmlDictPtr dict, const xmlChar *name, int len);
275/// ```
276///
277/// If `len` < 0, the string is assumed to be null-terminated and its length
278/// is computed via strlen. If `len` >= 0, exactly `len` bytes are used.
279///
280/// Returns a pointer to the interned string, or NULL on failure.
281/// The returned pointer is valid for the lifetime of the dictionary.
282///
283/// # SAFETY
284///
285/// - `dict` must be a valid pointer to a Dict, or NULL.
286/// - `name` must be a valid pointer to a null-terminated string (if len < 0)
287///   or a buffer of at least `len` bytes (if len >= 0).
288pub unsafe fn dict_lookup(dict: *mut Dict, name: *const xmlChar, len: c_int) -> *const xmlChar {
289    if dict.is_null() || name.is_null() {
290        return ptr::null();
291    }
292
293    let dict_ref = unsafe { &mut *dict };
294
295    // Determine the string length
296    let s_len = if len < 0 {
297        // SAFETY: Caller guarantees name is null-terminated.
298        unsafe { crate::abi::exports_xml2::xmlStrlen(name) as usize }
299    } else {
300        len as usize
301    };
302
303    if s_len == 0 {
304        return ptr::null();
305    }
306
307    // Compute hash
308    let hash = hash_xml_str(name, s_len);
309
310    // Try to find in this dictionary first
311    if let Some(found) = dict_ref.find_entry(hash, name, s_len) {
312        let entry = &dict_ref.entries[found];
313        return entry.data as *const xmlChar;
314    }
315
316    // If this is a sub-dictionary, try parent
317    if let Some(ref parent_ref) = dict_ref.parent {
318        let parent = unsafe { &*parent_ref.ptr };
319        if let Some(found) = parent.find_entry(hash, name, s_len) {
320            // Found in parent — add reference to parent's entry
321            // by looking it up in the parent's table
322            let entry = &parent.entries[found];
323            // Increment refcount
324            let _entry_ref = &parent.entries[found];
325            // We need to modify the parent's entry. This is safe because
326            // sub-dictionaries share the parent's entries by reference.
327            // SAFETY: The parent entry's ref_count is behind a shared reference,
328            // but we need to modify it. In upstream, sub-dictionaries use
329            // the same entries directly, so this is observable behavior.
330            // We use an unsafe cell approach.
331            let entry_ptr = &parent.entries[found] as *const DictEntry as *mut DictEntry;
332            unsafe {
333                (*entry_ptr).ref_count += 1;
334            }
335            return entry.data as *const xmlChar;
336        }
337    }
338
339    // Check limit
340    if dict_ref.limit > 0 && dict_ref.active_count >= dict_ref.limit {
341        return ptr::null();
342    }
343
344    // Add new entry
345    let data_copy = unsafe { allocator::xmlMallocImpl(s_len + 1) as *mut u8 };
346    if data_copy.is_null() {
347        return ptr::null();
348    }
349    unsafe {
350        ptr::copy_nonoverlapping(name, data_copy, s_len);
351        *data_copy.add(s_len) = 0;
352    }
353
354    let entry_idx = dict_ref.entries.len();
355    dict_ref.entries.push(DictEntry {
356        ref_count: 1,
357        data: data_copy,
358        len: s_len,
359        hash,
360    });
361
362    // Add to hash table
363    dict_ref
364        .table
365        .insert_unique(hash, (hash, entry_idx), |(h, _)| *h);
366
367    dict_ref.active_count += 1;
368
369    data_copy as *const xmlChar
370}
371
372/// Check if a string exists in the dictionary without adding it.
373///
374/// # UPSTREAM-PARITY
375///
376/// ```c
377/// const xmlChar *xmlDictExists(xmlDictPtr dict, const xmlChar *name, int len);
378/// ```
379///
380/// Returns a pointer to the interned string if found, or NULL if not found.
381///
382/// # SAFETY
383///
384/// - `dict` must be a valid pointer to a Dict, or NULL.
385/// - `name` must be a valid pointer to a null-terminated string (if len < 0)
386///   or a buffer of at least `len` bytes (if len >= 0).
387pub unsafe fn dict_exists(dict: *mut Dict, name: *const xmlChar, len: c_int) -> *const xmlChar {
388    if dict.is_null() || name.is_null() {
389        return ptr::null();
390    }
391
392    let dict_ref = unsafe { &*dict };
393
394    let s_len = if len < 0 {
395        unsafe { crate::abi::exports_xml2::xmlStrlen(name) as usize }
396    } else {
397        len as usize
398    };
399
400    if s_len == 0 {
401        return ptr::null();
402    }
403
404    let hash = hash_xml_str(name, s_len);
405
406    if let Some(found) = dict_ref.find_entry(hash, name, s_len) {
407        let entry = &dict_ref.entries[found];
408        return entry.data as *const xmlChar;
409    }
410
411    // Check parent
412    if let Some(ref parent_ref) = dict_ref.parent {
413        let parent = unsafe { &*parent_ref.ptr };
414        if let Some(found) = parent.find_entry(hash, name, s_len) {
415            let entry = &parent.entries[found];
416            return entry.data as *const xmlChar;
417        }
418    }
419
420    ptr::null()
421}
422
423/// Whether `s` is one of this dictionary's interned strings (walking the
424/// parent chain for sub-dictionaries).
425///
426/// # UPSTREAM-PARITY
427///
428/// Upstream `xmlDictOwns` checks whether `str` points inside the dict's
429/// string pool; this scans the entry data pointers, which IS the pool. The
430/// scan runs on the SHARED dict memory, so it is cross-DSO coherent
431/// (R-000177): a dict interned through libxml2.so.16 is recognized by
432/// libxslt.so.1's teardown paths, which must not free interned strings.
433///
434/// # SAFETY
435///
436/// - `dict` must be NULL or a valid Dict; `s` must be NULL or a valid
437///   pointer (only compared, never dereferenced).
438pub unsafe fn dict_owns(dict: *mut Dict, s: *const xmlChar) -> bool {
439    if dict.is_null() || s.is_null() {
440        return false;
441    }
442    let mut cur = dict;
443    while !cur.is_null() {
444        let d = unsafe { &*cur };
445        for e in &d.entries {
446            if std::ptr::eq(e.data, s) {
447                return true;
448            }
449        }
450        cur = match &d.parent {
451            Some(p) => p.ptr,
452            None => ptr::null_mut(),
453        };
454    }
455    false
456}
457
458/// Get the number of entries in the dictionary.
459///
460/// # UPSTREAM-PARITY
461///
462/// ```c
463/// int xmlDictSize(xmlDictPtr dict);
464/// ```
465///
466/// Returns the number of active entries, or -1 if dict is NULL.
467pub const fn dict_size(dict: *const Dict) -> c_int {
468    if dict.is_null() {
469        return -1;
470    }
471    let dict_ref = unsafe { &*dict };
472    dict_ref.active_count as c_int
473}
474
475/// Free a dictionary and all its interned strings.
476///
477/// # UPSTREAM-PARITY
478///
479/// ```c
480/// void xmlDictFree(xmlDictPtr dict);
481/// ```
482///
483/// # SAFETY
484///
485/// - `dict` must be a valid pointer to a Dict, or NULL.
486/// - After this call, any strings obtained from the dictionary become invalid.
487pub unsafe fn dict_free(dict: *mut Dict) {
488    if dict.is_null() {
489        return;
490    }
491
492    let dict_ref = unsafe { &mut *dict };
493
494    // Decrement refcounts on parent entries
495    // (For sub-dictionaries, we don't own the data directly)
496    if dict_ref.is_sub {
497        // Sub-dictionaries decrement parent entry refcounts
498        // For simplicity in Phase 1, we just free the dictionary structure.
499        // In a more complete implementation, we'd walk the entries and
500        // decrement parent refcounts.
501    } else {
502        // Free all entry data
503        for entry in dict_ref.entries.iter() {
504            if !entry.data.is_null() && entry.ref_count > 0 {
505                allocator::xmlFreeImpl(entry.data as *mut c_void);
506            }
507        }
508    }
509
510    // Drop the dictionary
511    drop(Box::from_raw(dict));
512}
513
514/// Set the maximum number of entries allowed in the dictionary.
515///
516/// # UPSTREAM-PARITY
517///
518/// ```c
519/// size_t xmlDictSetLimit(xmlDictPtr dict, size_t limit);
520/// ```
521///
522/// Returns the previous limit.
523///
524/// # Safety
525///
526/// - `dict` must be NULL or a valid, initialized `Dict` that stays alive
527///   for the call; the limit field is written in place.
528pub fn dict_set_limit(dict: *mut Dict, limit: usize) -> usize {
529    if dict.is_null() {
530        return 0;
531    }
532    let dict_ref = unsafe { &mut *dict };
533    let prev = dict_ref.limit;
534    dict_ref.limit = limit;
535    prev
536}
537
538/// Get the current number of entries in the dictionary.
539///
540/// # UPSTREAM-PARITY
541///
542/// ```c
543/// size_t xmlDictGetUsage(xmlDictPtr dict);
544/// ```
545///
546/// Returns the number of active entries.
547pub fn dict_get_usage(dict: *mut Dict) -> usize {
548    if dict.is_null() {
549        return 0;
550    }
551    let dict_ref = unsafe { &*dict };
552    dict_ref.active_count
553}
554
555// ═══════════════════════════════════════════════════════════════════════════════
556// Internal Methods
557// ═══════════════════════════════════════════════════════════════════════════════
558
559impl Dict {
560    /// Find an entry by hash and content.
561    fn find_entry(&self, hash: u64, name: *const xmlChar, len: usize) -> Option<usize> {
562        // SAFETY: name must be valid for len bytes.
563        let name_slice = unsafe { core::slice::from_raw_parts(name, len) };
564
565        self.table
566            .find(hash, |(entry_hash, entry_idx)| {
567                if *entry_hash != hash {
568                    return false;
569                }
570                if *entry_idx >= self.entries.len() {
571                    return false;
572                }
573                let entry = &self.entries[*entry_idx];
574                if entry.len != len {
575                    return false;
576                }
577                if entry.data.is_null() {
578                    return false;
579                }
580                // SAFETY: entry.data is valid for entry.len bytes.
581                let entry_slice = unsafe { core::slice::from_raw_parts(entry.data, entry.len) };
582                entry_slice == name_slice
583            })
584            .map(|entry| entry.1)
585    }
586}
587
588// ═══════════════════════════════════════════════════════════════════════════════
589// Tests
590// ═══════════════════════════════════════════════════════════════════════════════
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    /// Allocate a NUL-terminated xmlChar string with the libxml2 allocator.
597    ///
598    /// # Safety
599    ///
600    /// - `s` must be a valid string; the returned pointer is
601    ///   allocator-owned, valid for `bytes.len() + 1` bytes, and must be
602    ///   released with `xmlFreeImpl`.
603    fn xml_str(s: &str) -> *const xmlChar {
604        // Create a null-terminated string
605        let bytes = s.as_bytes();
606        let buf = unsafe { allocator::xmlMallocImpl(bytes.len() + 1) } as *mut u8;
607        unsafe {
608            ptr::copy_nonoverlapping(bytes.as_ptr(), buf, bytes.len());
609            *buf.add(bytes.len()) = 0;
610        }
611        buf as *const xmlChar
612    }
613
614    /// Free a string previously allocated by `xml_str` (NULL-safe).
615    ///
616    /// # Safety
617    ///
618    /// - `s` must be NULL or a pointer allocated with `xmlFreeImpl`'s
619    ///   allocator; it is freed exactly once and must not be used
620    ///   afterwards.
621    fn free_xml_str(s: *const xmlChar) {
622        if !s.is_null() {
623            unsafe { allocator::xmlFreeImpl(s as *mut c_void) };
624        }
625    }
626
627    /// Create and free a dictionary.
628    ///
629    /// # Safety
630    ///
631    /// - `dict` is non-NULL (asserted) and valid until `dict_free`
632    ///   releases it exactly once.
633    #[test]
634    fn test_dict_create_free() {
635        unsafe {
636            let dict = dict_create();
637            assert!(!dict.is_null());
638            dict_free(dict);
639        }
640    }
641
642    /// Look up a string and check pointer stability across lookups.
643    ///
644    /// # Safety
645    ///
646    /// - `dict` is non-NULL (asserted); `name`/`name2` are valid
647    ///   NUL-terminated strings allocated by `xml_str` and freed by
648    ///   `free_xml_str`; `dict_free` releases the dict exactly once; the
649    ///   returned interned pointer is only compared, never dereferenced.
650    #[test]
651    fn test_dict_lookup() {
652        unsafe {
653            let dict = dict_create();
654            let name = xml_str("hello");
655            let result = dict_lookup(dict, name, -1);
656            assert!(!result.is_null());
657
658            // Same string should return the same pointer
659            let result2 = dict_lookup(dict, name, -1);
660            assert_eq!(result, result2);
661
662            // Different string should return a different pointer
663            let name2 = xml_str("world");
664            let result3 = dict_lookup(dict, name2, -1);
665            assert!(!result3.is_null());
666            assert_ne!(result, result3);
667
668            free_xml_str(name);
669            free_xml_str(name2);
670            dict_free(dict);
671        }
672    }
673
674    /// Check existence of an entry before and after insertion.
675    ///
676    /// # Safety
677    ///
678    /// - `dict` is non-NULL (asserted); `name` is a valid NUL-terminated
679    ///   string freed by `free_xml_str`; `dict_free` releases the dict
680    ///   exactly once; returned pointers are only compared.
681    #[test]
682    fn test_dict_exists() {
683        unsafe {
684            let dict = dict_create();
685            let name = xml_str("test_string");
686
687            // Should not exist yet
688            let result = dict_exists(dict, name, -1);
689            assert!(result.is_null());
690
691            // Add it
692            let added = dict_lookup(dict, name, -1);
693            assert!(!added.is_null());
694
695            // Now it should exist
696            let found = dict_exists(dict, name, -1);
697            assert!(!found.is_null());
698            assert_eq!(found, added);
699
700            free_xml_str(name);
701            dict_free(dict);
702        }
703    }
704
705    /// Verify the dictionary size tracks distinct insertions.
706    ///
707    /// # Safety
708    ///
709    /// - `dict` is non-NULL (asserted); the three key strings are valid
710    ///   NUL-terminated strings freed by `free_xml_str`; `dict_free`
711    ///   releases the dict exactly once; returned pointers are only
712    ///   compared.
713    #[test]
714    fn test_dict_size() {
715        unsafe {
716            let dict = dict_create();
717            assert_eq!(dict_size(dict), 0);
718
719            let name1 = xml_str("a");
720            let name2 = xml_str("b");
721            let name3 = xml_str("c");
722
723            dict_lookup(dict, name1, -1);
724            assert_eq!(dict_size(dict), 1);
725
726            dict_lookup(dict, name2, -1);
727            assert_eq!(dict_size(dict), 2);
728
729            dict_lookup(dict, name3, -1);
730            assert_eq!(dict_size(dict), 3);
731
732            // Duplicate lookup shouldn't increase size
733            dict_lookup(dict, name1, -1);
734            assert_eq!(dict_size(dict), 3);
735
736            free_xml_str(name1);
737            free_xml_str(name2);
738            free_xml_str(name3);
739            dict_free(dict);
740        }
741    }
742
743    /// Verify the entry limit rejects insertions past it.
744    ///
745    /// # Safety
746    ///
747    /// - `dict` is non-NULL (asserted); the key strings are valid
748    ///   NUL-terminated strings freed by `free_xml_str`; `dict_free`
749    ///   releases the dict exactly once; returned pointers are only
750    ///   compared for NULL.
751    #[test]
752    fn test_dict_set_limit() {
753        unsafe {
754            let dict = dict_create();
755            assert_eq!(dict_set_limit(dict, 2), 0);
756
757            let name1 = xml_str("x");
758            let name2 = xml_str("y");
759            let name3 = xml_str("z");
760
761            let r1 = dict_lookup(dict, name1, -1);
762            assert!(!r1.is_null());
763
764            let r2 = dict_lookup(dict, name2, -1);
765            assert!(!r2.is_null());
766
767            // Should fail due to limit
768            let r3 = dict_lookup(dict, name3, -1);
769            assert!(r3.is_null());
770
771            assert_eq!(dict_get_usage(dict), 2);
772
773            free_xml_str(name1);
774            free_xml_str(name2);
775            free_xml_str(name3);
776            dict_free(dict);
777        }
778    }
779
780    /// Create a sub-dictionary sharing strings with its parent.
781    ///
782    /// # Safety
783    ///
784    /// - `parent` and `sub` are non-NULL (asserted); `name` is a valid
785    ///   NUL-terminated string; `sub` must be freed before `parent`;
786    ///   `name` is freed after both dicts; returned pointers are only
787    ///   compared.
788    #[test]
789    fn test_dict_create_sub() {
790        unsafe {
791            let parent = dict_create();
792            let name = xml_str("shared");
793
794            let r1 = dict_lookup(parent, name, -1);
795            assert!(!r1.is_null());
796
797            let sub = dict_create_sub(parent);
798            assert!(!sub.is_null());
799
800            // Sub should find parent's strings
801            let r2 = dict_lookup(sub, name, -1);
802            assert!(!r2.is_null());
803            // Same pointer since sub shares with parent
804            assert_eq!(r1, r2);
805
806            dict_free(sub);
807            dict_free(parent);
808            free_xml_str(name);
809        }
810    }
811
812    /// NULL dict/name arguments must be tolerated without crashing.
813    ///
814    /// # Safety
815    ///
816    /// - `dict_lookup`, `dict_exists`, `dict_size`, `dict_set_limit`,
817    ///   `dict_get_usage` and `dict_free` handle NULL dict pointers as
818    ///   documented no-ops; no pointer is dereferenced.
819    #[test]
820    fn test_dict_null_handling() {
821        unsafe {
822            assert!(dict_lookup(ptr::null_mut(), ptr::null(), -1).is_null());
823            assert!(dict_exists(ptr::null_mut(), ptr::null(), -1).is_null());
824            assert_eq!(dict_size(ptr::null()), -1);
825            assert_eq!(dict_set_limit(ptr::null_mut(), 10), 0);
826            assert_eq!(dict_get_usage(ptr::null_mut()), 0);
827            dict_free(ptr::null_mut()); // Should not crash
828        }
829    }
830}