libxml-rs 0.1.0-alpha.50

Native-Rust forensic reimplementation of libxml2+libxslt with C ABI drop-in replacement. Cross-version oracle matrix (libxml2 2.7.8-2.15.3, libxslt 1.1.26-1.1.45) with semantic epochs; full xmllint/xmlcatalog/xsltproc CLIs; differential-court-verified C API closure; three-DSO ELF packaging (libxml2.so.16 core + libxslt.so.1/libexslt.so.0 facades, upstream NEEDED chain); fail-closed oracle-isolated ABI-FUNCTION-SIGNATURE plane (SOURCE_PROTOTYPE + MACHINE_ABI fingerprints, zero silent omissions); Phase-12 real downstream substitution (binary/static/docker substitution, export-surface disposition, ELF version graphs); Phase-13 hostile audit courts (ABI/ownership/allocator/callbacks/failure/threads/oracle-contamination) byte-identical vs the system oracle incl. the upstream thread-local globals model; Phase-14 downstream custodian validation courts (lxml/Nokogiri/PHP/Debian). Test counts live in atlas/TEST_COUNTS.json, residuals in atlas/RESIDUAL_LEDGER.json (generated evidence).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
//! Parser helper functions โ€” bridge between C ABI exports and the internal parser (ยง19).
//!
//! This module provides the glue layer that C ABI entry points (in `crate::abi::exports_xml2`)
//! call into. Each helper takes raw C pointers and converts them to safe Rust types,
//! delegates to the internal parser, and converts results back.
//!
//! # Ownership model
//!
//! When `setup_parser_input` is called, the `InputBuffer` is boxed and leaked (stored as a raw
//! pointer in a per-context side table, NOT in `ctxt._private`). This keeps the buffer's data
//! alive so that the `_xmlParserInput` pointers (`base`/`cur`/`end`) remain valid.
//! `parse_document` and `parse_chunk` take ownership of that boxed buffer, move it into an
//! `InputStack`, create an `XmlParser`, run it, and drop everything โ€” consuming the buffer in
//! the process.
//!
//! # Why a side table?
//!
//! `ctxt._private` is application data (upstream `xmlCtxtSetPrivate`/`xmlCtxtGetPrivate`); the
//! candidate must never stash internal parse state there, or freeing a context whose private
//! field the application set would free the application's pointer as an `InputBuffer`
//! (11.1-X R-000165 closure discovery). The boxed input lives in `PARSER_INPUT_STASH` keyed by
//! context address and is released by `free_parser_ctxt`/`xmlCtxtReset`/`parse_document`.
//!
//! # Upstream contract
//!
//! Glue layer between the C ABI entry points (`crate::abi::exports_*`) and the
//! internal parser; mirrors the context-creation and parse-entry surface of
//! upstream parser.c and parserInternals.c (SRC-LIBXML2-2.15.0). Parity
//! target: the system libxml2 2.15.3 oracle.
//!
//! # Conceptual behavior
//!
//! Each helper takes raw C pointers, converts them to safe Rust types,
//! delegates to the internal parser, and converts results back. It also owns
//! the per-context input-buffer stash that keeps `_xmlParserInput`
//! base/cur/end pointers alive across the parse.
//!
//! # Ownership & safety invariants
//!
//! Ownership model (see above): `setup_parser_input` boxes and leaks the
//! InputBuffer into the `PARSER_INPUT_STASH` side table keyed by context
//! address; `parse_document` / `parse_chunk` take that box back, run the
//! parser, and drop everything. SAFETY: `ctxt._private` is never used for
//! internal state โ€” it is application data (`xmlCtxtSetPrivate`), and stashing
//! the input there would free the application pointer as an InputBuffer
//! (11.1-X R-000165 closure discovery). Filenames are owned duplicates
//! (`xml_strndup`, R-000169).
//!
//! # Historical quirks & epochs
//!
//! The side-table design dates from the 11.1-X closure (R-000165/R-000169):
//! earlier code either borrowed the boxed buffer Rust String into
//! `_xmlParserInput.filename` (dangling) or `xml_strdup`ed a non-NUL-
//! terminated `as_ptr()` (heap-buffer-overflow, caught by ASan). Epoch: the
//! 2.15.3 oracle era.
//!
//! # Deliberate oddities
//!
//! The stash is a deliberate oddity: a global mutex map keyed by context
//! address with StashPtr manually Send+Sync โ€” required because the C-visible
//! input must outlive the caller input-buffer handle but cannot live in
//! `_private`.
//!
//! # Proving courts
//!
//! Exercised by TREE-001 (doc->URL / base fingerprints), ERROR-001 (filename
//! prefixes like e.xml:1:), the DSO-LOADER court and `cargo test --lib`
//! (ASan-clean). Receipts under courts/receipts/phase-11.
//!
//! # Tempting simplifications that would break parity
//!
//! The tempting simplification is stashing the boxed input in `ctxt._private`
//! โ€” that would free the application pointer when the context is freed
//! (11.1-X discovery) and break `xmlCtxtSetPrivate` consumers. A second one
//! is passing Rust String slices as filenames โ€” non-NUL-terminated and
//! dangling after the context dies (R-000169). Never do either.

use core::ffi::CStr;
use core::ptr;
use std::collections::{HashMap, HashSet};
use std::mem::size_of;
use std::os::raw::{c_char, c_int, c_void};

use crate::abi::allocator::{xmlFreeImpl, xmlMallocImpl, xmlMallocZero};
use crate::abi::callbacks::{xmlInputCloseCallback, xmlInputReadCallback};
use crate::abi::structs::{_xmlParserCtxt, _xmlParserInput, _xmlParserInputBuffer, _xmlSAXHandler};
use crate::xml::parser::input::{InputBuffer, InputStack};
use crate::xml::parser::state::XmlParser;
use crate::xml::sax::xmlSAX2InitDefaultSAXHandler;

/// Per-context stash of the boxed `InputBuffer` backing the C-visible
/// `_xmlParserInput` (see the module docs for why this is not `ctxt._private`).
struct StashPtr(*mut InputBuffer);
unsafe impl Send for StashPtr {}
unsafe impl Sync for StashPtr {}

static PARSER_INPUT_STASH: once_cell::sync::Lazy<parking_lot::Mutex<HashMap<usize, StashPtr>>> =
    once_cell::sync::Lazy::new(Default::default);

/// Per-context incremental-push state (SP-14.3.1-3). Each non-final
/// `xmlParseChunk` call probes the accumulated input and delivers the events
/// not yet fired; `delivered_bytes` tracks how far delivery got so re-parses
/// never fire an event twice.
#[derive(Default)]
struct PushState {
    /// `ctxt->wellFormed` captured when the incremental parse started, before
    /// any probe mutates the context. Consumers may pre-zero it โ€” PHP
    /// ext/xml's expat-compat layer sets `wellFormed = 0` right after creating
    /// the push context โ€” and every probe/delivery/final re-parse must observe
    /// the same starting state, so `parse_chunk` restores it before each
    /// `XmlParser` construction (SP-14.3.1-4: the reference-substitution gate
    /// mirrors upstream's `if (!ctxt->wellFormed) return;`, which holds for the
    /// whole document when the consumer disabled well-formed tracking).
    start_well_formed: i32,
    /// Whether `start_well_formed` was captured yet.
    captured: bool,
    /// Byte offset of the accumulated input whose SAX events were already
    /// delivered by eager-partial parses on earlier non-final calls
    /// (SP-14.3.1-6). Later parses suppress the events at or below this
    /// offset and deliver only the new tail; 0 = nothing delivered yet.
    delivered_bytes: usize,
    /// Whether THIS parser created an internal SAX-compat registry document
    /// (XML_DOC_INTERNAL) on `ctxt->myDoc` (state.rs
    /// `ensure_entity_registry_dtd`). Only such docs are reclaimed by
    /// `free_parser_ctxt` โ€” caller-owned documents are never dereferenced or
    /// freed there (upstream xmlFreeParserCtxt ignores myDoc entirely).
    internal_doc_created: bool,
    /// Count of trailing `\r` bytes withheld from the accumulated input
    /// (upstream `xmlParseChunk`'s `end_in_lf`, parser.c): a NON-final chunk
    /// ending in `\r` has that byte held back โ€” upstream decrements the
    /// pushed size, runs `xmlParseTryOrFinish`, and only THEN re-pushes the
    /// `\r`. The byte is therefore present in the buffer but unparsed at the
    /// end of the call, so a CRLF pair split across two chunks still
    /// normalizes to one `\n` and a lone trailing `\r` is not turned into an
    /// EOL by the call that received it. The candidate parses the whole
    /// accumulated buffer on every call, so the equivalent behavior is to
    /// withhold the byte(s) and prepend them to the next call's chunk (byte
    /// order then matches upstream's buffer exactly).
    ///
    /// A COUNT, not a flag: consecutive CRs must not collapse. Upstream
    /// keeps every withheld `\r` in the buffer (call 1 appends CRโ‚, call 2
    /// appends CRโ‚‚ after its parse), so `<a>x\r\ry</a>` fed as `<a>x\r`,
    /// `\r`, `y</a>` leaves two unread CRs, not one โ€” a `bool` would lose a
    /// byte. Cleared by `free_push_state` (xmlCtxtReset/free), never leaked
    /// across a context reset.
    pending_crs: usize,
}

static PUSH_STATE: once_cell::sync::Lazy<parking_lot::Mutex<HashMap<usize, PushState>>> =
    once_cell::sync::Lazy::new(Default::default);

fn push_state(ctxt: *mut _xmlParserCtxt) -> parking_lot::MappedMutexGuard<'static, PushState> {
    parking_lot::MutexGuard::map(PUSH_STATE.lock(), |m| {
        m.entry(ctxt as usize).or_insert_with(PushState::default)
    })
}

/// Non-creating read of the push-state flag used by the cleanup path.
///
/// `free_parser_ctxt` must NOT insert a default `PushState` merely to discover
/// whether the parser created an internal registry doc (Phase 16.5.1): the
/// normal synchronous `xmlReadMemory` lifecycle never creates push state, so a
/// creating lookup here allocates a HashMap entry on every single parse just so
/// it can be removed a line later.
fn push_state_flag(ctxt: *mut _xmlParserCtxt) -> bool {
    PUSH_STATE
        .lock()
        .get(&(ctxt as usize))
        .map_or(false, |st| st.internal_doc_created)
}

/// Record that the parser created the internal SAX-compat registry document
/// (XML_DOC_INTERNAL) on `ctxt->myDoc`, so `free_parser_ctxt` can reclaim it
/// without dereferencing a possibly caller-freed pointer.
pub(crate) fn mark_internal_doc(ctxt: *mut _xmlParserCtxt) {
    push_state(ctxt).internal_doc_created = true;
}

/// Re-apply the `wellFormed` value the context had when the incremental parse
/// started (see `PushState::start_well_formed`). Each probe/delivery/final
/// re-parse must observe the same starting state: PHP's expat-compat layer
/// zeroes `wellFormed` at create, and the engine mirrors upstream's
/// `if (!ctxt->wellFormed) return;` reference guard for such contexts
/// (SP-14.3.1-4).
fn restore_start_well_formed(ctxt: *mut _xmlParserCtxt) {
    let guard = PUSH_STATE.lock();
    if let Some(st) = guard.get(&(ctxt as usize)) {
        if st.captured {
            unsafe {
                (*ctxt).wellFormed = st.start_well_formed;
            }
        }
    }
}

/// Drop the incremental-push state for `ctxt`, if any.
pub(crate) fn free_push_state(ctxt: *mut _xmlParserCtxt) {
    PUSH_STATE.lock().remove(&(ctxt as usize));
}

/// Stash the boxed input buffer for `ctxt` (takes ownership of `buf`).
pub(crate) fn stash_input_buffer(ctxt: *mut _xmlParserCtxt, buf: *mut InputBuffer) {
    PARSER_INPUT_STASH
        .lock()
        .insert(ctxt as usize, StashPtr(buf));
}

/// Take (remove) the stashed input buffer for `ctxt`; the caller owns it.
pub(crate) fn take_stashed_input_buffer(ctxt: *mut _xmlParserCtxt) -> *mut InputBuffer {
    PARSER_INPUT_STASH
        .lock()
        .remove(&(ctxt as usize))
        .map_or(ptr::null_mut(), |s| s.0)
}

/// Apply a whole-buffer encoding override to the still-stashed memory input
/// of `ctxt` (upstream `xmlSwitchToEncoding` against a memory parser
/// context whose `input->buf` is NULL โ€” the PHP `overrideEncoding` flow
/// switches between `xmlCreateMemoryParserCtxt` and `xmlParseDocument`).
///
/// Transcodes the buffered bytes with the named converter, resets the
/// position, and repopulates `ctxt->input` so the parse reads the new UTF-8
/// allocation (the old Vec is dropped by the `InputBuffer` itself).
///
/// Returns 0 on success, -1 when no stashed memory input exists or the
/// override cannot be applied.
///
/// # Safety
///
/// - `ctxt` must be a valid `_xmlParserCtxt` set up via `setup_parser_input`
///   whose parse has not started; `name` must be a valid NUL-terminated
///   string.
pub(crate) unsafe fn apply_memory_encoding_override(
    ctxt: *mut _xmlParserCtxt,
    name: *const c_char,
) -> c_int {
    if ctxt.is_null() || name.is_null() {
        return -1;
    }
    let ib_ptr = PARSER_INPUT_STASH
        .lock()
        .get(&(ctxt as usize))
        .map_or(ptr::null_mut(), |s| s.0);
    if ib_ptr.is_null() {
        return -1;
    }
    let name_bytes = unsafe { CStr::from_ptr(name).to_bytes() };
    unsafe {
        let ib = &mut *ib_ptr;
        if !ib.apply_name_encoding_override(name_bytes) {
            return -1;
        }
        // The conversion replaced the data Vec; point ctxt->input at the new
        // allocation from the stream start.
        let pi = (*ctxt).input;
        if pi.is_null() {
            return -1;
        }
        ib.populate_parser_input_without_filename(&mut *pi);
    }
    0
}

/// Drop the stashed input buffer for `ctxt`, if any.
pub(crate) fn free_stashed_input_buffer(ctxt: *mut _xmlParserCtxt) {
    if let Some(buf) = PARSER_INPUT_STASH.lock().remove(&(ctxt as usize)) {
        // SAFETY: the pointer was stashed via Box::into_raw.
        unsafe { drop(Box::from_raw(buf.0)) };
    }
}

// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// Context creation / destruction
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

/// Create a new parser context with a default SAX2 handler.
///
/// Returns a pointer to a zero-initialised `_xmlParserCtxt` with:
///
/// - `sax` โ€” a newly allocated `_xmlSAXHandler` initialised via
///   `xmlSAX2InitDefaultSAXHandler`
/// - `userData` โ€” set to `ctxt` itself (so SAX callbacks can recover the context)
/// - `wellFormed` โ€” `1` (the document starts well-formed)
/// - `instate` โ€” `0` (`XML_PARSER_START`)
/// - `keepBlanks` โ€” the deprecated `xmlKeepBlanksDefaultValue` (fresh contexts
///   snapshot it exactly like upstream `xmlInitParserCtxt`, so
///   `xmlKeepBlanksDefault(0)` suppresses whitespace-only text nodes)
/// - `replaceEntities` โ€” the deprecated `xmlSubstituteEntitiesDefaultValue`
/// - `linenumbers` โ€” `1` (track line numbers)
///
/// # Safety
///
/// The caller must eventually free the returned context with `free_parser_ctxt`.
/// The returned pointer may be null if allocation fails.
#[allow(non_snake_case)]
pub(crate) unsafe fn create_parser_ctxt() -> *mut _xmlParserCtxt {
    // SAFETY: xmlMallocZero returns zero-initialised memory or NULL.
    let ctxt = unsafe { xmlMallocZero(size_of::<_xmlParserCtxt>()) } as *mut _xmlParserCtxt;
    if ctxt.is_null() {
        return ptr::null_mut();
    }

    // Allocate and initialise the default SAX2 handler.
    // SAFETY: xmlMallocZero returns a valid pointer or NULL.
    let sax = unsafe { xmlMallocZero(size_of::<_xmlSAXHandler>()) } as *mut _xmlSAXHandler;
    if sax.is_null() {
        // Free the context since the SAX allocation failed.
        // SAFETY: ctxt was just allocated above and is non-null.
        unsafe { xmlFreeImpl(ctxt as *mut c_void) };
        return ptr::null_mut();
    }

    // SAFETY: sax is non-null and points to zero-initialised memory.
    unsafe { xmlSAX2InitDefaultSAXHandler(sax) };

    unsafe {
        let c = &mut *ctxt;
        c.sax = sax;
        c.userData = ctxt as *mut c_void;
        c.wellFormed = 1;
        c.instate = 0; // XML_PARSER_START
                       // UPSTREAM-PARITY (parserInternals.c xmlInitParserCtxt): a fresh
                       // context snapshots the deprecated per-thread defaults. keepBlanks
                       // is load-bearing: the executed 2.15.3 oracle never re-raises it once
                       // seeded (only XML_PARSE_NOBLANKS lowers it), so
                       // `xmlKeepBlanksDefault(0)` governs whitespace-only text nodes in
                       // fresh-context reads even when the read options omit NOBLANKS
                       // (R-000177 three-DSO reads resolve the process-visible cell).
        c.keepBlanks = crate::xml::globals::get_keep_blanks_default();
        c.replaceEntities = crate::xml::globals::get_substitute_entities_default();
        c.linenumbers = 1;
        // UPSTREAM-PARITY (parser.c xmlInitParserCtxt): a fresh context is
        // valid and namespace-well-formed until a failure says otherwise;
        // endDocument mirrors these into the document properties.
        c.valid = 1;
        c.nsWellFormed = 1;
        // UPSTREAM-PARITY: the standalone flag is tri-state: -1 unknown/unset,
        // 0 "no", 1 "yes" (xmlNewParserCtxt initialises it to -1).
        c.standalone = -1;
        c.errNo = crate::abi::types::XML_ERR_OK;
        c.options = 0;
    }

    ctxt
}

/// Free a parser context and all associated resources.
///
/// This frees:
///
/// - The SAX handler (if `sax` is non-null)
/// - All input buffers in `inputTab`
/// - The `inputTab` array itself
/// - The node stack (`nodeTab`)
/// - The name stack (`nameTab`)
/// - The stored `InputBuffer` (from `_private`)
/// - The context struct itself
///
/// # Safety
///
/// `ctxt` must be a valid pointer returned by `create_parser_ctxt` (or NULL,
/// in which case this function is a no-op).
#[allow(non_snake_case)]
pub(crate) unsafe fn free_parser_ctxt(ctxt: *mut _xmlParserCtxt) {
    if ctxt.is_null() {
        return;
    }

    unsafe {
        // Free SAX handler (upstream parserInternals.c xmlFreeParserCtxt): a
        // handler that IS one of the exported STATIC defaults โ€” the XML
        // xmlDefaultSAXHandler or the htmlDefaultSAXHandler global selected by
        // html contexts created with a NULL sax โ€” is not heap-owned and must
        // never be freed here (only heap handler structs are reclaimed).
        let sax = (*ctxt).sax;
        if !sax.is_null() {
            let statics = [
                core::ptr::addr_of!(crate::abi::data_globals::xmlDefaultSAXHandler)
                    as *const c_void,
                core::ptr::addr_of!(crate::abi::data_globals::htmlDefaultSAXHandler)
                    as *const c_void,
            ];
            let is_static = statics.iter().any(|&p| p == sax as *const c_void);
            if !is_static {
                xmlFreeImpl(sax as *mut c_void);
            }
        }

        // Free all inputs in the input stack.
        let input_nr = (*ctxt).inputNr;
        let input_tab = (*ctxt).inputTab;
        if !input_tab.is_null() {
            for i in 0..input_nr {
                let input = *input_tab.add(i as usize);
                if !input.is_null() {
                    free_parser_input(input);
                }
            }
            xmlFreeImpl(input_tab as *mut c_void);
        }

        // Free the current input (if not already in inputTab).
        let cur_input = (*ctxt).input;
        if !cur_input.is_null() {
            // If inputTab was set up, the current input is already in the tab
            // and was freed above. We only free it here if inputTab was NULL.
            if input_tab.is_null() {
                free_parser_input(cur_input);
            }
        }

        // Free the node stack (the array itself; nodes are owned by the doc).
        let node_tab = (*ctxt).nodeTab;
        if !node_tab.is_null() {
            xmlFreeImpl(node_tab as *mut c_void);
        }

        // Free the name stack: every live entry (heap NUL-terminated name
        // owned by the context nameTab) is reclaimed before the array itself
        // (Phase 16 ASan fuzz fix: names pushed but never popped on abort
        // paths must not leak).
        let name_nr = (*ctxt).nameNr;
        let name_tab = (*ctxt).nameTab;
        if !name_tab.is_null() {
            for i in 0..name_nr {
                let entry = *name_tab.add(i as usize);
                if !entry.is_null() {
                    xmlFreeImpl(entry as *mut c_void);
                }
            }
            xmlFreeImpl(name_tab as *mut c_void);
        }
        (*ctxt).nameNr = 0;
        (*ctxt).name = ptr::null();

        // UPSTREAM-PARITY (parserInternals.c xmlFreeParserCtxt): the
        // declaration strings recorded on the context (xmlParseXMLDecl) are
        // context-owned heap buffers โ€” xmlFree(ctxt->version/encoding/
        // directory). Without this, every document with an XML declaration
        // leaked its version+encoding strings (Phase 16 ASan fuzz finding).
        if !(*ctxt).version.is_null() {
            xmlFreeImpl((*ctxt).version as *mut c_void);
        }
        if !(*ctxt).encoding.is_null() {
            xmlFreeImpl((*ctxt).encoding as *mut c_void);
        }
        if !(*ctxt).directory.is_null() {
            xmlFreeImpl((*ctxt).directory as *mut c_void);
        }
        (*ctxt).version = ptr::null_mut();
        (*ctxt).encoding = ptr::null_mut();
        (*ctxt).directory = ptr::null_mut();

        // Free the stored InputBuffer (stashed in the side table by
        // setup_parser_input). `ctxt._private` is application data and is
        // NEVER touched here (11.1-X).
        free_stashed_input_buffer(ctxt);

        // UPSTREAM-PARITY (parser SAX-compat entity registry): a document the
        // parser created internally (XML_DOC_INTERNAL โ€” see
        // state.rs::ensure_entity_registry_dtd) is never delivered to the
        // caller, so it must be reclaimed here. Ownership is tracked by a flag
        // (set at creation) instead of dereferencing `ctxt->myDoc`, which the
        // caller may already have freed โ€” upstream xmlFreeParserCtxt never
        // touches myDoc, and caller-owned documents are never reclaimed here.
        let reclaim_internal_doc = push_state_flag(ctxt);
        // Drop incremental-push state so a later context allocated at the
        // same address starts clean (SP-14.3.1-3).
        free_push_state(ctxt);
        if reclaim_internal_doc {
            let my_doc = (*ctxt).myDoc;
            if !my_doc.is_null() {
                (*ctxt).myDoc = ptr::null_mut();
                crate::xml::tree::free_doc(my_doc);
            }
        }

        // Free the parser dictionary (upstream xmlFreeParserCtxt).
        if !(*ctxt).dict.is_null() {
            crate::abi::exports_xml2::xmlDictFree((*ctxt).dict);
        }

        // Free the per-context last error's owned strings (the error paths
        // now fill ctxt->lastError with strdup'd copies).
        crate::xml::globals::free_error_strings(&(*ctxt).lastError);

        // UPSTREAM-PARITY (parser SAX-compat entity registry): a document the
        // parser created internally (SAX_COMPAT_MODE docs kept so expat-style
        // SAX consumers can resolve NOENT general entities โ€” see
        // state.rs::ensure_entity_registry_dtd) is never delivered to the
        // caller, so it must be reclaimed here. Normal caller-owned documents
        // are NOT `XML_DOC_INTERNAL` and are detached by the front-ends before
        // the context is freed, so they are untouched.
        let my_doc = (*ctxt).myDoc;
        if !my_doc.is_null()
            && ((*my_doc).properties
                & (crate::abi::types::xmlDocProperties::XML_DOC_INTERNAL as c_int))
                != 0
        {
            (*ctxt).myDoc = ptr::null_mut();
            crate::xml::tree::free_doc(my_doc);
        }

        // Free the context itself.
        xmlFreeImpl(ctxt as *mut c_void);
    }
}

// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// Input buffer creation helpers
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

/// Create an `InputBuffer` from a raw memory buffer.
///
/// The bytes are copied into an owned buffer, so the caller may free `buffer`
/// after calling this function.
///
/// If `size` is negative or `buffer` is null, an empty `InputBuffer` is returned.
///
/// # Safety
///
/// - `buffer` must be valid for reads of at least `size` bytes, or NULL.
/// - If `size` is negative, `buffer` is not dereferenced.
pub(crate) unsafe fn input_from_memory(buffer: *const c_char, size: c_int) -> InputBuffer {
    let slice = if size > 0 && !buffer.is_null() {
        // SAFETY: Caller guarantees `buffer` points to at least `size` readable bytes.
        unsafe { std::slice::from_raw_parts(buffer as *const u8, size as usize) }
    } else {
        &[]
    };
    InputBuffer::from_memory(slice, None)
}

/// Create an `InputBuffer` from memory with a source URI recorded as the
/// input's filename (upstream `xmlCtxtReadMemory` sets `input->filename` from
/// the URL, which feeds the `file:line:` error prefix). Non-UTF-8 URIs are
/// dropped (documented divergence โ€” the candidate's input layer stores
/// filenames as UTF-8).
///
/// # Safety
///
/// - `buffer` must be valid for reads of at least `size` bytes, or NULL.
/// - `uri` must be a valid NUL-terminated C string, or NULL.
pub(crate) unsafe fn input_from_memory_named(
    buffer: *const c_char,
    size: c_int,
    uri: *const c_char,
) -> InputBuffer {
    let slice = if size > 0 && !buffer.is_null() {
        // SAFETY: Caller guarantees `buffer` points to at least `size` readable bytes.
        unsafe { std::slice::from_raw_parts(buffer as *const u8, size as usize) }
    } else {
        &[]
    };
    let uri_str = if !uri.is_null() {
        // SAFETY: Caller guarantees `uri` is a valid C string.
        unsafe { std::ffi::CStr::from_ptr(uri) }
            .to_str()
            .ok()
            .map(|s| s.to_string())
    } else {
        None
    };
    InputBuffer::from_memory(slice, uri_str.as_deref())
}

/// ยง16.5.2 zero-copy variant of [`input_from_memory`]: the returned
/// `InputBuffer` BORROWS the caller's `buffer` instead of copying it.
/// Only the one-call synchronous front-ends (`xmlReadMemory`, `xmlReadDoc`,
/// `xmlSAXParseMemory`, โ€ฆ) may use this โ€” they create, parse and free the
/// parser context inside one exported call, so the borrow (and the C-visible
/// `_xmlParserInput` pointers into it) never outlives the call.
///
/// # Safety
///
/// - `buffer` must be valid for reads of at least `size` bytes and must stay
///   valid until the parse consuming the returned buffer has finished.
/// - If `size` is negative or `buffer` is NULL, no borrow happens (an empty
///   owned buffer is returned).
pub(crate) unsafe fn input_from_memory_borrowed(buffer: *const c_char, size: c_int) -> InputBuffer {
    let slice = if size > 0 && !buffer.is_null() {
        // SAFETY: Caller guarantees `buffer` points to at least `size` readable bytes.
        unsafe { std::slice::from_raw_parts(buffer as *const u8, size as usize) }
    } else {
        &[]
    };
    // SAFETY: see fn docs โ€” the caller's buffer outlives the synchronous parse.
    unsafe { InputBuffer::from_memory_borrowed(slice, None) }
}

/// ยง16.5.2 zero-copy variant of [`input_from_memory_named`] โ€” see
/// [`input_from_memory_borrowed`] for the lifetime contract.
///
/// # Safety
///
/// - `buffer` must be valid for reads of at least `size` bytes and must stay
///   valid until the parse consuming the returned buffer has finished.
/// - `uri` must be a valid NUL-terminated C string, or NULL.
pub(crate) unsafe fn input_from_memory_named_borrowed(
    buffer: *const c_char,
    size: c_int,
    uri: *const c_char,
) -> InputBuffer {
    let slice = if size > 0 && !buffer.is_null() {
        // SAFETY: Caller guarantees `buffer` points to at least `size` readable bytes.
        unsafe { std::slice::from_raw_parts(buffer as *const u8, size as usize) }
    } else {
        &[]
    };
    let uri_str = if !uri.is_null() {
        // SAFETY: Caller guarantees `uri` is a valid C string.
        unsafe { std::ffi::CStr::from_ptr(uri) }
            .to_str()
            .ok()
            .map(|s| s.to_string())
    } else {
        None
    };
    // SAFETY: see fn docs โ€” the caller's buffer outlives the synchronous parse.
    unsafe { InputBuffer::from_memory_borrowed(slice, uri_str.as_deref()) }
}

/// Create an `InputBuffer` from a file path.
///
/// Returns `Ok(InputBuffer)` on success, or `Err(())` if the file cannot be
/// opened, read, or the path is not valid UTF-8.
///
/// # Safety
///
/// `filename` must be a valid null-terminated C string, or NULL (in which case
/// `Err(())` is returned).
pub(crate) unsafe fn input_from_file(filename: *const c_char) -> Result<InputBuffer, ()> {
    if filename.is_null() {
        return Err(());
    }

    // SAFETY: Caller guarantees `filename` is a valid null-terminated C string.
    let path = unsafe { CStr::from_ptr(filename) };
    let path_str = path.to_str().map_err(|_| ())?;
    // UPSTREAM-PARITY (xmlIO.c xmlFileOpen / xmlParserInputBufferCreateFilename):
    // a file:// URI opens through the plain-file path; strip the scheme and
    // any authority component so the OS open sees a local path.
    let path_str = if let Some(rest) = path_str.strip_prefix("file://") {
        match rest.find('/') {
            Some(idx) => &rest[idx..],
            None => "/",
        }
    } else if let Some(rest) = path_str.strip_prefix("file:") {
        rest
    } else {
        path_str
    };
    InputBuffer::from_file(path_str).map_err(|_| ())
}

/// No-op input close callback: upstream `xmlReaderForIO` accepts a NULL
/// close callback (xmlIO.c xmlNewIOInputStream), so `input_from_io` cannot
/// require one.
const unsafe extern "C" fn noop_input_close(_ctx: *mut c_void) -> c_int {
    0
}

/// Create an `InputBuffer` from I/O callbacks.
///
/// The callbacks are used to read all available data from the source.
/// If reading fails, an empty `InputBuffer` is returned (the error is silently
/// swallowed, matching libxml2's behaviour in some code paths).
///
/// # Safety
///
/// - `ioread` must be a valid function pointer or `None`.
/// - `ioclose` must be a valid function pointer or `None`.
/// - If callbacks are provided, `ioctx` must be a valid context pointer for them.
pub(crate) unsafe fn input_from_io(
    ioread: Option<xmlInputReadCallback>,
    ioclose: Option<xmlInputCloseCallback>,
    ioctx: *mut c_void,
) -> InputBuffer {
    let (read, close) = match ioread {
        Some(r) => (
            r,
            ioclose.unwrap_or(noop_input_close as xmlInputCloseCallback),
        ),
        None => return InputBuffer::from_memory(&[], None),
    };

    // SAFETY: The callbacks are used immediately to read all data. The caller
    // guarantees the function pointers and context are valid for the duration
    // of this call.
    match InputBuffer::from_callback(read, close, ioctx) {
        Ok(buf) => buf,
        Err(_) => {
            // UPSTREAM-PARITY (xmlIO.c/xmlParserInputBufferGrow): a read
            // callback that reports an error makes the parser raise an I/O
            // error on the first grow โ€” NOT an empty-document parse
            // (HOSTILE-CALLBACKS C4).
            InputBuffer::failed_source()
        }
    }
}

// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// Context โ†” input wiring
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

/// Set up the parser context with an input buffer.
///
/// This function:
///
/// 1. Boxes the `InputBuffer` and stores the raw pointer in `ctxt._private`
///    (keeping the data alive for the lifetime of the context).
/// 2. Allocates a `_xmlParserInput` pointing into the buffer's data.
/// 3. Sets `ctxt.input` to the new input.
/// 4. Allocates `inputTab` (initial capacity 4) and pushes the input onto it.
///
/// # Safety
///
/// - `ctxt` must be a valid, writable pointer to a `_xmlParserCtxt`.
/// - After this call, the context owns the `InputBuffer` (via `_private`).
/// - The caller must not use the `InputBuffer` directly afterwards.
#[allow(non_snake_case)]
pub(crate) unsafe fn setup_parser_input(ctxt: *mut _xmlParserCtxt, input: InputBuffer) {
    // Box the InputBuffer and leak it so _xmlParserInput pointers stay valid.
    // SAFETY: Box::into_raw gives us a raw pointer that we later reconstruct
    // in free_parser_ctxt or consume in parse_document/parse_chunk.
    let input_buf_ptr = Box::into_raw(Box::new(input));

    unsafe {
        let c = &mut *ctxt;

        // Store the leaked pointer in the side table so we can free it later
        // (ctxt._private stays application data โ€” 11.1-X).
        stash_input_buffer(ctxt, input_buf_ptr);

        // SAFETY: input_buf_ptr points to a live InputBuffer whose data Vec
        // will not move while the _xmlParserInput references it.
        let parser_input = alloc_parser_input(&*input_buf_ptr, None);
        c.input = parser_input;

        // Allocate inputTab with initial capacity of 4 pointers.
        let tab_size = 4 * size_of::<*mut _xmlParserInput>();
        // SAFETY: xmlMalloc returns uninitialised memory or NULL.
        let tab = xmlMallocImpl(tab_size) as *mut *mut _xmlParserInput;
        if tab.is_null() {
            // Allocation failure โ€” leave inputTab null, inputNr 0.
            c.inputTab = ptr::null_mut();
            c.inputMax = 0;
            c.inputNr = 0;
            return;
        }
        // Zero-initialise the table.
        ptr::write_bytes(tab, 0, 4);

        *tab = parser_input;
        c.inputTab = tab;
        c.inputMax = 4;
        c.inputNr = 1;
    }
}

// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// Parsing entry points
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

/// Parse a complete document using the internal parser.
///
/// Takes ownership of the `InputBuffer` stashed for `ctxt` (from a
/// previous `setup_parser_input` call), creates an `InputStack` and
/// `XmlParser`, and runs `parse_document`.
///
/// Returns `0` on success, `-1` on error.
///
/// # Safety
///
/// - `ctxt` must be a valid pointer to a `_xmlParserCtxt` that was set up via
///   `setup_parser_input` (or equivalent).
/// - After this call, the `InputBuffer` is consumed and the stash entry is
///   removed. The context's `input` and `inputTab` may contain dangling
///   pointers and should not be used for further parsing.
pub(crate) unsafe fn parse_document(ctxt: *mut _xmlParserCtxt) -> c_int {
    // Take ownership of the stashed InputBuffer.
    // SAFETY: The pointer was stashed by setup_parser_input via Box::into_raw.
    let input_buf = {
        let ptr = take_stashed_input_buffer(ctxt);
        if ptr.is_null() {
            // No input buffer โ€” nothing to parse.
            return -1;
        }
        // SAFETY: ptr is a valid Box<InputBuffer> from Box::into_raw.
        unsafe { Box::from_raw(ptr) }
    };

    // Move the InputBuffer into an InputStack.
    let input_stack = InputStack::new(*input_buf);
    // input_buf is consumed here.

    // SAFETY: ctxt is a valid, initialised parser context.
    let mut parser = unsafe { XmlParser::new(input_stack, ctxt) };
    parser.parse_document()
    // parser is dropped here, which drops the tokenizer and its InputStack,
    // which drops the InputBuffer.
}

/// Parse a chunk of input (push parser mode).
///
/// Each call processes the given chunk as part of the input stream. If
/// `terminate` is non-zero, the document is finalised and fully parsed.
///
/// Returns `0` on success (or if more data is expected), `-1` on error.
///
/// # Upstream contract (SP-14.3.1-3)
///
/// Upstream `xmlParseChunk` parses each chunk eagerly and fires events as
/// data becomes available; a non-terminating call on a COMPLETE document
/// therefore delivers everything, which consumers such as PHP ext/xml's
/// expat-compat layer rely on (`xml_parse()` defaults `isFinal = false` โ€”
/// bug25666/xml009/xml010). The candidate accumulates the input and parses
/// it as a whole. To keep both models: on every non-terminating call the
/// accumulated input is probed with a silent parse (no handler delivery, no
/// diagnostics โ€” SP-14.3.1-3); when the probe reaches a clean document end
/// the buffer is parsed again with full delivery exactly once, and later
/// calls (epilog only) merely buffer until the terminating call, which runs
/// a silent probe plus, if the document is no longer clean (trailing junk),
/// a diagnostics-only pass so late errors still surface.
///
/// # Safety
///
/// - `ctxt` must be a valid pointer to a `_xmlParserCtxt`.
/// - `chunk` must be a valid pointer to at least `size` readable bytes, or NULL
///   (in which case `size` should be 0).
pub(crate) unsafe fn parse_chunk(
    ctxt: *mut _xmlParserCtxt,
    chunk: *const c_char,
    size: c_int,
    terminate: c_int,
) -> c_int {
    // Build a byte slice from the chunk.
    let chunk_slice = if size > 0 && !chunk.is_null() {
        // SAFETY: Caller guarantees the chunk pointer is valid for `size` bytes.
        unsafe { std::slice::from_raw_parts(chunk as *const u8, size as usize) }
    } else {
        &[]
    };

    // UPSTREAM-PARITY (parser.c xmlParseChunk): a context whose parse was
    // stopped (disableSAX != 0 โ€” xmlStopParser's disableSAX = 2, or the
    // disableSAX = 1 set by any non-recovery fatal error) refuses further
    // chunks and reports the recorded error (SP-14.3.1-4, bug71592: PHP's
    // expat-compat external-entity-ref handler returns FALSE โ†’ xmlStopParser
    // + errNo = XML_ERROR_EXTERNAL_ENTITY_HANDLING; every later xmlParseChunk
    // must return that error instead of parsing the remainder โ€” and upstream
    // gates on disableSAX != 0, so a fatal reported on one call means every
    // later call returns errNo without parsing or firing anything again).
    if unsafe { (*ctxt).disableSAX } != 0 {
        return unsafe { (*ctxt).errNo };
    }

    // UPSTREAM-PARITY (parser.c xmlParseTryOrFinish `case XML_PARSER_EOF`):
    // a context that finished a complete document stays at XML_PARSER_EOF,
    // so every later xmlParseChunk parses nothing and reports the previous
    // outcome (0 when well-formed). gh12254 calls xml_parse_into_struct twice
    // on the same parser; the second call must not fire the element events
    // again (SP-14.3.1-7). Incomplete parses never set instate = EOF, so the
    // multi-call incremental flows are unaffected.
    if unsafe { (*ctxt).instate } == crate::abi::types::xmlParserInputState::XML_PARSER_EOF as c_int
    {
        if unsafe { (*ctxt).wellFormed } == 0 {
            return unsafe { (*ctxt).errNo };
        }
        return 0;
    }

    // Take ownership of the stashed InputBuffer (the base accumulated so
    // far โ€” the constructor's initial chunk is stashed by
    // setup_parser_input), or start empty.
    let mut base: InputBuffer = {
        let ptr = take_stashed_input_buffer(ctxt);
        if ptr.is_null() {
            InputBuffer::for_push(&[], None)
        } else {
            // SAFETY: ptr is a valid Box<InputBuffer> from Box::into_raw.
            unsafe { *Box::from_raw(ptr) }
        }
    };

    // UPSTREAM-PARITY (parser.c xmlParseChunk, `end_in_lf`): a NON-final
    // chunk whose last byte is `\r` has that byte withheld from this call's
    // parse โ€” upstream pushes size-1 bytes, runs xmlParseTryOrFinish, and
    // re-pushes the `\r` afterwards (so the byte sits unparsed at the buffer
    // end). The replay model parses the whole accumulated buffer, so it
    // withholds the byte here and prepends the previously withheld one; the
    // byte order seen by each parse then matches upstream exactly. The
    // terminating call never withholds (upstream's condition requires
    // !terminate), so a document ending in `\r` still processes it.
    //
    // A withheld `\r` is restored into the candidate's replay input only
    // when this call gives the replay parser new bytes to work with (a
    // nonempty chunk, so the `\r`s become parseable input followed by the
    // new bytes) or terminates (the `\r`s are final input). This does NOT
    // claim the replay parser's progress equals xmlParseTryOrFinish: upstream
    // may still leave the CR unread even with more bytes available (char data
    // below XML_PARSER_BIG_BUFFER_SIZE only advances when
    // xmlParseLookupCharData finds a `<`/`&`). A ZERO-LENGTH non-final call
    // must leave them parked: upstream's xmlParseTryOrFinish finds no
    // `<`/`&` for a lone `\r` and consumes nothing โ€” flushing them here
    // would deliver the EOL one zero-length call too early. Emulating the
    // remaining progress rules is deliberately NOT attempted here; the
    // persistent engine deletes this scaffolding.
    // The withheld bytes are COUNTED (pending_crs), not flagged: consecutive
    // CRs must not collapse (upstream keeps every one in the buffer).
    let mut deferred_crs: usize = 0;
    let mut slice = chunk_slice;
    if terminate == 0 && !slice.is_empty() && slice[slice.len() - 1] == b'\r' {
        deferred_crs = 1;
        slice = &slice[..slice.len() - 1];
    }
    let had_pending_crs = push_state(ctxt).pending_crs;
    let restore_crs = had_pending_crs > 0 && (!slice.is_empty() || terminate != 0);
    // The new trailing `\r` (if any) stays parked; the old ones stay parked
    // when this call could not consume them.
    push_state(ctxt).pending_crs = if restore_crs {
        deferred_crs
    } else {
        had_pending_crs + deferred_crs
    };
    if restore_crs {
        for _ in 0..had_pending_crs {
            base.push_bytes(b"\r");
        }
    }

    // Append the chunk to the accumulated input (upstream xmlParseChunk
    // grows ctxt->input's base with each chunk; the candidate parses the
    // whole accumulated stream). `terminate` is what lets the source decoder
    // tell a truncated encoding unit at the end of the stream (an error) from
    // one that more input may still complete (a suspension).
    base.push_bytes_ex(slice, terminate != 0);

    // UPSTREAM-PARITY (parser.c xmlParseTryOrFinish `case XML_PARSER_START`):
    // a NON-final call whose source input cannot decide its encoding yet
    // (`avail < 4`; the EBCDIC signature `4C 6F A7 94` waits for 200 bytes)
    // parks โ€” nothing is parsed, no event fires, no error is raised. The held
    // source bytes stay buffered for the call that brings enough (or the
    // terminating call, which always detects).
    if base.source_parked() {
        stash_input_buffer(ctxt, Box::into_raw(Box::new(base)));
        return 0;
    }

    // UPSTREAM-PARITY (parser.c xmlParseChunk): when the source encoder finds
    // a definite invalid unit, xmlParserInputBufferPush fails and
    // xmlParseChunk reports xmlCtxtErrIO's XML_ERR_INVALID_ENCODING (81)
    // immediately โ€” before xmlParseTryOrFinish runs, so no event fires on
    // this call and every later call returns the recorded errNo.
    if base.source_encoding_error() {
        let (line, col) = base.end_line_col();
        raise_invalid_encoding(ctxt, line as c_int, col as c_int);
        return unsafe { (*ctxt).errNo };
    }
    // An incomplete unit left pending by THIS terminating call keeps the
    // bytes unread: upstream only turns it into an error in
    // xmlParserCheckEOF, and only if the document itself parsed cleanly.
    let source_truncated = base.source_truncated();
    // Error position for the encoder flush: the end of the decoded stream
    // (computed here because `base` is consumed by the final parse).
    let (end_line, end_col) = base.end_line_col();

    // Capture the consumer-set wellFormed BEFORE the first parse mutates it
    // (PHP expat-compat zeroes it at create; see PushState docs). Restored
    // before every engine construction below.
    {
        let mut st = push_state(ctxt);
        if !st.captured {
            st.captured = true;
            st.start_well_formed = unsafe { (*ctxt).wellFormed };
        }
    }

    if terminate == 0 {
        // Non-final call. Upstream parses each chunk eagerly: events fire as
        // soon as their construct completed, even when the document is not
        // finished (SP-14.3.1-6 โ€” the XML_OPTION_PARSE_HUGE multi-call flow
        // delivers CONTAINER/A/A/SECOND on the first call and only the
        // container's end on the final call). The candidate re-parses the
        // whole accumulated input, so each call probes silently first and
        // then runs a delivery parse that suppresses the events at or below
        // `delivered_bytes` (already fired by earlier partial/complete
        // parses).
        //
        // The probe runs on EVERY non-final call โ€” also after a document
        // already completed and delivered. The oracle rests at
        // XML_PARSER_EPILOG once a document completed (xmlParseTryOrFinish
        // only reaches XML_PARSER_EOF at the terminating call or at an
        // epilog fatal), so a later call that brings trailing junk reports
        // "Extra content at the end of the document" on THAT call, and a
        // later call bringing epilog comments/PIs delivers them. Buffering
        // such input silently until the terminating call would both lose the
        // epilog events and defer the error.
        //
        // Silent completeness probe: tells us whether the accumulated
        // input forms a complete document (clean end or a definitive
        // failure on a complete token), paused at a clean construct
        // boundary (more data may arrive), or is truncated mid-construct.
        let probe_buf = base.duplicate_for_reparse();
        let input_stack = InputStack::new(probe_buf);
        // SAFETY: ctxt is a valid, initialised parser context.
        restore_start_well_formed(ctxt);
        let mut probe = unsafe { XmlParser::new_with_mode(input_stack, ctxt, true) };
        let rc = probe.parse_document();
        let paused = probe.is_paused();
        let truncated = probe.was_truncated_abort();
        if rc == 0 || (rc != 0 && !paused && !truncated) {
            // The accumulated input parsed through to its end: either a
            // clean document end or a failure on a COMPLETE token at the
            // end of the input (e.g. an end-tag mismatch closing the
            // root, or trailing junk in the epilog). Deliver everything
            // not yet delivered, exactly once (PHP xml_parse defaults
            // isFinal=false โ€” bug25666/xml009); errors surface live from
            // this parse.
            let delivered = push_state(ctxt).delivered_bytes;
            let delivery_buf = base.duplicate_for_reparse();
            let input_stack = InputStack::new(delivery_buf);
            // SAFETY: ctxt is a valid, initialised parser context.
            restore_start_well_formed(ctxt);
            let mut parser = unsafe { XmlParser::new_with_resume(input_stack, ctxt, delivered) };
            parser.parse_document();
            // UPSTREAM-PARITY (parser.c xmlParseTryOrFinish / xmlParseChunk):
            // a document parsed to its end on a NON-final call rests at
            // XML_PARSER_EPILOG โ€” the context only reaches XML_PARSER_EOF at
            // the terminating call (or an epilog fatal). Without this reset
            // the next chunk would be swallowed by the EOF gate above
            // instead of surfacing its epilog content/errors.
            if unsafe { (*ctxt).wellFormed } != 0
                && unsafe { (*ctxt).instate }
                    == crate::abi::types::xmlParserInputState::XML_PARSER_EOF as c_int
            {
                unsafe {
                    (*ctxt).instate =
                        crate::abi::types::xmlParserInputState::XML_PARSER_EPILOG as c_int;
                }
            }
            // UPSTREAM-PARITY (parser.c xmlParseChunk): the delivery
            // parse's outcome is reported on the non-final call โ€” the
            // recorded error code once the document is no longer
            // well-formed (a fatal error, or a stop from an
            // entity-resolving SAX handler that leaves errNo set and
            // wellFormed = 0), 0 otherwise. PHP's expat-compat XML_Parse
            // maps a non-zero return to FALSE, so xml_parse() returns
            // FALSE for bug71592 exactly like the oracle.
            let failed = unsafe { (*ctxt).wellFormed } == 0;
            let err = unsafe { (*ctxt).errNo };
            push_state(ctxt).delivered_bytes = base.len();
            // More data may still arrive: stash the accumulated buffer
            // for the next xmlParseChunk call (upstream keeps the data in
            // ctxt->input even after a failed non-final call).
            stash_input_buffer(ctxt, Box::into_raw(Box::new(base)));
            return if failed { err } else { 0 };
        } else if paused && !truncated {
            // Incomplete document whose constructs up to the end of the
            // available input are all complete: deliver them eagerly,
            // exactly like upstream's per-chunk parsing, and record how
            // far the delivery got so later calls only fire the new tail.
            let delivered = push_state(ctxt).delivered_bytes;
            let delivery_buf = base.duplicate_for_reparse();
            let input_stack = InputStack::new(delivery_buf);
            // SAFETY: ctxt is a valid, initialised parser context.
            restore_start_well_formed(ctxt);
            let mut parser =
                unsafe { XmlParser::new_with_partial_resume(input_stack, ctxt, delivered) };
            parser.parse_document(); // pauses at the end of the input
            push_state(ctxt).delivered_bytes = base.len();
            stash_input_buffer(ctxt, Box::into_raw(Box::new(base)));
            return 0;
        }
        // Truncated mid-construct: every construct completed BEFORE the
        // truncation point is still delivered eagerly (upstream's state
        // machine scans the completed prefix and only stops mid-construct โ€”
        // test_events sees `start element` as soon as `<element ...>`
        // completed, even when the feed ends with `</element` still
        // unterminated). The delivery boundary advances to the START of the
        // truncated construct: the construct itself re-scans from scratch on
        // the next call (where it may complete and fire its own events once).
        let delivered = push_state(ctxt).delivered_bytes;
        let delivery_buf = base.duplicate_for_reparse();
        let input_stack = InputStack::new(delivery_buf);
        // SAFETY: ctxt is a valid, initialised parser context.
        restore_start_well_formed(ctxt);
        let mut parser =
            unsafe { XmlParser::new_with_partial_resume(input_stack, ctxt, delivered) };
        parser.parse_document(); // stops at the truncated construct
        let boundary = parser.truncated_offset().unwrap_or(base.len());
        if boundary > delivered {
            push_state(ctxt).delivered_bytes = boundary;
        }
        stash_input_buffer(ctxt, Box::into_raw(Box::new(base)));
        0
    } else {
        // Terminating call: parse everything accumulated so far. Earlier
        // non-final calls already delivered a prefix (and every event of a
        // completed document); the resume parse suppresses the events at or
        // below `delivered_bytes` and surfaces โ€” exactly once โ€” whatever the
        // tail adds: epilog events, trailing-junk diagnostics (a completed
        // document followed by extra content), or the truncation errors of
        // an unfinished construct (upstream xmlParseChunk's terminating
        // checks: XML_ERR_TAG_NOT_FINISHED for open elements, DOCUMENT_END
        // for epilog junk).
        let delivered = push_state(ctxt).delivered_bytes;
        let input_stack = InputStack::new(base);
        // SAFETY: ctxt is a valid, initialised parser context.
        restore_start_well_formed(ctxt);
        let mut parser = unsafe { XmlParser::new_with_resume(input_stack, ctxt, delivered) };
        let rc = parser.parse_document();
        free_push_state(ctxt);
        // UPSTREAM-PARITY (parser.c xmlParseChunk): report the recorded
        // error code when the document ended not well-formed.
        if unsafe { (*ctxt).wellFormed } == 0 {
            return unsafe { (*ctxt).errNo };
        }
        // UPSTREAM-PARITY (parser.c xmlParserCheckEOF): the terminating call
        // flushes the encoder (`xmlCharEncInput(..., flush = 1)`). A source
        // unit still incomplete at that point is XML_ERR_INVALID_ENCODING.
        // The check only runs when the document itself parsed cleanly โ€”
        // xmlParserCheckEOF returns early once errNo is set, so a
        // malformed-document error (e.g. tag not finished) wins.
        if source_truncated && unsafe { (*ctxt).errNo } == 0 {
            raise_invalid_encoding(ctxt, end_line as c_int, end_col as c_int);
            return unsafe { (*ctxt).errNo };
        }
        rc
        // parser is dropped here.
    }
}

/// Raise upstream `xmlCtxtErrIO(ctxt, XML_ERR_INVALID_ENCODING)` โ€” the error
/// the input-buffer encoder produces, either from
/// `xmlParserInputBufferPush` (a definite invalid unit) or from
/// `xmlParserCheckEOF`'s flush of a still-incomplete unit
/// (`xmlCharEncInput(..., flush = 1)` -> `XML_ENC_ERR_INPUT` ->
/// `XML_ERR_INVALID_ENCODING`, domain `XML_FROM_IO`, level fatal, message
/// "Invalid bytes in character encoding").
///
/// `xmlCtxtErrIO` routes through `xmlCtxtVErr`, so this mirrors the parser's
/// own fatal-error bookkeeping: `errNo` = code, `wellFormed` = 0,
/// `disableSAX` = 1 (every later `xmlParseChunk` returns the recorded errNo),
/// and the error is delivered through the context's structured/generic error
/// channel.
///
/// # Safety
///
/// `ctxt` must be a valid, initialized `_xmlParserCtxt`.
unsafe fn raise_invalid_encoding(ctxt: *mut _xmlParserCtxt, line: c_int, col: c_int) {
    let code = crate::abi::types::XML_ERR_INVALID_ENCODING;
    // SAFETY: caller guarantees ctxt is valid and initialized.
    unsafe {
        (*ctxt).errNo = code;
        (*ctxt).wellFormed = 0;
        (*ctxt).disableSAX = 1;
        (*ctxt).nbErrors = (*ctxt).nbErrors.wrapping_add(1);
        let delivery = crate::xml::errors::parser_delivery(ctxt);
        let msg = c"Invalid bytes in character encoding\n";
        crate::xml::errors::raise_error_streamed(
            ctxt as *mut c_void,
            crate::abi::types::XML_FROM_IO,
            code,
            crate::abi::types::xmlErrorLevel::XML_ERR_FATAL as c_int,
            ptr::null(),
            line,
            col,
            ptr::null(),
            ptr::null(),
            ptr::null(),
            0,
            msg.as_ptr(),
            None,
            None,
            delivery,
            None,
        );
    }
}

// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// C ABI struct allocation / deallocation
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

/// Allocate and initialise a C ABI `_xmlParserInput` from an `InputBuffer`.
///
/// The returned `_xmlParserInput` contains raw pointers (`base`, `cur`, `end`)
/// that point into the `InputBuffer`'s internal data storage. The caller must
/// ensure the `InputBuffer` outlives the returned struct.
///
/// If `filename` is `Some`, it is used as the input's filename; otherwise the
/// filename from the `InputBuffer` is used.
///
/// # Safety
///
/// - `input` must remain alive and unmoved for the lifetime of the returned
///   `_xmlParserInput`.
/// - The returned pointer must be freed with `free_parser_input` or `xmlFree`.
pub(crate) unsafe fn alloc_parser_input(
    input: &InputBuffer,
    filename: Option<&str>,
) -> *mut _xmlParserInput {
    // SAFETY: xmlMallocZero returns zero-initialised memory or NULL.
    let ptr = unsafe { xmlMallocZero(size_of::<_xmlParserInput>()) } as *mut _xmlParserInput;
    if ptr.is_null() {
        return ptr::null_mut();
    }

    unsafe {
        let pi = &mut *ptr;

        // Use the InputBuffer's populate method to fill in the core fields
        // (base, cur, end, line, col, length, consumed). The Rust-side
        // filename is NOT borrowed here: `populate_parser_input` would point
        // at a Rust String that the parser later moves/drops (a dangling C
        // pointer โ€” the observed heap-reuse garbage). Instead the filename is
        // duplicated into memory owned by the _xmlParserInput itself
        // (upstream keeps the filename on the input struct and frees it with
        // xmlFreeInputStream).
        input.populate_parser_input_without_filename(pi);

        // Own a C copy of the filename: the explicit override wins, else the
        // buffer's own filename. NOTE: `xml_strndup` (not `xml_strdup`) is
        // used because the source is a Rust `String` whose `as_ptr()` is NOT
        // NUL-terminated โ€” `xml_strdup` would scan past the end of the
        // allocation (heap-buffer-overflow) and copy garbage, which the
        // TREE-001 probe observed as `URL=t.xml<V>`. The explicit length
        // makes the copy exact and NUL-terminates it.
        let owned = filename
            .map(|s| s.to_string())
            .or_else(|| input.filename().map(|s| s.to_string()));
        if let Some(fname) = owned {
            pi.filename = crate::xml::string::xml_strndup(
                fname.as_ptr() as *const crate::abi::types::xmlChar,
                fname.len(),
            ) as *const c_char;
        }

        // Set remaining fields that populate_parser_input does not touch.
        pi.buf = ptr::null_mut();
        pi.directory = ptr::null();
        pi.free = None;
        pi.encoding = ptr::null();
        pi.version = ptr::null();
        pi.flags = 0;
        pi.id = 0;
        pi.parentConsumed = 0;
        pi.entity = ptr::null_mut();
    }

    ptr
}

/// Allocate and initialise a C ABI `_xmlParserInputBuffer`.
///
/// The returned buffer is zero-initialised with all fields set to NULL/0.
///
/// # Safety
///
/// The returned pointer must be freed with `free_parser_input_buffer` or
/// `xmlFree` when no longer needed.
pub(crate) unsafe fn alloc_parser_input_buffer() -> *mut _xmlParserInputBuffer {
    // SAFETY: xmlMallocZero returns zero-initialised memory or NULL.
    unsafe { xmlMallocZero(size_of::<_xmlParserInputBuffer>()) as *mut _xmlParserInputBuffer }
}

/// Owned content of `_xmlParserInputBuffer`s built by
/// `xmlParserInputBufferCreateMem`. Upstream (xmlIO.c) copies the memory
/// into the buffer's internal `buffer` xmlBuf and leaves `readcallback`
/// NULL โ€” the parser pulls bytes from the xmlBuf. The candidate's
/// `_xmlParserInputBuffer` is a field shim (no live xmlBuf object), so the
/// bytes live here, keyed by the buffer address, and are consumed by the
/// text-reader setup (`reader_from_input`) when no read callback is set.
static PARSER_INPUT_BUF_CONTENT_STASH: once_cell::sync::Lazy<
    parking_lot::Mutex<HashMap<usize, Vec<u8>>>,
> = once_cell::sync::Lazy::new(Default::default);

/// Self-closed (`<a/>`) element nodes of the CURRENT reader parse, keyed by
/// document. The whole-tree XML reader rebuilds traversal events from the
/// parsed tree, but the tree cannot tell `<a/>` from `<a></a>` โ€” upstream's
/// streaming reader knows from the SAX stream (an explicitly closed element
/// fires END_ELEMENT, a self-closed one does not). While
/// `ctxt->parseMode == XML_PARSE_READER`, the parser records every
/// self-closed element node here under its document; the reader's event
/// builder consumes the entries as it walks (reader/mod.rs build_events) and
/// drops a document's whole entry set when its parse fails, so stale markers
/// cannot linger across parses (keyed by doc โ€” parses run on many threads).
static SELF_CLOSED_NODES: once_cell::sync::Lazy<
    parking_lot::Mutex<HashMap<usize, HashSet<usize>>>,
> = once_cell::sync::Lazy::new(Default::default);

/// Record `node` (in `doc`) as parsed from a self-closed `<a/>` start tag.
pub(crate) fn mark_self_closed(
    doc: *mut crate::abi::structs::_xmlDoc,
    node: *mut crate::abi::structs::_xmlNode,
) {
    if doc.is_null() || node.is_null() {
        return;
    }
    SELF_CLOSED_NODES
        .lock()
        .entry(doc as usize)
        .or_default()
        .insert(node as usize);
}

/// Whether `node` (in `doc`) was parsed from a self-closed `<a/>` start tag
/// (consumes the marker โ€” the reader's event walk visits each element once).
pub(crate) fn take_self_closed(
    doc: *mut crate::abi::structs::_xmlDoc,
    node: *mut crate::abi::structs::_xmlNode,
) -> bool {
    if doc.is_null() || node.is_null() {
        return false;
    }
    SELF_CLOSED_NODES
        .lock()
        .get_mut(&(doc as usize))
        .is_some_and(|set| set.remove(&(node as usize)))
}

/// Drop every self-closed marker recorded for `doc` (failed reader parse).
pub(crate) fn drop_self_closed(doc: *mut crate::abi::structs::_xmlDoc) {
    if doc.is_null() {
        return;
    }
    SELF_CLOSED_NODES.lock().remove(&(doc as usize));
}

/// Stash the owned byte content of a memory parser input buffer.
pub(crate) fn stash_input_buf_content(buf: *mut _xmlParserInputBuffer, data: Vec<u8>) {
    PARSER_INPUT_BUF_CONTENT_STASH
        .lock()
        .insert(buf as usize, data);
}

/// Take (remove) the stashed byte content of a memory parser input buffer.
pub(crate) fn take_input_buf_content(buf: *mut _xmlParserInputBuffer) -> Option<Vec<u8>> {
    PARSER_INPUT_BUF_CONTENT_STASH
        .lock()
        .remove(&(buf as usize))
}

/// Allocate a memory parser input buffer holding a COPY of `buffer[..size]`
/// (upstream xmlIO.c xmlParserInputBufferCreateMem: content in the internal
/// buffer, `readcallback` NULL).
///
/// # Safety
///
/// - `buffer` must be valid for reads of at least `size` bytes.
pub(crate) unsafe fn alloc_parser_input_buffer_with_mem(
    buffer: *const c_char,
    size: c_int,
) -> *mut _xmlParserInputBuffer {
    // SAFETY: caller guarantees the slice is readable.
    let bytes = unsafe { core::slice::from_raw_parts(buffer as *const u8, size as usize) }.to_vec();
    let buf = unsafe { alloc_parser_input_buffer() };
    if !buf.is_null() {
        stash_input_buf_content(buf, bytes);
    }
    buf
}

/// Free a C ABI `_xmlParserInput`.
///
/// Mirrors upstream `xmlFreeInputStream` (parserInternals.c): the
/// deallocation callback, when set, takes full ownership of the input
/// (it frees the struct itself); otherwise the owned filename, directory
/// and buffer are freed before the struct.
///
/// # Safety
///
/// `input` must be a valid pointer returned by `alloc_parser_input`,
/// `xmlNewInputStream`, `xmlNewInputFrom*` or `xmlMalloc`, or NULL (in
/// which case this is a no-op).
pub(crate) unsafe fn free_parser_input(input: *mut _xmlParserInput) {
    if input.is_null() {
        return;
    }
    // SAFETY: The filename is an owned xmlMalloc'd copy made by
    // alloc_parser_input (upstream frees input->filename with the input).
    // The deallocation callback (upstream input->free) is invoked first and
    // owns the whole input when set.
    if let Some(free_cb) = unsafe { (*input).free } {
        // SAFETY: The callback contract matches upstream xmlFreeInputStream:
        // it frees the input (and its buffer) itself.
        unsafe { free_cb(input as *mut c_char) };
        return;
    }
    unsafe {
        if !(*input).filename.is_null() {
            crate::abi::allocator::xmlFreeImpl((*input).filename as *mut c_void);
        }
        if !(*input).directory.is_null() {
            crate::abi::allocator::xmlFreeImpl((*input).directory as *mut c_void);
        }
        if !(*input).buf.is_null() {
            // The input owns its buffer (xmlNewInputFrom* family and the
            // xmlLoadExternalEntity parser_input_from_buf path).
            crate::xml::io::input_buffer_free((*input).buf);
        }
        // SAFETY: The pointer was allocated via xmlMalloc (or xmlMallocZero).
        xmlFreeImpl(input as *mut c_void);
    }
}

/// Free a C ABI `_xmlParserInputBuffer`.
///
/// This frees only the struct itself โ€” any resources referenced by its
/// callbacks or buffers must be managed separately.
///
/// # Safety
///
/// `buf` must be a valid pointer returned by `alloc_parser_input_buffer` or
/// `xmlMalloc`, or NULL (in which case this is a no-op).
pub(crate) unsafe fn free_parser_input_buffer(buf: *mut _xmlParserInputBuffer) {
    if buf.is_null() {
        return;
    }
    // Drop any stashed memory content (xmlParserInputBufferCreateMem).
    take_input_buf_content(buf);
    // SAFETY: The pointer was allocated via xmlMalloc (or xmlMallocZero).
    unsafe { xmlFreeImpl(buf as *mut c_void) };
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Push-parser chunk accumulation: xmlParseChunk-style feeds accumulate
    /// into the stashed input and the terminating call parses the whole
    /// stream (parse4.c โ€” Phase-12 EXTERNAL-CONSUMERS court).
    ///
    /// # Safety
    ///
    /// - `ctxt` is created and freed exactly once; `myDoc` is freed exactly
    ///   once.
    #[test]
    fn test_push_chunk_accumulates() {
        unsafe {
            let ctxt = create_parser_ctxt();
            assert!(!ctxt.is_null());
            // initial chunk (xmlCreatePushParserCtxt feeds the first bytes
            // through setup_parser_input)
            let c0 = b"<doc";
            let input = InputBuffer::from_memory(c0, None);
            setup_parser_input(ctxt, input);
            assert_eq!(parse_chunk(ctxt, c"/>\n".as_ptr(), 3, 0), 0);
            // terminating call with no new data parses the accumulated input
            assert_eq!(parse_chunk(ctxt, ptr::null(), 0, 1), 0);
            let doc = (*ctxt).myDoc;
            assert!(!doc.is_null());
            let root = (*doc).children;
            assert!(!root.is_null());
            assert_eq!(crate::xml::string::xmlstr_to_bytes((*root).name), b"doc");
            crate::xml::tree::free_doc(doc);
            free_parser_ctxt(ctxt);
        }
    }
}