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