makiri 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/conformance.yml +22 -0
  3. data/.github/workflows/libfuzzer.yml +83 -0
  4. data/.github/workflows/security.yml +88 -3
  5. data/.github/workflows/valgrind.yml +135 -0
  6. data/CHANGELOG.md +60 -2
  7. data/README.md +81 -77
  8. data/Rakefile +194 -3
  9. data/ext/makiri/bridge/ruby_string.c +119 -66
  10. data/ext/makiri/core/mkr_alloc.c +40 -3
  11. data/ext/makiri/core/mkr_alloc.h +27 -4
  12. data/ext/makiri/core/mkr_buf.c +13 -3
  13. data/ext/makiri/core/mkr_buf.h +80 -5
  14. data/ext/makiri/core/mkr_core.c +143 -0
  15. data/ext/makiri/core/mkr_core.h +10 -1
  16. data/ext/makiri/core/mkr_span.h +186 -0
  17. data/ext/makiri/core/mkr_utf8.c +101 -0
  18. data/ext/makiri/core/mkr_utf8.h +88 -0
  19. data/ext/makiri/extconf.rb +104 -9
  20. data/ext/makiri/fuzz/Makefile +95 -0
  21. data/ext/makiri/fuzz/check_fuzzer.cc +4 -0
  22. data/ext/makiri/fuzz/xml_fuzz.c +24 -0
  23. data/ext/makiri/fuzz/xpath_fuzz.c +109 -0
  24. data/ext/makiri/glue/glue.h +8 -0
  25. data/ext/makiri/glue/ruby_doc.c +20 -24
  26. data/ext/makiri/glue/ruby_html_css.c +58 -12
  27. data/ext/makiri/glue/ruby_html_mutate.c +11 -6
  28. data/ext/makiri/glue/ruby_html_node.c +3 -32
  29. data/ext/makiri/glue/ruby_node.c +39 -0
  30. data/ext/makiri/glue/ruby_xml.c +198 -16
  31. data/ext/makiri/glue/ruby_xml_node.c +46 -59
  32. data/ext/makiri/glue/ruby_xpath.c +4 -4
  33. data/ext/makiri/lexbor_compat/source_loc.c +14 -16
  34. data/ext/makiri/lexbor_compat/utf8_input.c +5 -78
  35. data/ext/makiri/makiri.c +45 -0
  36. data/ext/makiri/xml/mkr_xml.h +2 -3
  37. data/ext/makiri/xml/mkr_xml_chars.c +67 -97
  38. data/ext/makiri/xml/mkr_xml_index.c +169 -0
  39. data/ext/makiri/xml/mkr_xml_index.h +48 -0
  40. data/ext/makiri/xml/mkr_xml_mutate.c +63 -121
  41. data/ext/makiri/xml/mkr_xml_node.c +147 -15
  42. data/ext/makiri/xml/mkr_xml_node.h +71 -6
  43. data/ext/makiri/xml/mkr_xml_tree.c +185 -149
  44. data/ext/makiri/xpath/mkr_css.c +1023 -0
  45. data/ext/makiri/xpath/mkr_css.h +65 -0
  46. data/ext/makiri/xpath/mkr_xpath.c +37 -0
  47. data/ext/makiri/xpath/mkr_xpath.h +13 -0
  48. data/ext/makiri/xpath/mkr_xpath_eval_body.h +373 -90
  49. data/ext/makiri/xpath/mkr_xpath_funcs_body.h +249 -231
  50. data/ext/makiri/xpath/mkr_xpath_internal.h +89 -9
  51. data/ext/makiri/xpath/mkr_xpath_lex.c +94 -124
  52. data/ext/makiri/xpath/mkr_xpath_node_access_xml.h +6 -3
  53. data/ext/makiri/xpath/mkr_xpath_number.c +109 -0
  54. data/ext/makiri/xpath/mkr_xpath_parse.c +79 -90
  55. data/ext/makiri/xpath/mkr_xpath_shared.c +40 -24
  56. data/ext/makiri/xpath/mkr_xpath_value_body.h +50 -24
  57. data/lib/makiri/cdata_section.rb +1 -3
  58. data/lib/makiri/comment.rb +1 -3
  59. data/lib/makiri/document.rb +8 -0
  60. data/lib/makiri/element.rb +1 -3
  61. data/lib/makiri/processing_instruction.rb +1 -3
  62. data/lib/makiri/text.rb +1 -3
  63. data/lib/makiri/version.rb +1 -1
  64. data/lib/makiri/xml/builder.rb +263 -0
  65. data/lib/makiri/xml/node_methods.rb +47 -0
  66. data/lib/makiri.rb +1 -0
  67. data/script/check_alloc_failures.rb +266 -0
  68. data/script/check_c_safety.rb +45 -2
  69. data/script/check_c_safety_allowlist.yml +19 -0
  70. data/script/check_leaks.rb +64 -0
  71. data/script/leaks_harness.rb +64 -0
  72. data/vendor/lexbor/CMakeLists.txt +6 -0
  73. data/vendor/lexbor/README.md +12 -0
  74. data/vendor/lexbor/config.cmake +1 -1
  75. data/vendor/lexbor/source/lexbor/core/base.h +1 -1
  76. data/vendor/lexbor/source/lexbor/core/config.cmake +9 -1
  77. data/vendor/lexbor/source/lexbor/css/selectors/pseudo_state.c +2 -3
  78. data/vendor/lexbor/source/lexbor/css/selectors/state.c +3 -0
  79. data/vendor/lexbor/source/lexbor/dom/interfaces/element.c +21 -0
  80. data/vendor/lexbor/source/lexbor/dom/interfaces/element.h +5 -0
  81. data/vendor/lexbor/source/lexbor/encoding/decode.c +33 -4
  82. data/vendor/lexbor/source/lexbor/html/base.h +1 -1
  83. data/vendor/lexbor/source/lexbor/html/interfaces/select_element.c +4 -0
  84. data/vendor/lexbor/source/lexbor/html/serialize.c +545 -41
  85. data/vendor/lexbor/source/lexbor/html/serialize.h +2 -1
  86. data/vendor/lexbor/source/lexbor/html/tokenizer.h +2 -2
  87. data/vendor/lexbor/source/lexbor/html/tree/insertion_mode/in_body.c +1 -1
  88. data/vendor/lexbor/source/lexbor/html/tree.c +6 -6
  89. data/vendor/lexbor/source/lexbor/selectors/selectors.c +12 -3
  90. data/vendor/lexbor/source/lexbor/url/base.h +1 -1
  91. data/vendor/lexbor/source/lexbor/url/url.c +5 -2
  92. data/vendor/lexbor/source/lexbor/url/url.h +9 -0
  93. data/vendor/lexbor/version +1 -1
  94. metadata +19 -1
@@ -28,7 +28,29 @@ abort "Lexbor source not found at #{LEXBOR_SRC}. Did you `git submodule update -
28
28
 
29
29
  cmake = find_executable("cmake") or abort "cmake is required to build Lexbor."
30
30
 
31
- unless File.exist?(File.join(LEXBOR_DST, "lib", "liblexbor_static.a"))
31
+ # Optionally build the vendored Lexbor itself under AddressSanitizer. This is the
32
+ # ONLY way to catch overflows *inside* Lexbor's bump (mraw) arena: a sub-allocation
33
+ # overrunning into the next one stays within one big malloc'd chunk, so the heap
34
+ # allocator's red-zones (and thus a plain ASan build of just our ext) never see it.
35
+ # Lexbor's own mraw is ASan-aware - with -DLEXBOR_BUILD_WITH_ASAN=ON its CMake
36
+ # defines LEXBOR_HAVE_ADDRESS_SANITIZER, and mraw then unpoisons exactly each
37
+ # allocation and re-poisons the gap, so an intra-arena overrun writes into
38
+ # poisoned memory and ASan reports it. Opt-in (slow full rebuild), only meaningful
39
+ # with MAKIRI_SANITIZE=...address...; drive it via `rake sanitize:lexbor`.
40
+ # vendor/lexbor stays vanilla - this is a build flag, not a source patch.
41
+ sanitize = ENV["MAKIRI_SANITIZE"].to_s.strip
42
+ lexbor_asan = !ENV["MAKIRI_SANITIZE_LEXBOR"].to_s.strip.empty? && sanitize.include?("address")
43
+ lexbor_mode = lexbor_asan ? "asan" : "plain"
44
+ lexbor_stamp = File.join(LEXBOR_DST, ".makiri_build_mode")
45
+
46
+ # Reuse the cached archive only when it was built in the mode we now want; a mode
47
+ # switch (plain <-> asan) forces a rebuild, so a sanitized Lexbor can never leak
48
+ # into a normal build or vice versa.
49
+ have_archive = File.exist?(File.join(LEXBOR_DST, "lib", "liblexbor_static.a"))
50
+ stamp_ok = have_archive && File.exist?(lexbor_stamp) && File.read(lexbor_stamp).strip == lexbor_mode
51
+ unless stamp_ok
52
+ FileUtils.rm_rf(LEXBOR_BLD)
53
+ FileUtils.rm_rf(LEXBOR_DST) if have_archive # drop a wrong-mode install
32
54
  FileUtils.mkdir_p(LEXBOR_BLD)
33
55
  Dir.chdir(LEXBOR_BLD) do
34
56
  cmd = [
@@ -41,12 +63,15 @@ unless File.exist?(File.join(LEXBOR_DST, "lib", "liblexbor_static.a"))
41
63
  "-DCMAKE_BUILD_TYPE=Release",
42
64
  "-DCMAKE_POSITION_INDEPENDENT_CODE=ON",
43
65
  "-DCMAKE_INSTALL_PREFIX=#{LEXBOR_DST}",
66
+ *(lexbor_asan ? ["-DLEXBOR_BUILD_WITH_ASAN=ON"] : []),
44
67
  LEXBOR_SRC,
45
68
  ].shelljoin
69
+ warn "makiri: building vendored Lexbor (mode=#{lexbor_mode})"
46
70
  system(cmd) or abort "cmake configure failed for Lexbor."
47
71
  system("#{cmake.shellescape} --build . --target install -- -j#{Etc.respond_to?(:nprocessors) ? Etc.nprocessors : 4}") or
48
72
  abort "cmake build/install failed for Lexbor."
49
73
  end
74
+ File.write(lexbor_stamp, lexbor_mode)
50
75
  end
51
76
 
52
77
  $INCFLAGS << " -I#{File.join(LEXBOR_DST, 'include').shellescape}"
@@ -60,11 +85,35 @@ $LDFLAGS << " #{lexbor_archive.shellescape}"
60
85
  # Sanitizer build (opt-in): MAKIRI_SANITIZE=address,undefined rake clean compile
61
86
  # Then run the suite under the runtime via `rake sanitize` (which preloads the
62
87
  # ASan runtime). Sanitizers replace the heap allocator, so even the vendored
63
- # (uninstrumented) Lexbor's allocations get red-zoned - heap overflows on
64
- # Lexbor-owned buffers are still caught. _FORTIFY_SOURCE is dropped here because
65
- # it conflicts with the sanitizer interceptors.
66
- sanitize = ENV["MAKIRI_SANITIZE"].to_s.strip
67
- if sanitize.empty?
88
+ # (uninstrumented) Lexbor's allocations get red-zoned - a heap overflow off the
89
+ # END of a Lexbor malloc is caught. Overflows *inside* Lexbor's mraw arena are
90
+ # NOT caught this way (they stay within one malloc'd chunk); for those, also build
91
+ # Lexbor under ASan via MAKIRI_SANITIZE_LEXBOR=1 (see the Lexbor build above and
92
+ # `rake sanitize:lexbor`). _FORTIFY_SOURCE is dropped here because it conflicts
93
+ # with the sanitizer interceptors.
94
+ # Coverage build (opt-in): MAKIRI_COVERAGE=1 instruments OUR sources with clang
95
+ # source-based coverage (the vendored Lexbor is built separately and is NOT
96
+ # instrumented - we measure only the code we write). Run via `rake coverage`,
97
+ # which sets LLVM_PROFILE_FILE and renders an llvm-cov report. -O0 keeps the
98
+ # region map close to the source; _FORTIFY_SOURCE is dropped (it needs -O2).
99
+ coverage = !ENV["MAKIRI_COVERAGE"].to_s.strip.empty?
100
+
101
+ # OOM-injection build (opt-in): MAKIRI_ALLOC_INJECT=1 compiles the core
102
+ # allocation-failure hook (mkr_alloc.h) so `rake oom` can sweep "the nth core
103
+ # allocation fails" over representative workloads and assert every OOM branch
104
+ # fails closed. Debug/test builds only - a normal build carries no hook.
105
+ # Composes with the sanitize/coverage modes below.
106
+ if ENV["MAKIRI_ALLOC_INJECT"].to_s.strip == "1"
107
+ $CFLAGS << " -DMKR_ALLOC_INJECT=1"
108
+ warn "makiri: building with allocation-failure injection (MKR_ALLOC_INJECT)"
109
+ end
110
+
111
+ if coverage
112
+ $CFLAGS << " -O0 -g -fprofile-instr-generate -fcoverage-mapping"
113
+ $LDFLAGS << " -fprofile-instr-generate"
114
+ $DLDFLAGS << " -fprofile-instr-generate"
115
+ warn "makiri: building with clang source-based coverage"
116
+ elsif sanitize.empty?
68
117
  # Security hardening flags. Keep -O2 active so _FORTIFY_SOURCE works.
69
118
  $CFLAGS << " -O2"
70
119
  $CFLAGS << " -D_FORTIFY_SOURCE=2"
@@ -72,6 +121,20 @@ else
72
121
  $CFLAGS << " -O1 -g -fno-omit-frame-pointer -fsanitize=#{sanitize}"
73
122
  $LDFLAGS << " -fsanitize=#{sanitize}"
74
123
  $DLDFLAGS << " -fsanitize=#{sanitize}"
124
+ if sanitize.include?("address")
125
+ # No ASan *stack* red zones in the ext. CRuby is built with
126
+ # RUBY_SETJMP = __builtin_setjmp, so rb_raise unwinds via __builtin_longjmp,
127
+ # which the ASan runtime does not intercept (no __asan_handle_no_return):
128
+ # any raise crossing an instrumented frame - ours, or Ruby code raising
129
+ # through rb_protect under the evaluator - leaves that frame's stack poison
130
+ # behind, and an interceptor (memcpy & co.) in the uninstrumented interpreter
131
+ # later trips over the stale shadow: a spurious report, which ASan itself
132
+ # then aborts while rendering (asan_thread.cpp kCurrentStackFrameMagic
133
+ # CHECK). Heap red zones, UBSan, and the manual arena poisoning in
134
+ # mkr_xml_node.c are unaffected; only stack-buffer checks are lost.
135
+ $CFLAGS << (RbConfig::CONFIG["CC"] =~ /clang/ || RbConfig::CONFIG["target_os"] =~ /darwin/ ?
136
+ " -mllvm -asan-stack=0" : " --param asan-stack=0")
137
+ end
75
138
  warn "makiri: building with -fsanitize=#{sanitize}"
76
139
  end
77
140
 
@@ -133,9 +196,41 @@ elsif RbConfig::CONFIG["target_os"] =~ /linux/
133
196
  $DLDFLAGS << " -Wl,--exclude-libs,ALL"
134
197
  end
135
198
 
136
- # Recursively pick up C sources under ext/makiri/.
137
- $srcs = Dir.glob(File.join(EXT_DIR, "**", "*.c")).map { |f| f.sub("#{EXT_DIR}/", "") }
199
+ # Recursively pick up C sources under ext/makiri/, excluding standalone
200
+ # libFuzzer harnesses. Those define LLVMFuzzerTestOneInput and are linked by
201
+ # ext/makiri/fuzz/Makefile, never into the Ruby extension.
202
+ $srcs = Dir.glob(File.join(EXT_DIR, "**", "*.c"))
203
+ .reject { |f| f.start_with?(File.join(EXT_DIR, "fuzz") + File::SEPARATOR) }
204
+ .map { |f| f.sub("#{EXT_DIR}/", "") }
138
205
  $VPATH ||= []
139
- $VPATH += Dir.glob(File.join(EXT_DIR, "**/")).map { |d| "$(srcdir)/#{d.sub("#{EXT_DIR}/", "")}".chomp("/") }
206
+ # fuzz/ must be excluded here too: after a `rake fuzz:libfuzzer_build`,
207
+ # fuzz/build/{core,xml,xpath}/ hold sanitizer-instrumented .o files, and a
208
+ # VPATH that includes them lets make resolve the extension's object
209
+ # prerequisites there instead of compiling them - breaking the link (or worse,
210
+ # silently mixing differently-flagged objects).
211
+ $VPATH += Dir.glob(File.join(EXT_DIR, "**/"))
212
+ .reject { |d| d.start_with?(File.join(EXT_DIR, "fuzz") + File::SEPARATOR) }
213
+ .map { |d| "$(srcdir)/#{d.sub("#{EXT_DIR}/", "")}".chomp("/") }
140
214
 
141
215
  create_makefile("makiri/makiri")
216
+
217
+ # mkmf's generated Makefile carries NO header dependencies, so editing a header
218
+ # (e.g. a struct layout in an internal .h) recompiles only the .c files whose
219
+ # own timestamps changed - the rest keep their stale layout and the objects
220
+ # silently disagree (ABI mismatch, runtime breakage). Append the coarsest sound
221
+ # rule instead: every object depends on every project header. A header edit
222
+ # then recompiles everything - a few seconds for this ext, and it can never
223
+ # rot (the list regenerates each configure; a header NEW since the last
224
+ # configure is reachable only from .c files edited to include it, which rebuild
225
+ # on their own timestamp). Ruby/Lexbor headers are deliberately excluded: a
226
+ # Ruby upgrade gets a fresh build dir from rake-compiler, and a Lexbor pin
227
+ # change already requires `rake clean:lexbor` (see CLAUDE.md).
228
+ project_headers = Dir.glob(File.join(EXT_DIR, "**", "*.h"))
229
+ .reject { |f| f.start_with?(File.join(EXT_DIR, "fuzz") + File::SEPARATOR) }
230
+ .map { |f| "$(srcdir)/#{f.sub("#{EXT_DIR}/", "")}" }
231
+ .sort
232
+ File.open("Makefile", "a") do |mk|
233
+ mk.puts
234
+ mk.puts "# Project-header dependencies appended by extconf.rb (mkmf emits none)."
235
+ mk.puts "$(OBJS): #{project_headers.join(" ")}"
236
+ end
@@ -0,0 +1,95 @@
1
+ # ext/makiri/fuzz/Makefile - libFuzzer harness build
2
+ #
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
7
+ #
8
+ # Requires: clang with libFuzzer support (usually part of the clang distribution)
9
+ # and the vendored Lexbor static library already built (via `rake compile`).
10
+
11
+ CLANG ?= clang
12
+ CXX := $(if $(filter default,$(origin CXX)),clang++,$(CXX))
13
+
14
+ # Paths relative to ext/makiri/fuzz/
15
+ EXT_DIR = ..
16
+ LEXBOR_SRC = ../../../vendor/lexbor
17
+ LEXBOR_DST = $(LEXBOR_SRC)/dist
18
+
19
+ # CFLAGS mirror the security flags from extconf.rb, minus Ruby-specific flags.
20
+ # Sanitizer instrumentation is added at compile time so every TU is covered.
21
+ SANITIZE ?= address,undefined
22
+ CFLAGS = -O2 -g -Wall -Wextra \
23
+ -fstack-protector-strong \
24
+ -Wformat -Wformat-security \
25
+ -fvisibility=hidden \
26
+ -fno-common \
27
+ -fsanitize=$(SANITIZE) \
28
+ -I$(LEXBOR_DST)/include \
29
+ -I$(EXT_DIR) \
30
+ -I$(EXT_DIR)/core \
31
+ -I$(EXT_DIR)/xml \
32
+ -I$(EXT_DIR)/xpath \
33
+ -I$(EXT_DIR)/lexbor_compat
34
+
35
+ # Linker flags: libFuzzer driver + sanitizers.
36
+ LDFLAGS = -fsanitize=fuzzer,$(SANITIZE)
37
+
38
+ BUILD_DIR = build
39
+
40
+ # Ruby-free C sources that the harnesses link against.
41
+ CORE_SRCS = \
42
+ $(EXT_DIR)/core/mkr_alloc.c \
43
+ $(EXT_DIR)/core/mkr_utf8.c \
44
+ $(EXT_DIR)/core/mkr_buf.c \
45
+ $(EXT_DIR)/core/mkr_core.c
46
+
47
+ XML_SRCS = \
48
+ $(EXT_DIR)/xml/mkr_xml_tree.c \
49
+ $(EXT_DIR)/xml/mkr_xml_node.c \
50
+ $(EXT_DIR)/xml/mkr_xml_chars.c \
51
+ $(EXT_DIR)/xml/mkr_xml_index.c \
52
+ $(EXT_DIR)/xml/mkr_xml_mutate.c
53
+
54
+ XPATH_SRCS = \
55
+ $(EXT_DIR)/xpath/mkr_xpath.c \
56
+ $(EXT_DIR)/xpath/mkr_xpath_lex.c \
57
+ $(EXT_DIR)/xpath/mkr_xpath_parse.c \
58
+ $(EXT_DIR)/xpath/mkr_xpath_shared.c \
59
+ $(EXT_DIR)/xpath/mkr_xpath_number.c \
60
+ $(EXT_DIR)/xpath/mkr_xpath_engine_xml.c \
61
+ $(EXT_DIR)/xpath/mkr_xpath_engine_html.c
62
+
63
+ CORE_OBJS = $(patsubst $(EXT_DIR)/%.c,$(BUILD_DIR)/%.o,$(CORE_SRCS))
64
+ XML_OBJS = $(patsubst $(EXT_DIR)/%.c,$(BUILD_DIR)/%.o,$(XML_SRCS))
65
+ XPATH_OBJS = $(patsubst $(EXT_DIR)/%.c,$(BUILD_DIR)/%.o,$(XPATH_SRCS))
66
+
67
+ .PHONY: all clean check-libfuzzer xml_fuzz xpath_fuzz
68
+
69
+ all: check-libfuzzer xml_fuzz xpath_fuzz
70
+
71
+ check-libfuzzer:
72
+ @mkdir -p $(BUILD_DIR)
73
+ @$(CXX) $(LDFLAGS) -o $(BUILD_DIR)/check_fuzzer check_fuzzer.cc >/dev/null 2>&1 || \
74
+ (echo "libFuzzer runtime not available for $(CXX). Install LLVM clang and run with CLANG=/path/to/clang CXX=/path/to/clang++." >&2; exit 1)
75
+
76
+ xml_fuzz: $(BUILD_DIR)/xml_fuzz.o $(CORE_OBJS) $(XML_OBJS)
77
+ $(CXX) $(LDFLAGS) -o $@ $^
78
+
79
+ xpath_fuzz: $(BUILD_DIR)/xpath_fuzz.o $(CORE_OBJS) $(XML_OBJS) $(XPATH_OBJS)
80
+ $(CXX) $(LDFLAGS) -o $@ $^ $(LEXBOR_DST)/lib/liblexbor_static.a
81
+
82
+ $(BUILD_DIR)/%.o: $(EXT_DIR)/%.c
83
+ @mkdir -p $(dir $@)
84
+ $(CLANG) $(CFLAGS) -c -o $@ $<
85
+
86
+ $(BUILD_DIR)/xml_fuzz.o: xml_fuzz.c
87
+ @mkdir -p $(dir $@)
88
+ $(CLANG) $(CFLAGS) -c -o $@ $<
89
+
90
+ $(BUILD_DIR)/xpath_fuzz.o: xpath_fuzz.c
91
+ @mkdir -p $(dir $@)
92
+ $(CLANG) $(CFLAGS) -c -o $@ $<
93
+
94
+ clean:
95
+ rm -rf $(BUILD_DIR) xml_fuzz xpath_fuzz
@@ -0,0 +1,4 @@
1
+ extern "C" int LLVMFuzzerTestOneInput(const unsigned char *, unsigned long)
2
+ {
3
+ return 0;
4
+ }
@@ -0,0 +1,24 @@
1
+ /* xml_fuzz.c - libFuzzer harness for mkr_xml_parse.
2
+ *
3
+ * Coverage-guided fuzzing of the XML tokenizer + tree builder.
4
+ * Ruby-free; runs directly on the pure-C parser surface.
5
+ */
6
+
7
+ #include <stdint.h>
8
+ #include <stddef.h>
9
+ #include "xml/mkr_xml.h"
10
+
11
+ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
12
+ {
13
+ mkr_xml_status_t status;
14
+ /* The parser contract says "valid UTF-8, NUL-free", but the fuzzer feeds
15
+ * raw bytes. mkr_xml_parse is fail-closed: it validates as it goes and
16
+ * returns an error status on malformed input, never a partial document.
17
+ * We pass the bytes through untouched so the fuzzer can reach the invalid-
18
+ * UTF-8 / unexpected-NUL error paths too. */
19
+ mkr_xml_doc_t *doc = mkr_xml_parse((const char *) data, size, &status);
20
+ if (doc) {
21
+ mkr_xml_doc_destroy(doc);
22
+ }
23
+ return 0;
24
+ }
@@ -0,0 +1,109 @@
1
+ /* xpath_fuzz.c - libFuzzer harness for the XPath compile + eval path.
2
+ *
3
+ * Coverage-guided fuzzing of the XPath lexer, parser, and evaluator.
4
+ * We build a small fixed XML document as the evaluation context, then
5
+ * treat the fuzzer input as the XPath expression string.
6
+ *
7
+ * Ruby-free; runs directly on the pure-C engine surface.
8
+ */
9
+
10
+ #include <stdint.h>
11
+ #include <stddef.h>
12
+ #include <string.h>
13
+ #include "core/mkr_alloc.h"
14
+ #include "xml/mkr_xml.h"
15
+ #include "xpath/mkr_xpath.h"
16
+ #include "xpath/mkr_xpath_internal.h"
17
+
18
+ /* A small, fixed XML document that gives the evaluator something to walk.
19
+ * The expression is the fuzzer input; the document is static so the coverage
20
+ * signal comes from the engine, not the parser. */
21
+ static const char FIXED_XML[] =
22
+ "<?xml version='1.0'?>"
23
+ "<root xmlns='http://example.com/default' xmlns:ns='http://example.com/ns'>"
24
+ " <a id='1' ns:attr='x'>text1</a>"
25
+ " <b id='2'><c/><c/></b>"
26
+ " <ns:d>namespaced</ns:d>"
27
+ " <!-- comment -->"
28
+ " <?pi target='value'?>"
29
+ "</root>";
30
+
31
+ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
32
+ {
33
+ /* 1. Parse the fixed document. */
34
+ mkr_xml_status_t status;
35
+ mkr_xml_doc_t *doc = mkr_xml_parse(FIXED_XML, sizeof(FIXED_XML) - 1, &status);
36
+ if (!doc) return 0;
37
+ if (!doc->doc_node) {
38
+ mkr_xml_doc_destroy(doc);
39
+ return 0;
40
+ }
41
+
42
+ /* 2. The fuzzer input is the XPath expression.
43
+ * The engine text contract requires no interior NUL and a NUL at
44
+ * ptr[len]. libFuzzer hands us exactly `size` bytes with no terminator,
45
+ * so we copy the expression prefix into an owned, NUL-terminated heap
46
+ * buffer and mint the verified-text token over that copy - this is what
47
+ * supplies the NUL-termination + no-interior-NUL the lexer's strtod and
48
+ * "%.10s" error path rely on. If the input contains a NUL we truncate to
49
+ * the prefix (the lexer hits the terminator and reports a syntax error,
50
+ * a path worth exercising). UTF-8 validity is deliberately NOT
51
+ * pre-checked: the lexer's strict decoder rejecting invalid UTF-8 is
52
+ * itself a path the fuzzer should hit. */
53
+ size_t expr_len = size;
54
+ for (size_t i = 0; i < size; i++) {
55
+ if (data[i] == '\0') {
56
+ expr_len = i;
57
+ break;
58
+ }
59
+ }
60
+ char *expr_copy = mkr_strndup((const char *) data, expr_len);
61
+ if (!expr_copy) {
62
+ mkr_xml_doc_destroy(doc);
63
+ return 0;
64
+ }
65
+ /* Empty expression is a quick syntax error; still worth a run. */
66
+ mkr_verified_text_t expr = { expr_copy, expr_len };
67
+
68
+ /* 3. Compile the expression. */
69
+ mkr_xpath_limits_t limits;
70
+ mkr_xpath_limits_init_defaults(&limits);
71
+ /* Tighten the compile-time budgets so a hostile expression fails fast
72
+ * rather than burning fuzzer time on pathological ASTs. */
73
+ limits.max_ast_nodes = 10000;
74
+ limits.max_expr_bytes = 16 * 1024;
75
+
76
+ mkr_xpath_error_t err = {0};
77
+ mkr_node_t *ast = mkr_parse(expr, &limits, &err);
78
+ if (!ast) {
79
+ mkr_xpath_error_clear(&err);
80
+ free(expr_copy);
81
+ mkr_xml_doc_destroy(doc);
82
+ return 0;
83
+ }
84
+
85
+ /* 4. Evaluate against the fixed document. */
86
+ mkr_xpath_context_t *ctx = mkr_xpath_context_new(doc->doc_node, doc->doc_node);
87
+ if (ctx) {
88
+ mkr_xpath_set_engine_kind(ctx, 1); /* XML engine */
89
+ mkr_xpath_limits_init_defaults(&limits);
90
+ limits.max_eval_ops = 5 * 1000 * 1000; /* 5M ops - enough for a real query */
91
+ limits.max_nodeset_size = 10000;
92
+ limits.max_string_bytes = 1024 * 1024;
93
+ limits.max_recursion_depth = 64;
94
+
95
+ mkr_xpath_value_t out = {0};
96
+ mkr_xpath_error_t eval_err = {0};
97
+ if (mkr_xpath_eval_compiled(ctx, ast, &out, &eval_err) == 0) {
98
+ mkr_xpath_value_clear(&out);
99
+ } else {
100
+ mkr_xpath_error_clear(&eval_err);
101
+ }
102
+ mkr_xpath_context_free(ctx);
103
+ }
104
+
105
+ mkr_node_free(ast);
106
+ free(expr_copy);
107
+ mkr_xml_doc_destroy(doc);
108
+ return 0;
109
+ }
@@ -64,6 +64,14 @@ lxb_dom_node_t *mkr_html_node_unwrap(VALUE rb_node);
64
64
  void *mkr_node_raw(VALUE rb_node);
65
65
  uintptr_t mkr_node_id(VALUE rb_node);
66
66
 
67
+ /* Representation-neutral identity methods (ruby_node.c): depend only on
68
+ * mkr_node_id, so the HTML and XML NodeMethods modules bind ==/eql? to
69
+ * mkr_node_equals, hash to mkr_node_hash, and pointer_id to mkr_node_pointer_id -
70
+ * one implementation, not one per representation. */
71
+ VALUE mkr_node_equals(VALUE self, VALUE other);
72
+ VALUE mkr_node_pointer_id(VALUE self);
73
+ VALUE mkr_node_hash(VALUE self);
74
+
67
75
  /* XML node bridge (glue/ruby_xml_node.c): wrap a custom XML node into the right
68
76
  * Makiri::XML::* leaf (Qnil for NULL, the Document VALUE for the document node). */
69
77
  struct mkr_xml_node;
@@ -140,10 +140,10 @@ mkr_resolve_fragment_context(lxb_dom_document_t *doc, VALUE context,
140
140
  mkr_ruby_borrowed_text_t cv = mkr_ruby_verified_text(context, "fragment context element");
141
141
  const lxb_char_t *p = (const lxb_char_t *)cv.ptr;
142
142
  size_t n = cv.len;
143
- if (n == 3 && memcmp(p, "svg", 3) == 0) {
143
+ if (mkr_bytes_eq(p, n, "svg", 3)) {
144
144
  *out_tag = LXB_TAG_SVG; *out_ns = LXB_NS_SVG; return;
145
145
  }
146
- if (n == 4 && memcmp(p, "math", 4) == 0) {
146
+ if (mkr_bytes_eq(p, n, "math", 4)) {
147
147
  *out_tag = LXB_TAG_MATH; *out_ns = LXB_NS_MATH; return;
148
148
  }
149
149
  lxb_tag_id_t tid = lxb_tag_id_by_name(doc->tags, p, n);
@@ -248,7 +248,6 @@ mkr_sanitize_html_input(VALUE html, const lxb_char_t **out, size_t *out_len,
248
248
  size_t n = (hv.len > 0) ? hv.len : 1;
249
249
  char *buf = mkr_reallocarray(NULL, n, 1);
250
250
  if (buf == NULL) {
251
- RB_GC_GUARD(hv.value);
252
251
  return -1;
253
252
  }
254
253
  if (hv.len > 0) {
@@ -268,19 +267,16 @@ mkr_sanitize_html_input(VALUE html, const lxb_char_t **out, size_t *out_len,
268
267
  *owned = NULL;
269
268
  *out = (const lxb_char_t *)hv.ptr;
270
269
  *out_len = hv.len;
271
- RB_GC_GUARD(hv.value);
272
270
  return 0;
273
271
  }
274
272
  lxb_char_t *clean = NULL;
275
273
  size_t clean_len = 0;
276
274
  if (mkr_utf8_sanitize((const lxb_char_t *)hv.ptr, hv.len, &clean, &clean_len) != 0) {
277
- RB_GC_GUARD(hv.value);
278
275
  return -1;
279
276
  }
280
277
  *owned = clean;
281
278
  *out = (clean != NULL) ? clean : (const lxb_char_t *)hv.ptr;
282
279
  *out_len = (clean != NULL) ? clean_len : hv.len;
283
- RB_GC_GUARD(hv.value);
284
280
  return 0;
285
281
  }
286
282
 
@@ -509,28 +505,28 @@ mkr_doc_s_parse(VALUE klass, VALUE rb_source)
509
505
  * preserved rather than read as raw UTF-8 bytes. */
510
506
  rb_source = mkr_ruby_to_utf8(rb_source);
511
507
 
512
- /* Allocate the wrapper first (with parsed == NULL) so that if parsing
513
- * fails the GC-managed object frees cleanly. This is the HTML parse entry
514
- * (defined on Makiri::HTML::Document), so the result is always HTML. */
515
- mkr_doc_data_t *d;
516
- VALUE obj = TypedData_Make_Struct(klass, mkr_doc_data_t, &mkr_html_doc_type, d);
517
- d->parsed = NULL;
518
- d->errors = rb_ary_new();
519
-
520
- /* Copy the source into a C buffer so the parse can run with the GVL
521
- * released without racing GC/compaction on the Ruby String's backing
522
- * store. The source is not retained past the parse (Lexbor copies what it
523
- * needs into the arena and the line table is built up front), so the
524
- * buffer is freed immediately after. */
508
+ /* Copy the source into a C buffer up front - BEFORE allocating the wrapper
509
+ * (a Ruby allocation, and thus a GC point) - so no GC can run between
510
+ * obtaining rb_source (possibly a fresh transcoded String) and copying its
511
+ * bytes, and the parse can then run with the GVL released without racing
512
+ * GC/compaction on the Ruby String's backing store. The source is not
513
+ * retained past the parse (Lexbor copies what it needs into the arena and
514
+ * the line table is built up front), so the buffer is freed immediately
515
+ * after. The coderange is read first (no scan): a source Ruby already knows
516
+ * is valid UTF-8 lets the parse skip its sanitisation scan. */
517
+ bool assume_valid = mkr_ruby_str_known_valid_utf8(rb_source);
525
518
  mkr_owned_bytes_t source = {0};
526
519
  if (mkr_ruby_copy_bytes(rb_source, &source) != 0) {
527
520
  rb_raise(mkr_eError, "out of memory copying source");
528
521
  }
529
- /* Read the coderange (no scan) before releasing the GVL; the copy is
530
- * byte-identical, so a source Ruby already knows is valid UTF-8 lets the
531
- * parse skip its sanitisation scan. */
532
- bool assume_valid = mkr_ruby_str_known_valid_utf8(rb_source);
533
- RB_GC_GUARD(rb_source);
522
+
523
+ /* Allocate the wrapper (with parsed == NULL) so that if parsing fails the
524
+ * GC-managed object frees cleanly. This is the HTML parse entry (defined on
525
+ * Makiri::HTML::Document), so the result is always HTML. */
526
+ mkr_doc_data_t *d;
527
+ VALUE obj = TypedData_Make_Struct(klass, mkr_doc_data_t, &mkr_html_doc_type, d);
528
+ d->parsed = NULL;
529
+ d->errors = rb_ary_new();
534
530
 
535
531
  mkr_parse_nogvl_t args = { (const lxb_char_t *)source.ptr, source.len,
536
532
  assume_valid, NULL };
@@ -71,11 +71,20 @@ mkr_css_engine_init(void)
71
71
  g_css_ready = 1;
72
72
  }
73
73
 
74
+ /* find: the callback runs inside Lexbor's traversal frames, so it must NOT
75
+ * raise - a longjmp would abort lxb_selectors_find mid-walk AND skip the
76
+ * post-run reset in mkr_with_compiled_selector, leaving the process-global
77
+ * parser/memory/selectors dirty for every later query. So matches are collected
78
+ * into a plain C array here (no Ruby at all); the node cap and a growth failure
79
+ * each latch a flag and STOP, and the caller raises only after the traversal
80
+ * has unwound normally and the engine was reset. */
74
81
  typedef struct {
75
- VALUE set;
76
- lxb_dom_node_t *root; /* excluded from results: css is descendant-only */
77
- size_t count;
78
- int overflow;
82
+ lxb_dom_node_t **nodes; /* malloc-backed; the caller owns + frees it */
83
+ size_t count;
84
+ size_t cap;
85
+ lxb_dom_node_t *root; /* excluded from results: css is descendant-only */
86
+ int overflow;
87
+ int oom;
79
88
  } mkr_css_ctx_t;
80
89
 
81
90
  static lxb_status_t
@@ -92,12 +101,39 @@ mkr_css_find_cb(lxb_dom_node_t *node, lxb_css_selector_specificity_t spec,
92
101
  c->overflow = 1;
93
102
  return LXB_STATUS_STOP; /* fail closed without raising mid-traversal */
94
103
  }
95
-
96
- mkr_node_set_push(c->set, (mkr_raw_node_t *)node);
97
- c->count++;
104
+ if (mkr_grow_reserve((void **)&c->nodes, &c->cap, c->count + 1,
105
+ sizeof(*c->nodes)) != MKR_OK) {
106
+ c->oom = 1;
107
+ return LXB_STATUS_STOP; /* ditto: report OOM after the walk unwinds */
108
+ }
109
+ c->nodes[c->count++] = node;
98
110
  return LXB_STATUS_OK;
99
111
  }
100
112
 
113
+ /* Move the collected matches into the NodeSet under rb_ensure, so the C array
114
+ * cannot leak even if a push raises (NoMemoryError). */
115
+ typedef struct {
116
+ VALUE set;
117
+ mkr_css_ctx_t *ctx;
118
+ } mkr_css_fill_t;
119
+
120
+ static VALUE
121
+ mkr_css_fill_set(VALUE p)
122
+ {
123
+ mkr_css_fill_t *f = (mkr_css_fill_t *)p;
124
+ for (size_t i = 0; i < f->ctx->count; i++) {
125
+ mkr_node_set_push(f->set, (mkr_raw_node_t *)f->ctx->nodes[i]);
126
+ }
127
+ return Qnil;
128
+ }
129
+
130
+ static VALUE
131
+ mkr_css_free_nodes(VALUE p)
132
+ {
133
+ free(((mkr_css_ctx_t *)p)->nodes);
134
+ return Qnil;
135
+ }
136
+
101
137
  /* at_css: capture the first matching descendant and stop. Avoids materialising a
102
138
  * NodeSet (and a Ruby #first dispatch) for the one node the caller wants. */
103
139
  typedef struct {
@@ -199,15 +235,25 @@ mkr_node_css(VALUE self, VALUE rb_selector)
199
235
  {
200
236
  lxb_dom_node_t *root = mkr_html_node_unwrap(self);
201
237
  VALUE document = mkr_node_document(self);
202
- VALUE set = mkr_node_set_new(document);
203
238
 
204
- mkr_css_ctx_t ctx = { .set = set, .root = root, .count = 0, .overflow = 0 };
239
+ /* A syntax error raises inside mkr_with_compiled_selector BEFORE the find
240
+ * runs, so ctx.nodes is still NULL there - nothing leaks on that path. */
241
+ mkr_css_ctx_t ctx = { .nodes = NULL, .count = 0, .cap = 0,
242
+ .root = root, .overflow = 0, .oom = 0 };
205
243
  mkr_with_compiled_selector(rb_selector, root, mkr_run_find, &ctx);
206
244
 
207
- if (ctx.overflow) {
208
- rb_raise(mkr_eError, "CSS result set exceeded the node limit (%u)",
209
- MKR_NODE_SET_MAX);
245
+ if (ctx.overflow || ctx.oom) {
246
+ free(ctx.nodes);
247
+ if (ctx.overflow) {
248
+ rb_raise(mkr_eError, "CSS result set exceeded the node limit (%u)",
249
+ MKR_NODE_SET_MAX);
250
+ }
251
+ rb_raise(mkr_eError, "out of memory collecting CSS results");
210
252
  }
253
+
254
+ VALUE set = mkr_node_set_new(document);
255
+ mkr_css_fill_t fill = { set, &ctx };
256
+ rb_ensure(mkr_css_fill_set, (VALUE)&fill, mkr_css_free_nodes, (VALUE)&ctx);
211
257
  return set;
212
258
  }
213
259
 
@@ -265,8 +265,7 @@ mkr_attr_find_ns(lxb_dom_element_t *el, lxb_ns_id_t ns_id,
265
265
  const lxb_char_t *q = lxb_dom_attr_qualified_name(at, &qlen);
266
266
  (void) lxb_dom_attr_local_name(at, &llen);
267
267
  if (q != NULL && qlen >= llen
268
- && llen == local_len
269
- && memcmp(q + (qlen - llen), local, local_len) == 0) {
268
+ && mkr_bytes_eq(q + (qlen - llen), llen, local, local_len)) {
270
269
  return at;
271
270
  }
272
271
  }
@@ -312,9 +311,11 @@ mkr_node_set_attribute_ns(VALUE self, VALUE rb_ns, VALUE rb_qname, VALUE rb_valu
312
311
  }
313
312
 
314
313
  const lxb_char_t *qn = (const lxb_char_t *)qv.ptr;
315
- const lxb_char_t *colon = memchr(qn, ':', qv.len);
316
- const lxb_char_t *local = colon ? colon + 1 : qn;
317
- size_t local_len = colon ? (size_t)(qv.len - (colon - qn) - 1) : qv.len;
314
+ mkr_span_t qspan = mkr_span((const char *)qn, qv.len);
315
+ size_t colon_off;
316
+ bool has_colon = mkr_span_find(&qspan, ':', &colon_off);
317
+ const lxb_char_t *local = has_colon ? qn + colon_off + 1 : qn;
318
+ size_t local_len = has_colon ? qv.len - colon_off - 1 : qv.len;
318
319
 
319
320
  /* A match keeps its qualified name (so re-setting with a different prefix
320
321
  * leaves the prefix unchanged); only the value updates. A miss appends a new
@@ -507,7 +508,11 @@ mkr_parse_fragment_into(lxb_dom_node_t *context_el, VALUE rb_html,
507
508
 
508
509
  mkr_import_fragment_children(doc, frag, emit, u);
509
510
 
510
- /* Frees the transient fragment document; our imported copies live on. */
511
+ /* lxb_html_parse_fragment built the fragment in a TRANSIENT document that
512
+ * destroying the parser does NOT free (measured: one document leaked per
513
+ * inner_html=/outer_html= call); our imported copies live in `doc`, so the
514
+ * transient document is destroyed explicitly. */
515
+ lxb_html_document_destroy(lxb_html_interface_document(frag->owner_document));
511
516
  lxb_html_parser_destroy(parser);
512
517
  RB_GC_GUARD(html);
513
518
  }