libxml_rs/xml/chvalid.rs
1//! XML character-class validation (upstream chvalid.c / parserInternals.c).
2//!
3//! The exported `xmlIs*` family and `xmlCharInRange` use the generated
4//! character-class tables from `unicode_tables.rs` (extracted verbatim from
5//! upstream `codegen/ranges.inc`; see
6//! `tools/archaeology/gen_chvalid_tables.py`).
7//!
8//! # UPSTREAM-PARITY
9//!
10//! The semantics mirror upstream chvalid.h macros exactly:
11//!
12//! - `xmlIsBaseCharQ`: `(c < 0x100) ? xmlIsBaseChar_ch(c) : xmlCharInRange(c, &xmlIsBaseCharGroup)`
13//! - `xmlIsBlankQ`: `(c < 0x100) ? (c==0x20 || 0x9<=c<=0xa || c==0xd) : 0`
14//! - `xmlIsCharQ`: `(c < 0x100) ? (0x9<=c<=0xa || c==0xd || 0x20<=c) : (0x100<=c<=0xd7ff || 0xe000<=c<=0xfffd || 0x10000<=c<=0x10ffff)`
15//! - `xmlIsCombiningQ`: `(c < 0x100) ? 0 : xmlCharInRange(c, &xmlIsCombiningGroup)`
16//! - `xmlIsDigitQ`: `(c < 0x100) ? (0x30<=c<=0x39) : xmlCharInRange(c, &xmlIsDigitGroup)`
17//! - `xmlIsExtenderQ`: `(c < 0x100) ? (c==0xb7) : xmlCharInRange(c, &xmlIsExtenderGroup)`
18//! - `xmlIsIdeographicQ`: `(c < 0x100) ? 0 : (0x4e00<=c<=0x9fa5 || c==0x3007 || 0x3021<=c<=0x3029)`
19//! - `xmlIsPubidCharQ`: `(c < 0x100) ? xmlIsPubidChar_tab[c] : 0`
20//! - `xmlIsLetter`: `xmlIsBaseCharQ(c) || xmlIsIdeographicQ(c)` (parserInternals.c)
21//! - `xmlIsBlankNode`: tree.c — text/CDATA node whose content is empty or
22//! all blank.
23//!
24//! # Courts
25//!
26//! CHVALID-* differential tests compare against the oracle DSO for the whole
27//! BMP + representative supplementary-plane code points.
28//!
29//! # Upstream contract
30//!
31//! Mirrors upstream `chvalid.c` / `xmlunicode.c` / `chvalid.h`
32//! (`SRC-LIBXML2-2.15.0-CHVALID-C` et al., parity target libxml2 2.15.3
33//! oracle): `xmlCharInRange`, the exported `xmlIs*` predicates and the
34//! `xmlIsPubidChar_tab` data table.
35//!
36//! # Conceptual behavior
37//!
38//! Implements the upstream Q-macro semantics verbatim (each `xmlIs*Q`
39//! expansion is listed above): sub-0x100 code points are answered from
40//! tables/linear tests, larger code points from the generated range
41//! groups via binary search. `xmlIsLetter` (parserInternals.c) and
42//! `xmlIsBlankNode` (tree.c) complete the surface.
43//!
44//! # Ownership & safety invariants
45//!
46//! The tables are immutable `static` data (extracted from upstream
47//! `codegen/ranges.inc`); `xmlCharInRange` only reads its group argument
48//! (SAFETY: NULL group returns 0, valid group must cover
49//! nbShortRange/nbLongRange entries). Nothing here allocates.
50//!
51//! # Historical quirks & epochs
52//!
53//! R-000135: the seven char-class tables were extracted verbatim from
54//! upstream ranges.inc by tools/archaeology/gen_chvalid_tables.py
55//! (sha256-bound, oracle sha256 e7575963…) and the DATA-GLOBALS-001 court
56//! fingerprints FNV-1a hashes of all nine `xmlIs*` functions over the BMP
57//! — the tables are stable across the 2.7.8 → 2.15.3 oracle span.
58//!
59//! # Deliberate oddities
60//!
61//! `xmlIsPubidChar` only accepts < 0x100 (per the Q-macro); the Latin-1
62//! linear cases in `xmlIsBaseChar` etc. are kept exactly as upstream
63//! encodes them rather than merged into the range groups.
64//!
65//! # Proving courts
66//!
67//! DATA-GLOBALS-001 (tools/abi/data_globals_probe.py + committed C probe)
68//! compiles the probe against the system libxml2 and the candidate DSO and
69//! requires byte-identical output; CHVALID-* differential tests cover the
70//! whole BMP; cargo test runs the unit assertions.
71//!
72//! # Tempting simplifications that would break parity
73//!
74//! Do not regenerate the tables from Unicode data files: upstream tables
75//! carry historical drift (e.g. ideographs bounded at 0x9fa5, the
76//! pubid table) that byte-parity requires. Do not replace the binary
77//! search with a hash set: the group ranges are what the C ABI exposes
78//! through `xmlChRangeGroup`.
79
80use crate::abi::structs::{_xmlNode, xmlChRangeGroup};
81use crate::xml::unicode_tables::*;
82use std::os::raw::{c_int, c_uint, c_ushort};
83
84/// Binary search over the short/long range tables (upstream `xmlCharInRange`,
85/// chvalid.c — the tables are sorted, so the search is exact).
86///
87/// # SAFETY
88///
89/// - `group` must be NULL or point to a valid `xmlChRangeGroup` whose range
90/// arrays cover `nbShortRange`/`nbLongRange` entries.
91#[no_mangle]
92pub const unsafe extern "C" fn xmlCharInRange(val: c_uint, group: *const xmlChRangeGroup) -> c_int {
93 if group.is_null() {
94 return 0;
95 }
96 let g = unsafe { &*group };
97 if val < 0x10000 {
98 // Short (16-bit) ranges.
99 if g.nbShortRange == 0 {
100 return 0;
101 }
102 let mut low = 0;
103 let mut high = g.nbShortRange - 1;
104 let sptr = g.shortRange;
105 if sptr.is_null() {
106 return 0;
107 }
108 while low <= high {
109 let mid = (low + high) / 2;
110 let s = unsafe { &*sptr.add(mid as usize) };
111 if (val as c_ushort) < s.low {
112 high = mid - 1;
113 } else if (val as c_ushort) > s.high {
114 low = mid + 1;
115 } else {
116 return 1;
117 }
118 }
119 0
120 } else {
121 // Long (32-bit) ranges.
122 if g.nbLongRange == 0 {
123 return 0;
124 }
125 let mut low = 0;
126 let mut high = g.nbLongRange - 1;
127 let lptr = g.longRange;
128 if lptr.is_null() {
129 return 0;
130 }
131 while low <= high {
132 let mid = (low + high) / 2;
133 let l = unsafe { &*lptr.add(mid as usize) };
134 if val < l.low {
135 high = mid - 1;
136 } else if val > l.high {
137 low = mid + 1;
138 } else {
139 return 1;
140 }
141 }
142 0
143 }
144}
145
146#[inline]
147fn is_base_char_ch(c: c_uint) -> bool {
148 // upstream xmlIsBaseChar_ch (genChRanges.py): ASCII letters plus the
149 // Latin-1 letters that do not fall in the group's short ranges.
150 (0x41..=0x5a).contains(&c)
151 || (0x61..=0x7a).contains(&c)
152 || (0xc0..=0xd6).contains(&c)
153 || (0xd8..=0xf6).contains(&c)
154 || c >= 0xf8
155}
156
157/// `xmlIsBaseChar(unsigned int ch)` — XML 1.0 BaseChar production.
158///
159/// # SAFETY
160///
161/// The function touches crate-global state only; it is safe
162/// as long as the caller respects the library's global
163/// initialization/cleanup ordering (xmlInitParser before use,
164/// xmlCleanupParser only after all users are done).
165///
166/// Violating the global lifecycle ordering, or calling this after
167/// teardown or from a signal handler, is undefined behavior.
168#[no_mangle]
169pub unsafe extern "C" fn xmlIsBaseChar(ch: c_uint) -> c_int {
170 if ch < 0x100 {
171 is_base_char_ch(ch) as c_int
172 } else {
173 unsafe { xmlCharInRange(ch, &xmlIsBaseCharGroup) }
174 }
175}
176
177/// `xmlIsBlank(unsigned int ch)` — space, tab, LF, CR.
178///
179/// # SAFETY
180///
181/// The function touches crate-global state only; it is safe
182/// as long as the caller respects the library's global
183/// initialization/cleanup ordering (xmlInitParser before use,
184/// xmlCleanupParser only after all users are done).
185///
186/// Violating the global lifecycle ordering, or calling this after
187/// teardown or from a signal handler, is undefined behavior.
188#[no_mangle]
189pub unsafe extern "C" fn xmlIsBlank(ch: c_uint) -> c_int {
190 if ch < 0x100 {
191 (ch == 0x20 || (0x9..=0xa).contains(&ch) || ch == 0xd) as c_int
192 } else {
193 0
194 }
195}
196
197/// `xmlIsChar(unsigned int ch)` — XML 1.0 Char production.
198///
199/// # SAFETY
200///
201/// The function touches crate-global state only; it is safe
202/// as long as the caller respects the library's global
203/// initialization/cleanup ordering (xmlInitParser before use,
204/// xmlCleanupParser only after all users are done).
205///
206/// Violating the global lifecycle ordering, or calling this after
207/// teardown or from a signal handler, is undefined behavior.
208#[no_mangle]
209pub unsafe extern "C" fn xmlIsChar(ch: c_uint) -> c_int {
210 if ch < 0x100 {
211 ((0x9..=0xa).contains(&ch) || ch == 0xd || ch >= 0x20) as c_int
212 } else {
213 ((0x100..=0xd7ff).contains(&ch)
214 || (0xe000..=0xfffd).contains(&ch)
215 || (0x10000..=0x10ffff).contains(&ch)) as c_int
216 }
217}
218
219/// `xmlIsCombining(unsigned int ch)` — XML 1.0 CombiningChar production.
220///
221/// # SAFETY
222///
223/// The function touches crate-global state only; it is safe
224/// as long as the caller respects the library's global
225/// initialization/cleanup ordering (xmlInitParser before use,
226/// xmlCleanupParser only after all users are done).
227///
228/// Violating the global lifecycle ordering, or calling this after
229/// teardown or from a signal handler, is undefined behavior.
230#[no_mangle]
231pub unsafe extern "C" fn xmlIsCombining(ch: c_uint) -> c_int {
232 if ch < 0x100 {
233 0
234 } else {
235 unsafe { xmlCharInRange(ch, &xmlIsCombiningGroup) }
236 }
237}
238
239/// `xmlIsDigit(unsigned int ch)` — XML 1.0 Digit production.
240///
241/// # SAFETY
242///
243/// The function touches crate-global state only; it is safe
244/// as long as the caller respects the library's global
245/// initialization/cleanup ordering (xmlInitParser before use,
246/// xmlCleanupParser only after all users are done).
247///
248/// Violating the global lifecycle ordering, or calling this after
249/// teardown or from a signal handler, is undefined behavior.
250#[no_mangle]
251pub unsafe extern "C" fn xmlIsDigit(ch: c_uint) -> c_int {
252 if ch < 0x100 {
253 (0x30..=0x39).contains(&ch) as c_int
254 } else {
255 unsafe { xmlCharInRange(ch, &xmlIsDigitGroup) }
256 }
257}
258
259/// `xmlIsExtender(unsigned int ch)` — XML 1.0 Extender production.
260///
261/// # SAFETY
262///
263/// The function touches crate-global state only; it is safe
264/// as long as the caller respects the library's global
265/// initialization/cleanup ordering (xmlInitParser before use,
266/// xmlCleanupParser only after all users are done).
267///
268/// Violating the global lifecycle ordering, or calling this after
269/// teardown or from a signal handler, is undefined behavior.
270#[no_mangle]
271pub unsafe extern "C" fn xmlIsExtender(ch: c_uint) -> c_int {
272 if ch < 0x100 {
273 (ch == 0xb7) as c_int
274 } else {
275 unsafe { xmlCharInRange(ch, &xmlIsExtenderGroup) }
276 }
277}
278
279/// `xmlIsIdeographic(unsigned int ch)` — XML 1.0 Ideographic production.
280///
281/// # SAFETY
282///
283/// The function touches crate-global state only; it is safe
284/// as long as the caller respects the library's global
285/// initialization/cleanup ordering (xmlInitParser before use,
286/// xmlCleanupParser only after all users are done).
287///
288/// Violating the global lifecycle ordering, or calling this after
289/// teardown or from a signal handler, is undefined behavior.
290#[no_mangle]
291pub unsafe extern "C" fn xmlIsIdeographic(ch: c_uint) -> c_int {
292 if ch < 0x100 {
293 0
294 } else {
295 ((0x4e00..=0x9fa5).contains(&ch) || ch == 0x3007 || (0x3021..=0x3029).contains(&ch))
296 as c_int
297 }
298}
299
300/// `xmlIsPubidChar(unsigned int ch)` — PubidChar production (ASCII table).
301///
302/// # SAFETY
303///
304/// The function touches crate-global state only; it is safe
305/// as long as the caller respects the library's global
306/// initialization/cleanup ordering (xmlInitParser before use,
307/// xmlCleanupParser only after all users are done).
308///
309/// Violating the global lifecycle ordering, or calling this after
310/// teardown or from a signal handler, is undefined behavior.
311#[no_mangle]
312pub unsafe extern "C" fn xmlIsPubidChar(ch: c_uint) -> c_int {
313 if ch >= 0x100 {
314 0
315 } else {
316 xmlIsPubidChar_tab[ch as usize] as c_int
317 }
318}
319
320/// `xmlIsLetter(int c)` — BaseChar or Ideographic (parserInternals.c).
321///
322/// # SAFETY
323///
324/// The function touches crate-global state only; it is safe
325/// as long as the caller respects the library's global
326/// initialization/cleanup ordering (xmlInitParser before use,
327/// xmlCleanupParser only after all users are done).
328///
329/// Violating the global lifecycle ordering, or calling this after
330/// teardown or from a signal handler, is undefined behavior.
331#[no_mangle]
332pub unsafe extern "C" fn xmlIsLetter(c: c_int) -> c_int {
333 let ch = c as c_uint;
334 if ch < 0x100 {
335 is_base_char_ch(ch) as c_int
336 } else {
337 unsafe { xmlIsBaseChar(ch) | xmlIsIdeographic(ch) }
338 }
339}
340
341/// `xmlIsBlankNode(const xmlNode *node)` — text/CDATA node with empty or
342/// whitespace-only content (tree.c 2.15).
343///
344/// # SAFETY
345///
346/// - `node` must be NULL or a valid node pointer.
347#[no_mangle]
348pub unsafe extern "C" fn xmlIsBlankNode(node: *const _xmlNode) -> c_int {
349 if node.is_null() {
350 return 0;
351 }
352 let n = unsafe { &*node };
353 if n.type_ != crate::abi::types::xmlElementType::XML_TEXT_NODE as c_int
354 && n.type_ != crate::abi::types::xmlElementType::XML_CDATA_SECTION_NODE as c_int
355 {
356 return 0;
357 }
358 if n.content.is_null() {
359 return 1;
360 }
361 let mut cur = n.content;
362 while !cur.is_null() && *cur != 0 {
363 let ch = *cur as c_uint;
364 if ch != 0x20 && !(0x9..=0xa).contains(&ch) && ch != 0xd {
365 return 0;
366 }
367 cur = cur.add(1);
368 }
369 1
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use crate::abi::allocator;
376 use crate::abi::types::xmlChar;
377 use std::os::raw::c_uint;
378
379 /// Differential-oracle spot checks (values verified against the system
380 /// libxml2 2.15.3 DSO via tools/abi/data_globals_probe.py).
381 fn oracle_is_base_char(ch: c_uint) -> c_int {
382 unsafe { xmlIsBaseChar(ch) }
383 }
384
385 #[test]
386 fn test_xml_is_char_basic() {
387 unsafe {
388 // XML 1.0 Char production.
389 assert_eq!(xmlIsChar(0x9), 1); // tab
390 assert_eq!(xmlIsChar(0xa), 1); // lf
391 assert_eq!(xmlIsChar(0xd), 1); // cr
392 assert_eq!(xmlIsChar(0x20), 1); // space
393 assert_eq!(xmlIsChar(0x1f), 0); // below space
394 assert_eq!(xmlIsChar(0xd7ff), 1);
395 assert_eq!(xmlIsChar(0xd800), 0); // surrogate
396 assert_eq!(xmlIsChar(0xe000), 1);
397 assert_eq!(xmlIsChar(0xfffe), 0);
398 assert_eq!(xmlIsChar(0x10000), 1);
399 assert_eq!(xmlIsChar(0x10ffff), 1);
400 assert_eq!(xmlIsChar(0x110000), 0);
401 }
402 }
403
404 #[test]
405 fn test_xml_is_blank() {
406 unsafe {
407 assert_eq!(xmlIsBlank(0x20), 1);
408 assert_eq!(xmlIsBlank(0x9), 1);
409 assert_eq!(xmlIsBlank(0xa), 1);
410 assert_eq!(xmlIsBlank(0xd), 1);
411 assert_eq!(xmlIsBlank(b'x' as c_uint), 0);
412 assert_eq!(xmlIsBlank(0x100), 0);
413 assert_eq!(xmlIsBlank(0x3000), 0); // ideographic space NOT blank upstream
414 }
415 }
416
417 #[test]
418 fn test_xml_is_base_char_ascii_and_ranges() {
419 unsafe {
420 assert_eq!(xmlIsBaseChar(b'A' as c_uint), 1);
421 assert_eq!(xmlIsBaseChar(b'z' as c_uint), 1);
422 assert_eq!(xmlIsBaseChar(b'0' as c_uint), 0);
423 assert_eq!(xmlIsBaseChar(0xc0), 1); // À
424 assert_eq!(xmlIsBaseChar(0xd7), 0);
425 assert_eq!(xmlIsBaseChar(0x100), 1); // Ā (short range)
426 assert_eq!(xmlIsBaseChar(0x132), 0); // between ranges
427 assert_eq!(xmlIsBaseChar(0x386), 1); // Greek
428 assert_eq!(xmlIsBaseChar(0x5d0), 1); // Hebrew
429 assert_eq!(xmlIsBaseChar(0xac00), 1); // Hangul
430 assert_eq!(xmlIsBaseChar(0xac00), oracle_is_base_char(0xac00));
431 assert_eq!(xmlIsBaseChar(0x2a8), 1);
432 assert_eq!(xmlIsBaseChar(0x2a9), 0);
433 }
434 }
435
436 #[test]
437 fn test_xml_is_digit() {
438 unsafe {
439 assert_eq!(xmlIsDigit(b'0' as c_uint), 1);
440 assert_eq!(xmlIsDigit(b'9' as c_uint), 1);
441 assert_eq!(xmlIsDigit(b'a' as c_uint), 0);
442 assert_eq!(xmlIsDigit(0x660), 1); // Arabic-Indic zero
443 assert_eq!(xmlIsDigit(0x6f9), 1);
444 assert_eq!(xmlIsDigit(0x670), 0);
445 }
446 }
447
448 #[test]
449 fn test_xml_is_combining_extender_ideographic() {
450 unsafe {
451 assert_eq!(xmlIsCombining(0x300), 1); // combining grave
452 assert_eq!(xmlIsCombining(0x20,), 0);
453 assert_eq!(xmlIsExtender(0xb7), 1); // middle dot
454 assert_eq!(xmlIsExtender(0x2d0), 1);
455 assert_eq!(xmlIsExtender(0x3005), 1);
456 assert_eq!(xmlIsExtender(0x20), 0);
457 assert_eq!(xmlIsIdeographic(0x4e00), 1); // CJK
458 assert_eq!(xmlIsIdeographic(0x3007), 1);
459 assert_eq!(xmlIsIdeographic(0x3029), 1);
460 assert_eq!(xmlIsIdeographic(0x302a), 0);
461 assert_eq!(xmlIsIdeographic(0x9fa5), 1);
462 assert_eq!(xmlIsIdeographic(b'A' as c_uint), 0);
463 }
464 }
465
466 #[test]
467 fn test_xml_is_pubid_char() {
468 unsafe {
469 assert_eq!(xmlIsPubidChar(b'a' as c_uint), 1);
470 assert_eq!(xmlIsPubidChar(b' ' as c_uint), 1);
471 assert_eq!(xmlIsPubidChar(b'!' as c_uint), 1);
472 // @ IS a PubidChar ([-'()+,./:=?;!*#@$_%]).
473 assert_eq!(xmlIsPubidChar(b'@' as c_uint), 1);
474 // ^ and ~ are not.
475 assert_eq!(xmlIsPubidChar(b'^' as c_uint), 0);
476 assert_eq!(xmlIsPubidChar(b'~' as c_uint), 0);
477 assert_eq!(xmlIsPubidChar(0x80), 0);
478 assert_eq!(xmlIsPubidChar(0x100), 0);
479 // tab is not a pubid char upstream.
480 assert_eq!(xmlIsPubidChar(0x9), 0);
481 }
482 }
483
484 #[test]
485 fn test_xml_is_letter() {
486 unsafe {
487 assert_eq!(xmlIsLetter(b'A' as c_int), 1);
488 assert_eq!(xmlIsLetter(0x4e00), 1); // ideographic counts
489 assert_eq!(xmlIsLetter(b'0' as c_int), 0);
490 assert_eq!(xmlIsLetter(0x386), 1);
491 }
492 }
493
494 #[test]
495 fn test_xml_char_in_range_null_group() {
496 unsafe {
497 assert_eq!(xmlCharInRange(0x41, core::ptr::null()), 0);
498 }
499 }
500
501 #[test]
502 fn test_xml_is_blank_node() {
503 unsafe {
504 use crate::abi::types::xmlElementType::*;
505 // Null node -> 0.
506 assert_eq!(xmlIsBlankNode(core::ptr::null()), 0);
507 // Text node with NULL content -> 1.
508 let node = allocator::xmlMallocImpl(core::mem::size_of::<_xmlNode>()) as *mut _xmlNode;
509 assert!(!node.is_null());
510 core::ptr::write(
511 node,
512 _xmlNode {
513 type_: XML_TEXT_NODE as c_int,
514 content: core::ptr::null_mut(),
515 ..core::mem::zeroed()
516 },
517 );
518 assert_eq!(xmlIsBlankNode(node), 1);
519 // Whitespace-only -> 1.
520 let ws = b" \t\n\r\0" as *const u8 as *mut xmlChar;
521 (*node).content = ws;
522 assert_eq!(xmlIsBlankNode(node), 1);
523 // Non-whitespace -> 0.
524 let nw = b" x\0" as *const u8 as *mut xmlChar;
525 (*node).content = nw;
526 assert_eq!(xmlIsBlankNode(node), 0);
527 // Non-text node -> 0.
528 (*node).type_ = XML_ELEMENT_NODE as c_int;
529 assert_eq!(xmlIsBlankNode(node), 0);
530 allocator::xmlFreeImpl(node as *mut libc::c_void);
531 }
532 }
533}