makiri 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/conformance.yml +22 -0
  3. data/.github/workflows/libfuzzer.yml +83 -0
  4. data/.github/workflows/release.yml +12 -7
  5. data/.github/workflows/security.yml +88 -3
  6. data/.github/workflows/valgrind.yml +135 -0
  7. data/CHANGELOG.md +152 -15
  8. data/README.md +183 -13
  9. data/Rakefile +294 -7
  10. data/ext/makiri/bridge/bridge.h +28 -0
  11. data/ext/makiri/bridge/ruby_string.c +282 -12
  12. data/ext/makiri/core/mkr_alloc.c +40 -3
  13. data/ext/makiri/core/mkr_alloc.h +28 -5
  14. data/ext/makiri/core/mkr_buf.c +47 -3
  15. data/ext/makiri/core/mkr_buf.h +112 -3
  16. data/ext/makiri/core/mkr_core.c +143 -0
  17. data/ext/makiri/core/mkr_core.h +11 -2
  18. data/ext/makiri/core/mkr_hash.h +1 -1
  19. data/ext/makiri/core/mkr_span.h +186 -0
  20. data/ext/makiri/core/mkr_text.h +8 -8
  21. data/ext/makiri/core/mkr_utf8.c +101 -0
  22. data/ext/makiri/core/mkr_utf8.h +88 -0
  23. data/ext/makiri/extconf.rb +123 -10
  24. data/ext/makiri/fuzz/Makefile +95 -0
  25. data/ext/makiri/fuzz/check_fuzzer.cc +4 -0
  26. data/ext/makiri/fuzz/xml_fuzz.c +24 -0
  27. data/ext/makiri/fuzz/xpath_fuzz.c +109 -0
  28. data/ext/makiri/glue/glue.h +55 -11
  29. data/ext/makiri/glue/ruby_doc.c +129 -59
  30. data/ext/makiri/glue/ruby_html_css.c +292 -0
  31. data/ext/makiri/glue/{ruby_mutate.c → ruby_html_mutate.c} +248 -52
  32. data/ext/makiri/glue/ruby_html_node.c +859 -0
  33. data/ext/makiri/glue/ruby_html_serialize.c +154 -0
  34. data/ext/makiri/glue/ruby_node.c +74 -729
  35. data/ext/makiri/glue/ruby_node_set.c +167 -32
  36. data/ext/makiri/glue/ruby_xml.c +602 -0
  37. data/ext/makiri/glue/ruby_xml_node.c +1373 -0
  38. data/ext/makiri/glue/ruby_xpath.c +63 -30
  39. data/ext/makiri/glue/ruby_xpath.h +19 -0
  40. data/ext/makiri/lexbor_compat/compat.h +42 -9
  41. data/ext/makiri/lexbor_compat/compat_internal.h +1 -1
  42. data/ext/makiri/lexbor_compat/dom_index.c +2 -2
  43. data/ext/makiri/lexbor_compat/post_parse.c +100 -10
  44. data/ext/makiri/lexbor_compat/source_loc.c +15 -13
  45. data/ext/makiri/lexbor_compat/text_index.c +14 -8
  46. data/ext/makiri/lexbor_compat/utf8_input.c +19 -33
  47. data/ext/makiri/makiri.c +184 -6
  48. data/ext/makiri/makiri.h +43 -2
  49. data/ext/makiri/xml/mkr_xml.h +125 -0
  50. data/ext/makiri/xml/mkr_xml_chars.c +195 -0
  51. data/ext/makiri/xml/mkr_xml_index.c +169 -0
  52. data/ext/makiri/xml/mkr_xml_index.h +48 -0
  53. data/ext/makiri/xml/mkr_xml_mutate.c +817 -0
  54. data/ext/makiri/xml/mkr_xml_mutate.h +139 -0
  55. data/ext/makiri/xml/mkr_xml_node.c +399 -0
  56. data/ext/makiri/xml/mkr_xml_node.h +184 -0
  57. data/ext/makiri/xml/mkr_xml_tree.c +1515 -0
  58. data/ext/makiri/xpath/mkr_css.c +1023 -0
  59. data/ext/makiri/xpath/mkr_css.h +65 -0
  60. data/ext/makiri/xpath/mkr_xpath.c +96 -32
  61. data/ext/makiri/xpath/mkr_xpath.h +109 -4
  62. data/ext/makiri/xpath/mkr_xpath_engine_html.c +17 -0
  63. data/ext/makiri/xpath/mkr_xpath_engine_xml.c +12 -0
  64. data/ext/makiri/xpath/{mkr_xpath_eval.c → mkr_xpath_eval_body.h} +551 -241
  65. data/ext/makiri/xpath/{mkr_xpath_funcs.c → mkr_xpath_funcs_body.h} +318 -276
  66. data/ext/makiri/xpath/mkr_xpath_internal.h +177 -206
  67. data/ext/makiri/xpath/mkr_xpath_lex.c +95 -125
  68. data/ext/makiri/xpath/mkr_xpath_node_access_html.h +138 -0
  69. data/ext/makiri/xpath/mkr_xpath_node_access_xml.h +145 -0
  70. data/ext/makiri/xpath/mkr_xpath_number.c +109 -0
  71. data/ext/makiri/xpath/mkr_xpath_parse.c +83 -94
  72. data/ext/makiri/xpath/mkr_xpath_prelude_html.h +30 -0
  73. data/ext/makiri/xpath/mkr_xpath_prelude_xml.h +28 -0
  74. data/ext/makiri/xpath/mkr_xpath_shared.c +609 -0
  75. data/ext/makiri/xpath/mkr_xpath_value_body.h +801 -0
  76. data/ext/makiri/xpath/mkr_xpath_xml_selftest.c +76 -0
  77. data/lib/makiri/{attribute.rb → attr.rb} +7 -3
  78. data/lib/makiri/cdata_section.rb +19 -0
  79. data/lib/makiri/comment.rb +10 -0
  80. data/lib/makiri/compat_aliases.rb +30 -0
  81. data/lib/makiri/document.rb +9 -73
  82. data/lib/makiri/document_fragment.rb +14 -9
  83. data/lib/makiri/element.rb +4 -4
  84. data/lib/makiri/html/document.rb +106 -0
  85. data/lib/makiri/html/node_methods.rb +19 -0
  86. data/lib/makiri/html.rb +12 -0
  87. data/lib/makiri/node.rb +58 -15
  88. data/lib/makiri/node_set.rb +8 -0
  89. data/lib/makiri/processing_instruction.rb +10 -0
  90. data/lib/makiri/text.rb +1 -1
  91. data/lib/makiri/version.rb +1 -1
  92. data/lib/makiri/xml/builder.rb +263 -0
  93. data/lib/makiri/xml/document.rb +24 -0
  94. data/lib/makiri/xml/node_methods.rb +84 -0
  95. data/lib/makiri/xml.rb +10 -0
  96. data/lib/makiri/xpath_context.rb +1 -1
  97. data/lib/makiri.rb +24 -5
  98. data/script/build_native_gem.rb +2 -2
  99. data/script/check_alloc_failures.rb +266 -0
  100. data/script/check_c_safety.rb +77 -2
  101. data/script/check_c_safety_allowlist.yml +102 -0
  102. data/script/check_leaks.rb +64 -0
  103. data/script/leaks_harness.rb +64 -0
  104. data/vendor/lexbor/CMakeLists.txt +6 -0
  105. data/vendor/lexbor/README.md +12 -0
  106. data/vendor/lexbor/config.cmake +1 -1
  107. data/vendor/lexbor/source/lexbor/core/base.h +1 -1
  108. data/vendor/lexbor/source/lexbor/core/config.cmake +9 -1
  109. data/vendor/lexbor/source/lexbor/css/selectors/pseudo_state.c +2 -3
  110. data/vendor/lexbor/source/lexbor/css/selectors/state.c +3 -0
  111. data/vendor/lexbor/source/lexbor/dom/interfaces/element.c +21 -0
  112. data/vendor/lexbor/source/lexbor/dom/interfaces/element.h +5 -0
  113. data/vendor/lexbor/source/lexbor/encoding/decode.c +33 -4
  114. data/vendor/lexbor/source/lexbor/html/base.h +1 -1
  115. data/vendor/lexbor/source/lexbor/html/interfaces/select_element.c +4 -0
  116. data/vendor/lexbor/source/lexbor/html/serialize.c +545 -41
  117. data/vendor/lexbor/source/lexbor/html/serialize.h +2 -1
  118. data/vendor/lexbor/source/lexbor/html/tokenizer.h +2 -2
  119. data/vendor/lexbor/source/lexbor/html/tree/insertion_mode/in_body.c +1 -1
  120. data/vendor/lexbor/source/lexbor/html/tree.c +6 -6
  121. data/vendor/lexbor/source/lexbor/selectors/selectors.c +12 -3
  122. data/vendor/lexbor/source/lexbor/url/base.h +1 -1
  123. data/vendor/lexbor/source/lexbor/url/url.c +5 -2
  124. data/vendor/lexbor/source/lexbor/url/url.h +9 -0
  125. data/vendor/lexbor/version +1 -1
  126. metadata +53 -9
  127. data/ext/makiri/glue/ruby_css.c +0 -185
  128. data/ext/makiri/glue/ruby_serialize.c +0 -92
  129. data/ext/makiri/xpath/mkr_xpath_value.c +0 -1286
  130. data/lib/makiri/cdata.rb +0 -6
@@ -0,0 +1,1515 @@
1
+ /* mkr_xml_tree.c - XML tokenizer + tree builder (§14 steps 5-9).
2
+ *
3
+ * Ruby-free. Parses a decoded, validated-UTF-8 byte buffer (§2.1) into the
4
+ * custom node arena: elements, attributes, character data, namespaces (§7),
5
+ * entity/char-reference expansion (§9.1), comments / CDATA sections / processing
6
+ * instructions / the XML declaration (§9). A '<!DOCTYPE' is RECOGNIZED but its
7
+ * DTD is not processed (§9.4 alternative): the name + external id are kept (in an
8
+ * off-tree DOCUMENT_TYPE node, for Document#internal_subset) and the rest is
9
+ * skipped to its true '>'. NOTHING in the DTD is registered - no entity is
10
+ * defined (a DTD-defined &name; stays an undefined-entity error) and no external
11
+ * subset is fetched (zero I/O), so XXE / billion-laughs remain structurally
12
+ * impossible. Comments and PIs in the prolog/epilog (outside the root) are
13
+ * retained as children of the DOCUMENT node (the XPath data model; like
14
+ * Nokogiri); inter-construct whitespace there is not a text node. Strict
15
+ * NameStartChar/NameChar (§9.2b) and duplicate-attribute rejection (§9.3) apply.
16
+ *
17
+ * Budgets (§4): element depth (MKR_XML_MAX_DEPTH) and node/attr counts (enforced
18
+ * in the arena) are fail-closed - a violation aborts with no partial document.
19
+ */
20
+ #include "mkr_xml.h"
21
+ #include "mkr_xml_node.h"
22
+ #include "../core/mkr_core.h"
23
+
24
+ #include <stdarg.h>
25
+ #include <stdio.h>
26
+ #include <stdlib.h>
27
+ #include <string.h>
28
+
29
+ /* A namespace binding in scope: prefix ("" for the default ns) -> URI. The
30
+ * prefix slice borrows the input (valid for the whole parse); the URI is
31
+ * arena-owned (nodes share it via ns_uri). */
32
+ typedef struct {
33
+ const char *pfx; uint32_t pfx_len;
34
+ const char *uri; uint32_t uri_len; /* uri_len 0 = "no namespace" (xmlns="") */
35
+ } ns_binding_t;
36
+
37
+ /* One attribute collected from a start tag before namespaces are resolved
38
+ * (xmlns declarations on the same element must be processed first). */
39
+ typedef struct {
40
+ const char *name; uint32_t name_len; /* raw QName slice (input) */
41
+ const char *val; uint32_t val_len; /* raw value slice (input, pre-expansion) */
42
+ } raw_attr_t;
43
+
44
+ typedef struct {
45
+ mkr_span_t in; /* bounded input reader (cursor + end; every
46
+ * read is bounds-checked by construction) */
47
+ const char *start; /* buffer origin (the only place the XML
48
+ * declaration is valid; compared, never read) */
49
+ uint32_t line, col; /* 1-based; col in bytes (§5) */
50
+ mkr_xml_doc_t *doc;
51
+ mkr_xml_node_t *fragment; /* NULL = parse a whole document; else parse a
52
+ * fragment INTO this DOCUMENT_FRAGMENT node:
53
+ * 0+ top-level nodes (char data, multiple
54
+ * elements; no XML decl / DOCTYPE / single-root
55
+ * rule) attach to it instead of the doc node */
56
+ mkr_xml_status_t status;
57
+ ns_binding_t *binds; size_t nbind, bind_cap; /* namespace scope stack */
58
+ raw_attr_t *ratt; size_t nratt, ratt_cap; /* reusable per-tag attr buffer */
59
+ mkr_xml_node_t **stack; size_t depth, scap; /* open-element stack */
60
+ size_t *frame; size_t fcap; /* parallel: ns-binding count when
61
+ * each open element was entered */
62
+ int saw_doctype; /* at most one DOCTYPE, prolog only */
63
+ } mkr_xml_parser_t;
64
+
65
+ #define XML_NS_URI "http://www.w3.org/XML/1998/namespace"
66
+ #define XMLNS_NS_URI "http://www.w3.org/2000/xmlns/"
67
+ #define LIT_LEN(s) ((uint32_t)(sizeof(s) - 1))
68
+
69
+ /* small leaf predicates defined later but used by earlier handlers */
70
+ static int is_space_byte(int c);
71
+ static int lit_ahead(const mkr_xml_parser_t *P, const char *lit, size_t n);
72
+
73
+ static void
74
+ set_syntax(mkr_xml_parser_t *P)
75
+ {
76
+ if (P->status == MKR_XML_OK) P->status = MKR_XML_ERR_SYNTAX;
77
+ }
78
+
79
+ /* Propagate an arena failure (node/attr/byte budget or OOM) recorded on the doc. */
80
+ static int
81
+ propagate_oom(mkr_xml_parser_t *P)
82
+ {
83
+ if (P->doc->oom != MKR_XML_OK) { P->status = P->doc->oom; return 1; }
84
+ return 0;
85
+ }
86
+
87
+ static void
88
+ advance(mkr_xml_parser_t *P)
89
+ {
90
+ int c = mkr_span_take(&P->in);
91
+ if (c < 0) return;
92
+ if (c == '\n') { P->line++; P->col = 1; }
93
+ else { P->col++; }
94
+ }
95
+
96
+ /* Advance n bytes, keeping line/col correct (the consumed span may contain LF:
97
+ * comment/CDATA/PI content). Input is line-ending-normalized, so only LF appears. */
98
+ static void
99
+ advance_n(mkr_xml_parser_t *P, size_t n)
100
+ {
101
+ while (n-- > 0 && mkr_span_left(&P->in) > 0) advance(P);
102
+ }
103
+
104
+ static void
105
+ skip_ws(mkr_xml_parser_t *P)
106
+ {
107
+ for (;;) {
108
+ int c = mkr_span_peek(&P->in);
109
+ if (c == ' ' || c == '\t' || c == '\n' || c == '\r') advance(P);
110
+ else break;
111
+ }
112
+ }
113
+
114
+ /* Scan a Name into [*out, *out+*len) (a slice of the input), codepoint by
115
+ * codepoint against the full XML 1.0 §2.3 NameStartChar / NameChar sets (§9.2b):
116
+ * the first codepoint must be a NameStartChar, the rest NameChar. 0 on success. */
117
+ static int
118
+ scan_name(mkr_xml_parser_t *P, const char **out, uint32_t *len)
119
+ {
120
+ const char *start = mkr_span_mark(&P->in);
121
+ uint32_t cp;
122
+ int bl = mkr_utf8_decode1_span(&P->in, &cp);
123
+ if (bl == 0 || !mkr_xml_is_name_start(cp)) { set_syntax(P); return -1; }
124
+ advance_n(P, (size_t)bl);
125
+ for (;;) {
126
+ bl = mkr_utf8_decode1_span(&P->in, &cp);
127
+ if (bl == 0 || !mkr_xml_is_name_char(cp)) break;
128
+ advance_n(P, (size_t)bl);
129
+ }
130
+ size_t n = mkr_span_since(&P->in, start);
131
+ if (n > UINT32_MAX) { P->status = MKR_XML_ERR_LIMIT; return -1; } /* fail closed, never truncate */
132
+ *out = start;
133
+ *len = (uint32_t)n;
134
+ return 0;
135
+ }
136
+
137
+ static void
138
+ append_child(mkr_xml_node_t *parent, mkr_xml_node_t *child)
139
+ {
140
+ child->parent = parent;
141
+ if (parent->last_child) { parent->last_child->next = child; child->prev = parent->last_child; }
142
+ else parent->first_child = child;
143
+ parent->last_child = child;
144
+ }
145
+
146
+ /* Append a character-data node (TEXT or CDATA) whose value is the arena slice
147
+ * [val, val+len). Adjacent character data of the SAME type is coalesced into the
148
+ * preceding sibling (as libxml2's xmlAddChild does, and per the XPath data
149
+ * model's "as much character data as possible is grouped" rule) - so
150
+ * <![CDATA[a]]><![CDATA[b]]> is one CDATA node, not two. Different types stay
151
+ * separate (text vs CDATA remain distinct DOM nodes). 0 / -1 (P->status set). */
152
+ static int
153
+ append_chardata(mkr_xml_parser_t *P, mkr_xml_node_t *parent, uint8_t type,
154
+ const char *val, uint32_t len)
155
+ {
156
+ mkr_xml_node_t *last = parent->last_child;
157
+ if (last != NULL && last->type == type) {
158
+ size_t total = (size_t)last->value_len + len;
159
+ if (total > UINT32_MAX) { P->status = MKR_XML_ERR_LIMIT; return -1; }
160
+ /* concatenate via the bounded writer: total == the two parts exactly, so
161
+ * it never overflows; the writer just removes the raw-pointer arithmetic. */
162
+ mkr_spanbuf_t b = mkr_xml_arena_spanbuf(P->doc, total);
163
+ mkr_spanbuf_write(&b, last->value, last->value_len);
164
+ mkr_spanbuf_write(&b, val, len);
165
+ const char *buf = mkr_spanbuf_finish(&b);
166
+ if (buf == NULL) { propagate_oom(P); return -1; } /* alloc failure */
167
+ last->value = buf; last->value_len = (uint32_t)b.pos;
168
+ return 0;
169
+ }
170
+ mkr_xml_node_t *n = mkr_xml_arena_node(P->doc, type);
171
+ if (n == NULL) { propagate_oom(P); return -1; }
172
+ n->value = val; n->value_len = len;
173
+ append_child(parent, n);
174
+ return 0;
175
+ }
176
+
177
+
178
+ /* Copy a slice into the arena; on failure record the arena status. NULL return
179
+ * with len>0 means budget/OOM (P->status set); len==0 yields "". */
180
+ static const char *
181
+ own(mkr_xml_parser_t *P, const char *src, uint32_t len)
182
+ {
183
+ const char *p = mkr_xml_arena_bytes(P->doc, src, len);
184
+ if (p == NULL && len > 0) propagate_oom(P);
185
+ return p;
186
+ }
187
+
188
+ /* Store an element/attribute's raw "prefix:local" QName as ONE arena copy, with
189
+ * local and prefix slicing into it. Keeping the qualified name contiguous lets
190
+ * Node#name / the XPath name() borrow it without rebuilding it. +loc+/+pl+ come
191
+ * from split_qname (loc points into +name+). 0 on success, -1 on OOM. Thin
192
+ * wrapper over the shared mkr_xml_qname_assign (one arena-copy rule with the
193
+ * mutators), propagating an OOM to P->status. */
194
+ static int
195
+ set_node_qname(mkr_xml_parser_t *P, mkr_xml_node_t *node, const char *name,
196
+ uint32_t nl, const char *loc, uint32_t ll, uint32_t pl)
197
+ {
198
+ mkr_xml_qname_t qn = { name, nl, name, pl, loc, ll };
199
+ if (mkr_xml_qname_assign(P->doc, node, &qn) != 0) { propagate_oom(P); return -1; }
200
+ return 0;
201
+ }
202
+
203
+ static int
204
+ slice_eq(const char *a, uint32_t al, const char *b, uint32_t bl)
205
+ {
206
+ return mkr_bytes_eq(a, al, b, bl);
207
+ }
208
+
209
+ /* Split a QName into prefix:local. 0 on success (no-colon names get prefix_len
210
+ * 0); -1 if malformed (>1 colon, or an empty prefix/local). */
211
+ static int
212
+ split_qname(const char *name, uint32_t len, const char **pfx, uint32_t *pfx_len,
213
+ const char **loc, uint32_t *loc_len)
214
+ {
215
+ mkr_span_t s = mkr_span(name, len);
216
+ size_t colon_at;
217
+ if (!mkr_span_find(&s, ':', &colon_at)) {
218
+ *pfx = name; *pfx_len = 0; *loc = name; *loc_len = len; return 0;
219
+ }
220
+ uint32_t pl = (uint32_t)colon_at;
221
+ const char *ls = name + colon_at + 1;
222
+ uint32_t ll = len - pl - 1;
223
+ if (pl == 0 || ll == 0) return -1; /* ":x" or "x:" */
224
+ mkr_span_t lsp = mkr_span(ls, ll);
225
+ size_t second;
226
+ if (mkr_span_find(&lsp, ':', &second)) return -1; /* second colon */
227
+ /* §9.2b: prefix and local must each be an NCName. scan_name already proved the
228
+ * whole QName is NameStartChar + NameChar* and split removed the only colon, so
229
+ * the one remaining check is that the local part starts with a NameStartChar
230
+ * (the prefix starts the QName, so it already does). Rejects e.g. "a:1b". */
231
+ uint32_t cp;
232
+ if (mkr_utf8_decode1_span(&lsp, &cp) == 0 || !mkr_xml_is_name_start(cp)) return -1;
233
+ *pfx = name; *pfx_len = pl; *loc = ls; *loc_len = ll;
234
+ return 0;
235
+ }
236
+
237
+ /* Resolve a prefix to its in-scope namespace URI, or NULL when unbound. The
238
+ * predefined 'xml' prefix always resolves; for the empty prefix this returns the
239
+ * default-namespace URI (possibly "" with *uri_len 0 = undeclared) or NULL. */
240
+ static const char *
241
+ ns_lookup(mkr_xml_parser_t *P, const char *pfx, uint32_t pfx_len, uint32_t *uri_len)
242
+ {
243
+ if (slice_eq(pfx, pfx_len, "xml", 3)) { *uri_len = LIT_LEN(XML_NS_URI); return XML_NS_URI; }
244
+ for (size_t i = P->nbind; i > 0; i--) {
245
+ ns_binding_t *b = &P->binds[i - 1];
246
+ if (slice_eq(b->pfx, b->pfx_len, pfx, pfx_len)) { *uri_len = b->uri_len; return b->uri; }
247
+ }
248
+ return NULL;
249
+ }
250
+
251
+ static int
252
+ push_binding(mkr_xml_parser_t *P, const char *pfx, uint32_t pfx_len,
253
+ const char *uri, uint32_t uri_len)
254
+ {
255
+ if (P->nbind + 1 > MKR_XML_MAX_NS) { P->status = MKR_XML_ERR_LIMIT; return -1; }
256
+ if (mkr_grow_reserve((void **)&P->binds, &P->bind_cap, P->nbind + 1, sizeof(*P->binds)) != MKR_OK) {
257
+ P->status = MKR_XML_ERR_OOM; return -1;
258
+ }
259
+ P->binds[P->nbind].pfx = pfx; P->binds[P->nbind].pfx_len = pfx_len;
260
+ P->binds[P->nbind].uri = uri; P->binds[P->nbind].uri_len = uri_len;
261
+ P->nbind++;
262
+ return 0;
263
+ }
264
+
265
+ /* Parse a start tag's attributes and close, resolving namespaces (§7). Collects
266
+ * the attributes, processes xmlns declarations into scope bindings, resolves the
267
+ * element's own namespace, then creates the attribute nodes (xmlns declarations
268
+ * kept as DOM attributes, §7.2). On '>' sets *pushed; on '/>' leaves a leaf.
269
+ * The element's prefix/local are already set by the caller; bindings pushed here
270
+ * are popped by the caller when the element closes. Returns 0 / -1. */
271
+ static int
272
+ parse_element_body(mkr_xml_parser_t *P, mkr_xml_node_t *el, int *pushed)
273
+ {
274
+ *pushed = 0;
275
+ P->nratt = 0;
276
+
277
+ /* Phase 1: scan all attributes + the tag close into the raw buffer. */
278
+ for (;;) {
279
+ skip_ws(P);
280
+ int c = mkr_span_peek(&P->in);
281
+ if (c < 0) { set_syntax(P); return -1; } /* unterminated tag */
282
+ if (c == '>') { advance(P); *pushed = 1; break; }
283
+ if (c == '/') {
284
+ advance(P);
285
+ if (mkr_span_peek(&P->in) != '>') { set_syntax(P); return -1; }
286
+ advance(P); break; /* self-closing */
287
+ }
288
+ const char *an; uint32_t alen;
289
+ if (scan_name(P, &an, &alen) != 0) return -1;
290
+ skip_ws(P);
291
+ if (mkr_span_peek(&P->in) != '=') { set_syntax(P); return -1; }
292
+ advance(P); skip_ws(P);
293
+ int q = mkr_span_peek(&P->in);
294
+ if (q != '"' && q != '\'') { set_syntax(P); return -1; }
295
+ advance(P);
296
+ const char *vs = mkr_span_mark(&P->in);
297
+ for (;;) {
298
+ int vc = mkr_span_peek(&P->in);
299
+ if (vc < 0 || vc == q) break;
300
+ if (vc == '<') { set_syntax(P); return -1; } /* raw '<' in AttValue */
301
+ advance(P);
302
+ }
303
+ if (mkr_span_left(&P->in) == 0) { set_syntax(P); return -1; } /* unterminated value */
304
+ size_t vraw = mkr_span_since(&P->in, vs);
305
+ if (vraw > UINT32_MAX) { P->status = MKR_XML_ERR_LIMIT; return -1; } /* fail closed */
306
+ uint32_t vlen = (uint32_t)vraw;
307
+ advance(P); /* closing quote */
308
+ /* §3.1: attributes are S-separated - after a value the next byte must be
309
+ * whitespace or the tag close ('>' / '/'), never another name. Rejects
310
+ * `<a x="1"y="2"/>`. */
311
+ int nx = mkr_span_peek(&P->in);
312
+ if (nx >= 0 && nx != '>' && nx != '/' && !is_space_byte(nx)) {
313
+ set_syntax(P); return -1;
314
+ }
315
+ if (P->nratt + 1 > MKR_XML_MAX_ATTRS) { P->status = MKR_XML_ERR_LIMIT; return -1; }
316
+ if (mkr_grow_reserve((void **)&P->ratt, &P->ratt_cap, P->nratt + 1, sizeof(*P->ratt)) != MKR_OK) {
317
+ P->status = MKR_XML_ERR_OOM; return -1;
318
+ }
319
+ P->ratt[P->nratt].name = an; P->ratt[P->nratt].name_len = alen;
320
+ P->ratt[P->nratt].val = vs; P->ratt[P->nratt].val_len = vlen;
321
+ P->nratt++;
322
+ }
323
+
324
+ /* Phase 2: xmlns declarations -> namespace bindings (§7.1 reserved rules). */
325
+ for (size_t i = 0; i < P->nratt; i++) {
326
+ raw_attr_t *r = &P->ratt[i];
327
+ const char *bpfx; uint32_t bpl;
328
+ if (!mkr_xml_xmlns_prefix(r->name, r->name_len, &bpfx, &bpl)) continue;
329
+ if (slice_eq(bpfx, bpl, "xmlns", 5)) { set_syntax(P); return -1; } /* xmlns:xmlns reserved */
330
+
331
+ uint32_t ulen;
332
+ const char *uri = mkr_xml_expand(P->doc, r->val, r->val_len,
333
+ MKR_XML_EXPAND_ATTR, &ulen, &P->status);
334
+ if (uri == NULL) return -1;
335
+ if (slice_eq(bpfx, bpl, "xml", 3)) {
336
+ if (!slice_eq(uri, ulen, XML_NS_URI, LIT_LEN(XML_NS_URI))) { set_syntax(P); return -1; }
337
+ } else if (slice_eq(uri, ulen, XML_NS_URI, LIT_LEN(XML_NS_URI))
338
+ || slice_eq(uri, ulen, XMLNS_NS_URI, LIT_LEN(XMLNS_NS_URI))) {
339
+ set_syntax(P); return -1; /* reserved URI bound to another prefix */
340
+ }
341
+ if (bpl > 0 && ulen == 0) { set_syntax(P); return -1; } /* xmlns:p="" (XML 1.0) */
342
+ if (push_binding(P, bpfx, bpl, uri, ulen) != 0) return -1;
343
+ }
344
+
345
+ /* Phase 3: resolve the element's own namespace. */
346
+ if (el->prefix_len > 0) {
347
+ if (slice_eq(el->prefix, el->prefix_len, "xmlns", 5)) { set_syntax(P); return -1; }
348
+ uint32_t ulen;
349
+ const char *uri = ns_lookup(P, el->prefix, el->prefix_len, &ulen);
350
+ if (uri == NULL) { set_syntax(P); return -1; } /* unbound prefix */
351
+ el->ns_uri = uri; el->ns_uri_len = ulen;
352
+ } else {
353
+ uint32_t ulen;
354
+ const char *uri = ns_lookup(P, "", 0, &ulen);
355
+ if (uri != NULL && ulen > 0) { el->ns_uri = uri; el->ns_uri_len = ulen; }
356
+ /* else: no default namespace -> the element is in no namespace */
357
+ }
358
+
359
+ /* Phase 4: create the attribute nodes (xmlns declarations kept, §7.2). The
360
+ * list is built with an explicit tail so each append is O(1) - walking to the
361
+ * tail per attribute would be O(n^2) over a MKR_XML_MAX_ATTRS-bounded set. */
362
+ mkr_xml_node_t *attr_tail = NULL;
363
+ for (size_t i = 0; i < P->nratt; i++) {
364
+ raw_attr_t *r = &P->ratt[i];
365
+ const char *pfx, *loc; uint32_t pl, ll;
366
+ if (split_qname(r->name, r->name_len, &pfx, &pl, &loc, &ll) != 0) { set_syntax(P); return -1; }
367
+
368
+ mkr_xml_node_t *attr = mkr_xml_arena_node(P->doc, MKR_XML_NODE_TYPE_ATTRIBUTE);
369
+ if (attr == NULL) { propagate_oom(P); return -1; }
370
+ (void)pfx;
371
+ if (set_node_qname(P, attr, r->name, r->name_len, loc, ll, pl) != 0) return -1;
372
+
373
+ if (mkr_xml_xmlns_prefix(r->name, r->name_len, NULL, NULL)) {
374
+ attr->ns_uri = XMLNS_NS_URI; attr->ns_uri_len = LIT_LEN(XMLNS_NS_URI);
375
+ } else if (pl > 0) { /* prefixed attribute */
376
+ uint32_t ulen;
377
+ const char *uri = ns_lookup(P, pfx, pl, &ulen);
378
+ if (uri == NULL) { set_syntax(P); return -1; } /* unbound prefix */
379
+ attr->ns_uri = uri; attr->ns_uri_len = ulen;
380
+ }
381
+ /* else: unprefixed attribute -> no namespace (the default ns never applies) */
382
+
383
+ attr->value = mkr_xml_expand(P->doc, r->val, r->val_len,
384
+ MKR_XML_EXPAND_ATTR, &attr->value_len, &P->status);
385
+ if (attr->value == NULL) return -1;
386
+ attr->parent = el; /* O(1) tail append */
387
+ if (attr_tail) attr_tail->next = attr; else el->attrs = attr;
388
+ attr_tail = attr;
389
+ }
390
+
391
+ /* §9.3: no two attributes may share the same (namespace URI, local name) -
392
+ * compared AFTER resolution, so "a:x" and "b:x" bound to the same URI collide
393
+ * while "xmlns:a"/"xmlns:b" (same xmlns URI, different local) do not. O(n^2)
394
+ * over a per-element set bounded by MKR_XML_MAX_ATTRS. */
395
+ for (mkr_xml_node_t *a = el->attrs; a != NULL; a = a->next) {
396
+ for (mkr_xml_node_t *b = a->next; b != NULL; b = b->next) {
397
+ if (slice_eq(a->local, a->local_len, b->local, b->local_len)
398
+ && slice_eq(a->ns_uri, a->ns_uri_len, b->ns_uri, b->ns_uri_len)) {
399
+ set_syntax(P); return -1; /* duplicate attribute */
400
+ }
401
+ }
402
+ }
403
+ return 0;
404
+ }
405
+
406
+ /* True if the remaining input begins with the literal [lit, lit+n). */
407
+ static int
408
+ lit_ahead(const mkr_xml_parser_t *P, const char *lit, size_t n)
409
+ {
410
+ return mkr_span_starts(&P->in, lit, n);
411
+ }
412
+
413
+ /* Takes the int-or-minus-one a span peek returns (-1 / end-of-input is never a
414
+ * space, so a "peek is space" test needs no separate end check). */
415
+ static int
416
+ is_space_byte(int c)
417
+ {
418
+ return c == ' ' || c == '\t' || c == '\n' || c == '\r';
419
+ }
420
+
421
+ /* '<!--' comment (P->p at '!'). XML 1.0: the content is XML Char and may not
422
+ * contain '--' except as part of the closing '-->'. Creates a COMMENT node under
423
+ * +parent+ - its open element, or the DOCUMENT node in the prolog/epilog (XPath
424
+ * data model; like Nokogiri). */
425
+ static int
426
+ parse_comment(mkr_xml_parser_t *P, mkr_xml_node_t *parent)
427
+ {
428
+ advance_n(P, 3); /* consume "!--" */
429
+ const char *cstart = mkr_span_mark(&P->in);
430
+ mkr_span_t s = P->in; /* scan copy: looks ahead, consumes nothing */
431
+ for (;;) {
432
+ size_t at;
433
+ if (!mkr_span_find(&s, '-', &at)) { set_syntax(P); return -1; } /* unterminated */
434
+ mkr_span_skip(&s, at); /* cursor on the '-' */
435
+ if (mkr_span_at(&s, 1) == '-') {
436
+ if (mkr_span_at(&s, 2) == '>') break; /* cursor on the closing "-->" */
437
+ set_syntax(P); return -1; /* '--' not part of '-->' */
438
+ }
439
+ mkr_span_skip(&s, 1);
440
+ }
441
+ size_t craw = mkr_span_since(&s, cstart);
442
+ if (craw > UINT32_MAX) { P->status = MKR_XML_ERR_LIMIT; return -1; }
443
+ uint32_t clen = (uint32_t)craw;
444
+ if (mkr_xml_validate_chars(cstart, clen) != 0) { set_syntax(P); return -1; }
445
+ if (parent != NULL) {
446
+ mkr_xml_node_t *c = mkr_xml_arena_node(P->doc, MKR_XML_NODE_TYPE_COMMENT);
447
+ if (c == NULL) { propagate_oom(P); return -1; }
448
+ c->value = own(P, cstart, clen); c->value_len = clen;
449
+ if (clen > 0 && c->value == NULL) return -1;
450
+ append_child(parent, c);
451
+ }
452
+ advance_n(P, craw + 3); /* content + "-->" */
453
+ return 0;
454
+ }
455
+
456
+ /* '<![CDATA[' section (P->p at '!'). Raw character data (no reference recognition)
457
+ * up to ']]>'. Character data is only well-formed inside an element. */
458
+ static int
459
+ parse_cdata(mkr_xml_parser_t *P, mkr_xml_node_t *parent)
460
+ {
461
+ if (P->depth == 0 && P->fragment == NULL) { set_syntax(P); return -1; } /* CDATA outside the root */
462
+ advance_n(P, 8); /* consume "![CDATA[" */
463
+ const char *cstart = mkr_span_mark(&P->in);
464
+ mkr_span_t s = P->in; /* scan copy */
465
+ for (;;) {
466
+ size_t at;
467
+ if (!mkr_span_find(&s, ']', &at)) { set_syntax(P); return -1; } /* unterminated */
468
+ mkr_span_skip(&s, at); /* cursor on the ']' */
469
+ if (mkr_span_at(&s, 1) == ']' && mkr_span_at(&s, 2) == '>') break; /* cursor on "]]>" */
470
+ mkr_span_skip(&s, 1);
471
+ }
472
+ size_t craw = mkr_span_since(&s, cstart);
473
+ if (craw > UINT32_MAX) { P->status = MKR_XML_ERR_LIMIT; return -1; }
474
+ uint32_t clen = (uint32_t)craw;
475
+ if (mkr_xml_validate_chars(cstart, clen) != 0) { set_syntax(P); return -1; }
476
+ const char *cval = own(P, cstart, clen);
477
+ if (clen > 0 && cval == NULL) return -1;
478
+ if (append_chardata(P, parent, MKR_XML_NODE_TYPE_CDATA_SECTION, cval, clen) != 0) return -1;
479
+ advance_n(P, craw + 3); /* content + "]]>" */
480
+ return 0;
481
+ }
482
+
483
+ /* ---- XML declaration (§2.8 / §4.3.1) strict pseudo-attribute grammar ---- */
484
+
485
+ /* match the literal keyword [kw, kw+n) at the cursor and consume it; 0/1. */
486
+ static int
487
+ eat_keyword(mkr_xml_parser_t *P, const char *kw, size_t n)
488
+ {
489
+ if (!mkr_span_starts(&P->in, kw, n)) return 0;
490
+ advance_n(P, n);
491
+ return 1;
492
+ }
493
+
494
+ /* Eq ::= S? '=' S? - 0 on success, -1 if '=' is missing. */
495
+ static int
496
+ decl_eq(mkr_xml_parser_t *P)
497
+ {
498
+ skip_ws(P);
499
+ if (mkr_span_peek(&P->in) != '=') { set_syntax(P); return -1; }
500
+ advance(P);
501
+ skip_ws(P);
502
+ return 0;
503
+ }
504
+
505
+ /* Read a quoted pseudo-attribute value into [*out, *out+*out_len). 0 on success,
506
+ * -1 on a missing/mismatched quote. */
507
+ static int
508
+ decl_value_get(mkr_xml_parser_t *P, const char **out, uint32_t *out_len)
509
+ {
510
+ int qch = mkr_span_peek(&P->in);
511
+ if (qch != '"' && qch != '\'') { set_syntax(P); return -1; }
512
+ advance(P);
513
+ const char *vs = mkr_span_mark(&P->in);
514
+ while (mkr_span_peek(&P->in) >= 0 && mkr_span_peek(&P->in) != qch) advance(P);
515
+ if (mkr_span_left(&P->in) == 0) { set_syntax(P); return -1; } /* unterminated / mismatched quote */
516
+ size_t n = mkr_span_since(&P->in, vs);
517
+ if (n > UINT32_MAX) { P->status = MKR_XML_ERR_LIMIT; return -1; } /* fail closed */
518
+ *out = vs; *out_len = (uint32_t)n;
519
+ advance(P); /* closing quote */
520
+ return 0;
521
+ }
522
+
523
+ /* A quoted pseudo-attribute value, validated whole by +ok+. 0 on success. */
524
+ static int
525
+ decl_value(mkr_xml_parser_t *P, int (*ok)(const char *, uint32_t))
526
+ {
527
+ const char *vs; uint32_t vl;
528
+ if (decl_value_get(P, &vs, &vl) != 0) return -1;
529
+ if (!ok(vs, vl)) { set_syntax(P); return -1; }
530
+ return 0;
531
+ }
532
+
533
+ static int is_version_num(const char *s, uint32_t n) { /* '1.' [0-9]+ */
534
+ mkr_span_t v = mkr_span(s, n);
535
+ if (n < 3 || !mkr_span_starts(&v, "1.", 2)) return 0;
536
+ for (uint32_t i = 2; i < n; i++) {
537
+ int c = mkr_span_at(&v, i);
538
+ if (c < '0' || c > '9') return 0;
539
+ }
540
+ return 1;
541
+ }
542
+ static int is_enc_name(const char *s, uint32_t n) { /* [A-Za-z] ([A-Za-z0-9._] | '-')* */
543
+ mkr_span_t v = mkr_span(s, n);
544
+ int c0 = mkr_span_peek(&v);
545
+ if (!((c0 >= 'A' && c0 <= 'Z') || (c0 >= 'a' && c0 <= 'z'))) return 0;
546
+ for (uint32_t i = 1; i < n; i++) {
547
+ int c = mkr_span_at(&v, i);
548
+ if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
549
+ || c == '.' || c == '_' || c == '-')) return 0;
550
+ }
551
+ return 1;
552
+ }
553
+ static int is_yes_no(const char *s, uint32_t n) {
554
+ return mkr_bytes_eq(s, n, "yes", 3) || mkr_bytes_eq(s, n, "no", 2);
555
+ }
556
+
557
+ /* '<?xml' already consumed (cursor just past "xml"). Validate
558
+ * S 'version' Eq VersionNum (S 'encoding' Eq EncName)? (S 'standalone' Eq ('yes'|'no'))? S? '?>'
559
+ * - pseudo-attributes are lowercase, in this order, each at most once, S-separated.
560
+ * The declaration is not retained as a node. 0 on success / -1 (P->status set). */
561
+ static int
562
+ parse_xml_decl_body(mkr_xml_parser_t *P)
563
+ {
564
+ if (!is_space_byte(mkr_span_peek(&P->in))) { set_syntax(P); return -1; } /* S */
565
+ skip_ws(P);
566
+ if (!eat_keyword(P, "version", 7)) { set_syntax(P); return -1; }
567
+ if (decl_eq(P) != 0) return -1;
568
+ const char *ver; uint32_t ver_len;
569
+ if (decl_value_get(P, &ver, &ver_len) != 0) return -1;
570
+ if (!is_version_num(ver, ver_len)) { set_syntax(P); return -1; } /* bad VersionNum syntax */
571
+ if (!mkr_bytes_eq(ver, ver_len, "1.0", 3)) {
572
+ /* well-formed, but a version Makiri does not implement (XML 1.1 / 1.x):
573
+ * fail closed rather than silently parse it under XML 1.0 rules. */
574
+ P->status = MKR_XML_ERR_VERSION; return -1;
575
+ }
576
+
577
+ int saw_enc = 0, saw_sd = 0;
578
+ for (;;) {
579
+ int had_s = 0;
580
+ while (is_space_byte(mkr_span_peek(&P->in))) { advance(P); had_s = 1; }
581
+ if (lit_ahead(P, "?>", 2)) { advance_n(P, 2); return 0; } /* end of declaration */
582
+ if (!had_s) { set_syntax(P); return -1; } /* attrs need an S separator */
583
+ if (!saw_enc && !saw_sd && eat_keyword(P, "encoding", 8)) {
584
+ saw_enc = 1; /* encoding precedes standalone */
585
+ P->doc->has_encoding_decl = 1; /* serializer emits encoding="UTF-8" only then */
586
+ if (decl_eq(P) != 0 || decl_value(P, is_enc_name) != 0) return -1;
587
+ } else if (!saw_sd && eat_keyword(P, "standalone", 10)) {
588
+ saw_sd = 1;
589
+ if (decl_eq(P) != 0 || decl_value(P, is_yes_no) != 0) return -1;
590
+ } else {
591
+ set_syntax(P); return -1; /* unknown / duplicate / out-of-order pseudo-attribute */
592
+ }
593
+ }
594
+ }
595
+
596
+ /* '<?' processing instruction (P->p at '?'). The '<?xml ...?>' declaration (exact
597
+ * lowercase target, document start only) is validated strictly by
598
+ * parse_xml_decl_body and not retained; a target matching "xml" in any OTHER case
599
+ * is a reserved PITarget (§2.6) and rejected. Every other PI is retained as a
600
+ * node - under its open element, or under the DOCUMENT node in the
601
+ * prolog/epilog (XPath data model; like Nokogiri). */
602
+ static int
603
+ parse_pi(mkr_xml_parser_t *P, mkr_xml_node_t *parent, int at_doc_start)
604
+ {
605
+ advance_n(P, 1); /* consume '?' */
606
+ const char *tgt; uint32_t tl;
607
+ if (scan_name(P, &tgt, &tl) != 0) return -1; /* PITarget (a Name) */
608
+ /* Namespaces in XML §3: a PITarget is an NCName, so a colon is not-wf under
609
+ * namespace processing (which Makiri always does, like Nokogiri::XML). */
610
+ mkr_span_t tsp = mkr_span(tgt, tl);
611
+ size_t colon_at;
612
+ if (mkr_span_find(&tsp, ':', &colon_at)) { set_syntax(P); return -1; }
613
+ int ci_xml = (tl == 3 && (mkr_span_at(&tsp, 0) | 0x20) == 'x'
614
+ && (mkr_span_at(&tsp, 1) | 0x20) == 'm'
615
+ && (mkr_span_at(&tsp, 2) | 0x20) == 'l');
616
+ int is_decl = (tl == 3 && mkr_span_starts(&tsp, "xml", 3));
617
+ if (is_decl) {
618
+ if (!at_doc_start || P->depth != 0) { set_syntax(P); return -1; } /* decl: doc start only */
619
+ return parse_xml_decl_body(P);
620
+ }
621
+ if (ci_xml) { set_syntax(P); return -1; } /* reserved target ("XML"/"xmL"/...) */
622
+
623
+ int empty_data = lit_ahead(P, "?>", 2);
624
+ if (!empty_data) {
625
+ if (!is_space_byte(mkr_span_peek(&P->in))) { set_syntax(P); return -1; } /* need S */
626
+ skip_ws(P);
627
+ }
628
+ const char *dstart = mkr_span_mark(&P->in);
629
+ mkr_span_t s = P->in; /* scan copy */
630
+ for (;;) {
631
+ size_t at;
632
+ if (!mkr_span_find(&s, '?', &at)) { set_syntax(P); return -1; } /* unterminated */
633
+ mkr_span_skip(&s, at); /* cursor on the '?' */
634
+ if (mkr_span_at(&s, 1) == '>') break; /* cursor on the closing "?>" */
635
+ mkr_span_skip(&s, 1);
636
+ }
637
+ size_t draw = mkr_span_since(&s, dstart);
638
+ if (draw > UINT32_MAX) { P->status = MKR_XML_ERR_LIMIT; return -1; }
639
+ uint32_t dlen = (uint32_t)draw;
640
+ if (mkr_xml_validate_chars(dstart, dlen) != 0) { set_syntax(P); return -1; }
641
+
642
+ if (parent != NULL) {
643
+ mkr_xml_node_t *pi = mkr_xml_arena_node(P->doc, MKR_XML_NODE_TYPE_PI);
644
+ if (pi == NULL) { propagate_oom(P); return -1; }
645
+ pi->local = own(P, tgt, tl); pi->local_len = tl;
646
+ pi->value = own(P, dstart, dlen); pi->value_len = dlen;
647
+ if (pi->local == NULL || (dlen > 0 && pi->value == NULL)) return -1;
648
+ append_child(parent, pi);
649
+ }
650
+ advance_n(P, draw + 2); /* data + "?>" */
651
+ return 0;
652
+ }
653
+
654
+ /* ---- per-construct handlers (the tokenizer dispatch calls one per token) ----
655
+ * Each takes only the parser (which now carries the open-element stack), returns
656
+ * 0 on success / -1 on a well-formedness or budget failure (P->status set), and
657
+ * leaves P->p just past the construct. */
658
+
659
+ /* The parent a node created at the cursor attaches to: the innermost open
660
+ * element, or - at the document level (prolog/epilog) - the DOCUMENT node, so a
661
+ * top-level comment / PI becomes its child (XPath data model; like Nokogiri).
662
+ * The document node exists from the start of the parse. */
663
+ static inline mkr_xml_node_t *
664
+ cur_parent(const mkr_xml_parser_t *P)
665
+ {
666
+ if (P->depth) return P->stack[P->depth - 1];
667
+ return P->fragment ? P->fragment : P->doc->doc_node;
668
+ }
669
+
670
+ /* End tag '</name S? >' (P->p at '/'): must match the innermost open element's
671
+ * raw QName; on match pops it and restores that element's namespace scope. */
672
+ static int
673
+ parse_end_tag(mkr_xml_parser_t *P)
674
+ {
675
+ advance(P); /* '/' */
676
+ const char *nm; uint32_t nl;
677
+ if (scan_name(P, &nm, &nl) != 0) return -1;
678
+ skip_ws(P);
679
+ if (mkr_span_peek(&P->in) != '>') { set_syntax(P); return -1; }
680
+ advance(P);
681
+ if (P->depth == 0) { set_syntax(P); return -1; } /* end tag with no open element */
682
+ mkr_xml_node_t *top = P->stack[P->depth - 1];
683
+ uint32_t tql = top->prefix_len ? top->prefix_len + 1 + top->local_len : top->local_len;
684
+ int match;
685
+ if (top->prefix_len) {
686
+ mkr_span_t nsp = mkr_span(nm, nl);
687
+ match = (nl == tql && mkr_span_starts(&nsp, top->prefix, top->prefix_len)
688
+ && mkr_span_at(&nsp, top->prefix_len) == ':');
689
+ if (match) {
690
+ mkr_span_skip(&nsp, (size_t)top->prefix_len + 1);
691
+ match = mkr_span_starts(&nsp, top->local, top->local_len);
692
+ }
693
+ } else {
694
+ match = mkr_bytes_eq(top->local, top->local_len, nm, nl);
695
+ }
696
+ if (!match) { set_syntax(P); return -1; } /* mismatched end tag */
697
+ P->depth--;
698
+ P->nbind = P->frame[P->depth]; /* pop this element's namespace scope */
699
+ return 0;
700
+ }
701
+
702
+ /* A quoted literal ("..." or '...'); its content is a slice of the input (no
703
+ * reference recognition). 0 on success, -1 on a missing/unterminated quote. */
704
+ static int
705
+ parse_quoted(mkr_xml_parser_t *P, const char **out, uint32_t *out_len)
706
+ {
707
+ int q = mkr_span_peek(&P->in);
708
+ if (q != '"' && q != '\'') { set_syntax(P); return -1; }
709
+ advance(P);
710
+ const char *s = mkr_span_mark(&P->in);
711
+ while (mkr_span_peek(&P->in) >= 0 && mkr_span_peek(&P->in) != q) advance(P);
712
+ if (mkr_span_left(&P->in) == 0) { set_syntax(P); return -1; } /* unterminated literal */
713
+ size_t n = mkr_span_since(&P->in, s);
714
+ if (n > UINT32_MAX) { P->status = MKR_XML_ERR_LIMIT; return -1; }
715
+ *out = s; *out_len = (uint32_t)n;
716
+ advance(P); /* closing quote */
717
+ return 0;
718
+ }
719
+
720
+ /* '<!DOCTYPE' (P->p at '!'): the DTD is RECOGNIZED but NOT PROCESSED (§9.4
721
+ * alternative). Valid only in the prolog (before the root element) and at most
722
+ * once. The Name and the ExternalID (SYSTEM/PUBLIC literals) are read for
723
+ * Document#internal_subset, then any internal subset '[ ... ]' is skipped to the
724
+ * true '>' (quote state + bracket depth balance a '>' inside a quoted literal or
725
+ * a markup declaration). NOTHING in the DTD is processed: no entity is defined
726
+ * (so a DTD-defined &name; stays an undefined-entity error per §9.1, keeping XXE
727
+ * and billion-laughs impossible) and no external subset is fetched (zero I/O).
728
+ * The metadata is kept in an off-tree DOCUMENT_TYPE node (doc->doctype). */
729
+ static int
730
+ parse_doctype(mkr_xml_parser_t *P)
731
+ {
732
+ if (P->depth != 0 || P->doc->root != NULL || P->saw_doctype) {
733
+ set_syntax(P); return -1; /* inside an element, after the root, or a second DOCTYPE */
734
+ }
735
+ P->saw_doctype = 1;
736
+ advance_n(P, LIT_LEN("!DOCTYPE"));
737
+ if (!is_space_byte(mkr_span_peek(&P->in))) { set_syntax(P); return -1; } /* S required */
738
+ skip_ws(P);
739
+ const char *name; uint32_t name_len;
740
+ if (scan_name(P, &name, &name_len) != 0) return -1; /* the document type name (a Name) */
741
+
742
+ const char *pub = NULL, *sys = NULL;
743
+ uint32_t pub_len = 0, sys_len = 0;
744
+
745
+ if (is_space_byte(mkr_span_peek(&P->in))) {
746
+ skip_ws(P);
747
+ if (lit_ahead(P, "SYSTEM", 6)) {
748
+ advance_n(P, 6);
749
+ if (!is_space_byte(mkr_span_peek(&P->in))) { set_syntax(P); return -1; }
750
+ skip_ws(P);
751
+ if (parse_quoted(P, &sys, &sys_len) != 0) return -1;
752
+ } else if (lit_ahead(P, "PUBLIC", 6)) {
753
+ advance_n(P, 6);
754
+ if (!is_space_byte(mkr_span_peek(&P->in))) { set_syntax(P); return -1; }
755
+ skip_ws(P);
756
+ if (parse_quoted(P, &pub, &pub_len) != 0) return -1;
757
+ if (!is_space_byte(mkr_span_peek(&P->in))) { set_syntax(P); return -1; }
758
+ skip_ws(P);
759
+ if (parse_quoted(P, &sys, &sys_len) != 0) return -1;
760
+ }
761
+ }
762
+
763
+ /* Skip the optional internal subset + trailing whitespace to the true '>'. */
764
+ int quote = 0;
765
+ int depth = 0, closed = 0;
766
+ for (;;) {
767
+ int c = mkr_span_peek(&P->in);
768
+ if (c < 0) break;
769
+ if (quote) { if (c == quote) quote = 0; }
770
+ else if (c == '"' || c == '\'') { quote = c; }
771
+ else if (c == '[') { depth++; }
772
+ else if (c == ']') { if (depth > 0) depth--; }
773
+ else if (c == '>' && depth == 0) { advance(P); closed = 1; break; }
774
+ advance(P);
775
+ }
776
+ if (!closed) { set_syntax(P); return -1; } /* unterminated DOCTYPE */
777
+
778
+ /* Retain the metadata off-tree (not a child of any node, so XPath is
779
+ * unaffected) for Document#internal_subset. Fields are repurposed:
780
+ * local/qname = name, prefix = public/external id, value = system id. */
781
+ mkr_xml_node_t *dt = mkr_xml_arena_node(P->doc, MKR_XML_NODE_TYPE_DOCUMENT_TYPE);
782
+ if (dt == NULL) { propagate_oom(P); return -1; }
783
+ dt->local = dt->qname = own(P, name, name_len);
784
+ dt->local_len = dt->qname_len = name_len;
785
+ if (name_len > 0 && dt->local == NULL) return -1;
786
+ if (pub != NULL) {
787
+ dt->prefix = own(P, pub, pub_len); dt->prefix_len = pub_len;
788
+ if (pub_len > 0 && dt->prefix == NULL) return -1;
789
+ }
790
+ if (sys != NULL) {
791
+ dt->value = own(P, sys, sys_len); dt->value_len = sys_len;
792
+ if (sys_len > 0 && dt->value == NULL) return -1;
793
+ }
794
+ P->doc->doctype = dt;
795
+ return 0;
796
+ }
797
+
798
+ /* '<!' markup (P->p at '!'): a comment, a CDATA section, or a DOCTYPE (recognized
799
+ * but not processed, §9.4). Any other markup declaration is rejected. */
800
+ static int
801
+ parse_markup(mkr_xml_parser_t *P)
802
+ {
803
+ mkr_xml_node_t *parent = cur_parent(P);
804
+ if (lit_ahead(P, "!--", 3)) return parse_comment(P, parent);
805
+ if (lit_ahead(P, "![CDATA[", 8)) return parse_cdata(P, parent);
806
+ if (lit_ahead(P, "!DOCTYPE", 8)) {
807
+ if (P->fragment != NULL) { set_syntax(P); return -1; } /* a fragment has no DOCTYPE */
808
+ return parse_doctype(P);
809
+ }
810
+ set_syntax(P); return -1;
811
+ }
812
+
813
+ /* Start tag (P->p at the QName's first byte; +tl+/+tc+ = the '<' position).
814
+ * Builds the element, resolves its namespaces, links it into the tree, then on
815
+ * '>' pushes it onto the open-element stack or on '/>' leaves it a leaf. */
816
+ static int
817
+ parse_start_tag(mkr_xml_parser_t *P, uint32_t tl, uint32_t tc)
818
+ {
819
+ const char *nm; uint32_t nl;
820
+ if (scan_name(P, &nm, &nl) != 0) return -1;
821
+ const char *epfx, *eloc; uint32_t epl, ell;
822
+ if (split_qname(nm, nl, &epfx, &epl, &eloc, &ell) != 0) { set_syntax(P); return -1; }
823
+ (void)epfx;
824
+ mkr_xml_node_t *el = mkr_xml_arena_node(P->doc, MKR_XML_NODE_TYPE_ELEMENT);
825
+ if (el == NULL) { propagate_oom(P); return -1; }
826
+ if (set_node_qname(P, el, nm, nl, eloc, ell, epl) != 0) return -1;
827
+ el->line = tl; el->col = tc;
828
+
829
+ if (P->depth == 0 && P->fragment == NULL) {
830
+ if (P->doc->root != NULL) { set_syntax(P); return -1; } /* multiple roots */
831
+ P->doc->root = el;
832
+ } /* a fragment allows multiple top-level elements and sets no doc->root */
833
+ append_child(cur_parent(P), el); /* depth 0: the DOCUMENT (or fragment) node; else the open element */
834
+
835
+ size_t bind_base = P->nbind; /* xmlns on this element go above here */
836
+ int pushed = 0;
837
+ if (parse_element_body(P, el, &pushed) != 0) return -1;
838
+ if (pushed) {
839
+ if (P->depth + 1 > MKR_XML_MAX_DEPTH) { P->status = MKR_XML_ERR_LIMIT; return -1; }
840
+ if (mkr_grow_reserve((void **)&P->stack, &P->scap, P->depth + 1, sizeof(*P->stack)) != MKR_OK
841
+ || mkr_grow_reserve((void **)&P->frame, &P->fcap, P->depth + 1, sizeof(*P->frame)) != MKR_OK) {
842
+ P->status = MKR_XML_ERR_OOM; return -1;
843
+ }
844
+ P->stack[P->depth] = el; P->frame[P->depth] = bind_base; P->depth++;
845
+ } else {
846
+ P->nbind = bind_base; /* self-closing: pop its namespace scope now */
847
+ }
848
+ return 0;
849
+ }
850
+
851
+ /* Character data up to the next '<'. Inside an element it becomes a TEXT node
852
+ * (references expanded, XML Char validated); at the document level only white
853
+ * space (misc) is permitted. */
854
+ static int
855
+ parse_text(mkr_xml_parser_t *P)
856
+ {
857
+ const char *tstart = mkr_span_mark(&P->in);
858
+ int nonspace = 0;
859
+ for (;;) {
860
+ int c = mkr_span_peek(&P->in);
861
+ if (c < 0 || c == '<') break;
862
+ /* §2.4: the literal "]]>" MUST NOT appear in character data - only a
863
+ * CDATA section (handled by parse_cdata) may end with it. A lone ']' or
864
+ * "]]" not followed by '>' is fine. */
865
+ if (c == ']' && lit_ahead(P, "]]>", 3)) { set_syntax(P); return -1; }
866
+ if (!(c == ' ' || c == '\t' || c == '\n' || c == '\r')) nonspace = 1;
867
+ advance(P);
868
+ }
869
+ size_t traw = mkr_span_since(&P->in, tstart);
870
+ if (traw > UINT32_MAX) { P->status = MKR_XML_ERR_LIMIT; return -1; } /* fail closed */
871
+ uint32_t tlen = (uint32_t)traw;
872
+ if (P->depth == 0 && P->fragment == NULL) {
873
+ if (nonspace) { set_syntax(P); return -1; } /* non-ws text outside any element */
874
+ return 0; /* document-level whitespace (misc): discarded */
875
+ }
876
+ /* expand char/entity references + validate XML Char (§9.1/§9.2). At the fragment
877
+ * top level (depth 0) char data is a child of the fragment node itself. */
878
+ uint32_t tvlen = 0;
879
+ const char *tval = mkr_xml_expand(P->doc, tstart, tlen, MKR_XML_EXPAND_TEXT, &tvlen, &P->status);
880
+ if (tval == NULL) return -1;
881
+ return append_chardata(P, cur_parent(P), MKR_XML_NODE_TYPE_TEXT, tval, tvlen);
882
+ }
883
+
884
+ /* Fold CRLF and a lone CR to LF over the whole input before tokenizing (§9.3b-A),
885
+ * so every later stage (attribute-value normalization, line counting) sees only
886
+ * LF. Only when a CR is actually present is the input copied into a freshly
887
+ * malloc'd scratch buffer (the common no-CR input tokenizes in place, zero copy);
888
+ * it can only shrink. On return, body+blen are the bytes to parse and norm is the
889
+ * scratch buffer the caller must free (NULL if none). Returns 0, or -1 on OOM. */
890
+ static int
891
+ normalize_newlines(const char *src, size_t len, const char **body, size_t *blen, char **norm)
892
+ {
893
+ *body = src; *blen = len; *norm = NULL;
894
+ mkr_span_t s = mkr_span(src, len);
895
+ size_t cr_at;
896
+ if (!mkr_span_find(&s, '\r', &cr_at)) return 0;
897
+ char *buf = mkr_reallocarray(NULL, len == 0 ? 1 : len, 1);
898
+ if (buf == NULL) return -1;
899
+ /* read through the span, write through the bounded writer (the output can
900
+ * only shrink, so `len` capacity always suffices - enforced, not trusted). */
901
+ mkr_spanbuf_t w = mkr_spanbuf(buf, len);
902
+ for (;;) {
903
+ int ch = mkr_span_take(&s);
904
+ if (ch < 0) break;
905
+ if (ch == '\r') {
906
+ mkr_spanbuf_putc(&w, '\n');
907
+ if (mkr_span_peek(&s) == '\n') mkr_span_skip(&s, 1); /* CRLF -> single LF */
908
+ } else {
909
+ mkr_spanbuf_putc(&w, (char)ch);
910
+ }
911
+ }
912
+ if (mkr_spanbuf_finish(&w) == NULL) { free(buf); return -1; } /* can't happen; fail closed */
913
+ *body = buf; *blen = w.pos; *norm = buf;
914
+ return 0;
915
+ }
916
+
917
+ /* Tokenizer dispatch: classify the construct at P->p and hand it to its handler.
918
+ * The handlers carry all parse state through P (cursor, namespace scope, AND the
919
+ * open-element stack), so this loop stays a thin classifier. Shared by the
920
+ * document and fragment entries - P->fragment selects the depth-0 rules; the
921
+ * caller does the mode-specific post-checks. */
922
+ static void
923
+ run_tokenizer(mkr_xml_parser_t *P)
924
+ {
925
+ while (mkr_span_left(&P->in) > 0) {
926
+ if (mkr_span_peek(&P->in) != '<') {
927
+ if (parse_text(P) != 0) break;
928
+ } else {
929
+ uint32_t tl = P->line, tc = P->col; /* the '<' position (start-tag line/col) */
930
+ int at_start = (mkr_span_mark(&P->in) == P->start) && P->fragment == NULL; /* '<?xml ...?>' only in a document prolog */
931
+ advance(P); /* '<' */
932
+ int c = mkr_span_peek(&P->in);
933
+ if (c < 0) { set_syntax(P); break; }
934
+ int rc;
935
+ switch (c) {
936
+ case '/': rc = parse_end_tag(P); break;
937
+ case '!': rc = parse_markup(P); break; /* comment / CDATA / DTD */
938
+ case '?': rc = parse_pi(P, cur_parent(P), at_start); break;
939
+ default: rc = parse_start_tag(P, tl, tc); break;
940
+ }
941
+ if (rc != 0) break;
942
+ }
943
+ if (P->status != MKR_XML_OK) break;
944
+ }
945
+ }
946
+
947
+ /* Seed a fragment parser's binding scope with the document root's in-scope
948
+ * namespace declarations (its xmlns / xmlns:* DOM attributes ARE the in-scope set:
949
+ * the root is the top, and the xml: prefix is implicit in ns_lookup), so a
950
+ * prefixed (or default-namespaced) name in a Document#fragment resolves against
951
+ * the document, Nokogiri-faithfully. 0, or -1 on a binding-stack failure. */
952
+ static int
953
+ seed_doc_namespaces(mkr_xml_parser_t *P)
954
+ {
955
+ mkr_xml_node_t *root = P->doc->root;
956
+ if (root == NULL) return 0;
957
+ for (mkr_xml_node_t *a = root->attrs; a != NULL; a = a->next) {
958
+ const char *bpfx; uint32_t bpl;
959
+ if (!mkr_xml_xmlns_prefix(a->qname, a->qname_len, &bpfx, &bpl)) continue;
960
+ if (push_binding(P, bpfx, bpl, a->value ? a->value : "", a->value_len) != 0) return -1;
961
+ }
962
+ return 0;
963
+ }
964
+
965
+ mkr_xml_doc_t *
966
+ mkr_xml_parse(const char *src, size_t len, mkr_xml_status_t *status)
967
+ {
968
+ return mkr_xml_parse_ex(src, len, NULL, status);
969
+ }
970
+
971
+ mkr_xml_doc_t *
972
+ mkr_xml_parse_ex(const char *src, size_t len, const mkr_xml_limits_t *limits,
973
+ mkr_xml_status_t *status)
974
+ {
975
+ mkr_xml_doc_t *doc = mkr_xml_doc_new();
976
+ if (doc == NULL) { if (status) *status = MKR_XML_ERR_OOM; return NULL; }
977
+
978
+ /* Apply per-parse budget overrides over the compile-time defaults (a 0 field
979
+ * leaves the default; a NULL +limits+ leaves all defaults). Only max_bytes is
980
+ * runtime-configurable today. */
981
+ if (limits != NULL && limits->max_bytes != 0) doc->max_bytes = limits->max_bytes;
982
+
983
+ /* Fail closed on an over-budget input before ANY allocation (src is not yet
984
+ * read): every retained byte is copied into the arena, so an input longer
985
+ * than the arena byte budget can never succeed - reject it now instead of
986
+ * after building most of a doomed document. This also bounds the line-ending
987
+ * scratch below (norm <= len <= max_bytes), so it cannot run a large
988
+ * out-of-budget allocation ahead of the arena's own checks. */
989
+ if (len > doc->max_bytes) {
990
+ if (status) *status = MKR_XML_ERR_LIMIT;
991
+ mkr_xml_doc_destroy(doc);
992
+ return NULL;
993
+ }
994
+
995
+ /* Create the DOCUMENT node up front: it is the XPath "/" root, what a Ruby
996
+ * Document wraps, and the parent that top-level comments / PIs and the root
997
+ * element attach to during the parse (cur_parent at depth 0). */
998
+ doc->doc_node = mkr_xml_arena_node(doc, MKR_XML_NODE_TYPE_DOCUMENT);
999
+ if (doc->doc_node == NULL) {
1000
+ if (status) *status = doc->oom;
1001
+ mkr_xml_doc_destroy(doc);
1002
+ return NULL;
1003
+ }
1004
+
1005
+ const char *body; size_t blen; char *norm;
1006
+ if (normalize_newlines(src, len, &body, &blen, &norm) != 0) {
1007
+ if (status) *status = MKR_XML_ERR_OOM;
1008
+ mkr_xml_doc_destroy(doc);
1009
+ return NULL;
1010
+ }
1011
+
1012
+ mkr_xml_parser_t P = { .in = mkr_span(body, blen), .start = body,
1013
+ .line = 1, .col = 1, .doc = doc, .status = MKR_XML_OK };
1014
+ run_tokenizer(&P);
1015
+
1016
+ if (P.status == MKR_XML_OK) {
1017
+ if (P.depth != 0) set_syntax(&P); /* unclosed element(s) */
1018
+ else if (doc->root == NULL) set_syntax(&P); /* no root element */
1019
+ }
1020
+
1021
+ free(P.stack);
1022
+ free(P.frame);
1023
+ free(P.binds);
1024
+ free(P.ratt);
1025
+ free(norm); /* scratch line-ending-normalized buffer (if any) */
1026
+ if (P.status != MKR_XML_OK) {
1027
+ if (status) *status = P.status;
1028
+ mkr_xml_doc_destroy(doc);
1029
+ return NULL;
1030
+ }
1031
+ if (status) *status = MKR_XML_OK;
1032
+ return doc;
1033
+ }
1034
+
1035
+ mkr_xml_node_t *
1036
+ mkr_xml_parse_fragment(mkr_xml_doc_t *doc, const char *src, size_t len,
1037
+ int inherit_doc_ns, mkr_xml_status_t *status)
1038
+ {
1039
+ /* Coarse fast-fail before any allocation; the arena's per-cut checks enforce
1040
+ * the remaining budget during the parse (an existing document has already
1041
+ * spent some of it). */
1042
+ if (len > doc->max_bytes) { if (status) *status = MKR_XML_ERR_LIMIT; return NULL; }
1043
+
1044
+ mkr_xml_node_t *frag = mkr_xml_arena_node(doc, MKR_XML_NODE_TYPE_DOCUMENT_FRAGMENT);
1045
+ if (frag == NULL) { if (status) *status = doc->oom; return NULL; }
1046
+
1047
+ const char *body; size_t blen; char *norm;
1048
+ if (normalize_newlines(src, len, &body, &blen, &norm) != 0) {
1049
+ if (status) *status = MKR_XML_ERR_OOM;
1050
+ return NULL; /* frag stays in the arena, detached (never freed) */
1051
+ }
1052
+
1053
+ mkr_xml_parser_t P = { .in = mkr_span(body, blen), .start = body, .line = 1,
1054
+ .col = 1, .doc = doc, .fragment = frag, .status = MKR_XML_OK };
1055
+
1056
+ if (inherit_doc_ns && seed_doc_namespaces(&P) != 0) {
1057
+ free(P.binds); free(norm);
1058
+ if (status) *status = P.status;
1059
+ return NULL;
1060
+ }
1061
+
1062
+ run_tokenizer(&P);
1063
+ if (P.status == MKR_XML_OK && P.depth != 0) set_syntax(&P); /* unclosed element(s) */
1064
+
1065
+ free(P.stack);
1066
+ free(P.frame);
1067
+ free(P.binds);
1068
+ free(P.ratt);
1069
+ free(norm);
1070
+ if (P.status != MKR_XML_OK) {
1071
+ if (status) *status = P.status;
1072
+ return NULL; /* the partial fragment stays detached in the arena */
1073
+ }
1074
+ if (status) *status = MKR_XML_OK;
1075
+ return frag;
1076
+ }
1077
+
1078
+ /* ---- parse self-test (structural; run from Makiri.__c_selftest under ASan) --
1079
+ * Returns 0 on success or the 1-based index of the first failing check. Test
1080
+ * strings are compile-time literals, so lengths come from sizeof - 1 (no
1081
+ * strlen on a non-validated string -> clint clean). */
1082
+ static int nlname(const mkr_xml_node_t *n, const char *s, size_t l)
1083
+ {
1084
+ return n && mkr_bytes_eq(n->local, n->local_len, s, l);
1085
+ }
1086
+ static int nlval(const mkr_xml_node_t *n, const char *s, size_t l)
1087
+ {
1088
+ return n && mkr_bytes_eq(n->value, n->value_len, s, l);
1089
+ }
1090
+ static int nlns(const mkr_xml_node_t *n, const char *s, size_t l)
1091
+ {
1092
+ return n && mkr_bytes_eq(n->ns_uri, n->ns_uri_len, s, l);
1093
+ }
1094
+ static int nlpfx(const mkr_xml_node_t *n, const char *s, size_t l)
1095
+ {
1096
+ return n && mkr_bytes_eq(n->prefix, n->prefix_len, s, l);
1097
+ }
1098
+ #define NAME_IS(n, lit) nlname((n), (lit), sizeof(lit) - 1)
1099
+ #define VAL_IS(n, lit) nlval((n), (lit), sizeof(lit) - 1)
1100
+ #define NS_IS(n, lit) nlns((n), (lit), sizeof(lit) - 1)
1101
+ #define PFX_IS(n, lit) nlpfx((n), (lit), sizeof(lit) - 1)
1102
+ #define NS_NONE(n) ((n) && (n)->ns_uri_len == 0)
1103
+ #define PARSE_LIT(lit, stp) mkr_xml_parse((lit), sizeof(lit) - 1, (stp))
1104
+
1105
+ int
1106
+ mkr_xml_parse_selftest(void)
1107
+ {
1108
+ mkr_xml_status_t st = MKR_XML_OK;
1109
+ int i = 0;
1110
+
1111
+ /* happy path: structure, attributes, mixed content, case preservation */
1112
+ i++; /* 1 */
1113
+ mkr_xml_doc_t *d = PARSE_LIT("<Feed x='1' y='two'>hi<b/>z</Feed>", &st);
1114
+ if (d == NULL || st != MKR_XML_OK) { if (d) mkr_xml_doc_destroy(d); return i; }
1115
+
1116
+ i++; /* 2: root element, case-preserved name, source position recorded */
1117
+ mkr_xml_node_t *root = d->root;
1118
+ if (!NAME_IS(root, "Feed") || root->type != MKR_XML_NODE_TYPE_ELEMENT
1119
+ || root->line != 1 || root->col != 1) { mkr_xml_doc_destroy(d); return i; }
1120
+
1121
+ i++; /* 3: attributes x=1, y=two in order */
1122
+ mkr_xml_node_t *a0 = root->attrs;
1123
+ mkr_xml_node_t *a1 = a0 ? a0->next : NULL;
1124
+ if (!a0 || a0->type != MKR_XML_NODE_TYPE_ATTRIBUTE || !NAME_IS(a0, "x") || !VAL_IS(a0, "1")
1125
+ || !a1 || !NAME_IS(a1, "y") || !VAL_IS(a1, "two") || a1->next != NULL) {
1126
+ mkr_xml_doc_destroy(d); return i;
1127
+ }
1128
+
1129
+ i++; /* 4: children = text "hi", element <b>, text "z" */
1130
+ mkr_xml_node_t *c0 = root->first_child;
1131
+ mkr_xml_node_t *c1 = c0 ? c0->next : NULL;
1132
+ mkr_xml_node_t *c2 = c1 ? c1->next : NULL;
1133
+ if (!c0 || c0->type != MKR_XML_NODE_TYPE_TEXT || !VAL_IS(c0, "hi")
1134
+ || !c1 || c1->type != MKR_XML_NODE_TYPE_ELEMENT || !NAME_IS(c1, "b") || c1->first_child != NULL
1135
+ || !c2 || c2->type != MKR_XML_NODE_TYPE_TEXT || !VAL_IS(c2, "z") || c2->next != NULL) {
1136
+ mkr_xml_doc_destroy(d); return i;
1137
+ }
1138
+ i++; /* 5: parent/sibling links */
1139
+ if (c1->parent != root || c1->prev != c0 || c2->prev != c1) { mkr_xml_doc_destroy(d); return i; }
1140
+ mkr_xml_doc_destroy(d);
1141
+
1142
+ /* case sensitivity: <X> and <x> are distinct */
1143
+ i++; /* 6 */
1144
+ d = PARSE_LIT("<X><x/></X>", &st);
1145
+ if (d == NULL || !NAME_IS(d->root, "X") || !NAME_IS(d->root->first_child, "x")) {
1146
+ if (d) mkr_xml_doc_destroy(d); return i;
1147
+ }
1148
+ mkr_xml_doc_destroy(d);
1149
+
1150
+ /* well-formedness errors fail closed (NULL + SYNTAX, no leak) */
1151
+ i++; /* 7: one literal per case keeps sizeof-based lengths */
1152
+ st = MKR_XML_OK;
1153
+ mkr_xml_doc_t *e;
1154
+ e = PARSE_LIT("<a>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; }
1155
+ st = MKR_XML_OK;
1156
+ e = PARSE_LIT("<a></b>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; }
1157
+ st = MKR_XML_OK;
1158
+ e = PARSE_LIT("<a/><b/>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; }
1159
+ st = MKR_XML_OK;
1160
+ e = PARSE_LIT("x<a/>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; }
1161
+ st = MKR_XML_OK;
1162
+ e = PARSE_LIT("<a x=>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; }
1163
+ st = MKR_XML_OK;
1164
+ e = PARSE_LIT("<a y='<'>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; }
1165
+
1166
+ /* §9.1: predefined + numeric references expand; text and attribute values */
1167
+ i++; /* 8 */
1168
+ d = PARSE_LIT("<a x='p&amp;q' y='&#65;&#x42;'>1&lt;2&gt;3&amp;4&apos;5&quot;6</a>", &st);
1169
+ if (d == NULL || st != MKR_XML_OK) { if (d) mkr_xml_doc_destroy(d); return i; }
1170
+ {
1171
+ mkr_xml_node_t *r = d->root;
1172
+ mkr_xml_node_t *ax = r->attrs, *ay = ax ? ax->next : NULL;
1173
+ mkr_xml_node_t *tx = r->first_child;
1174
+ if (!VAL_IS(ax, "p&q") || !VAL_IS(ay, "AB")
1175
+ || !tx || tx->type != MKR_XML_NODE_TYPE_TEXT || !VAL_IS(tx, "1<2>3&4'5\"6")) {
1176
+ mkr_xml_doc_destroy(d); return i;
1177
+ }
1178
+ }
1179
+ mkr_xml_doc_destroy(d);
1180
+
1181
+ /* §9.1/§9.2: undefined entity, bad/empty reference, and non-XML-Char fail closed */
1182
+ i++; /* 9 */
1183
+ st = MKR_XML_OK;
1184
+ e = PARSE_LIT("<a>&nbsp;</a>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; }
1185
+ st = MKR_XML_OK;
1186
+ e = PARSE_LIT("<a>x & y</a>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; } /* bare & */
1187
+ st = MKR_XML_OK;
1188
+ e = PARSE_LIT("<a>&#0;</a>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; } /* NUL not XML Char */
1189
+ st = MKR_XML_OK;
1190
+ e = PARSE_LIT("<a>&#xD800;</a>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; } /* surrogate */
1191
+ st = MKR_XML_OK;
1192
+ e = PARSE_LIT("<a>&#;</a>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; } /* no digits */
1193
+
1194
+ /* §7: namespaces - prefixed element + attribute, default-ns inheritance,
1195
+ * unprefixed attribute has no namespace, xmlns kept as a DOM attribute */
1196
+ i++; /* 10 */
1197
+ d = PARSE_LIT("<a:e xmlns:a='urn:a' xmlns='urn:d' a:x='1' y='2'><c/></a:e>", &st);
1198
+ if (d == NULL || st != MKR_XML_OK) { if (d) mkr_xml_doc_destroy(d); return i; }
1199
+ {
1200
+ mkr_xml_node_t *r = d->root;
1201
+ if (!NAME_IS(r, "e") || !PFX_IS(r, "a") || !NS_IS(r, "urn:a")) { mkr_xml_doc_destroy(d); return i; }
1202
+ mkr_xml_node_t *c = r->first_child;
1203
+ if (!NAME_IS(c, "c") || !NS_IS(c, "urn:d")) { mkr_xml_doc_destroy(d); return i; } /* default ns inherited */
1204
+ /* attrs in order: xmlns:a, xmlns, a:x, y */
1205
+ mkr_xml_node_t *a = r->attrs;
1206
+ if (!a || !NAME_IS(a, "a") || !PFX_IS(a, "xmlns") || !NS_IS(a, XMLNS_NS_URI)) { mkr_xml_doc_destroy(d); return i; }
1207
+ a = a->next; /* xmlns */
1208
+ if (!a || !NAME_IS(a, "xmlns") || a->prefix_len != 0) { mkr_xml_doc_destroy(d); return i; }
1209
+ a = a->next; /* a:x */
1210
+ if (!a || !NAME_IS(a, "x") || !PFX_IS(a, "a") || !NS_IS(a, "urn:a") || !VAL_IS(a, "1")) { mkr_xml_doc_destroy(d); return i; }
1211
+ a = a->next; /* y -> NO namespace (default ns never applies to attributes) */
1212
+ if (!a || !NAME_IS(a, "y") || a->prefix_len != 0 || !NS_NONE(a) || !VAL_IS(a, "2")) { mkr_xml_doc_destroy(d); return i; }
1213
+ }
1214
+ mkr_xml_doc_destroy(d);
1215
+
1216
+ /* §7.1: namespace errors fail closed */
1217
+ i++; /* 11 */
1218
+ st = MKR_XML_OK;
1219
+ e = PARSE_LIT("<a:b/>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; } /* unbound element prefix */
1220
+ st = MKR_XML_OK;
1221
+ e = PARSE_LIT("<a x:y='1'/>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; } /* unbound attr prefix */
1222
+ st = MKR_XML_OK;
1223
+ e = PARSE_LIT("<a xmlns:xml='wrong'/>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; } /* rebinding xml */
1224
+ st = MKR_XML_OK;
1225
+ e = PARSE_LIT("<a:b xmlns:a=''/>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; } /* xmlns:p="" */
1226
+
1227
+ /* §3.3.3: attribute-value normalization - a LITERAL tab/newline/CR in the
1228
+ * source folds to a space, while a tab/LF from a numeric reference is kept.
1229
+ * Text content is NOT folded (the literal newline survives verbatim). */
1230
+ i++; /* 12 */
1231
+ d = PARSE_LIT("<a x=\"p\tq\nr\" y=\"p&#9;q&#10;r\">u\tv\nw</a>", &st);
1232
+ if (d == NULL || st != MKR_XML_OK) { if (d) mkr_xml_doc_destroy(d); return i; }
1233
+ {
1234
+ mkr_xml_node_t *r = d->root;
1235
+ mkr_xml_node_t *ax = r->attrs, *ay = ax ? ax->next : NULL;
1236
+ mkr_xml_node_t *tx = r->first_child;
1237
+ if (!VAL_IS(ax, "p q r") /* literal whitespace -> spaces */
1238
+ || !VAL_IS(ay, "p\tq\nr") /* reference-derived whitespace preserved */
1239
+ || !tx || tx->type != MKR_XML_NODE_TYPE_TEXT || !VAL_IS(tx, "u\tv\nw")) { /* text unfolded */
1240
+ mkr_xml_doc_destroy(d); return i;
1241
+ }
1242
+ }
1243
+ mkr_xml_doc_destroy(d);
1244
+
1245
+ /* §9: comment / CDATA / PI nodes inside an element; the prolog XML declaration
1246
+ * is not retained, but a prolog PI / comment IS, as a child of the DOCUMENT
1247
+ * node (before the root); '<!DOCTYPE' is fail-closed elsewhere. */
1248
+ i++; /* 13 */
1249
+ d = PARSE_LIT("<?xml version=\"1.0\"?><?xml-stylesheet href=\"x\"?><!--top-->"
1250
+ "<r><!--c--><![CDATA[a<b]]><?pi dat?></r><?tail t?>", &st);
1251
+ if (d == NULL || st != MKR_XML_OK) { if (d) mkr_xml_doc_destroy(d); return i; }
1252
+ {
1253
+ mkr_xml_node_t *r = d->root;
1254
+ if (!NAME_IS(r, "r")) { mkr_xml_doc_destroy(d); return i; }
1255
+ mkr_xml_node_t *cm = r->first_child;
1256
+ mkr_xml_node_t *cd = cm ? cm->next : NULL;
1257
+ mkr_xml_node_t *pi = cd ? cd->next : NULL;
1258
+ if (!cm || cm->type != MKR_XML_NODE_TYPE_COMMENT || !VAL_IS(cm, "c")
1259
+ || !cd || cd->type != MKR_XML_NODE_TYPE_CDATA_SECTION || !VAL_IS(cd, "a<b") /* raw '<' kept */
1260
+ || !pi || pi->type != MKR_XML_NODE_TYPE_PI || !NAME_IS(pi, "pi") || !VAL_IS(pi, "dat")
1261
+ || pi->next != NULL) {
1262
+ mkr_xml_doc_destroy(d); return i;
1263
+ }
1264
+ /* the DOCUMENT node's children: prolog PI (xml-stylesheet) + comment,
1265
+ * then the root, then the epilog PI (the <?xml?> declaration is NOT a
1266
+ * node); inter-construct whitespace is not retained. */
1267
+ mkr_xml_node_t *dn = d->doc_node;
1268
+ mkr_xml_node_t *p1 = dn ? dn->first_child : NULL; /* <?xml-stylesheet?> */
1269
+ mkr_xml_node_t *p2 = p1 ? p1->next : NULL; /* <!--top--> */
1270
+ mkr_xml_node_t *p3 = p2 ? p2->next : NULL; /* <r> (root) */
1271
+ mkr_xml_node_t *p4 = p3 ? p3->next : NULL; /* <?tail?> */
1272
+ if (!p1 || p1->type != MKR_XML_NODE_TYPE_PI || !NAME_IS(p1, "xml-stylesheet")
1273
+ || !p2 || p2->type != MKR_XML_NODE_TYPE_COMMENT || !VAL_IS(p2, "top")
1274
+ || p3 != r || r->parent != dn
1275
+ || !p4 || p4->type != MKR_XML_NODE_TYPE_PI || !NAME_IS(p4, "tail")
1276
+ || p4->next != NULL) {
1277
+ mkr_xml_doc_destroy(d); return i;
1278
+ }
1279
+ }
1280
+ mkr_xml_doc_destroy(d);
1281
+
1282
+ i++; /* 14: §9 fail-closed cases */
1283
+ st = MKR_XML_OK;
1284
+ e = PARSE_LIT("<r/><!DOCTYPE r>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; } /* DOCTYPE after root */
1285
+ st = MKR_XML_OK;
1286
+ e = PARSE_LIT("<!DOCTYPE r [ <!ENTITY x \"y\"> ]><r>&x;</r>", &st);
1287
+ if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; } /* DTD entity not registered -> &x; undefined */
1288
+ st = MKR_XML_OK;
1289
+ e = PARSE_LIT("<r><!-- a--b --></r>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; } /* '--' in comment */
1290
+ st = MKR_XML_OK;
1291
+ e = PARSE_LIT("<r><!-- c </r>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; } /* unterminated comment */
1292
+ st = MKR_XML_OK;
1293
+ e = PARSE_LIT(" <?xml version=\"1.0\"?><r/>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; } /* decl not at start */
1294
+ st = MKR_XML_OK;
1295
+ e = PARSE_LIT("<![CDATA[x]]><r/>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; } /* CDATA outside root */
1296
+
1297
+ /* §9.4 alternative: DOCTYPE is recognized but not processed - the external ID
1298
+ * and the internal subset (with a '>' inside a markup decl, and '>' inside a
1299
+ * quoted literal) are skipped to the true '>', nothing in the DTD is
1300
+ * registered, and parsing continues into the root. The name + external ID are
1301
+ * retained in an off-tree DOCUMENT_TYPE node (doc->doctype, NOT a child of any
1302
+ * node, so the tree/XPath is unaffected); here SYSTEM "a>b" (with a quoted
1303
+ * '>') gives system id "a>b" and no public id. */
1304
+ i++; /* 14b */
1305
+ d = PARSE_LIT("<!DOCTYPE r SYSTEM \"a>b\" [ <!ELEMENT r (#PCDATA)> ]><r>ok</r>", &st);
1306
+ if (d == NULL || st != MKR_XML_OK || !NAME_IS(d->root, "r")
1307
+ || !VAL_IS(d->root->first_child, "ok")) {
1308
+ if (d) mkr_xml_doc_destroy(d); return i;
1309
+ }
1310
+ {
1311
+ mkr_xml_node_t *dt = d->doctype;
1312
+ if (dt == NULL || dt->type != MKR_XML_NODE_TYPE_DOCUMENT_TYPE
1313
+ || dt->parent != NULL || dt->next != NULL || dt->prev != NULL /* off-tree */
1314
+ || !slice_eq(dt->local, dt->local_len, "r", 1)
1315
+ || dt->prefix != NULL /* no PUBLIC id */
1316
+ || !slice_eq(dt->value, dt->value_len, "a>b", 3)) { /* SYSTEM id */
1317
+ mkr_xml_doc_destroy(d); return i;
1318
+ }
1319
+ }
1320
+ mkr_xml_doc_destroy(d);
1321
+
1322
+ /* §9.3b-A: line-ending normalization happens before attribute-value
1323
+ * normalization, so a literal CRLF inside an attribute collapses to ONE space
1324
+ * (CRLF -> LF -> space); in text content CRLF/CR just fold to LF, unfolded. */
1325
+ i++; /* 15 */
1326
+ d = PARSE_LIT("<a x=\"p\r\nq\r\">m\r\nn\ro</a>", &st);
1327
+ if (d == NULL || st != MKR_XML_OK) { if (d) mkr_xml_doc_destroy(d); return i; }
1328
+ {
1329
+ mkr_xml_node_t *r = d->root;
1330
+ mkr_xml_node_t *ax = r->attrs;
1331
+ mkr_xml_node_t *tx = r->first_child;
1332
+ if (!VAL_IS(ax, "p q ") /* CRLF and lone CR each -> one space */
1333
+ || !tx || tx->type != MKR_XML_NODE_TYPE_TEXT || !VAL_IS(tx, "m\nn\no")) { /* folded to LF */
1334
+ mkr_xml_doc_destroy(d); return i;
1335
+ }
1336
+ }
1337
+ mkr_xml_doc_destroy(d);
1338
+
1339
+ i++; /* 16: §9.2b strict names + §9.3 duplicate attributes fail closed */
1340
+ st = MKR_XML_OK;
1341
+ e = PARSE_LIT("<1bad/>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; } /* NameStartChar */
1342
+ st = MKR_XML_OK;
1343
+ e = PARSE_LIT("<a:1b xmlns:a='u'/>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; } /* NCName local */
1344
+ st = MKR_XML_OK;
1345
+ e = PARSE_LIT("<a x='1' x='2'/>", &st); if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; } /* duplicate attr */
1346
+ st = MKR_XML_OK;
1347
+ e = PARSE_LIT("<e xmlns:a='u' xmlns:b='u' a:x='1' b:x='2'/>", &st);
1348
+ if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; } /* same-URI duplicate */
1349
+ st = MKR_XML_OK;
1350
+ e = PARSE_LIT("<a>foo]]>bar</a>", &st); /* §2.4: literal ]]> forbidden in content */
1351
+ if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; }
1352
+ st = MKR_XML_OK;
1353
+ d = PARSE_LIT("<a>1]2]]3</a>", &st); /* but a lone ] / ]] is fine */
1354
+ if (d == NULL || st != MKR_XML_OK || !VAL_IS(d->root->first_child, "1]2]]3")) {
1355
+ if (d) mkr_xml_doc_destroy(d); return i;
1356
+ }
1357
+ mkr_xml_doc_destroy(d);
1358
+
1359
+ i++; /* 17: §2.8 XML declaration grammar + §2.6 reserved/colon PI targets */
1360
+ {
1361
+ /* a fully-specified valid declaration parses */
1362
+ st = MKR_XML_OK;
1363
+ d = PARSE_LIT("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><r/>", &st);
1364
+ if (d == NULL || st != MKR_XML_OK) { if (d) mkr_xml_doc_destroy(d); return i; }
1365
+ mkr_xml_doc_destroy(d);
1366
+ static const char *bad[] = {
1367
+ "<?xml VERSION=\"1.0\"?><r/>", /* keyword case */
1368
+ "<?xml version=\"1.0\" standalone=\"YES\"?><r/>", /* value case */
1369
+ "<?xml encoding=\"UTF-8\"?><r/>", /* version required */
1370
+ "<?xml version=\"1.0\"encoding=\"UTF-8\"?><r/>", /* missing S separator */
1371
+ "<?xml version=\"1.0\" version=\"1.0\"?><r/>", /* duplicate */
1372
+ "<?xml version=\"1.0\" valid=\"no\"?><r/>", /* unknown pseudo-attr */
1373
+ "<?xml version=\"1.0' ?><r/>", /* mismatched quotes */
1374
+ "<?xml version=\"1.0^\"?><r/>", /* bad VersionNum char */
1375
+ "<?XML version=\"1.0\"?><r/>", /* reserved target (wrong case) */
1376
+ "<r><?a:b data?></r>", /* colon in PITarget (NS) */
1377
+ "<a x=\"1\"y=\"2\"/>", /* §3.1: missing S between attrs */
1378
+ "<a>&#X58;</a>", /* §4.1: hex marker must be lowercase x */
1379
+ };
1380
+ for (size_t k = 0; k < sizeof(bad) / sizeof(bad[0]); k++) {
1381
+ st = MKR_XML_OK;
1382
+ e = PARSE_LIT(bad[k], &st);
1383
+ if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; }
1384
+ }
1385
+ /* a well-formed but unsupported version (XML 1.1 / 1.x) is fail-closed
1386
+ * with its own status, distinct from a malformedness SYNTAX error. */
1387
+ st = MKR_XML_OK;
1388
+ e = PARSE_LIT("<?xml version=\"1.1\"?><r/>", &st);
1389
+ if (e || st != MKR_XML_ERR_VERSION) { if (e) mkr_xml_doc_destroy(e); return i; }
1390
+ st = MKR_XML_OK;
1391
+ e = PARSE_LIT("<?xml version=\"1.5\"?><r/>", &st);
1392
+ if (e || st != MKR_XML_ERR_VERSION) { if (e) mkr_xml_doc_destroy(e); return i; }
1393
+ /* version="2.0" is not even a valid VersionNum ('1.' [0-9]+) -> SYNTAX */
1394
+ st = MKR_XML_OK;
1395
+ e = PARSE_LIT("<?xml version=\"2.0\"?><r/>", &st);
1396
+ if (e || st != MKR_XML_ERR_SYNTAX) { if (e) mkr_xml_doc_destroy(e); return i; }
1397
+ }
1398
+
1399
+ /* §4 byte-budget entry guard: an input longer than MKR_XML_MAX_BYTES fails
1400
+ * closed (MKR_XML_ERR_LIMIT) before any allocation. The guard tests `len`
1401
+ * only, so a tiny buffer with a huge claimed length exercises it without
1402
+ * allocating - and proves `src` is not dereferenced past the budget. */
1403
+ i++; /* 18 */
1404
+ st = MKR_XML_OK;
1405
+ {
1406
+ static const char tiny[] = "<r/>";
1407
+ if (mkr_xml_parse(tiny, (size_t)MKR_XML_MAX_BYTES + 1u, &st) != NULL
1408
+ || st != MKR_XML_ERR_LIMIT) {
1409
+ return i;
1410
+ }
1411
+ /* and the boundary (== budget) is NOT rejected by the guard (it parses,
1412
+ * since this real 4-byte input is well under the budget). */
1413
+ st = MKR_XML_OK;
1414
+ d = mkr_xml_parse(tiny, sizeof(tiny) - 1, &st);
1415
+ if (d == NULL || st != MKR_XML_OK) { if (d) mkr_xml_doc_destroy(d); return i; }
1416
+ mkr_xml_doc_destroy(d);
1417
+ }
1418
+
1419
+ /* §4 per-parse override (mkr_xml_parse_ex): a tiny max_bytes lowers the
1420
+ * effective budget so a document that parses under the default now fails
1421
+ * closed, and a NULL/zeroed limits reproduces the default. */
1422
+ i++; /* 19 */
1423
+ {
1424
+ static const char doc_src[] = "<root><a/><b/><c/></root>";
1425
+ size_t dlen = sizeof(doc_src) - 1;
1426
+ /* a 2-byte budget trips the entry guard (len > max_bytes) */
1427
+ st = MKR_XML_OK;
1428
+ mkr_xml_limits_t tiny_budget = { .max_bytes = 2 };
1429
+ if (mkr_xml_parse_ex(doc_src, dlen, &tiny_budget, &st) != NULL || st != MKR_XML_ERR_LIMIT) {
1430
+ return i;
1431
+ }
1432
+ /* a budget that passes the entry guard but cannot hold the nodes trips the
1433
+ * arena allocator instead (one 128-byte node already exceeds 64). */
1434
+ st = MKR_XML_OK;
1435
+ mkr_xml_limits_t small_budget = { .max_bytes = 64 };
1436
+ if (mkr_xml_parse_ex(doc_src, dlen, &small_budget, &st) != NULL || st != MKR_XML_ERR_LIMIT) {
1437
+ return i;
1438
+ }
1439
+ /* a generous override parses the same document; zeroed limits == default */
1440
+ st = MKR_XML_OK;
1441
+ mkr_xml_limits_t big_budget = { .max_bytes = (size_t)1024u * 1024u };
1442
+ d = mkr_xml_parse_ex(doc_src, dlen, &big_budget, &st);
1443
+ if (d == NULL || st != MKR_XML_OK || !NAME_IS(d->root, "root")) {
1444
+ if (d) mkr_xml_doc_destroy(d); return i;
1445
+ }
1446
+ mkr_xml_doc_destroy(d);
1447
+ st = MKR_XML_OK;
1448
+ mkr_xml_limits_t zero = { 0 };
1449
+ d = mkr_xml_parse_ex(doc_src, dlen, &zero, &st);
1450
+ if (d == NULL || st != MKR_XML_OK) { if (d) mkr_xml_doc_destroy(d); return i; }
1451
+ mkr_xml_doc_destroy(d);
1452
+ }
1453
+
1454
+ /* 20: fragment - multiple top-level nodes (no single-root rule), char data at
1455
+ * the top level, a self-declared namespace. */
1456
+ i++;
1457
+ {
1458
+ static const char fsrc[] = "<a/>txt<p:b xmlns:p='urn:p'>x</p:b>";
1459
+ mkr_xml_doc_t *fd = mkr_xml_doc_new();
1460
+ if (fd == NULL) return i;
1461
+ st = MKR_XML_OK;
1462
+ mkr_xml_node_t *frag = mkr_xml_parse_fragment(fd, fsrc, sizeof(fsrc) - 1, 0, &st);
1463
+ if (frag == NULL || st != MKR_XML_OK
1464
+ || frag->type != MKR_XML_NODE_TYPE_DOCUMENT_FRAGMENT) { mkr_xml_doc_destroy(fd); return i; }
1465
+ mkr_xml_node_t *c0 = frag->first_child;
1466
+ mkr_xml_node_t *c1 = c0 ? c0->next : NULL;
1467
+ mkr_xml_node_t *c2 = c1 ? c1->next : NULL;
1468
+ if (!NAME_IS(c0, "a") || c0->type != MKR_XML_NODE_TYPE_ELEMENT
1469
+ || c1 == NULL || c1->type != MKR_XML_NODE_TYPE_TEXT || !VAL_IS(c1, "txt")
1470
+ || !NAME_IS(c2, "b") || !NS_IS(c2, "urn:p") || c2->next != NULL) {
1471
+ mkr_xml_doc_destroy(fd); return i;
1472
+ }
1473
+ mkr_xml_doc_destroy(fd);
1474
+ }
1475
+
1476
+ /* 21: a fragment fails closed on an XML declaration, a DOCTYPE, a stray end
1477
+ * tag, an unclosed element, and (standalone) an unbound prefix. */
1478
+ i++;
1479
+ {
1480
+ mkr_xml_doc_t *fd = mkr_xml_doc_new();
1481
+ if (fd == NULL) return i;
1482
+ #define FRAG_REJECTS(lit) do { \
1483
+ st = MKR_XML_OK; \
1484
+ if (mkr_xml_parse_fragment(fd, (lit), sizeof(lit) - 1, 0, &st) != NULL \
1485
+ || st == MKR_XML_OK) { mkr_xml_doc_destroy(fd); return i; } \
1486
+ } while (0)
1487
+ FRAG_REJECTS("<?xml version='1.0'?>");
1488
+ FRAG_REJECTS("<!DOCTYPE r>");
1489
+ FRAG_REJECTS("</x>");
1490
+ FRAG_REJECTS("<a>");
1491
+ FRAG_REJECTS("<p:a/>");
1492
+ #undef FRAG_REJECTS
1493
+ mkr_xml_doc_destroy(fd);
1494
+ }
1495
+
1496
+ /* 22: inherit_doc_ns resolves a prefixed (and default-namespaced) fragment name
1497
+ * against the document's root namespaces (Document#fragment). */
1498
+ i++;
1499
+ {
1500
+ static const char fsrc[] = "<p:a/><plain/>";
1501
+ st = MKR_XML_OK;
1502
+ mkr_xml_doc_t *fd = PARSE_LIT("<r xmlns:p='urn:p' xmlns='urn:d'/>", &st);
1503
+ if (fd == NULL || st != MKR_XML_OK) { if (fd) mkr_xml_doc_destroy(fd); return i; }
1504
+ st = MKR_XML_OK;
1505
+ mkr_xml_node_t *frag = mkr_xml_parse_fragment(fd, fsrc, sizeof(fsrc) - 1, 1, &st);
1506
+ mkr_xml_node_t *a = frag ? frag->first_child : NULL;
1507
+ mkr_xml_node_t *plain = a ? a->next : NULL;
1508
+ if (frag == NULL || st != MKR_XML_OK || !NS_IS(a, "urn:p") || !NS_IS(plain, "urn:d")) {
1509
+ mkr_xml_doc_destroy(fd); return i;
1510
+ }
1511
+ mkr_xml_doc_destroy(fd);
1512
+ }
1513
+
1514
+ return 0;
1515
+ }