makiri 0.5.0 → 0.6.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/libfuzzer.yml +1 -1
  3. data/.github/workflows/verify.yml +37 -0
  4. data/CHANGELOG.md +19 -4
  5. data/README.md +16 -0
  6. data/Rakefile +13 -0
  7. data/ext/makiri/bridge/bridge.h +28 -4
  8. data/ext/makiri/bridge/ruby_string.c +25 -0
  9. data/ext/makiri/core/mkr_alloc.c +6 -0
  10. data/ext/makiri/core/mkr_alloc.h +13 -4
  11. data/ext/makiri/core/mkr_buf.h +1 -0
  12. data/ext/makiri/core/mkr_text.h +28 -9
  13. data/ext/makiri/core/mkr_utf8.h +8 -1
  14. data/ext/makiri/fuzz/Makefile +14 -6
  15. data/ext/makiri/fuzz/xml_xpath_fuzz.c +87 -0
  16. data/ext/makiri/glue/ruby_html_css.c +99 -10
  17. data/ext/makiri/glue/ruby_html_mutate.c +5 -5
  18. data/ext/makiri/xpath/mkr_xpath.c +12 -0
  19. data/ext/makiri/xpath/mkr_xpath_eval_body.h +18 -8
  20. data/ext/makiri/xpath/mkr_xpath_funcs_body.h +47 -38
  21. data/ext/makiri/xpath/mkr_xpath_xml_selftest.c +7 -10
  22. data/lib/makiri/version.rb +1 -1
  23. data/script/check_c_safety.rb +11 -0
  24. data/script/check_c_safety_allowlist.yml +9 -12
  25. data/suppressions/ruby.supp +22 -0
  26. data/verify/Makefile +237 -0
  27. data/verify/cbmc_models.c +38 -0
  28. data/verify/harness_alloc.c +257 -0
  29. data/verify/harness_buf.c +167 -0
  30. data/verify/harness_hash.c +28 -0
  31. data/verify/harness_span.c +213 -0
  32. data/verify/harness_spanbuf.c +98 -0
  33. data/verify/harness_utf8.c +70 -0
  34. data/verify/harness_utf8_chain.c +64 -0
  35. data/verify/harness_utf8_words.c +54 -0
  36. data/verify/harness_xml_chars.c +59 -0
  37. data/verify/harness_xml_expand.c +72 -0
  38. data/verify/harness_xml_parse.c +42 -0
  39. data/verify/harness_xpath_lex.c +51 -0
  40. data/verify/harness_xpath_number.c +68 -0
  41. data/verify/harness_xpath_parse.c +57 -0
  42. data/verify/selftest_main.c +16 -0
  43. data/verify/stub.c +10 -0
  44. data/verify/verify.h +54 -0
  45. metadata +22 -1
@@ -219,7 +219,7 @@ mkr_node_aset(VALUE self, VALUE rb_name, VALUE rb_value)
219
219
  rb_raise(mkr_eError, "cannot set an attribute on a non-element node");
220
220
  }
221
221
  mkr_ruby_borrowed_text_t nv = mkr_ruby_verified_text(rb_name, "attribute name");
222
- mkr_ruby_borrowed_text_t vv = mkr_ruby_verified_text(rb_value, "attribute value");
222
+ mkr_ruby_borrowed_data_t vv = mkr_ruby_verified_data(rb_value, "attribute value");
223
223
  lxb_dom_attr_t *attr = lxb_dom_element_set_attribute(
224
224
  lxb_dom_interface_element(node),
225
225
  (const lxb_char_t *)nv.ptr, nv.len,
@@ -289,7 +289,7 @@ mkr_node_set_attribute_ns(VALUE self, VALUE rb_ns, VALUE rb_qname, VALUE rb_valu
289
289
  lxb_dom_element_t *el = lxb_dom_interface_element(node);
290
290
 
291
291
  mkr_ruby_borrowed_text_t qv = mkr_ruby_verified_text(rb_qname, "attribute qualified name");
292
- mkr_ruby_borrowed_text_t vv = mkr_ruby_verified_text(rb_value, "attribute value");
292
+ mkr_ruby_borrowed_data_t vv = mkr_ruby_verified_data(rb_value, "attribute value");
293
293
 
294
294
  mkr_ruby_borrowed_text_t nv = {0};
295
295
  bool have_ns = false;
@@ -442,7 +442,7 @@ static VALUE
442
442
  mkr_node_set_content(VALUE self, VALUE rb_text)
443
443
  {
444
444
  lxb_dom_node_t *node = mkr_node_unwrap_mutable(self);
445
- mkr_ruby_borrowed_text_t tv = mkr_ruby_verified_text(rb_text, "node content");
445
+ mkr_ruby_borrowed_data_t tv = mkr_ruby_verified_data(rb_text, "node content");
446
446
  lxb_status_t st = lxb_dom_node_text_content_set(
447
447
  node, (const lxb_char_t *)tv.ptr, tv.len);
448
448
  RB_GC_GUARD(tv.value);
@@ -584,7 +584,7 @@ static VALUE
584
584
  mkr_doc_create_text_node(VALUE self, VALUE rb_text)
585
585
  {
586
586
  lxb_dom_document_t *doc = mkr_html_doc_unwrap(self);
587
- mkr_ruby_borrowed_text_t tv = mkr_ruby_verified_text(rb_text, "text content");
587
+ mkr_ruby_borrowed_data_t tv = mkr_ruby_verified_data(rb_text, "text content");
588
588
  lxb_dom_text_t *t = lxb_dom_document_create_text_node(
589
589
  doc, (const lxb_char_t *)tv.ptr, tv.len);
590
590
  RB_GC_GUARD(tv.value);
@@ -598,7 +598,7 @@ static VALUE
598
598
  mkr_doc_create_comment(VALUE self, VALUE rb_text)
599
599
  {
600
600
  lxb_dom_document_t *doc = mkr_html_doc_unwrap(self);
601
- mkr_ruby_borrowed_text_t tv = mkr_ruby_verified_text(rb_text, "comment content");
601
+ mkr_ruby_borrowed_data_t tv = mkr_ruby_verified_data(rb_text, "comment content");
602
602
  lxb_dom_comment_t *c = lxb_dom_document_create_comment(
603
603
  doc, (const lxb_char_t *)tv.ptr, tv.len);
604
604
  RB_GC_GUARD(tv.value);
@@ -258,6 +258,18 @@ mkr_limit_ast_node(mkr_xpath_limits_t *L, mkr_xpath_error_t *err)
258
258
  return 0;
259
259
  }
260
260
 
261
+ /*
262
+ * THE evaluator progress gate. This is the single primitive that bounds runtime
263
+ * work: every loop in the engine whose trip count is input-derived charges ONE
264
+ * tick per iteration through here (the axis walk per visited node, the M*N
265
+ * compare per pair, the index-bucket scan per element, eval_node per AST node).
266
+ * One uniform rule - "input-bounded loop => one eval_op per step" - so checking
267
+ * the DoS bound is local: confirm each such loop calls this. Kept deliberately
268
+ * uniform (no bulk/up-front variant): a bulk charge would only suit
269
+ * run-to-completion loops and would wrongly reject an early-exiting query if
270
+ * misapplied, trading one foot-gun-free rule for a conditional one. Overrun is
271
+ * fail-closed (MKR_XPATH_ERR_LIMIT), never a truncated result.
272
+ */
261
273
  int
262
274
  mkr_limit_eval_op(mkr_xpath_limits_t *L, mkr_xpath_error_t *err)
263
275
  {
@@ -1509,14 +1509,15 @@ eval_fncall(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1509
1509
  mkr_val_t *out, mkr_xpath_error_t *err)
1510
1510
  {
1511
1511
  const char *ns_uri = NULL;
1512
+ size_t ns_uri_len = 0;
1512
1513
  if (n->u.fncall.prefix.ptr) {
1513
- ns_uri = mkr_ctx_lookup_ns(ctx, n->u.fncall.prefix.ptr, n->u.fncall.prefix.len, NULL);
1514
+ ns_uri = mkr_ctx_lookup_ns(ctx, n->u.fncall.prefix.ptr, n->u.fncall.prefix.len, &ns_uri_len);
1514
1515
  if (ns_uri == NULL) {
1515
1516
  mkr_err_setf(err, MKR_XPATH_ERR_RUNTIME, "unknown namespace prefix '%s'", n->u.fncall.prefix.ptr);
1516
1517
  return -1;
1517
1518
  }
1518
1519
  }
1519
- mkr_func_impl_t fn = mkr_lookup_function(ns_uri, n->u.fncall.name.ptr);
1520
+ mkr_func_impl_t fn = mkr_lookup_function(ns_uri, ns_uri_len, n->u.fncall.name.ptr, n->u.fncall.name.len);
1520
1521
 
1521
1522
  /* Evaluate arguments once and reuse for either path. */
1522
1523
  mkr_val_t *args = NULL;
@@ -1666,22 +1667,30 @@ eval_node(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1666
1667
  mkr_val_t *out, mkr_xpath_error_t *err)
1667
1668
  {
1668
1669
  /* Budget + recursion bookkeeping. Every AST node visit counts as one
1669
- * eval op; recursion depth tracks how deep we are in expression
1670
- * nodes so we abort cleanly on pathological inputs. */
1670
+ * eval op; recursion depth tracks how deep we are in expression nodes
1671
+ * so we abort cleanly on pathological inputs.
1672
+ *
1673
+ * eval_node is the engine's ONLY recursive function, so "AST-driven
1674
+ * recursion is bounded" reduces entirely to this one enter/leave pair:
1675
+ * charge one op + one recursion level on entry, release the level at the
1676
+ * SINGLE exit below. Keeping eval_node single-exit is what makes that
1677
+ * balance locally verifiable - there is no path from the enter here to a
1678
+ * return that skips the mkr_limit_recurse_leave. Do not add an early
1679
+ * `return` between here and the leave; set `rc` and fall through. */
1671
1680
  mkr_xpath_limits_t *L = mkr_ctx_limits(ctx);
1672
1681
  if (mkr_limit_eval_op(L, err) != 0) return -1;
1673
1682
  if (mkr_limit_recurse_enter(L, err) != 0) return -1;
1674
1683
 
1684
+ int rc;
1685
+
1675
1686
  /* Hoisting fast path: a CI subtree that's already been computed in
1676
1687
  * this evaluate is returned as a clone. The clone keeps ownership
1677
1688
  * semantics clean - mkr_val_clear on either copy is safe. */
1678
1689
  if (n->is_context_independent && n->memoized) {
1679
- int rc = mkr_val_clone(&n->memo_value, out, err);
1680
- mkr_limit_recurse_leave(L);
1681
- return rc;
1690
+ rc = mkr_val_clone(&n->memo_value, out, err);
1691
+ goto done; /* single exit: the recurse_leave below still runs */
1682
1692
  }
1683
1693
 
1684
- int rc;
1685
1694
  switch (n->kind) {
1686
1695
  case MKR_NK_LITERAL_STR: {
1687
1696
  mkr_owned_text_t text;
@@ -1772,6 +1781,7 @@ eval_node(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1772
1781
  }
1773
1782
  }
1774
1783
 
1784
+ done:
1775
1785
  mkr_limit_recurse_leave(L);
1776
1786
  return rc;
1777
1787
  }
@@ -329,13 +329,17 @@ fn_nokogiri_local_name_is(mkr_xpath_context_t *ctx, const mkr_focus_t *focus,
329
329
 
330
330
  typedef struct {
331
331
  const char *name;
332
- mkr_func_impl_t fn;
332
+ size_t name_len;
333
+ mkr_func_impl_t fn;
333
334
  } fn_entry_t;
334
335
 
336
+ #define FN_ENTRY(name, fn) { (name), sizeof(name) - 1, (fn) }
337
+ #define FN_ENTRY_END { NULL, 0, NULL }
338
+
335
339
  static const fn_entry_t fn_table_nokogiri_builtin[] = {
336
- { "css-class", fn_nokogiri_css_class },
337
- { "local-name-is", fn_nokogiri_local_name_is },
338
- { NULL, NULL },
340
+ FN_ENTRY("css-class", fn_nokogiri_css_class),
341
+ FN_ENTRY("local-name-is", fn_nokogiri_local_name_is),
342
+ FN_ENTRY_END,
339
343
  };
340
344
 
341
345
  #ifdef MKR_HOST_XML
@@ -1047,58 +1051,61 @@ fn_lang(mkr_xpath_context_t *ctx, const mkr_focus_t *focus,
1047
1051
 
1048
1052
  static const fn_entry_t fn_table[] = {
1049
1053
  /* node-set */
1050
- { "last", fn_last },
1051
- { "position", fn_position },
1052
- { "count", fn_count },
1053
- { "id", fn_id },
1054
- { "local-name", fn_local_name },
1055
- { "namespace-uri", fn_namespace_uri },
1056
- { "name", fn_name },
1054
+ FN_ENTRY("last", fn_last),
1055
+ FN_ENTRY("position", fn_position),
1056
+ FN_ENTRY("count", fn_count),
1057
+ FN_ENTRY("id", fn_id),
1058
+ FN_ENTRY("local-name", fn_local_name),
1059
+ FN_ENTRY("namespace-uri", fn_namespace_uri),
1060
+ FN_ENTRY("name", fn_name),
1057
1061
  /* string */
1058
- { "string", fn_string },
1059
- { "concat", fn_concat },
1060
- { "starts-with", fn_starts_with },
1061
- { "contains", fn_contains },
1062
- { "substring-before", fn_substring_before },
1063
- { "substring-after", fn_substring_after },
1064
- { "substring", fn_substring },
1065
- { "string-length", fn_string_length },
1066
- { "normalize-space", fn_normalize_space },
1067
- { "translate", fn_translate },
1062
+ FN_ENTRY("string", fn_string),
1063
+ FN_ENTRY("concat", fn_concat),
1064
+ FN_ENTRY("starts-with", fn_starts_with),
1065
+ FN_ENTRY("contains", fn_contains),
1066
+ FN_ENTRY("substring-before", fn_substring_before),
1067
+ FN_ENTRY("substring-after", fn_substring_after),
1068
+ FN_ENTRY("substring", fn_substring),
1069
+ FN_ENTRY("string-length", fn_string_length),
1070
+ FN_ENTRY("normalize-space", fn_normalize_space),
1071
+ FN_ENTRY("translate", fn_translate),
1068
1072
  /* boolean */
1069
- { "not", fn_not },
1070
- { "true", fn_true },
1071
- { "false", fn_false },
1072
- { "boolean", fn_boolean },
1073
- { "lang", fn_lang },
1073
+ FN_ENTRY("not", fn_not),
1074
+ FN_ENTRY("true", fn_true),
1075
+ FN_ENTRY("false", fn_false),
1076
+ FN_ENTRY("boolean", fn_boolean),
1077
+ FN_ENTRY("lang", fn_lang),
1074
1078
  /* number */
1075
- { "number", fn_number },
1076
- { "sum", fn_sum },
1077
- { "floor", fn_floor },
1078
- { "ceiling", fn_ceiling },
1079
- { "round", fn_round },
1079
+ FN_ENTRY("number", fn_number),
1080
+ FN_ENTRY("sum", fn_sum),
1081
+ FN_ENTRY("floor", fn_floor),
1082
+ FN_ENTRY("ceiling", fn_ceiling),
1083
+ FN_ENTRY("round", fn_round),
1080
1084
  #ifdef MKR_HOST_XML
1081
1085
  /* CSS-only internal hooks for untyped :*-of-type; the \x01-prefixed names are
1082
1086
  * unreachable from a user XPath expression (the lexer cannot produce them). */
1083
- { MKR_FN_OF_TYPE_POS, fn_xml_of_type_pos },
1084
- { MKR_FN_OF_TYPE_POS_LAST, fn_xml_of_type_pos_last },
1087
+ FN_ENTRY(MKR_FN_OF_TYPE_POS, fn_xml_of_type_pos),
1088
+ FN_ENTRY(MKR_FN_OF_TYPE_POS_LAST, fn_xml_of_type_pos_last),
1085
1089
  #endif
1086
- { NULL, NULL },
1090
+ FN_ENTRY_END,
1087
1091
  };
1088
1092
 
1089
1093
  /* File-static: the function table is engine-internal. eval_fncall (later in the
1090
1094
  * merged engine TU) resolves through it; nothing outside the engine does. */
1091
1095
  static mkr_func_impl_t
1092
- mkr_lookup_function(const char *ns_uri, const char *local_name)
1096
+ mkr_lookup_function(const char *ns_uri, size_t ns_uri_len,
1097
+ const char *local_name, size_t local_name_len)
1093
1098
  {
1094
1099
  if (local_name == NULL) return NULL;
1095
1100
  if (ns_uri != NULL) {
1096
1101
  /* Nokogiri-compatible builtins live in MKR_NS_NOKOGIRI_BUILTIN_URI.
1097
1102
  * Other registered namespaces resolve to user-defined functions -
1098
1103
  * not yet implemented (Phase 3 TODO: custom function registry). */
1099
- if (strcmp(ns_uri, MKR_NS_NOKOGIRI_BUILTIN_URI) == 0) {
1104
+ if (mkr_bytes_eq(ns_uri, ns_uri_len, MKR_NS_NOKOGIRI_BUILTIN_URI,
1105
+ sizeof(MKR_NS_NOKOGIRI_BUILTIN_URI) - 1)) {
1100
1106
  for (size_t i = 0; fn_table_nokogiri_builtin[i].name; ++i) {
1101
- if (strcmp(fn_table_nokogiri_builtin[i].name, local_name) == 0) {
1107
+ if (mkr_bytes_eq(fn_table_nokogiri_builtin[i].name, fn_table_nokogiri_builtin[i].name_len,
1108
+ local_name, local_name_len)) {
1102
1109
  return fn_table_nokogiri_builtin[i].fn;
1103
1110
  }
1104
1111
  }
@@ -1107,7 +1114,9 @@ mkr_lookup_function(const char *ns_uri, const char *local_name)
1107
1114
  }
1108
1115
  /* Default namespace - XPath 1.0 standard library. */
1109
1116
  for (size_t i = 0; fn_table[i].name; ++i) {
1110
- if (strcmp(fn_table[i].name, local_name) == 0) return fn_table[i].fn;
1117
+ if (mkr_bytes_eq(fn_table[i].name, fn_table[i].name_len, local_name, local_name_len)) {
1118
+ return fn_table[i].fn;
1119
+ }
1111
1120
  }
1112
1121
  return NULL;
1113
1122
  }
@@ -9,12 +9,11 @@
9
9
  #include "mkr_xpath_internal.h"
10
10
  #include "../xml/mkr_xml.h"
11
11
 
12
- /* Evaluate +expr+ (length +elen+) against the XML +doc+ and return the resulting
13
- * node-set count, or SIZE_MAX on any error / non-node-set. Registers prefix
14
- * "d" -> "urn:d". Lengths come from sizeof on the compile-time literals (no
15
- * strlen - this file is outside the clint core+bridge allowlist). */
12
+ /* Evaluate +expr+ (a verified-text token over a compile-time literal) against
13
+ * the XML +doc+ and return the resulting node-set count, or SIZE_MAX on any
14
+ * error / non-node-set. Registers prefix "d" -> "urn:d". */
16
15
  static size_t
17
- xml_count(mkr_xml_doc_t *doc, const char *expr, size_t elen)
16
+ xml_count(mkr_xml_doc_t *doc, mkr_verified_text_t expr)
18
17
  {
19
18
  /* The context's "document" and root context node are both the DOCUMENT node
20
19
  * (the XPath "/" root); for XML the engine's namespace services ignore the
@@ -24,13 +23,11 @@ xml_count(mkr_xml_doc_t *doc, const char *expr, size_t elen)
24
23
  if (ctx == NULL) return (size_t)-1;
25
24
  mkr_xpath_set_engine_kind(ctx, 1);
26
25
 
27
- mkr_verified_text_t dp = { "d", 1 }, du = { "urn:d", 5 };
28
- mkr_xpath_register_ns(ctx, dp, du);
26
+ mkr_xpath_register_ns(ctx, mkr_verified_text_lit("d"), mkr_verified_text_lit("urn:d"));
29
27
 
30
28
  mkr_xpath_limits_t *L = mkr_ctx_limits(ctx);
31
29
  mkr_xpath_error_t err = {0};
32
- mkr_verified_text_t e = { expr, elen };
33
- mkr_node_t *ast = mkr_parse(e, L, &err);
30
+ mkr_node_t *ast = mkr_parse(expr, L, &err);
34
31
  size_t out = (size_t)-1;
35
32
  if (ast != NULL) {
36
33
  mkr_xpath_value_t v = {0};
@@ -57,7 +54,7 @@ mkr_xml_xpath_selftest(void)
57
54
  if (doc == NULL) return ++idx; /* 1 */
58
55
 
59
56
  #define CHECK(expr, want) \
60
- do { idx++; if (xml_count(doc, (expr), sizeof(expr) - 1) != (size_t)(want)) { \
57
+ do { idx++; if (xml_count(doc, mkr_verified_text_lit(expr)) != (size_t)(want)) { \
61
58
  mkr_xml_doc_destroy(doc); return idx; } } while (0)
62
59
 
63
60
  CHECK("//b", 2); /* 1: unprefixed -> no-namespace only (excludes d:b) */
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Makiri
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -83,6 +83,17 @@ RULES = [
83
83
  # buffer unnoticed.
84
84
  regex: /\(\s*mkr_verified_text_t\s*\)\s*\{|\bmkr_verified_text_t\s+\w+\s*=\s*\{/
85
85
  ),
86
+ Rule.new(
87
+ id: "borrowed_text_forge",
88
+ message: "mkr_borrowed_text_t must be built via mkr_borrowed_text()/mkr_borrowed_text_lit()/mkr_borrowed_text_from_*, not a raw struct literal",
89
+ # Same two forge shapes as verified_text_forge. Not a capability guard (a raw
90
+ # literal is semantically identical to the constructor); it funnels the
91
+ # "asserted-valid borrowed slice" through the single named constructor so the
92
+ # assertion stays auditable, matching mkr_text.h's stated intent ("replacing
93
+ # scattered casts"). core/** - where the constructors live - is globally
94
+ # ignore-pathed, so the definitions in mkr_text.h are exempt automatically.
95
+ regex: /\(\s*mkr_borrowed_text_t\s*\)\s*\{|\bmkr_borrowed_text_t\s+\w+\s*=\s*\{/
96
+ ),
86
97
  # --- HTML/XML representation boundary (see docs/html_xml_boundary_hardening) ---
87
98
  # These symbols assume one DOM representation; using them outside their
88
99
  # representation-correct / kind-checked home is how shared glue (XPath, NodeSet,
@@ -99,19 +99,16 @@ ignore_paths:
99
99
 
100
100
  # --- parser-TU reader discipline (raw_scan_call / raw_cursor_member) ---
101
101
  allowlist:
102
- - path: ext/makiri/xpath/mkr_xpath_funcs_body.h
103
- rule: raw_scan_call
104
- max: 3
105
- reason: "mkr_lookup_function's three strcmp over compile-time function-name tables: both sides are NUL-terminated (table literals / owned AST text), and the lookup signature takes bare const char* from the evaluator."
106
-
107
- # --- verified_text_forge: the two Ruby-free test entry points. Neither can use
108
- # mkr_verified_text_from_view (bridge = Ruby boundary), so each mints the
109
- # token itself and must supply the contract by construction. ---
110
- - path: ext/makiri/xpath/mkr_xpath_xml_selftest.c
111
- rule: verified_text_forge
112
- max: 2
113
- reason: "Selftest expressions are compile-time string literals: NUL-terminated, NUL-free, valid UTF-8 by construction."
102
+ # --- verified_text_forge: the Ruby-free test entry points that mint over a
103
+ # runtime buffer (not a compile-time literal, which uses
104
+ # mkr_verified_text_lit). Neither can use mkr_verified_text_from_view
105
+ # (bridge = Ruby boundary), so each mints itself and must supply the
106
+ # contract by construction. ---
114
107
  - path: ext/makiri/fuzz/xpath_fuzz.c
115
108
  rule: verified_text_forge
116
109
  max: 1
117
110
  reason: "The libFuzzer harness mints over an owned mkr_strndup copy, which supplies NUL-termination and no-interior-NUL; UTF-8 validity is deliberately left to the lexer's strict decoder (its rejection path is fuzz-target behavior)."
111
+ - path: ext/makiri/fuzz/xml_xpath_fuzz.c
112
+ rule: verified_text_forge
113
+ max: 1
114
+ reason: "The expression mint is over an owned mkr_strndup copy, which supplies NUL-termination and no-interior-NUL; UTF-8 validity is pre-checked (the harness filters invalid UTF-8 before parsing). Mirrors xpath_fuzz.c."
@@ -125,6 +125,28 @@
125
125
  fun:mark_roots
126
126
  }
127
127
 
128
+ # ---- incremental GC write barrier: uninitialised-value reads ----
129
+ # An allocating Ruby C-API call from one of our cfuncs (e.g. rb_str_subseq in
130
+ # mkr_xml_decode_input) fires the incremental-GC write barrier synchronously;
131
+ # `gc_writebarrier_incremental` then reads an object's not-yet-written RVALUE
132
+ # flag word via `RVALUE_BLACK_P` - the same uninit-flag false positive as the GC
133
+ # drivers above, but reached through the barrier rather than a mark/sweep cycle,
134
+ # so the driver-anchored entries miss it. Our code never touches RVALUE flags;
135
+ # it only calls the allocating API, so anchoring on the barrier cannot mask a
136
+ # real uninit read of ours.
137
+ {
138
+ ruby-gc-cond-writebarrier_incremental
139
+ Memcheck:Cond
140
+ ...
141
+ fun:gc_writebarrier_incremental
142
+ }
143
+ {
144
+ ruby-gc-value8-writebarrier_incremental
145
+ Memcheck:Value8
146
+ ...
147
+ fun:gc_writebarrier_incremental
148
+ }
149
+
128
150
  # ---- Ruby VM internal caches that are never freed before exit ----
129
151
  # The global constant-cache / inline method-cache id_table is allocated lazily
130
152
  # on method lookup and lives for the life of the VM, so RUBY_FREE_AT_EXIT still
data/verify/Makefile ADDED
@@ -0,0 +1,237 @@
1
+ # Verification build for the XML + XPath carve-out.
2
+ #
3
+ # Fully separate from the gem's extconf build: nothing here touches ext/'s
4
+ # Makefile or object files, and no Ruby VM or Lexbor library is linked (the
5
+ # only Lexbor pieces are UNMODIFIED vendor encoding sources - the vanilla
6
+ # constraint is about not patching, not about not compiling).
7
+ #
8
+ # Targets:
9
+ # make smoke - build every CBMC harness as a plain executable and run it
10
+ # (the link check: CBMC treats a missing function body as a
11
+ # nondet return, so only the linker catches a wrong source list)
12
+ # make selftest - build + run the carve-out selftest (XML parse + XPath eval,
13
+ # no Ruby, no Lexbor lib)
14
+ # make cbmc - the proven set (alloc/utf8/utf8-chain/utf8-words/span/
15
+ # spanbuf/buf/hash/xml-chars/xpath-number; ~8 min total)
16
+ # make cbmc-deep - the experimental set (xpath-lex/xpath-parse/xml-parse/
17
+ # xml-expand).
18
+ # These do NOT converge in practical time on current
19
+ # bit-level solvers (measured: >10 min each even at 3-4
20
+ # byte inputs, with MiniSat, kissat and Bitwuzla alike);
21
+ # kept as tuning knobs for future solver/harness work,
22
+ # e.g. make cbmc-xpath-parse XPATH_EXPR_MAX=4 XPATH_UNWIND=12
23
+ #
24
+ # Native libFuzzer targets live in ext/makiri/fuzz/ (their own Makefile + the
25
+ # nightly libfuzzer.yml workflow), not here - this directory is proofs only.
26
+
27
+ ROOT := ..
28
+ EXT := $(ROOT)/ext/makiri
29
+ LXB := $(ROOT)/vendor/lexbor/source
30
+
31
+ CC ?= cc
32
+ CBMC ?= cbmc
33
+ CFLAGS ?= -O1 -g -Wall -Wextra
34
+ # fmod & co. need an explicit -lm on Linux (folded into libc on macOS).
35
+ LDLIBS ?= -lm
36
+ INC := -I $(EXT) -I $(LXB) -I .
37
+
38
+ CORE_SRC := $(EXT)/core/mkr_alloc.c $(EXT)/core/mkr_buf.c \
39
+ $(EXT)/core/mkr_core.c $(EXT)/core/mkr_utf8.c
40
+ XML_SRC := $(EXT)/xml/mkr_xml_chars.c $(EXT)/xml/mkr_xml_index.c \
41
+ $(EXT)/xml/mkr_xml_mutate.c $(EXT)/xml/mkr_xml_node.c \
42
+ $(EXT)/xml/mkr_xml_tree.c
43
+ # Lexer/parser/number + shared support: what mkr_parse reaches.
44
+ XPATH_FRONT_SRC := $(EXT)/xpath/mkr_xpath_lex.c $(EXT)/xpath/mkr_xpath_parse.c \
45
+ $(EXT)/xpath/mkr_xpath_shared.c $(EXT)/xpath/mkr_xpath_number.c
46
+ # The full engine: dispatcher + the XML monomorphization (HTML side is stub.c).
47
+ XPATH_FULL_SRC := $(XPATH_FRONT_SRC) $(EXT)/xpath/mkr_xpath.c \
48
+ $(EXT)/xpath/mkr_xpath_engine_xml.c
49
+ # Unmodified vendor sources for lxb_encoding_decode_valid_utf_8_single.
50
+ LXB_ENC_SRC := $(LXB)/lexbor/encoding/decode.c $(LXB)/lexbor/encoding/multi_res.c \
51
+ $(LXB)/lexbor/encoding/range_res.c $(LXB)/lexbor/encoding/single_res.c
52
+
53
+ ENGINE_SRC := $(CORE_SRC) $(XML_SRC) $(XPATH_FULL_SRC) $(LXB_ENC_SRC) stub.c
54
+
55
+ BUILD := build
56
+
57
+ # ---------------------------------------------------------------- smoke + selftest
58
+
59
+ HARNESSES := alloc utf8 utf8-chain utf8-words span spanbuf buf hash xml-chars xml-expand xpath-number xpath-lex xpath-parse xml-parse
60
+
61
+ smoke: $(HARNESSES:%=$(BUILD)/harness-%)
62
+ @for h in $^; do echo "== $$h"; $$h || exit 1; done
63
+ @echo "smoke: all harnesses link and run"
64
+
65
+ $(BUILD)/harness-alloc: harness_alloc.c $(EXT)/core/mkr_alloc.c | $(BUILD)
66
+ $(CC) $(CFLAGS) $(INC) $^ -o $@ $(LDLIBS)
67
+ $(BUILD)/harness-utf8: harness_utf8.c $(EXT)/core/mkr_utf8.c | $(BUILD)
68
+ $(CC) $(CFLAGS) $(INC) $^ -o $@ $(LDLIBS)
69
+ $(BUILD)/harness-utf8-chain: harness_utf8_chain.c $(EXT)/core/mkr_utf8.c | $(BUILD)
70
+ $(CC) $(CFLAGS) $(INC) $^ -o $@ $(LDLIBS)
71
+ $(BUILD)/harness-utf8-words: harness_utf8_words.c $(EXT)/core/mkr_utf8.c | $(BUILD)
72
+ $(CC) $(CFLAGS) $(INC) $^ -o $@ $(LDLIBS)
73
+ $(BUILD)/harness-span: harness_span.c | $(BUILD)
74
+ $(CC) $(CFLAGS) $(INC) $^ -o $@ $(LDLIBS)
75
+ $(BUILD)/harness-spanbuf: harness_spanbuf.c | $(BUILD)
76
+ $(CC) $(CFLAGS) $(INC) $^ -o $@ $(LDLIBS)
77
+ $(BUILD)/harness-buf: harness_buf.c $(EXT)/core/mkr_buf.c $(EXT)/core/mkr_alloc.c | $(BUILD)
78
+ $(CC) $(CFLAGS) $(INC) $^ -o $@ $(LDLIBS)
79
+ $(BUILD)/harness-hash: harness_hash.c | $(BUILD)
80
+ $(CC) $(CFLAGS) $(INC) $^ -o $@ $(LDLIBS)
81
+ $(BUILD)/harness-xpath-number: harness_xpath_number.c $(EXT)/xpath/mkr_xpath_number.c $(EXT)/core/mkr_alloc.c | $(BUILD)
82
+ $(CC) $(CFLAGS) $(INC) $^ -o $@ $(LDLIBS)
83
+ # mkr_parse's limit checks + limits_init_defaults live in mkr_xpath.c (the
84
+ # dispatcher), which references both engine instances - so the smoke link
85
+ # takes the full engine (stub.c supplies the HTML side).
86
+ $(BUILD)/harness-xpath-parse: harness_xpath_parse.c $(ENGINE_SRC) | $(BUILD)
87
+ $(CC) $(CFLAGS) $(INC) $^ -o $@ $(LDLIBS)
88
+ $(BUILD)/harness-xpath-lex: harness_xpath_lex.c $(ENGINE_SRC) | $(BUILD)
89
+ $(CC) $(CFLAGS) $(INC) $^ -o $@ $(LDLIBS)
90
+ $(BUILD)/harness-xml-parse: harness_xml_parse.c $(XML_SRC) $(CORE_SRC) | $(BUILD)
91
+ $(CC) $(CFLAGS) $(INC) $^ -o $@ $(LDLIBS)
92
+ $(BUILD)/harness-xml-chars: harness_xml_chars.c $(XML_SRC) $(CORE_SRC) | $(BUILD)
93
+ $(CC) $(CFLAGS) $(INC) $^ -o $@ $(LDLIBS)
94
+ $(BUILD)/harness-xml-expand: harness_xml_expand.c $(XML_SRC) $(CORE_SRC) | $(BUILD)
95
+ $(CC) $(CFLAGS) $(INC) $^ -o $@ $(LDLIBS)
96
+
97
+ selftest: $(BUILD)/selftest
98
+ $(BUILD)/selftest
99
+
100
+ $(BUILD)/selftest: selftest_main.c $(ENGINE_SRC) $(EXT)/xpath/mkr_xpath_xml_selftest.c | $(BUILD)
101
+ $(CC) $(CFLAGS) $(INC) $^ -o $@ $(LDLIBS)
102
+
103
+ $(BUILD):
104
+ mkdir -p $(BUILD)
105
+
106
+ # ---------------------------------------------------------------- CBMC proofs
107
+ #
108
+ # Every proof gets --bounds-check --pointer-check (memory safety) and
109
+ # --signed-overflow-check (UB); --unsigned-overflow-check is added ONLY where
110
+ # wrapping would be a bug (the size arithmetic) - the UTF-8 validator's
111
+ # word-at-a-time scan and hash-style code wrap on purpose, and unsigned wrap
112
+ # is defined C. --malloc-may-fail --malloc-fail-null verifies the fail-closed
113
+ # OOM branches. Unwind bounds are per-harness knobs; --unwinding-assertions
114
+ # turns "too small a bound" into a failure instead of a silent partial proof.
115
+
116
+ CBMC_MEM := --bounds-check --pointer-check --signed-overflow-check \
117
+ --div-by-zero-check --unwinding-assertions --object-bits 12
118
+ CBMC_OOM := --malloc-may-fail --malloc-fail-null
119
+ # libc bodies CBMC lacks on this platform (see the file's comment).
120
+ CBMC_MODELS := cbmc_models.c
121
+
122
+ # mkr_grow_capacity doubles from 8, so <= ~60 iterations before the cap check.
123
+ ALLOC_UNWIND ?= 64
124
+ cbmc-alloc:
125
+ $(CBMC) harness_alloc.c $(CBMC_MODELS) $(EXT)/core/mkr_alloc.c $(INC) \
126
+ $(CBMC_MEM) $(CBMC_OOM) --unsigned-overflow-check --conversion-check \
127
+ --unwind $(ALLOC_UNWIND)
128
+
129
+ UTF8_UNWIND ?= 12
130
+ cbmc-utf8:
131
+ $(CBMC) harness_utf8.c $(CBMC_MODELS) $(EXT)/core/mkr_utf8.c $(INC) \
132
+ $(CBMC_MEM) --unwind $(UTF8_UNWIND)
133
+
134
+ # Chain length and unwind grow together (unwind must exceed the byte count;
135
+ # see the harness header for the measured length/time trade-off).
136
+ UTF8_CHAIN_MAX ?= 12
137
+ UTF8_CHAIN_UNWIND ?= 16
138
+ cbmc-utf8-chain:
139
+ $(CBMC) harness_utf8_chain.c $(CBMC_MODELS) $(EXT)/core/mkr_utf8.c $(INC) \
140
+ -DVERIFY_UTF8_CHAIN_MAX=$(UTF8_CHAIN_MAX) \
141
+ $(CBMC_MEM) --unwind $(UTF8_CHAIN_UNWIND)
142
+
143
+ cbmc-utf8-words:
144
+ $(CBMC) harness_utf8_words.c $(CBMC_MODELS) $(EXT)/core/mkr_utf8.c $(INC) \
145
+ $(CBMC_MEM) --unwind 20
146
+
147
+ SPAN_UNWIND ?= 12
148
+ cbmc-span:
149
+ $(CBMC) harness_span.c $(CBMC_MODELS) $(INC) \
150
+ $(CBMC_MEM) --unwind $(SPAN_UNWIND)
151
+
152
+ cbmc-spanbuf:
153
+ $(CBMC) harness_spanbuf.c $(CBMC_MODELS) $(INC) \
154
+ $(CBMC_MEM) --unwind $(SPAN_UNWIND)
155
+
156
+ cbmc-buf:
157
+ $(CBMC) harness_buf.c $(CBMC_MODELS) $(EXT)/core/mkr_buf.c $(EXT)/core/mkr_alloc.c $(INC) \
158
+ $(CBMC_MEM) $(CBMC_OOM) --unwind 16
159
+
160
+ # The sizer only shifts, so unlike the mul/div guards this closes at full
161
+ # width; the loop runs at most one step per size_t bit.
162
+ HASH_UNWIND ?= 66
163
+ cbmc-hash:
164
+ $(CBMC) harness_hash.c $(CBMC_MODELS) $(INC) \
165
+ $(CBMC_MEM) --unwind $(HASH_UNWIND)
166
+
167
+ # XML character classes: predicate inclusions over all code points + the
168
+ # validators cross-checked against the independent UTF-8 validator. CBMC only
169
+ # needs mkr_xml_chars.c + mkr_utf8.c (the other xml/ entries the header
170
+ # declares stay bodyless - unreachable from this harness).
171
+ XML_CHARS_UNWIND ?= 12
172
+ cbmc-xml-chars:
173
+ $(CBMC) harness_xml_chars.c $(CBMC_MODELS) $(EXT)/xml/mkr_xml_chars.c $(EXT)/core/mkr_utf8.c $(INC) \
174
+ $(CBMC_MEM) --unwind $(XML_CHARS_UNWIND)
175
+
176
+ # The XPath Number scanner/converter. The cbmc_models.c strtod ("conversion
177
+ # unavailable") steers from_extent down its own fallback chain - the isolating
178
+ # reparse and the hand assembly, i.e. exactly our code - on every input; the
179
+ # libc-success path is trusted, not verified (like memcpy).
180
+ cbmc-xpath-number:
181
+ $(CBMC) harness_xpath_number.c $(CBMC_MODELS) $(EXT)/xpath/mkr_xpath_number.c $(EXT)/core/mkr_alloc.c $(INC) \
182
+ $(CBMC_MEM) $(CBMC_OOM) --unwind 12
183
+
184
+ cbmc: cbmc-alloc cbmc-utf8 cbmc-utf8-chain cbmc-utf8-words cbmc-span cbmc-spanbuf cbmc-buf cbmc-hash cbmc-xml-chars cbmc-xpath-number
185
+ @echo "cbmc: all proofs passed"
186
+
187
+ # ------------------------------------------------- experimental (cbmc-deep)
188
+ #
189
+ # The whole-entry parser/lexer proofs below are formulated and smoke-checked,
190
+ # but no current solver closes them in practical time (see the header). CBMC
191
+ # gets mkr_xpath.c for the limit checks but NOT the engine instances or the
192
+ # vendor encoding tables (nothing there is reachable from mkr_parse; the eval
193
+ # entries mkr_xpath.c references stay bodyless - unreachable from these
194
+ # harnesses). Expression/input length and unwind grow together.
195
+
196
+ XPATH_EXPR_MAX ?= 6
197
+ XPATH_UNWIND ?= 16
198
+ cbmc-xpath-lex:
199
+ $(CBMC) harness_xpath_lex.c $(CBMC_MODELS) $(XPATH_FRONT_SRC) $(EXT)/xpath/mkr_xpath.c $(CORE_SRC) $(INC) \
200
+ -DVERIFY_XPATH_EXPR_MAX=$(XPATH_EXPR_MAX) \
201
+ $(CBMC_MEM) --unwind $(XPATH_UNWIND)
202
+
203
+ cbmc-xpath-parse:
204
+ $(CBMC) harness_xpath_parse.c $(CBMC_MODELS) $(XPATH_FRONT_SRC) $(EXT)/xpath/mkr_xpath.c $(CORE_SRC) $(INC) \
205
+ -DVERIFY_XPATH_EXPR_MAX=$(XPATH_EXPR_MAX) \
206
+ $(CBMC_MEM) $(CBMC_OOM) --unwind $(XPATH_UNWIND)
207
+
208
+ XML_INPUT_MAX ?= 6
209
+ XML_UNWIND ?= 16
210
+ cbmc-xml-parse:
211
+ $(CBMC) harness_xml_parse.c $(CBMC_MODELS) $(XML_SRC) $(CORE_SRC) $(INC) \
212
+ -DVERIFY_XML_INPUT_MAX=$(XML_INPUT_MAX) \
213
+ $(CBMC_MEM) $(CBMC_OOM) --unwind $(XML_UNWIND)
214
+
215
+ # Entity/char-ref expansion over nondet valid-UTF-8 input (the document it
216
+ # writes into is parsed from a concrete 4-byte input, so only the expansion
217
+ # input is nondet). Does not converge: 7-byte input, --unwind 12, still in
218
+ # BMC when the 420 s alarm killed it (measured; higher unwinds are strictly
219
+ # larger). No $(CBMC_OOM): a NULL doc is treated as early-exit, OOM injection
220
+ # is not what this harness is about.
221
+ # Also measured: 5-byte input still did not converge in 6 min - the cost is
222
+ # the whole-parser+arena state behind the doc handle, not the input length.
223
+ XML_EXPAND_MAX ?= 7
224
+ XML_EXPAND_UNWIND ?= 12
225
+ cbmc-xml-expand:
226
+ $(CBMC) harness_xml_expand.c $(CBMC_MODELS) $(XML_SRC) $(CORE_SRC) $(INC) \
227
+ -DVERIFY_XML_EXPAND_MAX=$(XML_EXPAND_MAX) \
228
+ $(CBMC_MEM) --unwind $(XML_EXPAND_UNWIND)
229
+
230
+ cbmc-deep: cbmc-xpath-lex cbmc-xpath-parse cbmc-xml-parse cbmc-xml-expand
231
+
232
+ clean:
233
+ rm -rf $(BUILD)
234
+
235
+ .PHONY: smoke selftest cbmc cbmc-alloc cbmc-utf8 cbmc-utf8-chain cbmc-utf8-words cbmc-span \
236
+ cbmc-spanbuf cbmc-buf cbmc-hash cbmc-xml-chars cbmc-xpath-number \
237
+ cbmc-deep cbmc-xpath-lex cbmc-xpath-parse cbmc-xml-parse cbmc-xml-expand clean