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,139 @@
1
+ /* mkr_xml_mutate.h - Ruby-free XML tree mutation primitives (Phase 1).
2
+ *
3
+ * The write counterpart of mkr_xml_tree.c's read-only build: in-place edits over
4
+ * the custom node arena (rename, attribute set/remove, content set, detach). All
5
+ * primitives are Ruby-free and operate only on arena-stable pointers, so they are
6
+ * fully covered by ASan/UBSan and the fuzzer; the Ruby boundary (glue/
7
+ * ruby_xml_node.c) coerces+verifies arguments and maps the status codes below to
8
+ * exceptions.
9
+ *
10
+ * DETACH-NEVER-DESTROY: a removed node is unlinked, never freed - the arena owns
11
+ * node memory and a live Ruby wrapper may still alias a removed node (the same
12
+ * invariant the read-only reader relied on). New nodes append to the arena and
13
+ * count against the per-document byte/node budgets (fail-closed -> MKR_XML_MUT_OOM).
14
+ *
15
+ * SECURITY: names are validated as XML 1.0 QNames and values as XML Char, so the
16
+ * tree stays serializable to well-formed XML; an unbound prefix or a reserved
17
+ * name fails closed rather than producing a wrong/unserializable node.
18
+ */
19
+ #ifndef MKR_XML_MUTATE_H
20
+ #define MKR_XML_MUTATE_H
21
+
22
+ #include <stdbool.h>
23
+ #include <stdint.h>
24
+ #include "mkr_xml_node.h"
25
+
26
+ /* Mutation outcome (the glue maps each to a Ruby exception). */
27
+ typedef enum {
28
+ MKR_XML_MUT_OK = 0,
29
+ MKR_XML_MUT_OOM, /* arena byte/node budget or allocation failure */
30
+ MKR_XML_MUT_BAD_NAME, /* not a well-formed XML 1.0 QName (or a reserved one) */
31
+ MKR_XML_MUT_BAD_CHARS, /* a non-XML-Char byte (§2.2) or a forbidden sequence
32
+ * for the context ("?>" in a PI, "--" in a comment,
33
+ * "]]>" in a CDATA section) */
34
+ MKR_XML_MUT_UNBOUND_NS, /* a prefix has no in-scope namespace binding */
35
+ MKR_XML_MUT_TYPE, /* the operation is invalid for this node type */
36
+ MKR_XML_MUT_CYCLE, /* the node would become a descendant of itself */
37
+ MKR_XML_MUT_HIERARCHY, /* invalid placement (attr/document child, second root, parentless ref) */
38
+ MKR_XML_MUT_BAD_NS_DECL /* a namespace declaration is invalid (xmlns:prefix bound to "") */
39
+ } mkr_xml_mut_status_t;
40
+
41
+ /* Detach +node+ from the tree: from its parent's child list, or - for an
42
+ * attribute - from its owner element's attribute list. No-op when already
43
+ * detached (no parent). The arena keeps the memory (detach-never-destroy); the
44
+ * caller clears doc->root if it detaches the root element. */
45
+ void mkr_xml_detach(mkr_xml_node_t *node);
46
+
47
+ /* Rename an element or attribute in place to the QName [name, name+nlen):
48
+ * validates it, copies it contiguously into the arena, re-splits prefix/local,
49
+ * and re-resolves the namespace against the node's in-scope declarations (the
50
+ * element itself for an element; the owner element for an attribute). The node
51
+ * pointer - and thus its identity and tree position - is preserved. */
52
+ mkr_xml_mut_status_t mkr_xml_rename(mkr_xml_doc_t *doc, mkr_xml_node_t *node,
53
+ const char *name, uint32_t nlen);
54
+
55
+ /* Set attribute [name,nlen]="[val,vlen]" on element +el+: replaces the value of
56
+ * an existing attribute with the same raw QName, otherwise appends a new
57
+ * attribute node. The namespace is resolved like the parser (xmlns / xmlns:* ->
58
+ * the xmlns namespace; the predefined xml: prefix -> the XML namespace; any other
59
+ * prefix -> its in-scope URI, else MKR_XML_MUT_UNBOUND_NS; unprefixed -> none).
60
+ * +val+ must be all XML Char. *out (may be NULL) receives the affected node. */
61
+ mkr_xml_mut_status_t mkr_xml_set_attribute(mkr_xml_doc_t *doc, mkr_xml_node_t *el,
62
+ const char *name, uint32_t nlen,
63
+ const char *val, uint32_t vlen,
64
+ mkr_xml_node_t **out);
65
+
66
+ /* Remove the first attribute of element +el+ whose raw QName is [name,nlen].
67
+ * Returns 1 if one was removed, 0 if none matched (detach-never-destroy). */
68
+ int mkr_xml_remove_attribute(mkr_xml_node_t *el, const char *name, uint32_t nlen);
69
+
70
+ /* Set +node+'s text content. For an ELEMENT: detach every child and append a
71
+ * single TEXT node holding [text,tlen] (an empty +text+ leaves an empty element).
72
+ * For a TEXT/CDATA/COMMENT/PI leaf: replace its value. +text+ must be all XML
73
+ * Char. Any other node type is MKR_XML_MUT_TYPE. */
74
+ mkr_xml_mut_status_t mkr_xml_set_content(mkr_xml_doc_t *doc, mkr_xml_node_t *node,
75
+ const char *text, uint32_t tlen);
76
+
77
+ /* ---- Phase 2: building new subtrees ---------------------------------------
78
+ *
79
+ * Factories build a DETACHED node in +doc+'s arena (no parent); its namespace is
80
+ * left UNRESOLVED (ns_uri NULL) until it is inserted, when the insertion site
81
+ * resolves the whole inserted subtree against its new in-scope declarations. */
82
+
83
+ /* A new detached ELEMENT named [name,nlen] (QName-validated + split). */
84
+ mkr_xml_mut_status_t mkr_xml_new_element(mkr_xml_doc_t *doc, const char *name, uint32_t nlen,
85
+ mkr_xml_node_t **out);
86
+
87
+ /* A new detached TEXT / CDATA_SECTION / COMMENT node holding [text,tlen]
88
+ * (validated as XML Char). +type+ selects which. */
89
+ mkr_xml_mut_status_t mkr_xml_new_chardata(mkr_xml_doc_t *doc, uint8_t type,
90
+ const char *text, uint32_t tlen,
91
+ mkr_xml_node_t **out);
92
+
93
+ /* A new detached PROCESSING_INSTRUCTION with target [target,tlen] (an NCName,
94
+ * not "xml" in any case) and data [data,dlen] (XML Char, may not contain "?>"). */
95
+ mkr_xml_mut_status_t mkr_xml_new_pi(mkr_xml_doc_t *doc, const char *target, uint32_t tlen,
96
+ const char *data, uint32_t dlen, mkr_xml_node_t **out);
97
+
98
+ /* Deep-copy +src+ and its whole subtree (attributes included) from its arena into
99
+ * +doc+'s arena, returning the detached copy in *out. Namespaces are left
100
+ * unresolved (the insertion site resolves them). Iterative (no recursion -> no
101
+ * stack DoS); fails closed on budget/OOM (the partial copy is abandoned in the
102
+ * arena and freed with the document). For moving a node BETWEEN documents. */
103
+ mkr_xml_mut_status_t mkr_xml_import_subtree(mkr_xml_doc_t *doc, const mkr_xml_node_t *src,
104
+ mkr_xml_node_t **out);
105
+
106
+ /* cloneNode: a detached deep (+deep+ true) or shallow copy of +src+ in the SAME
107
+ * document's arena, returned in *out (parent/siblings NULL). Unlike import the
108
+ * resolved namespace URI IS copied (a clone is never re-resolved), so the result
109
+ * keeps its namespace; element/attribute name case and the CDATA node type are
110
+ * preserved. Fails closed on budget/OOM. */
111
+ mkr_xml_mut_status_t mkr_xml_clone_node(mkr_xml_doc_t *doc, const mkr_xml_node_t *src,
112
+ bool deep, mkr_xml_node_t **out);
113
+
114
+ /* Insert +node+ into +doc+'s tree: as the last child of +parent+, or before /
115
+ * after the sibling +ref+, or in place of +ref+ (replace). +node+ MUST already
116
+ * live in +doc+'s arena (the caller imports a cross-document node first). Each:
117
+ * - validates placement first (no cycle, no attribute/document/doctype as a
118
+ * child, a single document-element root, a ref that has a parent) and
119
+ * resolves the inserted subtree's namespaces against the prospective context
120
+ * - ALL before any structural change, so a failure leaves the tree untouched
121
+ * (fully fail-closed, even for a move);
122
+ * - then detaches +node+ from any current position (move) and links it;
123
+ * - keeps doc->root in sync when the container is the document node.
124
+ * Detach-never-destroy: a replaced/displaced node is unlinked, never freed. */
125
+ mkr_xml_mut_status_t mkr_xml_insert_child (mkr_xml_doc_t *doc, mkr_xml_node_t *parent,
126
+ mkr_xml_node_t *node);
127
+ mkr_xml_mut_status_t mkr_xml_insert_before(mkr_xml_doc_t *doc, mkr_xml_node_t *ref,
128
+ mkr_xml_node_t *node);
129
+ mkr_xml_mut_status_t mkr_xml_insert_after (mkr_xml_doc_t *doc, mkr_xml_node_t *ref,
130
+ mkr_xml_node_t *node);
131
+ mkr_xml_mut_status_t mkr_xml_replace_node (mkr_xml_doc_t *doc, mkr_xml_node_t *ref,
132
+ mkr_xml_node_t *node);
133
+
134
+ /* Structural self-test (validation, namespace resolution, link/unlink). Returns 0
135
+ * on success or the 1-based index of the first failing check. Run from
136
+ * Makiri.__c_selftest. */
137
+ int mkr_xml_mutate_selftest(void);
138
+
139
+ #endif /* MKR_XML_MUTATE_H */
@@ -0,0 +1,399 @@
1
+ /* mkr_xml_node.c - secure-by-design append-only arena + custom XML node.
2
+ * Ruby-free. See mkr_xml_node.h and docs/xml_parser_plan.ja.md §8.1/§8.2. */
3
+ #include "mkr_xml_node.h"
4
+ #include "mkr_xml.h"
5
+ #include "mkr_xml_index.h"
6
+ #include "../core/mkr_core.h"
7
+
8
+ #include <stddef.h> /* max_align_t */
9
+ #include <stdlib.h>
10
+ #include <string.h>
11
+
12
+ /* ASan red-zoning for the bump arena. Like Lexbor's mraw, our arena carves many
13
+ * sub-allocations out of one malloc'd chunk, so a write past one cut into the
14
+ * next stays inside that single malloc and is INVISIBLE to a plain ASan build
15
+ * (the heap red-zones only guard the chunk's outer boundary - this is exactly
16
+ * how the v3.0.0 :lexbor-contains overflow hid). So we poison each fresh chunk
17
+ * and unpoison only the bytes a cut actually hands out, leaving the alignment
18
+ * tail and not-yet-cut space poisoned: an intra-arena overrun then writes into
19
+ * poisoned memory and ASan reports it. Auto-active whenever our ext is built
20
+ * with -fsanitize=address (rake sanitize / fuzz:sanitize) - no extra flag,
21
+ * because unlike vendored Lexbor this is our own TU. A no-op otherwise. */
22
+ #if defined(__SANITIZE_ADDRESS__)
23
+ # define MKR_XML_HAVE_ASAN 1
24
+ #elif defined(__has_feature)
25
+ # if __has_feature(address_sanitizer)
26
+ # define MKR_XML_HAVE_ASAN 1
27
+ # endif
28
+ #endif
29
+ #if defined(MKR_XML_HAVE_ASAN)
30
+ # include <sanitizer/asan_interface.h>
31
+ # define MKR_ARENA_POISON(p, n) __asan_poison_memory_region((p), (n))
32
+ # define MKR_ARENA_UNPOISON(p, n) __asan_unpoison_memory_region((p), (n))
33
+ #else
34
+ # define MKR_ARENA_POISON(p, n) ((void)(p), (void)(n))
35
+ # define MKR_ARENA_UNPOISON(p, n) ((void)(p), (void)(n))
36
+ #endif
37
+
38
+ /* Alignment for arena cuts: the strictest fundamental alignment, so any object
39
+ * fits. The chunk payload starts at an aligned offset past the header, and every
40
+ * cut size is rounded up to ARENA_ALIGN, so each returned pointer is aligned. */
41
+ #define ARENA_ALIGN _Alignof(max_align_t)
42
+ _Static_assert((ARENA_ALIGN & (ARENA_ALIGN - 1)) == 0, "ARENA_ALIGN must be a power of two");
43
+ #define CHUNK_MIN (64u * 1024u)
44
+
45
+ struct mkr_xml_arena_chunk {
46
+ struct mkr_xml_arena_chunk *next;
47
+ size_t used, cap;
48
+ /* payload follows, at offset align_up(sizeof(chunk), ARENA_ALIGN) */
49
+ };
50
+
51
+ static inline size_t align_up(size_t n, size_t a) { return (n + (a - 1)) & ~(a - 1); }
52
+
53
+ mkr_xml_doc_t *
54
+ mkr_xml_doc_new(void)
55
+ {
56
+ mkr_xml_doc_t *doc = mkr_callocarray(1, sizeof *doc);
57
+ if (doc == NULL) return NULL;
58
+ doc->max_bytes = MKR_XML_MAX_BYTES;
59
+ doc->max_nodes = MKR_XML_MAX_NODES;
60
+ return doc;
61
+ }
62
+
63
+ void
64
+ mkr_xml_doc_destroy(mkr_xml_doc_t *doc)
65
+ {
66
+ if (doc == NULL) return;
67
+ mkr_xml_name_index_invalidate(doc); /* free the heap-side element-name index */
68
+ /* whole-arena free: no individual node/byte free anywhere (read-only). */
69
+ const size_t hdr = align_up(sizeof(mkr_xml_arena_chunk_t), ARENA_ALIGN);
70
+ for (mkr_xml_arena_chunk_t *c = doc->chunks; c != NULL; ) {
71
+ mkr_xml_arena_chunk_t *n = c->next;
72
+ MKR_ARENA_UNPOISON((char *)c, hdr + c->cap); /* hand clean memory back to ASan's allocator */
73
+ free(c);
74
+ c = n;
75
+ }
76
+ free(doc);
77
+ }
78
+
79
+ size_t
80
+ mkr_xml_doc_memsize(const mkr_xml_doc_t *doc)
81
+ {
82
+ if (doc == NULL) return 0;
83
+ size_t total = sizeof *doc;
84
+ const size_t hdr = align_up(sizeof(mkr_xml_arena_chunk_t), ARENA_ALIGN);
85
+ for (const mkr_xml_arena_chunk_t *c = doc->chunks; c != NULL; c = c->next) {
86
+ size_t chunk;
87
+ /* saturate rather than wrap: a bogus huge memsize is harmless, a wrapped
88
+ * small one would lie to Ruby's GC accounting. */
89
+ if (!mkr_size_add(hdr, c->cap, &chunk) || !mkr_size_add(total, chunk, &total)) {
90
+ return SIZE_MAX;
91
+ }
92
+ }
93
+ return total;
94
+ }
95
+
96
+ /* THE single checked alloc choke point. PRIVATE - callers use the typed
97
+ * wrappers below. Overflow- and budget-checked; on any failure sets doc->oom
98
+ * (sticky) and returns NULL. Nothing else cuts arena. */
99
+ static void *
100
+ arena_alloc(mkr_xml_doc_t *doc, size_t size)
101
+ {
102
+ /* fail-closed contract: the internal callers never pass NULL, but a primitive
103
+ * must not deref it - return NULL rather than crash (there is no doc to mark). */
104
+ if (doc == NULL || doc->oom) return NULL;
105
+
106
+ size_t need = align_up(size, ARENA_ALIGN);
107
+ if (need < size) { doc->oom = MKR_XML_ERR_LIMIT; return NULL; } /* align overflow */
108
+
109
+ /* budget BEFORE allocation - fail-closed */
110
+ size_t projected;
111
+ if (!mkr_size_add(doc->arena_bytes, need, &projected) || projected > doc->max_bytes) {
112
+ doc->oom = MKR_XML_ERR_LIMIT;
113
+ return NULL;
114
+ }
115
+
116
+ const size_t hdr = align_up(sizeof(mkr_xml_arena_chunk_t), ARENA_ALIGN);
117
+ mkr_xml_arena_chunk_t *c = doc->chunks;
118
+ /* subtractive form (used <= cap invariant) avoids an addition that could
119
+ * overflow with a pathological chunk. */
120
+ if (c == NULL || need > c->cap - c->used) {
121
+ size_t cap = need > CHUNK_MIN ? need : CHUNK_MIN;
122
+ size_t total;
123
+ if (!mkr_size_add(hdr, cap, &total)) { doc->oom = MKR_XML_ERR_LIMIT; return NULL; }
124
+ mkr_xml_arena_chunk_t *nc = mkr_reallocarray(NULL, total, 1);
125
+ if (nc == NULL) { doc->oom = MKR_XML_ERR_OOM; return NULL; }
126
+ nc->next = doc->chunks;
127
+ nc->used = 0;
128
+ nc->cap = cap;
129
+ doc->chunks = nc;
130
+ c = nc;
131
+ /* poison the whole payload; each cut unpoisons only what it returns. */
132
+ MKR_ARENA_POISON((char *)nc + hdr, cap);
133
+ }
134
+ void *p = (char *)c + hdr + c->used;
135
+ c->used += need;
136
+ doc->arena_bytes += need;
137
+ /* Hand out exactly +size+ usable bytes; the [size, need) alignment tail (and
138
+ * everything past it in the chunk) stays poisoned as a red-zone, so a write
139
+ * one byte past the request is caught instead of silently clobbering the
140
+ * next cut. ASan tolerates the sub-granule trailing byte (ARENA_ALIGN cuts
141
+ * keep each slot on an 8-byte shadow boundary). */
142
+ MKR_ARENA_UNPOISON(p, size);
143
+ return p;
144
+ }
145
+
146
+ static int
147
+ valid_xn_type(mkr_xml_node_type_t type)
148
+ {
149
+ switch (type) {
150
+ case MKR_XML_NODE_TYPE_ELEMENT: case MKR_XML_NODE_TYPE_ATTRIBUTE: case MKR_XML_NODE_TYPE_TEXT:
151
+ case MKR_XML_NODE_TYPE_CDATA_SECTION: case MKR_XML_NODE_TYPE_PI: case MKR_XML_NODE_TYPE_COMMENT:
152
+ case MKR_XML_NODE_TYPE_DOCUMENT: case MKR_XML_NODE_TYPE_DOCUMENT_TYPE:
153
+ case MKR_XML_NODE_TYPE_DOCUMENT_FRAGMENT:
154
+ return 1;
155
+ default:
156
+ return 0;
157
+ }
158
+ }
159
+
160
+ mkr_xml_node_t *
161
+ mkr_xml_arena_node(mkr_xml_doc_t *doc, mkr_xml_node_type_t type)
162
+ {
163
+ if (doc == NULL) return NULL; /* fail-closed: never deref a NULL document */
164
+ /* guard against a caller passing a bogus type (programming error) so it can
165
+ * never be silently miscounted/mis-walked - caught by the self-test/ASan. */
166
+ if (!valid_xn_type(type)) { doc->oom = MKR_XML_ERR_INTERNAL; return NULL; }
167
+ /* every node (attributes included) counts toward the single node budget; the
168
+ * per-element attribute cap is enforced by the tree builder. */
169
+ if (doc->nodes + 1 > doc->max_nodes) { doc->oom = MKR_XML_ERR_LIMIT; return NULL; }
170
+
171
+ mkr_xml_node_t *n = arena_alloc(doc, sizeof *n);
172
+ if (n == NULL) return NULL;
173
+ memset(n, 0, sizeof *n); /* zero-init: no uninitialised reads; 0 = "no source loc" */
174
+ n->type = type;
175
+ doc->nodes++;
176
+ return n;
177
+ }
178
+
179
+ const char *
180
+ mkr_xml_arena_bytes(mkr_xml_doc_t *doc, const char *src, uint32_t len)
181
+ {
182
+ if (len == 0) return ""; /* never read off the "" literal */
183
+ if (src == NULL) return NULL; /* contract: len>0 needs a real source (fail closed) */
184
+ char *p = arena_alloc(doc, len);
185
+ if (p == NULL) return NULL;
186
+ memcpy(p, src, len); /* copy-on-store: the arena owns the bytes */
187
+ return p;
188
+ }
189
+
190
+ /* A raw, uninitialised arena buffer of exactly +len+ bytes. PRIVATE: the only
191
+ * way to fill arena bytes by hand is mkr_xml_arena_spanbuf below, which wraps
192
+ * this in a core mkr_spanbuf (cursor + bounds check) so a caller can never
193
+ * overrun it. Budget-/
194
+ * overflow-checked via the same choke point. */
195
+ static char *
196
+ arena_scratch_bytes(mkr_xml_doc_t *doc, size_t len)
197
+ {
198
+ /* a 0-length buffer needs no storage - never cut a whole chunk for it. The
199
+ * sentinel is a valid non-NULL pointer the caller must not write to (there is
200
+ * nothing to write), mirroring mkr_xml_arena_bytes's "" return. */
201
+ if (len == 0) return (char *)"";
202
+ return arena_alloc(doc, len);
203
+ }
204
+
205
+ /* Carve +cap+ arena bytes and wrap them in a core mkr_spanbuf (see
206
+ * mkr_xml_node.h). The spanbuf owns the cursor + bounds check, so no caller can
207
+ * overrun the cut; this is the sole sanctioned hand-fill path and the only thing
208
+ * that touches the private arena_scratch_bytes. On alloc failure the buffer is
209
+ * NULL, so the returned writer is already not-ok (every write a no-op). */
210
+ mkr_spanbuf_t
211
+ mkr_xml_arena_spanbuf(mkr_xml_doc_t *doc, size_t cap)
212
+ {
213
+ return mkr_spanbuf(arena_scratch_bytes(doc, cap), cap);
214
+ }
215
+
216
+ int
217
+ mkr_xml_xmlns_prefix(const char *name, uint32_t len, const char **prefix, uint32_t *plen)
218
+ {
219
+ const char *p; uint32_t pl;
220
+ mkr_span_t s = mkr_span(name, len);
221
+ if (len == 5 && mkr_span_starts(&s, "xmlns", 5)) { /* read through the span - no raw name deref (NULL-safe) */
222
+ p = ""; pl = 0;
223
+ } else if (len > 6 && mkr_span_starts(&s, "xmlns:", 6)) {
224
+ p = name + 6; pl = len - 6;
225
+ } else {
226
+ return 0;
227
+ }
228
+ if (prefix != NULL) *prefix = p;
229
+ if (plen != NULL) *plen = pl;
230
+ return 1;
231
+ }
232
+
233
+ int
234
+ mkr_xml_node_xmlns_decl(const mkr_xml_node_t *a, const char **prefix, uint32_t *plen,
235
+ const char **uri, uint32_t *ulen)
236
+ {
237
+ /* a built node's local IS qname+6 for "xmlns:p", so the byte detector's
238
+ * prefix slice equals the node's local - one shared rule, value read here. */
239
+ if (!mkr_xml_xmlns_prefix(a->qname, a->qname_len, prefix, plen)) return 0;
240
+ *uri = a->value ? a->value : ""; *ulen = a->value_len;
241
+ return 1;
242
+ }
243
+
244
+ mkr_xml_node_t *
245
+ mkr_xml_preorder_next(const mkr_xml_node_t *root, mkr_xml_node_t *cur)
246
+ {
247
+ if (cur->first_child != NULL) return cur->first_child;
248
+ /* climb until a node with a next sibling, stopping at (and not above) root */
249
+ while (cur != root && cur->next == NULL) cur = cur->parent;
250
+ if (cur == root) return NULL;
251
+ return cur->next;
252
+ }
253
+
254
+ int
255
+ mkr_xml_qname_split(const char *name, uint32_t len, mkr_xml_qname_t *out)
256
+ {
257
+ if (len == 0) return -1;
258
+ mkr_span_t s = mkr_span(name, len);
259
+ uint32_t cp;
260
+ int bl = mkr_utf8_decode1_span(&s, &cp);
261
+ if (bl == 0 || !mkr_xml_is_name_start(cp)) return -1;
262
+ mkr_span_skip(&s, (size_t)bl);
263
+ while (mkr_span_left(&s) > 0) {
264
+ bl = mkr_utf8_decode1_span(&s, &cp);
265
+ if (bl == 0 || !mkr_xml_is_name_char(cp)) return -1;
266
+ mkr_span_skip(&s, (size_t)bl);
267
+ }
268
+ out->qname = name; out->qname_len = len;
269
+ mkr_span_t q = mkr_span(name, len);
270
+ size_t colon_at;
271
+ if (!mkr_span_find(&q, ':', &colon_at)) { /* no prefix */
272
+ out->prefix = name; out->prefix_len = 0;
273
+ out->local = name; out->local_len = len;
274
+ return 0;
275
+ }
276
+ uint32_t pl = (uint32_t)colon_at;
277
+ const char *ls = name + colon_at + 1;
278
+ uint32_t ll = len - pl - 1;
279
+ if (pl == 0 || ll == 0) return -1; /* ":x" or "x:" */
280
+ mkr_span_t lsp = mkr_span(ls, ll);
281
+ size_t second;
282
+ if (mkr_span_find(&lsp, ':', &second)) return -1; /* a second colon */
283
+ if (mkr_utf8_decode1_span(&lsp, &cp) == 0 || !mkr_xml_is_name_start(cp))
284
+ return -1; /* local must be an NCName */
285
+ out->prefix = name; out->prefix_len = pl;
286
+ out->local = ls; out->local_len = ll;
287
+ return 0;
288
+ }
289
+
290
+ int
291
+ mkr_xml_qname_assign(mkr_xml_doc_t *doc, mkr_xml_node_t *node, const mkr_xml_qname_t *qn)
292
+ {
293
+ /* Contract: qn->local must alias INTO [qn->qname, qn->qname+qname_len) - the
294
+ * rebase below adds (local - qname) onto the arena copy, so a non-aliasing
295
+ * local would mint an out-of-bounds slice (wrong-but-plausible data, the worst
296
+ * failure mode here). Both in-tree producers (split_qname in mkr_xml_tree.c,
297
+ * mkr_xml_qname_split above) honour it; pin it so a violation fails closed
298
+ * (INTERNAL, sticky) instead of silently passing. */
299
+ if (!(qn->local >= qn->qname && qn->local_len <= qn->qname_len
300
+ && (size_t)(qn->local - qn->qname) <= qn->qname_len - qn->local_len)) {
301
+ if (doc != NULL) doc->oom = MKR_XML_ERR_INTERNAL;
302
+ return -1;
303
+ }
304
+ const char *q = mkr_xml_arena_bytes(doc, qn->qname, qn->qname_len);
305
+ if (qn->qname_len > 0 && q == NULL) return -1; /* OOM: node left untouched */
306
+ node->qname = q; node->qname_len = qn->qname_len;
307
+ node->local = q + (size_t)(qn->local - qn->qname); node->local_len = qn->local_len;
308
+ node->prefix = q; node->prefix_len = qn->prefix_len;
309
+ return 0;
310
+ }
311
+
312
+ /* ---- self-test (Makiri.__c_selftest) - mirrors tmp/xml_spike/arena_spike.c --- */
313
+ int
314
+ mkr_xml_node_selftest(void)
315
+ {
316
+ int idx = 0;
317
+ mkr_xml_doc_t *doc = mkr_xml_doc_new();
318
+ if (doc == NULL) return ++idx; /* 1 */
319
+
320
+ /* node zero-init + name copy */
321
+ idx++; /* 2 */
322
+ mkr_xml_node_t *root = mkr_xml_arena_node(doc, MKR_XML_NODE_TYPE_ELEMENT);
323
+ char nm[] = "Feed";
324
+ const char *local = mkr_xml_arena_bytes(doc, nm, 4);
325
+ if (root == NULL || root->first_child != NULL || root->type != MKR_XML_NODE_TYPE_ELEMENT
326
+ || local == NULL || local == nm || !mkr_bytes_eq(local, 4, "Feed", 4)) {
327
+ mkr_xml_doc_destroy(doc); return idx;
328
+ }
329
+ /* pointer alignment */
330
+ idx++; /* 3 */
331
+ if (((uintptr_t)root % ARENA_ALIGN) != 0) { mkr_xml_doc_destroy(doc); return idx; }
332
+
333
+ /* build 1000 children */
334
+ idx++; /* 4 */
335
+ for (int i = 0; i < 1000; i++) {
336
+ mkr_xml_node_t *c = mkr_xml_arena_node(doc, MKR_XML_NODE_TYPE_ELEMENT);
337
+ if (c == NULL) { mkr_xml_doc_destroy(doc); return idx; }
338
+ c->parent = root;
339
+ if (root->last_child) { root->last_child->next = c; c->prev = root->last_child; }
340
+ else root->first_child = c;
341
+ root->last_child = c;
342
+ }
343
+ size_t cnt = 0;
344
+ for (mkr_xml_node_t *c = root->first_child; c != NULL; c = c->next) cnt++;
345
+ if (cnt != 1000 || doc->oom != MKR_XML_OK) { mkr_xml_doc_destroy(doc); return idx; }
346
+ mkr_xml_doc_destroy(doc);
347
+
348
+ /* pathological size fails closed (no overflow / wrap) */
349
+ idx++; /* 5 */
350
+ doc = mkr_xml_doc_new();
351
+ if (doc == NULL) return idx;
352
+ if (arena_alloc(doc, ((size_t)-1) - 8) != NULL || doc->oom == MKR_XML_OK) {
353
+ mkr_xml_doc_destroy(doc); return idx;
354
+ }
355
+ mkr_xml_doc_destroy(doc);
356
+
357
+ /* byte budget enforced inside the allocator (LIMIT before overrun) */
358
+ idx++; /* 6 */
359
+ doc = mkr_xml_doc_new();
360
+ if (doc == NULL) return idx;
361
+ doc->max_bytes = 4096;
362
+ int hit = 0;
363
+ for (int i = 0; i < 100000; i++) {
364
+ if (mkr_xml_arena_node(doc, MKR_XML_NODE_TYPE_ELEMENT) == NULL) {
365
+ hit = (doc->oom == MKR_XML_ERR_LIMIT);
366
+ break;
367
+ }
368
+ }
369
+ if (!hit) { mkr_xml_doc_destroy(doc); return idx; }
370
+ mkr_xml_doc_destroy(doc);
371
+
372
+ /* node budget enforced */
373
+ idx++; /* 7 */
374
+ doc = mkr_xml_doc_new();
375
+ if (doc == NULL) return idx;
376
+ doc->max_nodes = 10;
377
+ int nlimit = 0;
378
+ for (int i = 0; i < 100; i++) {
379
+ if (mkr_xml_arena_node(doc, MKR_XML_NODE_TYPE_ELEMENT) == NULL) {
380
+ nlimit = (doc->oom == MKR_XML_ERR_LIMIT);
381
+ break;
382
+ }
383
+ }
384
+ if (!nlimit) { mkr_xml_doc_destroy(doc); return idx; }
385
+ mkr_xml_doc_destroy(doc);
386
+
387
+ /* fail-closed on a NULL document (contract guard, no deref/crash) */
388
+ idx++; /* 8 */
389
+ if (mkr_xml_arena_node(NULL, MKR_XML_NODE_TYPE_ELEMENT) != NULL
390
+ || mkr_xml_arena_bytes(NULL, "x", 1) != NULL
391
+ || mkr_xml_arena_spanbuf(NULL, 1).ok) { /* alloc fails -> not ok */
392
+ return idx;
393
+ }
394
+
395
+ return 0; /* all checks passed */
396
+ }
397
+
398
+ /* mkr_xml_parse now lives in mkr_xml_tree.c (the minimal tokenizer + tree
399
+ * builder). This file owns only the node/arena primitives. */