makiri 0.5.1 → 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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +2 -1
  3. data/.github/workflows/libfuzzer.yml +1 -1
  4. data/.github/workflows/release.yml +28 -13
  5. data/.github/workflows/verify.yml +37 -0
  6. data/CHANGELOG.md +52 -0
  7. data/README.md +16 -0
  8. data/Rakefile +14 -7
  9. data/ext/makiri/bridge/bridge.h +28 -4
  10. data/ext/makiri/bridge/bridge_internal.h +28 -0
  11. data/ext/makiri/bridge/ruby_string.c +29 -221
  12. data/ext/makiri/bridge/xml_decode.c +237 -0
  13. data/ext/makiri/core/mkr_alloc.c +6 -0
  14. data/ext/makiri/core/mkr_alloc.h +13 -4
  15. data/ext/makiri/core/mkr_buf.h +1 -0
  16. data/ext/makiri/core/mkr_text.h +28 -9
  17. data/ext/makiri/core/mkr_utf8.h +8 -1
  18. data/ext/makiri/dom_adapter/cross_import.c +20 -7
  19. data/ext/makiri/dom_adapter/dom_index.c +22 -21
  20. data/ext/makiri/dom_adapter/post_parse.c +27 -22
  21. data/ext/makiri/dom_adapter/text_index.c +28 -19
  22. data/ext/makiri/extconf.rb +40 -3
  23. data/ext/makiri/fuzz/Makefile +15 -7
  24. data/ext/makiri/fuzz/xml_xpath_fuzz.c +87 -0
  25. data/ext/makiri/fuzz/xpath_fuzz.c +10 -5
  26. data/ext/makiri/glue/glue.h +14 -0
  27. data/ext/makiri/glue/ruby_doc.c +50 -24
  28. data/ext/makiri/glue/ruby_html_mutate.c +181 -71
  29. data/ext/makiri/glue/ruby_html_node.c +76 -52
  30. data/ext/makiri/glue/ruby_xml.c +60 -91
  31. data/ext/makiri/glue/ruby_xml_node.c +175 -14
  32. data/ext/makiri/makiri.c +8 -7
  33. data/ext/makiri/xml/mkr_xml.h +5 -0
  34. data/ext/makiri/xml/mkr_xml_chars.c +10 -0
  35. data/ext/makiri/xml/mkr_xml_mutate.c +228 -38
  36. data/ext/makiri/xml/mkr_xml_mutate.h +46 -8
  37. data/ext/makiri/xml/mkr_xml_node.c +21 -12
  38. data/ext/makiri/xml/mkr_xml_node.h +39 -9
  39. data/ext/makiri/xml/mkr_xml_tree.c +39 -45
  40. data/ext/makiri/xpath/mkr_css.c +19 -34
  41. data/ext/makiri/xpath/mkr_xpath.c +34 -31
  42. data/ext/makiri/xpath/mkr_xpath_eval_body.h +30 -31
  43. data/ext/makiri/xpath/mkr_xpath_funcs_body.h +76 -71
  44. data/ext/makiri/xpath/mkr_xpath_internal.h +1 -1
  45. data/ext/makiri/xpath/mkr_xpath_parse.c +125 -157
  46. data/ext/makiri/xpath/mkr_xpath_value_body.h +34 -45
  47. data/ext/makiri/xpath/mkr_xpath_xml_selftest.c +7 -10
  48. data/lib/makiri/attr.rb +0 -3
  49. data/lib/makiri/comment.rb +1 -3
  50. data/lib/makiri/compat_aliases.rb +1 -12
  51. data/lib/makiri/version.rb +1 -1
  52. data/script/check_c_safety.rb +11 -0
  53. data/script/check_c_safety_allowlist.yml +7 -16
  54. data/suppressions/ruby.supp +22 -0
  55. data/verify/Makefile +237 -0
  56. data/verify/cbmc_models.c +38 -0
  57. data/verify/harness_alloc.c +257 -0
  58. data/verify/harness_buf.c +167 -0
  59. data/verify/harness_hash.c +28 -0
  60. data/verify/harness_span.c +213 -0
  61. data/verify/harness_spanbuf.c +98 -0
  62. data/verify/harness_utf8.c +70 -0
  63. data/verify/harness_utf8_chain.c +64 -0
  64. data/verify/harness_utf8_words.c +54 -0
  65. data/verify/harness_xml_chars.c +59 -0
  66. data/verify/harness_xml_expand.c +72 -0
  67. data/verify/harness_xml_parse.c +42 -0
  68. data/verify/harness_xpath_lex.c +51 -0
  69. data/verify/harness_xpath_number.c +68 -0
  70. data/verify/harness_xpath_parse.c +57 -0
  71. data/verify/selftest_main.c +16 -0
  72. data/verify/stub.c +10 -0
  73. data/verify/verify.h +54 -0
  74. metadata +25 -2
  75. /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
+ }
@@ -39,6 +39,12 @@ mkr_reallocarray(void *ptr, size_t count, size_t elem)
39
39
  free(ptr);
40
40
  return NULL;
41
41
  }
42
+ if (elem == 0) {
43
+ /* Fail closed like mkr_callocarray rather than fall through to a
44
+ * realloc(ptr, 0) whose free-or-not is implementation-defined; the
45
+ * caller keeps ownership of ptr. */
46
+ return NULL;
47
+ }
42
48
  size_t bytes;
43
49
  if (!mkr_size_mul(count, elem, &bytes)) {
44
50
  return NULL; /* overflow: leave ptr unchanged */
@@ -48,8 +48,10 @@ mkr_size_mul(size_t a, size_t b, size_t *out)
48
48
  }
49
49
 
50
50
  /* Compute a new capacity (in elements) that is >= need and grows geometrically
51
- * from cap, with cap*elem guaranteed to fit in size_t. Returns false on
52
- * overflow. */
51
+ * from cap, with new_cap*elem guaranteed to fit in size_t. Succeeds EXACTLY
52
+ * when need*elem fits: if the geometric target overshoots what elem allows, it
53
+ * falls back to `need` rather than failing, so the only failure is `need`
54
+ * itself being too large. (cbmc-alloc proves success <=> need*elem fits.) */
53
55
  static inline bool
54
56
  mkr_grow_capacity(size_t cap, size_t need, size_t elem, size_t *new_cap)
55
57
  {
@@ -59,14 +61,21 @@ mkr_grow_capacity(size_t cap, size_t need, size_t elem, size_t *new_cap)
59
61
  nc *= 2;
60
62
  }
61
63
  size_t bytes;
62
- if (!mkr_size_mul(nc, elem, &bytes)) return false;
64
+ if (!mkr_size_mul(nc, elem, &bytes)) {
65
+ /* Geometric overshoot doesn't fit; retry at exactly `need` so a
66
+ * representable need never fails from the *2 headroom alone. */
67
+ nc = need;
68
+ if (!mkr_size_mul(nc, elem, &bytes)) return false;
69
+ }
63
70
  *new_cap = nc;
64
71
  return true;
65
72
  }
66
73
 
67
74
  /* realloc(ptr, count * elem) with the multiply overflow-checked. Returns NULL
68
75
  * on overflow or OOM; the original ptr is then unchanged (caller still owns
69
- * it). count == 0 frees and returns NULL. */
76
+ * it). count == 0 frees ptr and returns NULL; elem == 0 (with count > 0)
77
+ * returns NULL and leaves ptr unchanged (no implementation-defined
78
+ * realloc(ptr, 0)). */
70
79
  void *mkr_reallocarray(void *ptr, size_t count, size_t elem);
71
80
 
72
81
  /* calloc(count, elem) with the multiply overflow-checked, result zeroed.
@@ -143,6 +143,7 @@ static inline void
143
143
  mkr_spanbuf_write(mkr_spanbuf_t *b, const void *src, size_t n)
144
144
  {
145
145
  if (!b->ok || n == 0) return;
146
+ if (src == NULL) { b->ok = false; return; } /* fail closed, never memcpy(,NULL,n) */
146
147
  if (b->pos > b->cap || n > b->cap - b->pos) { b->ok = false; return; }
147
148
  memcpy(b->buf + b->pos, src, n);
148
149
  b->pos += n;
@@ -22,17 +22,28 @@ extern "C" {
22
22
  /* A borrowed byte slice whose contents are guaranteed to satisfy Makiri's
23
23
  * engine text contract: valid UTF-8, no interior NUL, and NUL-terminated at
24
24
  * ptr[len]. The XPath engine's public string entry points accept ONLY this
25
- * type, so a raw `const char *` cannot be passed without a compile error. The
26
- * sole constructor is mkr_verified_text_from_view() at the Ruby boundary (bridge),
27
- * which can only be fed a view that mkr_ruby_verified_text() already validated;
28
- * there is deliberately no Ruby-free constructor, so unvalidated bytes have no
29
- * path into the engine. (ptr is non-owning: the caller keeps it alive for the
30
- * duration of the engine call.) */
25
+ * type, so a raw `const char *` cannot be passed without a compile error.
26
+ * Constructors:
27
+ * - mkr_verified_text_from_view() at the Ruby boundary (bridge), fed a view
28
+ * that mkr_ruby_verified_text() already validated;
29
+ * - mkr_verified_text_lit() below, for compile-time string literals (which
30
+ * meet the contract by construction).
31
+ * There is deliberately no Ruby-free constructor over runtime bytes, so
32
+ * unvalidated bytes have no path into the engine. (ptr is non-owning: the
33
+ * caller keeps it alive for the duration of the engine call.) */
31
34
  typedef struct {
32
35
  const char *ptr;
33
36
  size_t len;
34
37
  } mkr_verified_text_t;
35
38
 
39
+ /* Wrap a compile-time string literal as a verified-text token. The literal is
40
+ * NUL-terminated, NUL-free, and valid UTF-8 by construction, so it meets the
41
+ * engine text contract without validation - the sole Ruby-free mint, for
42
+ * selftest / fuzz harness literals that the bridge cannot reach. Do not pass a
43
+ * char* variable: sizeof(pointer) would not be the byte length. */
44
+ #define mkr_verified_text_lit(lit) \
45
+ ((mkr_verified_text_t){ (lit), sizeof(lit) - 1 })
46
+
36
47
  /* ---------------------------------------------------------------- */
37
48
  /* string-type lattice */
38
49
  /* ---------------------------------------------------------------- */
@@ -59,10 +70,18 @@ typedef struct {
59
70
  * raw slice today. When something does, name it mkr_borrowed_bytes_t per the
60
71
  * convention above; do not resurrect a placeholder before there is a user.
61
72
  *
73
+ * One bridge-only variant sits BETWEEN bytes and text on the contract axis:
74
+ * mkr_ruby_borrowed_data_t (bridge.h) is "valid UTF-8 but interior NUL
75
+ * PERMITTED" - a strict superset of text - for DOM data-family mutations
76
+ * (text/comment/CDATA content, attribute value), where U+0000 is legal data.
77
+ * It never enters the engine (the engine still requires NUL-free text), so it
78
+ * is deliberately absent from this engine lattice.
79
+ *
62
80
  * (*) mkr_verified_text_t (defined above) is the one deliberate naming exception:
63
- * it is a borrowed valid text AND a capability token. Its sole constructor is
64
- * mkr_verified_text_from_view() at the Ruby boundary, so an unvalidated const char*
65
- * cannot reach the engine's public API. Internally the engine carries the
81
+ * it is a borrowed valid text AND a capability token. Its constructors are
82
+ * mkr_verified_text_from_view() at the Ruby boundary and mkr_verified_text_lit()
83
+ * for compile-time literals, so an unvalidated runtime const char* cannot
84
+ * reach the engine's public API. Internally the engine carries the
66
85
  * freely-constructible mkr_borrowed_text_t instead.
67
86
  *
68
87
  * Conversions - the only sanctioned edges. The points that actually VALIDATE
@@ -32,6 +32,10 @@
32
32
  extern "C" {
33
33
  #endif
34
34
 
35
+ /* src must be non-NULL when len > 0 (it is dereferenced through [0, len)); src
36
+ * may be NULL only when len == 0 (trivially valid). len > 0 with src == NULL is
37
+ * a caller-contract violation, not a fail-closed case - this is a hot path
38
+ * guarded at the call site, not here. */
35
39
  bool mkr_utf8_valid(const unsigned char *src, size_t len);
36
40
 
37
41
  /* mkr_utf8_decode1 - decode ONE code point from [p, p+len), strictly: rejects
@@ -53,7 +57,10 @@ mkr_utf8_decode1_span(const mkr_span_t *s, uint32_t *cp)
53
57
  /* mkr_utf8_count_chars - count Unicode code points in [ptr, ptr+len): every
54
58
  * byte that is NOT a 0x80..0xBF continuation byte starts a new code point.
55
59
  * Length-bounded (does not rely on a NUL terminator); ptr may be NULL when
56
- * len == 0. Used where XPath measures string length / offsets in characters. */
60
+ * len == 0. Used where XPath measures string length / offsets in characters.
61
+ * Semantics: on VALID UTF-8 this equals the code-point count (proved in
62
+ * cbmc-utf8-chain); on invalid bytes it is only a lead-byte-class scan (no
63
+ * decoding), so the strong count/boundary guarantees hold for valid text. */
57
64
  static inline size_t
58
65
  mkr_utf8_count_chars(const char *ptr, size_t len)
59
66
  {
@@ -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