makiri 0.5.1 → 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 (43) 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 +12 -0
  5. data/README.md +16 -0
  6. data/Rakefile +6 -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_mutate.c +5 -5
  17. data/ext/makiri/xpath/mkr_xpath_eval_body.h +3 -2
  18. data/ext/makiri/xpath/mkr_xpath_funcs_body.h +47 -38
  19. data/ext/makiri/xpath/mkr_xpath_xml_selftest.c +7 -10
  20. data/lib/makiri/version.rb +1 -1
  21. data/script/check_c_safety.rb +11 -0
  22. data/script/check_c_safety_allowlist.yml +9 -12
  23. data/suppressions/ruby.supp +22 -0
  24. data/verify/Makefile +237 -0
  25. data/verify/cbmc_models.c +38 -0
  26. data/verify/harness_alloc.c +257 -0
  27. data/verify/harness_buf.c +167 -0
  28. data/verify/harness_hash.c +28 -0
  29. data/verify/harness_span.c +213 -0
  30. data/verify/harness_spanbuf.c +98 -0
  31. data/verify/harness_utf8.c +70 -0
  32. data/verify/harness_utf8_chain.c +64 -0
  33. data/verify/harness_utf8_words.c +54 -0
  34. data/verify/harness_xml_chars.c +59 -0
  35. data/verify/harness_xml_expand.c +72 -0
  36. data/verify/harness_xml_parse.c +42 -0
  37. data/verify/harness_xpath_lex.c +51 -0
  38. data/verify/harness_xpath_number.c +68 -0
  39. data/verify/harness_xpath_parse.c +57 -0
  40. data/verify/selftest_main.c +16 -0
  41. data/verify/stub.c +10 -0
  42. data/verify/verify.h +54 -0
  43. metadata +22 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ee7e950514b33a2fa483cb6f4f4f48d5b80b8b6cc2205dbf720c2f47f790821d
4
- data.tar.gz: 5c6739b1eabec71dbc34b8ce410c4ada7053112dd88d04d278840070178b4aa4
3
+ metadata.gz: ad4966a3bf44b4cbc01822bab034227c3c5a5c18347135bfc5cfbc13963a42eb
4
+ data.tar.gz: 4669d45c028deb66343788e8adb27c72819fb78c71a3f6efa14f8c5acc07efc4
5
5
  SHA512:
6
- metadata.gz: 51af947ded032f4e09d3c622374cf09bbbc36f92f3f2006c98bd2f6d4884b51de83ded0a6faf8174be27cece0986d317ea2cd553e6837286d7c1d0a743c3261d
7
- data.tar.gz: da5184494302a3c43cd8014972cf43e6c684460af1a53ef219a42bdfac9238e98a792ec2976b442462661ea63328330195408dd31d9b180f8dd421122676de13
6
+ metadata.gz: a076b9c0c2f8e790d6bf433912f8f27cf1c9439de66dbcd403a010736ee26e3716531c975969ea03274bb0fdac34e4c46ab25dbc6777eb50ec753e41a0c95e90
7
+ data.tar.gz: ad0d15f5fc4b6de3bf8850b21f69b0e66848c0dfe9f19b1304e9f0acde2754979506ca618a2af0449c9191052491fddc3b9ace9f226beed4b94349c48b4c09cc
@@ -21,7 +21,7 @@ jobs:
21
21
  strategy:
22
22
  fail-fast: false
23
23
  matrix:
24
- target: [xml, xpath]
24
+ target: [xml, xpath, xml_xpath]
25
25
 
26
26
  steps:
27
27
  - name: Checkout (with vendored Lexbor submodule)
@@ -0,0 +1,37 @@
1
+ name: Verify
2
+
3
+ on:
4
+ push:
5
+ branches: [main, master]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ cbmc:
11
+ name: CBMC proofs
12
+ runs-on: ubuntu-24.04
13
+ timeout-minutes: 20
14
+
15
+ steps:
16
+ - name: Checkout (with vendored Lexbor submodule)
17
+ uses: actions/checkout@v6
18
+ with:
19
+ submodules: recursive
20
+
21
+ - name: Set up Ruby
22
+ uses: ruby/setup-ruby@v1
23
+ with:
24
+ ruby-version: "3.4"
25
+ bundler-cache: true
26
+
27
+ - name: Install CBMC
28
+ env:
29
+ CBMC_VERSION: "6.9.0"
30
+ run: |
31
+ curl -fsSLO "https://github.com/diffblue/cbmc/releases/download/cbmc-${CBMC_VERSION}/ubuntu-24.04-cbmc-${CBMC_VERSION}-Linux.deb"
32
+ sudo apt-get update
33
+ sudo apt-get install -y "./ubuntu-24.04-cbmc-${CBMC_VERSION}-Linux.deb"
34
+ cbmc --version
35
+
36
+ - name: Run verification suite
37
+ run: bundle exec rake verify
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0] - 2026-07-05
4
+
5
+ ### Changed
6
+
7
+ * Text and comment node content (`create_text_node`, `create_comment`,
8
+ `content=`) and attribute values (`[]=`, `set_attribute_ns`) now accept an
9
+ embedded NUL (U+0000) and store it verbatim, matching the WHATWG DOM and
10
+ browsers, instead of raising. Names, tag names, namespaces, CSS selectors, and
11
+ XPath expressions stay NUL-strict, and invalid UTF-8 is still rejected. XML
12
+ documents (`Makiri::XML`) are unchanged and still reject NUL everywhere, since
13
+ XML 1.0 has no legal U+0000 character.
14
+
3
15
  ## [0.5.1] - 2026-06-22
4
16
 
5
17
  ### Changed
data/README.md CHANGED
@@ -267,6 +267,22 @@ Detailed, test-backed notes live in `spec/conformance/README.md`.
267
267
  * Type selectors are ASCII case-insensitive (CSS-correct for HTML; `LI` matches `<li>`)
268
268
  * `Nokogiri::HTML5` is case-sensitive there.
269
269
 
270
+ ### Text input (mutation APIs)
271
+
272
+ * Programmatic string arguments must be **valid UTF-8** (invalid bytes raise
273
+ `Makiri::Error`, never silently repaired - unlike HTML *parsing*, which decodes
274
+ leniently to U+FFFD).
275
+ * An embedded **NUL (U+0000)** is **accepted in HTML data-family content** -
276
+ text/comment node content (`create_text_node`, `create_comment`, `content=`)
277
+ and attribute values (`[]=`, `set_attribute_ns`) - and stored/read back
278
+ verbatim, matching the WHATWG DOM / browsers (`document.createTextNode("\0")`).
279
+ It is still **rejected** in names, tag names, namespaces, PI target/data, CSS
280
+ selectors, and XPath expressions/variable names (a NUL there raises).
281
+ * On re-parse, the HTML tokenizer replaces a U+0000 in text/attributes with
282
+ U+FFFD (WHATWG), so a serialized-then-reparsed round-trip is not byte-identical.
283
+ * `Makiri::XML` rejects NUL everywhere: XML 1.0 has no legal U+0000 character,
284
+ so admitting it would produce non-well-formed XML.
285
+
270
286
  ## Conformance
271
287
 
272
288
  The XPath engine and XML parser are original code, so their correctness is held by
data/Rakefile CHANGED
@@ -287,6 +287,12 @@ task :oom do
287
287
  puts "(injection build left in place; run `rake clean compile` to restore a normal build)"
288
288
  end
289
289
 
290
+ desc "CBMC proofs over the Ruby/Lexbor-free carve-out (core + XML + XPath front; " \
291
+ "needs cbmc; see docs/formal_verification.ja.md and verify/Makefile)"
292
+ task :verify do
293
+ sh "make -C verify smoke selftest cbmc"
294
+ end
295
+
290
296
  desc "Run the performance benchmark (Makiri vs Nokogiri reference)"
291
297
  task bench: :compile do
292
298
  # Run outside the bundle so the bench-only gems (nokogiri, benchmark-ips)
@@ -24,6 +24,22 @@ typedef struct {
24
24
  size_t len;
25
25
  } mkr_ruby_borrowed_text_t;
26
26
 
27
+ /* A validated, borrowed view of a Ruby String argument for a DATA-FAMILY
28
+ * mutation (text/comment/CDATA node content, attribute value). Contract: valid
29
+ * UTF-8, interior NUL ALLOWED, NUL-terminated at ptr[len] but - unlike
30
+ * mkr_ruby_borrowed_text_t - NOT usable as a C string, because an interior NUL
31
+ * may truncate it. Every consumer MUST pass (ptr, len) explicitly. `value`
32
+ * keeps `ptr` alive while the view stays on the C stack. This type is
33
+ * deliberately distinct from mkr_ruby_borrowed_text_t so the compiler forbids a
34
+ * NUL-bearing value from reaching a name or engine API that requires the
35
+ * NUL-free contract (DOM createTextNode/setAttribute must accept U+0000; names
36
+ * and engine strings still must not). */
37
+ typedef struct {
38
+ VALUE value;
39
+ const char *ptr;
40
+ size_t len;
41
+ } mkr_ruby_borrowed_data_t;
42
+
27
43
  /* A borrowed raw byte view of a Ruby String. This deliberately does NOT enforce
28
44
  * Makiri's strict text contract; HTML parsing consumes raw bytes and decodes
29
45
  * invalid UTF-8 leniently. */
@@ -37,6 +53,12 @@ typedef struct {
37
53
  * Makiri::Error (naming +what+) on a NUL byte or invalid UTF-8. */
38
54
  mkr_ruby_borrowed_text_t mkr_ruby_verified_text(VALUE in, const char *what);
39
55
 
56
+ /* Constructor for mkr_ruby_borrowed_data_t (see its contract above): coerce +in+
57
+ * to a String, requiring valid UTF-8 but permitting an interior NUL. Raises
58
+ * Makiri::Error (naming +what+) on invalid UTF-8 only. For data-family values;
59
+ * names and engine strings use mkr_ruby_verified_text. */
60
+ mkr_ruby_borrowed_data_t mkr_ruby_verified_data(VALUE in, const char *what);
61
+
40
62
  /* Coerce +in+ to a String and return its raw bytes. No UTF-8 / NUL validation.
41
63
  * The returned `ptr` borrows the bytes of `value`; use mkr_ruby_copy_bytes when
42
64
  * the buffer must outlive the source String. */
@@ -84,10 +106,12 @@ const char *mkr_ruby_try_verified_text(VALUE sv, size_t max_bytes,
84
106
  * escape. Embedded NULs are intentionally truncated by %s formatting. */
85
107
  void mkr_ruby_exception_message(VALUE exc, char *buf, size_t len);
86
108
 
87
- /* The sole constructor of mkr_verified_text_t. Turns an already-validated view
88
- * (from mkr_ruby_verified_text or mkr_ruby_try_verified_text) into the token the
89
- * XPath engine accepts. The returned token borrows v.ptr; the caller must keep
90
- * v.value alive for the duration of the engine call. */
109
+ /* The Ruby-bound constructor of mkr_verified_text_t. Turns an already-validated
110
+ * view (from mkr_ruby_verified_text or mkr_ruby_try_verified_text) into the token
111
+ * the XPath engine accepts. The returned token borrows v.ptr; the caller must
112
+ * keep v.value alive for the duration of the engine call. The sole Ruby-free
113
+ * constructor is mkr_verified_text_lit (core/mkr_text.h), for compile-time
114
+ * literals. */
91
115
  mkr_verified_text_t mkr_verified_text_from_view(mkr_ruby_borrowed_text_t v);
92
116
 
93
117
  /* Assemble a UTF-8 Ruby String of exactly +total+ bytes by concatenating +n+
@@ -115,6 +115,31 @@ mkr_ruby_verified_text(VALUE in, const char *what)
115
115
  return v;
116
116
  }
117
117
 
118
+ mkr_ruby_borrowed_data_t
119
+ mkr_ruby_verified_data(VALUE in, const char *what)
120
+ {
121
+ VALUE s = rb_String(in);
122
+ const char *ptr = RSTRING_PTR(s);
123
+ size_t len = (size_t)RSTRING_LEN(s);
124
+
125
+ /* Data-family (text/comment/CDATA content, attribute value): only invalid
126
+ * UTF-8 is fatal; an interior NUL is permitted so DOM data can hold U+0000.
127
+ * mkr_verify_text is not reused because it raises on NUL. mkr_text_check is
128
+ * allocation-free, so the borrow above is not held across a GC point. */
129
+ switch (mkr_text_check(s, ptr, len)) {
130
+ case MKR_TEXT_INVALID_UTF8:
131
+ rb_raise(mkr_eError, "%s must be valid UTF-8", what);
132
+ case MKR_TEXT_HAS_NUL: /* permitted for data-family */
133
+ case MKR_TEXT_OK:
134
+ break;
135
+ }
136
+ mkr_ruby_borrowed_data_t v;
137
+ v.value = s;
138
+ v.ptr = ptr;
139
+ v.len = len;
140
+ return v;
141
+ }
142
+
118
143
  mkr_ruby_borrowed_bytes_t
119
144
  mkr_ruby_bytes_view(VALUE in)
120
145
  {
@@ -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
  {
@@ -1,9 +1,10 @@
1
1
  # ext/makiri/fuzz/Makefile - libFuzzer harness build
2
2
  #
3
3
  # Usage:
4
- # make xml_fuzz # build the XML parser harness
5
- # make xpath_fuzz # build the XPath compile+eval harness
6
- # make clean # remove build artifacts
4
+ # make xml_fuzz # build the XML parser harness
5
+ # make xpath_fuzz # build the XPath compile+eval harness (fixed document)
6
+ # make xml_xpath_fuzz # build the combined harness (fuzzer-controlled document + expression)
7
+ # make clean # remove build artifacts
7
8
  #
8
9
  # Requires: clang with libFuzzer support (usually part of the clang distribution)
9
10
  # and the vendored Lexbor static library already built (via `rake compile`).
@@ -64,9 +65,9 @@ CORE_OBJS = $(patsubst $(EXT_DIR)/%.c,$(BUILD_DIR)/%.o,$(CORE_SRCS))
64
65
  XML_OBJS = $(patsubst $(EXT_DIR)/%.c,$(BUILD_DIR)/%.o,$(XML_SRCS))
65
66
  XPATH_OBJS = $(patsubst $(EXT_DIR)/%.c,$(BUILD_DIR)/%.o,$(XPATH_SRCS))
66
67
 
67
- .PHONY: all clean check-libfuzzer xml_fuzz xpath_fuzz
68
+ .PHONY: all clean check-libfuzzer xml_fuzz xpath_fuzz xml_xpath_fuzz
68
69
 
69
- all: check-libfuzzer xml_fuzz xpath_fuzz
70
+ all: check-libfuzzer xml_fuzz xpath_fuzz xml_xpath_fuzz
70
71
 
71
72
  check-libfuzzer:
72
73
  @mkdir -p $(BUILD_DIR)
@@ -79,6 +80,9 @@ xml_fuzz: $(BUILD_DIR)/xml_fuzz.o $(CORE_OBJS) $(XML_OBJS)
79
80
  xpath_fuzz: $(BUILD_DIR)/xpath_fuzz.o $(CORE_OBJS) $(XML_OBJS) $(XPATH_OBJS)
80
81
  $(CXX) $(LDFLAGS) -o $@ $^ $(LEXBOR_DST)/lib/liblexbor_static.a
81
82
 
83
+ xml_xpath_fuzz: $(BUILD_DIR)/xml_xpath_fuzz.o $(CORE_OBJS) $(XML_OBJS) $(XPATH_OBJS)
84
+ $(CXX) $(LDFLAGS) -o $@ $^ $(LEXBOR_DST)/lib/liblexbor_static.a
85
+
82
86
  $(BUILD_DIR)/%.o: $(EXT_DIR)/%.c
83
87
  @mkdir -p $(dir $@)
84
88
  $(CLANG) $(CFLAGS) -c -o $@ $<
@@ -91,5 +95,9 @@ $(BUILD_DIR)/xpath_fuzz.o: xpath_fuzz.c
91
95
  @mkdir -p $(dir $@)
92
96
  $(CLANG) $(CFLAGS) -c -o $@ $<
93
97
 
98
+ $(BUILD_DIR)/xml_xpath_fuzz.o: xml_xpath_fuzz.c
99
+ @mkdir -p $(dir $@)
100
+ $(CLANG) $(CFLAGS) -c -o $@ $<
101
+
94
102
  clean:
95
- rm -rf $(BUILD_DIR) xml_fuzz xpath_fuzz
103
+ rm -rf $(BUILD_DIR) xml_fuzz xpath_fuzz xml_xpath_fuzz
@@ -0,0 +1,87 @@
1
+ /* xml_xpath_fuzz.c - libFuzzer harness where the fuzzer controls BOTH the
2
+ * document and the expression.
3
+ *
4
+ * Complements the other two targets: xml_fuzz covers the parser alone, and
5
+ * xpath_fuzz covers the engine over one fixed document shape - so evaluator
6
+ * paths that depend on document structure (namespace scopes, index buckets,
7
+ * axis edge cases) only ever see that shape. Here the input's bytes up to the
8
+ * first NUL are the XML document and the rest is the expression (both engine
9
+ * contracts forbid embedded NUL, so the separator costs no coverage).
10
+ *
11
+ * Contract violations (invalid UTF-8, non-XML chars) are filtered the same
12
+ * way the Ruby bridge does, so the harness only exercises in-contract inputs;
13
+ * the validators themselves run on every input and are fuzzed for free.
14
+ * Ruby-free; the XML arena self-poisons under ASan, so intra-arena overruns
15
+ * are caught. */
16
+ #include <stddef.h>
17
+ #include <stdint.h>
18
+ #include <string.h>
19
+
20
+ #include "core/mkr_core.h"
21
+ #include "xml/mkr_xml.h"
22
+ #include "xpath/mkr_xpath.h"
23
+
24
+ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size);
25
+
26
+ int
27
+ LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
28
+ {
29
+ const uint8_t *sep = memchr(data, 0, size);
30
+ if (sep == NULL) return 0;
31
+
32
+ const char *xml = (const char *)data;
33
+ size_t xml_len = (size_t)(sep - data);
34
+ const char *expr = (const char *)sep + 1;
35
+ size_t expr_len = size - xml_len - 1;
36
+
37
+ /* In-contract filter, mirroring the bridge's strict gate. */
38
+ if (!mkr_utf8_valid((const unsigned char *)xml, xml_len)) return 0;
39
+ if (mkr_xml_validate_chars(xml, (uint32_t)xml_len) != 0) return 0;
40
+ if (!mkr_utf8_valid((const unsigned char *)expr, expr_len)) return 0;
41
+
42
+ mkr_xml_status_t st = MKR_XML_OK;
43
+ mkr_xml_doc_t *doc = mkr_xml_parse(xml, xml_len, &st);
44
+ if (doc == NULL) return 0;
45
+
46
+ mkr_xpath_context_t *ctx = mkr_xpath_context_new(doc->doc_node, doc->doc_node);
47
+ if (ctx != NULL) {
48
+ mkr_xpath_set_engine_kind(ctx, 1); /* XML monomorphization */
49
+
50
+ /* Shrink the per-evaluate budgets so a single hostile input can't
51
+ * stall the fuzzer; overruns fail closed, which is the point. */
52
+ mkr_xpath_limits_t *L = mkr_ctx_limits(ctx);
53
+ L->max_eval_ops = 20000;
54
+ L->max_nodeset_size = 1024;
55
+ L->max_string_bytes = 4096;
56
+
57
+ mkr_xpath_register_ns(ctx, mkr_verified_text_lit("d"), mkr_verified_text_lit("urn:d"));
58
+
59
+ /* The expression is the bytes after the separator NUL. libFuzzer
60
+ * gives exactly `size` bytes with no trailing terminator, so copy
61
+ * into an owned, NUL-terminated heap buffer and mint over that
62
+ * (mirroring xpath_fuzz.c): this supplies the NUL at ptr[len] the
63
+ * lexer's strtod / "%.10s" error path rely on. UTF-8 validity was
64
+ * checked above; the copy preserves it. */
65
+ char *expr_copy = mkr_strndup(expr, expr_len);
66
+ if (expr_copy == NULL) {
67
+ mkr_xpath_context_free(ctx);
68
+ mkr_xml_doc_destroy(doc);
69
+ return 0;
70
+ }
71
+
72
+ mkr_xpath_error_t err = {0};
73
+ mkr_verified_text_t e = { expr_copy, expr_len };
74
+ mkr_node_t *ast = mkr_parse(e, L, &err);
75
+ if (ast != NULL) {
76
+ mkr_xpath_value_t v = {0};
77
+ (void)mkr_xpath_eval_compiled(ctx, ast, &v, &err);
78
+ mkr_xpath_value_clear(&v);
79
+ mkr_node_free(ast);
80
+ }
81
+ mkr_xpath_error_clear(&err);
82
+ free(expr_copy);
83
+ mkr_xpath_context_free(ctx);
84
+ }
85
+ mkr_xml_doc_destroy(doc);
86
+ return 0;
87
+ }
@@ -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);
@@ -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;