makiri 0.6.0 → 0.7.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +2 -1
  3. data/.github/workflows/release.yml +28 -13
  4. data/CHANGELOG.md +40 -0
  5. data/Rakefile +9 -8
  6. data/ext/makiri/bridge/bridge_internal.h +28 -0
  7. data/ext/makiri/bridge/ruby_string.c +4 -221
  8. data/ext/makiri/bridge/xml_decode.c +237 -0
  9. data/ext/makiri/dom_adapter/cross_import.c +20 -7
  10. data/ext/makiri/dom_adapter/dom_index.c +22 -21
  11. data/ext/makiri/dom_adapter/post_parse.c +27 -22
  12. data/ext/makiri/dom_adapter/text_index.c +28 -19
  13. data/ext/makiri/extconf.rb +40 -3
  14. data/ext/makiri/fuzz/Makefile +1 -1
  15. data/ext/makiri/fuzz/xpath_fuzz.c +10 -5
  16. data/ext/makiri/glue/glue.h +14 -0
  17. data/ext/makiri/glue/ruby_doc.c +50 -24
  18. data/ext/makiri/glue/ruby_html_mutate.c +176 -66
  19. data/ext/makiri/glue/ruby_html_node.c +76 -52
  20. data/ext/makiri/glue/ruby_xml.c +60 -91
  21. data/ext/makiri/glue/ruby_xml_node.c +175 -14
  22. data/ext/makiri/makiri.c +8 -7
  23. data/ext/makiri/xml/mkr_xml.h +5 -0
  24. data/ext/makiri/xml/mkr_xml_chars.c +10 -0
  25. data/ext/makiri/xml/mkr_xml_mutate.c +228 -38
  26. data/ext/makiri/xml/mkr_xml_mutate.h +46 -8
  27. data/ext/makiri/xml/mkr_xml_node.c +21 -12
  28. data/ext/makiri/xml/mkr_xml_node.h +39 -9
  29. data/ext/makiri/xml/mkr_xml_tree.c +39 -45
  30. data/ext/makiri/xpath/mkr_css.c +19 -34
  31. data/ext/makiri/xpath/mkr_xpath.c +34 -31
  32. data/ext/makiri/xpath/mkr_xpath_eval_body.h +27 -29
  33. data/ext/makiri/xpath/mkr_xpath_funcs_body.h +29 -33
  34. data/ext/makiri/xpath/mkr_xpath_internal.h +1 -1
  35. data/ext/makiri/xpath/mkr_xpath_parse.c +125 -157
  36. data/ext/makiri/xpath/mkr_xpath_value_body.h +34 -45
  37. data/lib/makiri/attr.rb +0 -3
  38. data/lib/makiri/comment.rb +1 -3
  39. data/lib/makiri/compat_aliases.rb +1 -12
  40. data/lib/makiri/version.rb +1 -1
  41. data/script/check_c_safety_allowlist.yml +4 -10
  42. data/verify/Makefile +1 -1
  43. metadata +4 -2
  44. /data/ext/makiri/core/{mkr_core.c → mkr_core_selftest.c} +0 -0
@@ -0,0 +1,237 @@
1
+ /* xml_decode.c - XML 1.0 Appendix F byte-encoding autodetection and strict
2
+ * decode-to-UTF-8 for the XML reader. Part of the bridge layer, split out of
3
+ * ruby_string.c so this XML-specific charset subsystem is separate from the
4
+ * generic Ruby String helpers. Raw RSTRING access stays isolated in
5
+ * ruby_string.c; this file borrows bytes only through mkr_ruby_bytes_view.
6
+ * Shares the allocation-free strict-text-contract core (mkr_text_check) via
7
+ * bridge_internal.h. See mkr_xml_decode_input in bridge.h for the contract. */
8
+ #include "bridge.h"
9
+ #include "../makiri.h"
10
+ #include "bridge_internal.h"
11
+
12
+ #include <string.h>
13
+
14
+ /* rb_str_encode with no replacement flags: an undefined conversion or invalid
15
+ * byte sequence RAISES (Encoding::UndefinedConversionError /
16
+ * Encoding::InvalidByteSequenceError) instead of substituting U+FFFD. Run under
17
+ * rb_protect so we can remap the Ruby Encoding error to Makiri::XML::SyntaxError. */
18
+ static VALUE
19
+ mkr_xml_strict_transcode_thunk(VALUE str)
20
+ {
21
+ return rb_str_encode(str, rb_enc_from_encoding(rb_utf8_encoding()), 0, Qnil);
22
+ }
23
+
24
+ /* --- XML 1.0 Appendix F: byte-encoding autodetection (BOM, then declaration) ---
25
+ *
26
+ * The leading byte-order mark, or NULL; *bom_len gets its length. UTF-32 BOMs are
27
+ * checked before the UTF-16 LE BOM they share a prefix with.
28
+ *
29
+ * *stride / *ascii_off get the interleave geometry of the ASCII column the decl
30
+ * scanner later extracts (default 1/0 for a single-byte stream). It is resolved
31
+ * HERE, at the match, rather than re-derived downstream, because that derivation
32
+ * needs rb_enc_find (it can autoload an encoding = a GC point) and the decl
33
+ * scanner reads a borrowed RSTRING view that must not be held across one - so
34
+ * the scanner is kept allocation-free until its reads are done. Each span read
35
+ * of p still finishes before the rb_enc_find in the return. */
36
+ static rb_encoding *
37
+ mkr_xml_bom_encoding(const unsigned char *p, long len, long *bom_len, long *stride, long *ascii_off)
38
+ {
39
+ mkr_span_t s = mkr_span((const char *)p, (size_t)len);
40
+ *bom_len = 0;
41
+ *stride = 1;
42
+ *ascii_off = 0;
43
+ if (mkr_span_starts(&s, "\x00\x00\xFE\xFF", 4)) { *bom_len = 4; *stride = 4; *ascii_off = 3; return rb_enc_find("UTF-32BE"); }
44
+ if (mkr_span_starts(&s, "\xFF\xFE\x00\x00", 4)) { *bom_len = 4; *stride = 4; *ascii_off = 0; return rb_enc_find("UTF-32LE"); }
45
+ if (mkr_span_starts(&s, "\xFE\xFF", 2)) { *bom_len = 2; *stride = 2; *ascii_off = 1; return rb_enc_find("UTF-16BE"); }
46
+ if (mkr_span_starts(&s, "\xFF\xFE", 2)) { *bom_len = 2; *stride = 2; *ascii_off = 0; return rb_enc_find("UTF-16LE"); }
47
+ if (mkr_span_starts(&s, "\xEF\xBB\xBF", 3)) { *bom_len = 3; return rb_utf8_encoding(); }
48
+ return NULL;
49
+ }
50
+
51
+ static int
52
+ mkr_decl_ws(int c)
53
+ {
54
+ return c == ' ' || c == '\t' || c == '\r' || c == '\n';
55
+ }
56
+
57
+ /* The encoding named in the '<?xml ... encoding="NAME" ?>' declaration, or NULL.
58
+ * The declaration is ASCII; for a UTF-16/32-detected document its bytes are
59
+ * stride-interleaved, so the ASCII column is extracted (stride/off resolved by
60
+ * the BOM matcher) before the scan, letting a BOM-vs-declaration conflict be
61
+ * caught even in UTF-16.
62
+ *
63
+ * p is a borrowed RSTRING view, so this stays allocation-free until every read
64
+ * of p is done: the stride/off geometry is passed in (rather than derived here
65
+ * via rb_enc_find, which can autoload = a GC point), and the only rb_enc_find -
66
+ * the final name lookup - runs after the bytes have been copied into head[]. */
67
+ static rb_encoding *
68
+ mkr_xml_decl_encoding(const unsigned char *p, long len, long stride, long off)
69
+ {
70
+ /* Extract the ASCII column (per the BOM stride) through bounded reads into
71
+ * a bounded writer - neither side trusts the loop arithmetic. */
72
+ mkr_span_t in = mkr_span((const char *)p, len < 0 ? 0 : (size_t)len);
73
+ char head[256];
74
+ mkr_spanbuf_t hw = mkr_spanbuf(head, sizeof(head));
75
+ for (size_t i = (size_t)off; hw.pos < sizeof(head); i += (size_t)stride) {
76
+ int c = mkr_span_at(&in, i);
77
+ if (c < 0) break;
78
+ mkr_spanbuf_putc(&hw, (char)c);
79
+ }
80
+ mkr_span_t h = mkr_span(head, hw.pos);
81
+ size_t hn = hw.pos;
82
+
83
+ size_t i = 0;
84
+ while (mkr_decl_ws(mkr_span_at(&h, i))) i++;
85
+ {
86
+ mkr_span_t t = mkr_span_tail(&h, i);
87
+ if (!mkr_span_starts(&t, "<?xml", 5)) return NULL;
88
+ }
89
+ i += 5;
90
+ /* find a whitespace-introduced "encoding" before the '?>' */
91
+ for (; i + 8 <= hn; i++) {
92
+ if (mkr_span_at(&h, i) == '?' && mkr_span_at(&h, i + 1) == '>') return NULL; /* end of decl */
93
+ mkr_span_t t = mkr_span_tail(&h, i);
94
+ if (!mkr_decl_ws(mkr_span_at(&h, i - 1)) || !mkr_span_starts(&t, "encoding", 8)) continue;
95
+ size_t j = i + 8;
96
+ while (mkr_decl_ws(mkr_span_at(&h, j))) j++;
97
+ if (mkr_span_at(&h, j) != '=') return NULL;
98
+ j++;
99
+ while (mkr_decl_ws(mkr_span_at(&h, j))) j++;
100
+ int q = mkr_span_at(&h, j);
101
+ if (q != '"' && q != '\'') return NULL;
102
+ j++;
103
+ size_t ns = j;
104
+ while (mkr_span_at(&h, j) >= 0 && mkr_span_at(&h, j) != q) j++;
105
+ if (j >= hn) return NULL;
106
+ char name[64];
107
+ size_t nl = j - ns;
108
+ if (nl == 0 || nl >= sizeof(name)) return NULL;
109
+ memcpy(name, head + ns, nl);
110
+ name[nl] = '\0';
111
+ return rb_enc_find(name); /* NULL for an unknown encoding name */
112
+ }
113
+ return NULL;
114
+ }
115
+
116
+ /* Two encodings agree for conflict purposes when identical, or when either is
117
+ * US-ASCII (a subset of UTF-8 and the single-byte encodings). */
118
+ static int
119
+ mkr_xml_enc_compatible(rb_encoding *a, rb_encoding *b)
120
+ {
121
+ return a == b || a == rb_usascii_encoding() || b == rb_usascii_encoding();
122
+ }
123
+
124
+ /* Phase 1: resolve the input's effective byte encoding (XML 1.0 Appendix F). A
125
+ * BOM wins, else the '<?xml encoding=?>' declaration, else the String's own
126
+ * declared encoding; ASCII-8BIT ("raw bytes, no claimed encoding") is decoded by
127
+ * the detected encoding instead. Raises Makiri::XML::SyntaxError on any
128
+ * BOM/declaration/String-encoding conflict, so the caller only ever sees a
129
+ * single, self-consistent encoding. Reads borrowed RSTRING bytes but stays
130
+ * allocation-free until its reads are done (see the BOM/decl scanners). */
131
+ static rb_encoding *
132
+ mkr_xml_effective_encoding(VALUE str)
133
+ {
134
+ rb_encoding *tag = rb_enc_get(str);
135
+ mkr_ruby_borrowed_bytes_t v = mkr_ruby_bytes_view(str);
136
+ const unsigned char *raw = (const unsigned char *)v.ptr;
137
+ long rawlen = (long)v.len;
138
+
139
+ long bom_len = 0, bom_stride = 1, bom_off = 0;
140
+ rb_encoding *bom = mkr_xml_bom_encoding(raw, rawlen, &bom_len, &bom_stride, &bom_off);
141
+ /* rb_enc_find inside the BOM lookup can autoload an encoding (a Ruby
142
+ * allocation = a GC point), so re-borrow the bytes before reading them
143
+ * again - a borrowed view must not be held across one. The interleave
144
+ * geometry (stride/off) is resolved by the BOM matcher and passed through,
145
+ * keeping the decl scanner itself allocation-free. */
146
+ v = mkr_ruby_bytes_view(str);
147
+ raw = (const unsigned char *)v.ptr;
148
+ rb_encoding *decl = mkr_xml_decl_encoding(raw + bom_len, rawlen - bom_len, bom_stride, bom_off);
149
+ int is_binary = (tag == rb_ascii8bit_encoding());
150
+
151
+ if (bom && decl && !mkr_xml_enc_compatible(bom, decl)) {
152
+ rb_raise(mkr_eXmlSyntaxError,
153
+ "XML encoding conflict: the byte-order mark and the encoding declaration disagree");
154
+ }
155
+ if (!is_binary && bom && !mkr_xml_enc_compatible(bom, tag)) {
156
+ rb_raise(mkr_eXmlSyntaxError,
157
+ "XML encoding conflict: the byte-order mark disagrees with the string's encoding");
158
+ }
159
+ if (!is_binary && decl && !mkr_xml_enc_compatible(decl, tag)) {
160
+ /* A concrete String encoding is authoritative for decoding, so the
161
+ * declaration is not used to transcode - but a declaration that names a
162
+ * different encoding than the String is tagged with (e.g. a Shift_JIS
163
+ * String declaring encoding="UTF-8") is a self-inconsistent document and
164
+ * a fatal error, not a silently-ignored mismatch. */
165
+ rb_raise(mkr_eXmlSyntaxError,
166
+ "XML encoding conflict: the encoding declaration disagrees with the string's encoding");
167
+ }
168
+
169
+ return is_binary ? (bom ? bom : (decl ? decl : rb_utf8_encoding())) : tag;
170
+ }
171
+
172
+ VALUE
173
+ mkr_xml_decode_input(VALUE str, size_t max_bytes)
174
+ {
175
+ /* Phase 1: resolve the single effective byte encoding (raises on conflict). */
176
+ rb_encoding *eff = mkr_xml_effective_encoding(str);
177
+
178
+ /* Phase 2: decode to UTF-8 (strict). UTF-8 / US-ASCII / ASCII-8BIT are
179
+ * already UTF-8 bytes (validated below); anything else is strict-transcoded,
180
+ * raising rather than substituting U+FFFD. */
181
+ VALUE s;
182
+ if (eff == rb_utf8_encoding() || eff == rb_usascii_encoding() || eff == rb_ascii8bit_encoding()) {
183
+ s = str;
184
+ } else {
185
+ VALUE in = str;
186
+ if (rb_enc_get(str) != eff) { in = rb_str_dup(str); rb_enc_associate(in, eff); }
187
+ int state = 0;
188
+ s = rb_protect(mkr_xml_strict_transcode_thunk, in, &state);
189
+ if (state != 0) {
190
+ VALUE exc = rb_errinfo();
191
+ rb_set_errinfo(Qnil);
192
+ char msg[256];
193
+ mkr_ruby_exception_message(exc, msg, sizeof msg);
194
+ rb_raise(mkr_eXmlSyntaxError,
195
+ "XML input could not be decoded to UTF-8: %s", msg);
196
+ }
197
+ }
198
+
199
+ mkr_ruby_borrowed_bytes_t sv_bytes = mkr_ruby_bytes_view(s);
200
+ const char *ptr = sv_bytes.ptr;
201
+ long len = (long)sv_bytes.len;
202
+ long off = 0;
203
+ /* §4.3.3: a leading BOM is the encoding signature, not document content -
204
+ * strip a U+FEFF (the transcode above turns any UTF-16/32 BOM into one). */
205
+ mkr_span_t sv = mkr_span(ptr, (size_t)len);
206
+ if (mkr_span_starts(&sv, "\xEF\xBB\xBF", 3)) {
207
+ off = 3; len -= 3;
208
+ mkr_span_skip(&sv, 3);
209
+ }
210
+
211
+ /* Fail closed on an over-budget input BEFORE the validation scan and the
212
+ * caller's GVL-release copy (an input whose UTF-8 length exceeds the arena
213
+ * budget can never parse). max_bytes == 0 disables the check (__decode). */
214
+ if (max_bytes != 0 && (size_t)len > max_bytes) {
215
+ rb_raise(mkr_eXmlLimitExceeded, "XML input exceeds the byte budget");
216
+ }
217
+
218
+ /* Strict UTF-8 validation via the shared, allocation-free core - no GC point
219
+ * while `ptr` is borrowed: an embedded NUL or any invalid UTF-8 is fatal (no
220
+ * U+FFFD repair - unlike the HTML mkr_utf8_sanitize path). The whole-string
221
+ * `s` is consulted for the cached coderange (it covers the BOM-stripped
222
+ * suffix too - the BOM is one complete UTF-8 character), while the validated
223
+ * bytes are the stripped suffix `ptr + off`. */
224
+ switch (mkr_text_check(s, ptr + off, (size_t)len)) {
225
+ case MKR_TEXT_HAS_NUL:
226
+ rb_raise(mkr_eXmlSyntaxError, "XML input must not contain a NUL byte");
227
+ case MKR_TEXT_INVALID_UTF8:
228
+ rb_raise(mkr_eXmlSyntaxError, "XML input must be valid UTF-8");
229
+ case MKR_TEXT_OK:
230
+ break;
231
+ }
232
+ /* Build the result through the VALUE, not the borrowed ptr (rb_str_subseq
233
+ * allocates, so the ptr must not be what it copies from). */
234
+ VALUE u = rb_str_subseq(s, off, len);
235
+ rb_enc_associate(u, rb_utf8_encoding());
236
+ return u; /* validated, UTF-8-tagged, BOM-stripped */
237
+ }
@@ -237,6 +237,21 @@ h2x_make(mkr_xml_doc_t *xdoc, lxb_dom_node_t *s, const char *pdef, uint32_t pdef
237
237
  }
238
238
  }
239
239
 
240
+ /* If +n+ is an HTML <template> element, set *content to its content fragment
241
+ * (which may itself be NULL) and return true; else return false. The shared
242
+ * template detection for both import directions - the two callers then apply
243
+ * their own (different) rule for a template whose content is NULL. */
244
+ static bool
245
+ mkr_lxb_template_content(lxb_dom_node_t *n, lxb_dom_document_fragment_t **content)
246
+ {
247
+ if (n->type == LXB_DOM_NODE_TYPE_ELEMENT
248
+ && n->local_name == LXB_TAG_TEMPLATE && n->ns == LXB_NS_HTML) {
249
+ *content = lxb_html_interface_template(n)->content;
250
+ return true;
251
+ }
252
+ return false;
253
+ }
254
+
240
255
  /* The children to translate under +s+. An HTML <template> keeps its content in a
241
256
  * separate document fragment, NOT the normal child chain, so a plain first_child
242
257
  * walk would silently drop template contents. We descend into the content fragment
@@ -245,9 +260,8 @@ h2x_make(mkr_xml_doc_t *xdoc, lxb_dom_node_t *s, const char *pdef, uint32_t pdef
245
260
  static lxb_dom_node_t *
246
261
  h2x_children_of(lxb_dom_node_t *s)
247
262
  {
248
- if (s->type == LXB_DOM_NODE_TYPE_ELEMENT
249
- && s->local_name == LXB_TAG_TEMPLATE && s->ns == LXB_NS_HTML) {
250
- lxb_dom_document_fragment_t *content = lxb_html_interface_template(s)->content;
263
+ lxb_dom_document_fragment_t *content;
264
+ if (mkr_lxb_template_content(s, &content)) {
251
265
  return content != NULL ? lxb_dom_interface_node(content)->first_child : NULL;
252
266
  }
253
267
  return s->first_child;
@@ -384,10 +398,9 @@ x2h_make(lxb_dom_document_t *hdoc, const mkr_xml_node_t *s, lxb_dom_node_t **out
384
398
  static lxb_dom_node_t *
385
399
  x2h_link_target(lxb_dom_node_t *el)
386
400
  {
387
- if (el->type == LXB_DOM_NODE_TYPE_ELEMENT
388
- && el->local_name == LXB_TAG_TEMPLATE && el->ns == LXB_NS_HTML) {
389
- lxb_dom_document_fragment_t *content = lxb_html_interface_template(el)->content;
390
- if (content != NULL) return lxb_dom_interface_node(content);
401
+ lxb_dom_document_fragment_t *content;
402
+ if (mkr_lxb_template_content(el, &content) && content != NULL) {
403
+ return lxb_dom_interface_node(content);
391
404
  }
392
405
  return el;
393
406
  }
@@ -126,44 +126,36 @@ mkr_dom_index_build(mkr_dom_index_t *idx, lxb_dom_document_t *doc)
126
126
  }
127
127
  }
128
128
 
129
+ /* The four sizing allocations, all freed once through `fail:` (single-exit).
130
+ * `cursor` is scratch (not stored on idx) and is freed on the success path too. */
131
+ mkr_dom_index_attr_slot_t *slots = NULL;
132
+ lxb_dom_node_t **tag_nodes = NULL;
133
+ size_t *tag_off = NULL;
134
+ size_t *cursor = NULL;
135
+ size_t cap = 0;
136
+
129
137
  /* Size the attr->owner table (load factor <= 0.5). */
130
- mkr_dom_index_attr_slot_t *slots = NULL;
131
- size_t cap = 0;
132
138
  if (n_attrs > 0) {
133
139
  size_t need;
134
- if (!mkr_size_mul(n_attrs, 2, &need) || !mkr_pow2_ceil(need, &cap)) {
135
- return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
136
- }
140
+ if (!mkr_size_mul(n_attrs, 2, &need) || !mkr_pow2_ceil(need, &cap)) goto fail;
137
141
  if (cap < 8) cap = 8;
138
142
  slots = mkr_callocarray(cap, sizeof(*slots));
139
- if (slots == NULL) return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
143
+ if (slots == NULL) goto fail;
140
144
  }
141
145
 
142
146
  /* Size the tag CSR: prefix-sum the per-tag counts into offsets, plus a
143
147
  * cursor (copy of the offsets) used to scatter in document order. */
144
- lxb_dom_node_t **tag_nodes = NULL;
145
- size_t *tag_off = NULL;
146
- size_t *cursor = NULL;
147
148
  if (n_indexed > 0) {
148
149
  size_t noff = (size_t)tag_max + 2;
149
150
  size_t off_bytes;
150
- if (!mkr_size_mul(noff, sizeof(*cursor), &off_bytes)) {
151
- free(slots);
152
- return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
153
- }
151
+ if (!mkr_size_mul(noff, sizeof(*cursor), &off_bytes)) goto fail;
154
152
  tag_off = mkr_reallocarray(NULL, noff, sizeof(*tag_off));
155
153
  cursor = mkr_reallocarray(NULL, noff, sizeof(*cursor));
156
154
  tag_nodes = mkr_reallocarray(NULL, n_indexed, sizeof(*tag_nodes));
157
- if (tag_off == NULL || cursor == NULL || tag_nodes == NULL) {
158
- free(slots); free(tag_off); free(cursor); free(tag_nodes);
159
- return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
160
- }
155
+ if (tag_off == NULL || cursor == NULL || tag_nodes == NULL) goto fail;
161
156
  tag_off[0] = 0;
162
157
  for (uintptr_t t = 0; t <= tag_max; t++) {
163
- if (!mkr_size_add(tag_off[t], counts[t], &tag_off[t + 1])) {
164
- free(slots); free(tag_off); free(cursor); free(tag_nodes);
165
- return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
166
- }
158
+ if (!mkr_size_add(tag_off[t], counts[t], &tag_off[t + 1])) goto fail;
167
159
  }
168
160
  memcpy(cursor, tag_off, off_bytes);
169
161
  }
@@ -205,6 +197,15 @@ mkr_dom_index_build(mkr_dom_index_t *idx, lxb_dom_document_t *doc)
205
197
  free(cursor);
206
198
  idx->built = true;
207
199
  return LXB_STATUS_OK;
200
+
201
+ fail:
202
+ /* Reached only before the idx->... assignments above transfer ownership, so
203
+ * these are all still locals (phase 2 has no failure path). */
204
+ free(slots);
205
+ free(tag_nodes);
206
+ free(tag_off);
207
+ free(cursor);
208
+ return LXB_STATUS_ERROR_MEMORY_ALLOCATION;
208
209
  }
209
210
 
210
211
  static lxb_dom_node_t *
@@ -22,46 +22,45 @@ mkr_parse_tracked(const lxb_char_t *src, size_t len, void **out_lines)
22
22
  {
23
23
  *out_lines = NULL;
24
24
 
25
- lxb_html_parser_t *parser = lxb_html_parser_create();
26
- if (parser == NULL || lxb_html_parser_init(parser) != LXB_STATUS_OK) {
27
- if (parser != NULL) {
28
- lxb_html_parser_destroy(parser);
29
- }
30
- return NULL;
31
- }
25
+ /* All resources declared up front and freed once through `fail:` (single-exit),
26
+ * so the correct free-set lives in one place instead of being re-spelled at
27
+ * each early return. Every destructor below is NULL-safe (guarded / by
28
+ * contract), so ordering across the acquisition points stays trivial. */
29
+ lxb_html_parser_t *parser = NULL;
30
+ lxb_html_document_t *doc = NULL;
31
+ mkr_pos_recorder_t *rec = NULL;
32
+ lxb_status_t st;
32
33
 
33
- lxb_html_document_t *doc = lxb_html_parse_chunk_begin(parser);
34
- if (doc == NULL) {
35
- lxb_html_parser_destroy(parser);
36
- return NULL;
37
- }
34
+ parser = lxb_html_parser_create();
35
+ if (parser == NULL || lxb_html_parser_init(parser) != LXB_STATUS_OK) goto fail;
36
+
37
+ doc = lxb_html_parse_chunk_begin(parser);
38
+ if (doc == NULL) goto fail;
38
39
 
39
40
  /* Install the recorder, chaining the parser's own tree-building callback
40
41
  * (set by chunk_begin). If the recorder can't be allocated we simply parse
41
42
  * without source tracking. */
42
- mkr_pos_recorder_t *rec = mkr_pos_recorder_create(src);
43
+ rec = mkr_pos_recorder_create(src);
43
44
  if (rec != NULL) {
44
45
  lxb_html_tokenizer_t *tkz = lxb_html_parser_tokenizer(parser);
46
+ /* Lexbor has a setter and a ctx getter for the token-done callback but no
47
+ * getter for the callback function itself, so read that one field
48
+ * directly (the ctx uses the public getter). */
45
49
  mkr_pos_recorder_set_delegate(rec, tkz->callback_token_done,
46
- tkz->callback_token_ctx);
50
+ lxb_html_tokenizer_callback_token_done_ctx(tkz));
47
51
  lxb_html_tokenizer_callback_token_done_set(tkz, mkr_pos_token_cb, rec);
48
52
  }
49
53
 
50
- lxb_status_t st = lxb_html_parse_chunk_process(parser, src, len);
54
+ st = lxb_html_parse_chunk_process(parser, src, len);
51
55
  if (st == LXB_STATUS_OK) {
52
56
  st = lxb_html_parse_chunk_end(parser);
53
57
  }
54
-
55
- if (st != LXB_STATUS_OK) {
56
- mkr_pos_recorder_destroy(rec);
57
- lxb_html_document_destroy(doc);
58
- lxb_html_parser_destroy(parser);
59
- return NULL;
60
- }
58
+ if (st != LXB_STATUS_OK) goto fail;
61
59
 
62
60
  if (rec != NULL) {
63
61
  mkr_pos_assign_to_dom(rec, lxb_dom_interface_node(doc));
64
62
  mkr_pos_recorder_destroy(rec);
63
+ rec = NULL; /* consumed */
65
64
  *out_lines = mkr_lines_build(src, len);
66
65
  }
67
66
 
@@ -69,6 +68,12 @@ mkr_parse_tracked(const lxb_char_t *src, size_t len, void **out_lines)
69
68
  * tokenizer and tree, never the document). */
70
69
  lxb_html_parser_destroy(parser);
71
70
  return doc;
71
+
72
+ fail:
73
+ mkr_pos_recorder_destroy(rec); /* NULL-safe */
74
+ if (doc != NULL) lxb_html_document_destroy(doc);
75
+ if (parser != NULL) lxb_html_parser_destroy(parser);
76
+ return NULL;
72
77
  }
73
78
 
74
79
  mkr_parsed_t *
@@ -115,6 +115,18 @@ mkr_tix_is_container(const lxb_dom_node_t *n)
115
115
  || n->type == LXB_DOM_NODE_TYPE_DOCUMENT_FRAGMENT;
116
116
  }
117
117
 
118
+ /* The non-empty character-data payload of a TEXT/CDATA node, or NULL (for a
119
+ * non-text node or an empty one). The single "does this node contribute a text
120
+ * slice" test, shared by the count and fill passes so the two-pass sizing and
121
+ * filling can never disagree about which nodes yield a slice. */
122
+ static const lexbor_str_t *
123
+ mkr_tix_text_slice(lxb_dom_node_t *n)
124
+ {
125
+ if (!mkr_tix_is_text(n)) return NULL;
126
+ const lexbor_str_t *d = &lxb_dom_interface_character_data(n)->data;
127
+ return (d->data != NULL && d->length != 0) ? d : NULL;
128
+ }
129
+
118
130
  /* Pass 1: count text slices and container (element/fragment) nodes under root
119
131
  * (inclusive), so the slice arrays and the range table are each sized once. */
120
132
  static void
@@ -124,9 +136,8 @@ mkr_tix_count(lxb_dom_node_t *root, size_t *out_slices, size_t *out_containers)
124
136
  for (lxb_dom_node_t *n = root; n != NULL; n = mkr_dom_preorder_next(n, root)) {
125
137
  if (mkr_tix_is_container(n)) {
126
138
  nc++;
127
- } else if (mkr_tix_is_text(n)) {
128
- const lexbor_str_t *d = &lxb_dom_interface_character_data(n)->data;
129
- if (d->data != NULL && d->length != 0) ns++;
139
+ } else if (mkr_tix_text_slice(n) != NULL) {
140
+ ns++;
130
141
  }
131
142
  }
132
143
  *out_slices = ns;
@@ -205,23 +216,21 @@ mkr_text_idx_build(lxb_dom_node_t *root)
205
216
  }
206
217
  top->child = child->next; /* advance the cursor for our return */
207
218
 
208
- if (mkr_tix_is_text(child)) {
209
- const lexbor_str_t *d = &lxb_dom_interface_character_data(child)->data;
210
- if (d->data != NULL && d->length != 0) {
211
- size_t i = t->nslices;
212
- size_t total;
213
- if (!mkr_size_add(t->prefix[i], d->length, &total)) {
214
- /* cumulative text length overflows size_t - give up the
215
- * index (fail closed); the caller falls back to a walk. */
216
- free(st);
217
- mkr_text_idx_destroy(t);
218
- return NULL;
219
- }
220
- t->slices[i].ptr = (const char *)d->data;
221
- t->slices[i].len = d->length;
222
- t->prefix[i + 1] = total;
223
- t->nslices = i + 1;
219
+ const lexbor_str_t *d = mkr_tix_text_slice(child);
220
+ if (d != NULL) {
221
+ size_t i = t->nslices;
222
+ size_t total;
223
+ if (!mkr_size_add(t->prefix[i], d->length, &total)) {
224
+ /* cumulative text length overflows size_t - give up the
225
+ * index (fail closed); the caller falls back to a walk. */
226
+ free(st);
227
+ mkr_text_idx_destroy(t);
228
+ return NULL;
224
229
  }
230
+ t->slices[i].ptr = (const char *)d->data;
231
+ t->slices[i].len = d->length;
232
+ t->prefix[i + 1] = total;
233
+ t->nslices = i + 1;
225
234
  } else if (mkr_tix_is_container(child)) {
226
235
  size_t r = mkr_tix_range_insert(t, child, t->nslices);
227
236
  /* grow_reserve may move st; top is re-fetched at the loop head and
@@ -28,6 +28,10 @@ abort "Lexbor source not found at #{LEXBOR_SRC}. Did you `git submodule update -
28
28
 
29
29
  cmake = find_executable("cmake") or abort "cmake is required to build Lexbor."
30
30
 
31
+ # Windows (RubyInstaller / MinGW-UCRT gcc). Used below to force the GNU
32
+ # toolchain for the Lexbor build and to keep the Ruby import-library link.
33
+ windows = RbConfig::CONFIG["target_os"] =~ /mingw|mswin/
34
+
31
35
  # Optionally build the vendored Lexbor itself under AddressSanitizer. This is the
32
36
  # ONLY way to catch overflows *inside* Lexbor's bump (mraw) arena: a sub-allocation
33
37
  # overrunning into the next one stays within one big malloc'd chunk, so the heap
@@ -55,6 +59,13 @@ unless stamp_ok
55
59
  Dir.chdir(LEXBOR_BLD) do
56
60
  cmd = [
57
61
  cmake,
62
+ # On Windows the runner ships Visual Studio, so a bare cmake defaults to the
63
+ # MSVC generator -> lexbor_static.lib under a Release/ subdir, breaking the
64
+ # hardcoded liblexbor_static.a path below. Force the GNU toolchain via Ninja
65
+ # (bundled by lukka/get-cmake; single-config, so no Release/ subdir) and pin
66
+ # the MinGW-UCRT gcc so cmake can't auto-probe MSVC. "MinGW Makefiles" is
67
+ # avoided: cmake refuses it when sh.exe is on PATH (MSYS2/Git-Bash put it there).
68
+ *(windows ? ["-G", "Ninja", "-DCMAKE_C_COMPILER=gcc"] : []),
58
69
  "-DLEXBOR_BUILD_SHARED=OFF",
59
70
  "-DLEXBOR_BUILD_STATIC=ON",
60
71
  "-DLEXBOR_BUILD_TESTS=OFF",
@@ -65,10 +76,18 @@ unless stamp_ok
65
76
  "-DCMAKE_INSTALL_PREFIX=#{LEXBOR_DST}",
66
77
  *(lexbor_asan ? ["-DLEXBOR_BUILD_WITH_ASAN=ON"] : []),
67
78
  LEXBOR_SRC,
68
- ].shelljoin
79
+ ]
69
80
  warn "makiri: building vendored Lexbor (mode=#{lexbor_mode})"
70
- system(cmd) or abort "cmake configure failed for Lexbor."
71
- system("#{cmake.shellescape} --build . --target install -- -j#{Etc.respond_to?(:nprocessors) ? Etc.nprocessors : 4}") or
81
+ # Multi-arg system() bypasses the shell, so args pass verbatim on every
82
+ # platform. Do NOT shelljoin: Shellwords escapes `=` as `\=`, which a POSIX
83
+ # shell unwraps but Windows cmd.exe does not, mangling every -DNAME=VALUE
84
+ # (cmake then ignores CMAKE_INSTALL_PREFIX etc. and installs to the default).
85
+ system(*cmd) or abort "cmake configure failed for Lexbor."
86
+ nproc = Etc.respond_to?(:nprocessors) ? Etc.nprocessors : 4
87
+ # `-- -jN` forwards to make and is wrong for Ninja; use cmake's portable
88
+ # --parallel (cmake >= 3.12, satisfied by get-cmake) on Windows.
89
+ build_parallel = windows ? ["--parallel", nproc.to_s] : ["--", "-j#{nproc}"]
90
+ system(cmake, "--build", ".", "--target", "install", *build_parallel) or
72
91
  abort "cmake build/install failed for Lexbor."
73
92
  end
74
93
  File.write(lexbor_stamp, lexbor_mode)
@@ -77,6 +96,13 @@ end
77
96
  $INCFLAGS << " -I#{File.join(LEXBOR_DST, 'include').shellescape}"
78
97
  $INCFLAGS << " -I#{File.join(EXT_DIR).shellescape}"
79
98
 
99
+ # On Windows, Lexbor's public headers default LXB_API to __declspec(dllimport)
100
+ # (lexbor/core/def.h), emitting __imp_* references that don't resolve against the
101
+ # STATIC archive we link. Lexbor's own static CMake target defines LEXBOR_STATIC to
102
+ # neutralize this, but we link the .a directly (not via CMake's exported target),
103
+ # so we must define it ourselves. Harmless (and a no-op macro) on other platforms.
104
+ $CFLAGS << " -DLEXBOR_STATIC" if windows
105
+
80
106
  # Hard-link the static archive rather than pass -L/-llexbor_static, to
81
107
  # avoid accidentally linking a system-installed Lexbor.
82
108
  lexbor_archive = File.join(LEXBOR_DST, "lib", "liblexbor_static.a")
@@ -176,6 +202,10 @@ elsif RbConfig::CONFIG["target_os"] =~ /linux/
176
202
  $LIBRUBYARG = "" # the ruby executable already provides them
177
203
  $LIBRUBYARG_SHARED = ""
178
204
  $LIBRUBYARG_STATIC = ""
205
+ # else (mingw/mswin): intentionally NOT blanked. PE/COFF has no dynamic_lookup
206
+ # equivalent, so a MinGW extension MUST link the Ruby import library - we fall
207
+ # through to mkmf's default libruby link. The precompiled x64-mingw-ucrt gem
208
+ # still loads on any compatible Ruby of that ABI (they share ruby.dll's name).
179
209
  end
180
210
 
181
211
  # Export ONLY Init_makiri from the compiled extension. `-fvisibility=hidden`
@@ -194,6 +224,13 @@ elsif RbConfig::CONFIG["target_os"] =~ /linux/
194
224
  # are already hidden by -fvisibility=hidden, leaving just RUBY_FUNC_EXPORTED
195
225
  # Init_makiri in the dynamic symbol table.
196
226
  $DLDFLAGS << " -Wl,--exclude-libs,ALL"
227
+ elsif windows
228
+ # PE DLLs export nothing unless __declspec(dllexport); Init_makiri (marked
229
+ # RUBY_FUNC_EXPORTED) is the only such export, which already turns off MinGW
230
+ # ld's auto-export-all. Belt-and-suspenders parity with the arms above: keep
231
+ # the export table to Init_makiri so a co-loaded Lexbor-based ext (e.g.
232
+ # nokolexbor) can't bind our private lxb_*.
233
+ $DLDFLAGS << " -Wl,--exclude-all-symbols"
197
234
  end
198
235
 
199
236
  # Recursively pick up C sources under ext/makiri/, excluding standalone
@@ -43,7 +43,7 @@ CORE_SRCS = \
43
43
  $(EXT_DIR)/core/mkr_alloc.c \
44
44
  $(EXT_DIR)/core/mkr_utf8.c \
45
45
  $(EXT_DIR)/core/mkr_buf.c \
46
- $(EXT_DIR)/core/mkr_core.c
46
+ $(EXT_DIR)/core/mkr_core_selftest.c
47
47
 
48
48
  XML_SRCS = \
49
49
  $(EXT_DIR)/xml/mkr_xml_tree.c \
@@ -86,11 +86,16 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
86
86
  mkr_xpath_context_t *ctx = mkr_xpath_context_new(doc->doc_node, doc->doc_node);
87
87
  if (ctx) {
88
88
  mkr_xpath_set_engine_kind(ctx, 1); /* XML engine */
89
- mkr_xpath_limits_init_defaults(&limits);
90
- limits.max_eval_ops = 5 * 1000 * 1000; /* 5M ops - enough for a real query */
91
- limits.max_nodeset_size = 10000;
92
- limits.max_string_bytes = 1024 * 1024;
93
- limits.max_recursion_depth = 64;
89
+ /* Shrink the per-evaluate budgets ON THE CONTEXT the evaluator actually
90
+ * reads: mkr_xpath_eval_compiled consults ctx->limits, not a local struct,
91
+ * so tighten via mkr_ctx_limits(ctx) (as xml_xpath_fuzz.c does) - otherwise
92
+ * a hostile expression would run under the large defaults and stall the
93
+ * fuzzer. */
94
+ mkr_xpath_limits_t *L = mkr_ctx_limits(ctx);
95
+ L->max_eval_ops = 5 * 1000 * 1000; /* 5M ops - enough for a real query */
96
+ L->max_nodeset_size = 10000;
97
+ L->max_string_bytes = 1024 * 1024;
98
+ L->max_recursion_depth = 64;
94
99
 
95
100
  mkr_xpath_value_t out = {0};
96
101
  mkr_xpath_error_t eval_err = {0};
@@ -105,6 +105,20 @@ void mkr_import_fragment_children(lxb_dom_document_t *doc, lxb_dom_node_t *root,
105
105
  void mkr_emit_append(lxb_dom_node_t *imported, void *u);
106
106
  void mkr_emit_before(lxb_dom_node_t *imported, void *u);
107
107
 
108
+ /* Shared transient-fragment-parser scaffold (glue/ruby_doc.c): create+init an
109
+ * HTML parser, sanitize +html+ to UTF-8 bytes, run +parse+ (which calls the
110
+ * appropriate lxb_html_parse_fragment* and returns the fragment root), free the
111
+ * decoded input, and destroy the parser - returning the root, which is owned by
112
+ * its (possibly transient) document and survives the parser's destruction.
113
+ * Raises on parser-create / OOM / parse failure. Both fragment paths
114
+ * (Document#fragment via a tag-id context, inner_html=/outer_html= via an element
115
+ * context) share this so the create/sanitize/free/destroy + error cleanup lives
116
+ * once. */
117
+ typedef lxb_dom_node_t *(*mkr_fragment_parse_fn)(lxb_html_parser_t *parser,
118
+ const lxb_char_t *hsrc, size_t hlen,
119
+ void *ctx);
120
+ lxb_dom_node_t *mkr_run_fragment_parser(VALUE html, mkr_fragment_parse_fn parse, void *ctx);
121
+
108
122
  /* Node#clone_node(deep=false): shallow/deep DOM clone owned by this node's
109
123
  * document (import_node + <template>-content fixup), detached from any parent.
110
124
  * Implemented in ruby_doc.c (next to the import machinery), bound in