makiri 0.3.0-aarch64-linux → 0.4.0-aarch64-linux

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.
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Makiri
4
+ module XML
5
+ # A Nokogiri-compatible DSL for building an XML document (or subtree) from
6
+ # scratch. It is a thin, pure-Ruby layer over the public construction surface
7
+ # (+XML::Document.new+, +Document#create_element+ / +#create_text_node+ /
8
+ # +#create_cdata+ / +#create_comment+, and +Node#add_child+); no C code is
9
+ # involved.
10
+ #
11
+ # @example Block-with-argument form (recommended)
12
+ # builder = Makiri::XML::Builder.new do |xml|
13
+ # xml.feed("xmlns" => "urn:a") do
14
+ # xml.entry do
15
+ # xml.title("Hello")
16
+ # end
17
+ # end
18
+ # end
19
+ # builder.to_xml
20
+ #
21
+ # @example instance_eval form (no block argument)
22
+ # builder = Makiri::XML::Builder.new do
23
+ # root { child("text") }
24
+ # end
25
+ #
26
+ # An element is created by calling a method named after the tag. Trailing
27
+ # arguments follow the Nokogiri convention: a Hash sets attributes (including
28
+ # +xmlns+ / +xmlns:prefix+ namespace declarations), any other argument becomes
29
+ # the element's text content, and a block builds nested children.
30
+ #
31
+ # Tag names that collide with a Ruby/Kernel method (or with one of this
32
+ # builder's own helpers below - +text+, +cdata+, +comment+, +doc+, +parent+,
33
+ # +to_xml+, +to_s+) must be written with a trailing underscore, which is
34
+ # stripped: +xml.id_("9")+ produces +<id>9</id>+. This matches Nokogiri.
35
+ #
36
+ # A namespace prefix is selected for the next element with +[]+:
37
+ # +xml["dc"].title+ produces +<dc:title>+ (the prefix must be in scope, i.e.
38
+ # declared via an +"xmlns:dc"+ attribute on an ancestor or on the element
39
+ # itself, exactly as Makiri resolves prefixes at insertion time).
40
+ class Builder
41
+ # The document being built (a {Makiri::XML::Document}).
42
+ attr_reader :doc
43
+
44
+ # The node new children are currently appended to. While a nested block is
45
+ # running this is that block's element; otherwise it is {#doc}.
46
+ attr_reader :parent
47
+
48
+ # @param options [Hash] accepted for Nokogiri compatibility and ignored - a
49
+ # Makiri document has no configurable options (it is always UTF-8).
50
+ # @param root [Makiri::XML::Node, nil] when given, build into this node:
51
+ # top-level calls append to it and its document is used. (This is what
52
+ # {.with} passes; mirrors +Nokogiri::XML::Builder.new(options, root)+.)
53
+ # @yield [self] when the block takes an argument; otherwise the block is
54
+ # +instance_eval+'d against the builder.
55
+ def initialize(options = {}, root = nil, &block)
56
+ if root
57
+ @doc = root.document
58
+ @parent = root
59
+ else
60
+ @parent = @doc = Makiri::XML::Document.new
61
+ end
62
+ @ns_prefix = nil
63
+ @arity = nil
64
+ return unless block
65
+
66
+ run(&block)
67
+ @parent = @doc # like Nokogiri: after a build block, settle back at the document
68
+ end
69
+
70
+ # Build into an existing node: top-level calls append to +node+, using
71
+ # +node+'s document. Mirrors +Nokogiri::XML::Builder.with+.
72
+ #
73
+ # @param node [Makiri::XML::Node]
74
+ # @return [Builder]
75
+ def self.with(node, &block)
76
+ new({}, node, &block)
77
+ end
78
+
79
+ # Append a text node to the current parent.
80
+ # @return [NodeBuilder]
81
+ def text(string)
82
+ insert(@doc.create_text_node(string.to_s))
83
+ end
84
+
85
+ # Append a CDATA section to the current parent.
86
+ # @return [NodeBuilder]
87
+ def cdata(string)
88
+ insert(@doc.create_cdata(string.to_s))
89
+ end
90
+
91
+ # Append a comment node to the current parent.
92
+ # @return [NodeBuilder]
93
+ def comment(string)
94
+ insert(@doc.create_comment(string.to_s))
95
+ end
96
+
97
+ # Parse +string+ as an XML fragment (against the document's in-scope
98
+ # namespaces) and append its children to the current parent. The Builder
99
+ # analogue of +Nokogiri::XML::Builder#<<+.
100
+ # @return [self]
101
+ def <<(string)
102
+ @doc.fragment(string).children.to_a.each { |child| insert(child) }
103
+ self
104
+ end
105
+
106
+ # Select the namespace prefix for the next element (consumed by the next
107
+ # tag method). Returns self so it reads as +xml["dc"].title+.
108
+ # @return [self]
109
+ def [](ns_prefix)
110
+ @ns_prefix = ns_prefix.to_s
111
+ self
112
+ end
113
+
114
+ # Serialize the built document. Forwards to {Document#to_xml} (so +pretty:+
115
+ # works).
116
+ def to_xml(...)
117
+ @doc.to_xml(...)
118
+ end
119
+ alias_method :to_s, :to_xml
120
+
121
+ # Any other method name is a tag: create the element and insert it.
122
+ def method_missing(name, *args, &block)
123
+ tag = name.to_s.sub(/[_!]\z/, "")
124
+ prefix = @ns_prefix
125
+ if prefix
126
+ tag = "#{prefix}:#{tag}"
127
+ @ns_prefix = nil
128
+ end
129
+ node = create_element(tag, args)
130
+ check_prefix_defined!(node, prefix) if prefix
131
+ insert(node, &block)
132
+ end
133
+
134
+ # Tag methods are open-ended, so report respond_to? truthfully for them
135
+ # (anything that is not already a real method is a candidate tag).
136
+ def respond_to_missing?(_name, _include_private = false)
137
+ true
138
+ end
139
+
140
+ private
141
+
142
+ # Translate the Nokogiri-style trailing arguments (a Hash is attributes,
143
+ # anything else is text content) into a {Document#create_element} call.
144
+ def create_element(tag, args)
145
+ text = nil
146
+ attributes = nil
147
+ args.each do |arg|
148
+ if arg.is_a?(Hash)
149
+ attributes = attributes ? attributes.merge(arg) : arg
150
+ else
151
+ text = arg
152
+ end
153
+ end
154
+ cargs = []
155
+ cargs << text.to_s unless text.nil?
156
+ cargs << attributes unless attributes.nil?
157
+ @doc.create_element(tag, *cargs)
158
+ end
159
+
160
+ # Raise like Nokogiri when a prefix selected via +[]+ resolves nowhere: not
161
+ # in scope at the insertion point (+@parent+ and its ancestors) and not
162
+ # self-declared on +node+ itself (e.g. +xml["foo"].root("xmlns:foo" => ...)+).
163
+ def check_prefix_defined!(node, prefix)
164
+ return if @parent.respond_to?(:namespaces) && @parent.namespaces.key?("xmlns:#{prefix}")
165
+ return if node.namespace_definitions.any? { |ns| ns.prefix == prefix }
166
+
167
+ raise ArgumentError, "Namespace #{prefix} has not been defined"
168
+ end
169
+
170
+ # Append +node+ to the current parent, then, if a block is given, descend
171
+ # into the inserted node for the duration of that block. Returns a
172
+ # {NodeBuilder} over the inserted node (which may be an imported copy, e.g.
173
+ # from a fragment), so the Nokogiri attribute-shortcut chain works.
174
+ def insert(node, &block)
175
+ node = @parent.add_child(node)
176
+ descend(node, &block) if block
177
+ NodeBuilder.new(node, self)
178
+ end
179
+
180
+ # Run +block+ with +node+ as the current parent, restoring the previous
181
+ # parent afterward (even if the block raises) and returning the block's
182
+ # value. The single place the parent is pushed/popped - shared by #insert and
183
+ # NodeBuilder's nested-block chain, so neither manipulates the parent state
184
+ # directly.
185
+ def descend(node, &block)
186
+ previous = @parent
187
+ @parent = node
188
+ begin
189
+ run(&block)
190
+ ensure
191
+ @parent = previous
192
+ end
193
+ end
194
+
195
+ # Run a DSL block, choosing instance_eval vs yield once (from the first
196
+ # block seen), the same way Nokogiri does, so the form is consistent
197
+ # throughout a build.
198
+ def run(&block)
199
+ @arity = block.arity if @arity.nil?
200
+ if @arity <= 0
201
+ instance_eval(&block)
202
+ else
203
+ block.call(self)
204
+ end
205
+ end
206
+
207
+ # The value returned by each element call, wrapping the just-inserted node
208
+ # so attributes can be added with the Nokogiri terse-chain syntax:
209
+ #
210
+ # xml.object.classy.thing! # => <object class="classy" id="thing"/>
211
+ #
212
+ # A plain method name appends to the +class+ attribute, +name!+ sets +id+
213
+ # (and content if given), +name=+ sets that attribute, a trailing Hash adds
214
+ # each key as an attribute, and a block descends into the node. +[]+ / +[]=+
215
+ # read and write attributes directly. Semantics mirror
216
+ # +Nokogiri::XML::Builder::NodeBuilder+.
217
+ class NodeBuilder
218
+ def initialize(node, doc_builder)
219
+ @node = node
220
+ @doc_builder = doc_builder
221
+ end
222
+
223
+ # Read an attribute of the wrapped node.
224
+ def [](key)
225
+ @node[key]
226
+ end
227
+
228
+ # Write an attribute of the wrapped node.
229
+ def []=(key, value)
230
+ @node[key] = value
231
+ end
232
+
233
+ def method_missing(method, *args, &block)
234
+ opts = args.last.is_a?(Hash) ? args.pop : {}
235
+ case method.to_s
236
+ when /\A(.*)!\z/
237
+ @node["id"] = Regexp.last_match(1)
238
+ @node.content = args.first if args.first
239
+ when /\A(.*)=\z/
240
+ @node[Regexp.last_match(1)] = args.first
241
+ else
242
+ @node["class"] = ((@node["class"] || "").split(/\s/) + [method.to_s]).join(" ")
243
+ @node.content = args.first if args.first
244
+ end
245
+
246
+ opts.each do |key, value|
247
+ @node[key.to_s] = ((@node[key.to_s] || "").split(/\s/) + [value]).join(" ")
248
+ end
249
+
250
+ # Descend into this node for a nested block via the builder's own parent
251
+ # stack (with its ensure-based restore), rather than re-rooting it by hand.
252
+ return @doc_builder.send(:descend, @node, &block) if block
253
+
254
+ self
255
+ end
256
+
257
+ def respond_to_missing?(_name, _include_private = false)
258
+ true
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
@@ -32,6 +32,53 @@ module Makiri
32
32
  end
33
33
 
34
34
  alias_method :has_attribute?, :key? unless method_defined?(:has_attribute?)
35
+
36
+ # CSS selector queries over XML, lowered to the native XPath engine (so
37
+ # matching is case-sensitive and namespace-aware, unlike a Lexbor HTML
38
+ # matcher). Nokogiri-compatible namespaces: the document's in-scope
39
+ # declarations are collected automatically (a bare type selector binds to
40
+ # the default namespace), and an optional +ns+ hash of {prefix => uri}
41
+ # supplements/overrides them.
42
+ #
43
+ # doc.css("entry") # default-namespace bound (Atom/RSS just work)
44
+ # doc.css("a|entry", "a" => uri) # explicit prefix
45
+ #
46
+ # @return [Makiri::NodeSet]
47
+ def css(selector, ns = nil)
48
+ _css(selector.to_s, _css_namespaces(ns))
49
+ end
50
+
51
+ # First descendant matching +selector+, or nil. @return [Makiri::Node, nil]
52
+ def at_css(selector, ns = nil)
53
+ _at_css(selector.to_s, _css_namespaces(ns))
54
+ end
55
+
56
+ # Whether this node matches +selector+ (full selector, combinators included).
57
+ # @return [Boolean]
58
+ def matches?(selector, ns = nil)
59
+ _css_matches(selector.to_s, _css_namespaces(ns))
60
+ end
61
+
62
+ private
63
+
64
+ # Build the {prefix => uri} hash the C primitives register. Matching
65
+ # Nokogiri: with NO explicit namespaces the document's own declarations are
66
+ # collected (the default namespace under the synthetic prefix "xmlns", so a
67
+ # bare type selector binds to it - the RSS/Atom common case); but once the
68
+ # caller passes a namespaces hash, ONLY those prefixes are used and a bare
69
+ # selector resolves to no namespace (Nokogiri disables the default binding
70
+ # the moment an explicit map is given). Reading only the root's
71
+ # declarations is O(root attributes), not the whole-document walk.
72
+ def _css_namespaces(user)
73
+ return user.transform_keys(&:to_s).transform_values(&:to_s) if user && !user.empty?
74
+
75
+ reg = {}
76
+ root = document&.root
77
+ root&.namespace_definitions&.each do |ns|
78
+ reg[ns.prefix.nil? ? "xmlns" : ns.prefix] = ns.href
79
+ end
80
+ reg
81
+ end
35
82
  end
36
83
  end
37
84
  end
data/lib/makiri.rb CHANGED
@@ -21,6 +21,7 @@ require_relative "makiri/html/document"
21
21
  require_relative "makiri/xml"
22
22
  require_relative "makiri/xml/node_methods"
23
23
  require_relative "makiri/xml/document"
24
+ require_relative "makiri/xml/builder"
24
25
  require_relative "makiri/element"
25
26
  require_relative "makiri/attr"
26
27
  require_relative "makiri/text"
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Allocation-failure injection sweep for the C extension (run via `rake oom`).
4
+ #
5
+ # The sanitizers and the leak gate prove the happy path is memory-safe; neither
6
+ # proves the OOM branches are CORRECT. Makiri's contract is fail-closed: when a
7
+ # core C allocation fails, a call must either raise a clean Makiri::Error /
8
+ # NoMemoryError or complete with the exact same result as the unfailed run -
9
+ # never a truncated/partial result (the property the XPath node-set caps and
10
+ # the "build OOM -> walk fallback" designs exist for). This gate machine-checks
11
+ # that contract: with the ext built under MAKIRI_ALLOC_INJECT=1, every core
12
+ # allocation site routes through a hook that can be armed to fail the nth
13
+ # attempt once. For each representative workload we record a failure-free
14
+ # BASELINE result and the total number of allocation attempts, then re-run the
15
+ # workload once per allocation site with exactly that site failing, and verify
16
+ # each run either raised cleanly or returned a baseline-identical value.
17
+ #
18
+ # A segfault/abort kills this process; the caller (rake/CI) sees the nonzero
19
+ # exit, which is the verdict too.
20
+ #
21
+ # bundle exec rake oom # rebuild + sweep
22
+ # bundle exec ruby -Ilib script/check_alloc_failures.rb # sweep current build
23
+
24
+ require "makiri"
25
+
26
+ unless Makiri.send(:__alloc_inject?)
27
+ abort "check_alloc_failures: extension built without the injection hook - " \
28
+ "rebuild with MAKIRI_ALLOC_INJECT=1 (`rake oom` does this)"
29
+ end
30
+
31
+ # Each scenario runs one workload END-TO-END and returns a canonical String, so
32
+ # an injected run's result can be compared (==) against the baseline. Fixtures
33
+ # are built INSIDE the lambda (unless reuse is the point) so the sweep covers
34
+ # their parse/build allocations too.
35
+ SCENARIOS = {
36
+ # XML parse covering the syntax surface: declaration, DOCTYPE (SYSTEM id +
37
+ # internal subset), default + prefixed namespaces, prefixed attributes,
38
+ # references, comment, CDATA, PI, nesting, CRLF normalization in an attr.
39
+ "xml_parse" => lambda do
40
+ src = <<~XML
41
+ <?xml version="1.0" encoding="UTF-8"?>
42
+ <!DOCTYPE root SYSTEM "urn:example:dtd" [<!ENTITY local "subset">]>
43
+ <root xmlns="urn:d" xmlns:p="urn:p" p:pa="pv" mixed="A&amp;&#x41; x\r\ny">
44
+ <!-- a comment -->
45
+ <p:branch><leaf depth="2">text &amp; &#x41; refs</leaf></p:branch>
46
+ <![CDATA[raw < cdata & bytes]]>
47
+ <?pi-target some data?>
48
+ <empty/>
49
+ </root>
50
+ XML
51
+ Makiri::XML::Document.parse(src).to_xml
52
+ end,
53
+
54
+ # Fragment parse + serialize + splice into a host document.
55
+ "xml_fragment" => lambda do
56
+ doc = Makiri::XML::Document.parse("<r xmlns='urn:d'><keep>k</keep></r>")
57
+ frag = doc.fragment("<a xmlns:p='u'><p:b>t</p:b></a>text")
58
+ out = frag.to_xml
59
+ doc.root.add_child(frag)
60
+ out + doc.to_xml
61
+ end,
62
+
63
+ # XPath battery: predicates, functions, a union, axes, plus an XPathContext
64
+ # evaluation with a registered namespace and variable.
65
+ "xml_xpath" => lambda do
66
+ doc = Makiri::XML::Document.parse(<<~XML)
67
+ <root xmlns:p="urn:p">
68
+ <a v="1">alpha</a>
69
+ <a v="2">beta</a>
70
+ <b n="3"> gamma delta </b>
71
+ <p:c><a v="9">nested</a></p:c>
72
+ </root>
73
+ XML
74
+ canon = lambda do |r|
75
+ r.is_a?(Makiri::NodeSet) ? r.map(&:to_xml).join("|") : r.inspect
76
+ end
77
+ exprs = [
78
+ "//a[@v='2']",
79
+ "//a[position()=2]",
80
+ "//a[last()]",
81
+ "count(//a)",
82
+ "sum(//b/@n)",
83
+ "concat(string(//a[1]), '-', substring('abcdef', 2, 3))",
84
+ "translate('abc', 'abc', 'xyz')",
85
+ "normalize-space(//b)",
86
+ "contains(//a[1], 'alp')",
87
+ "starts-with(//b, ' g')",
88
+ "//a | //b",
89
+ "//a[1]/ancestor::root",
90
+ "//a[1]/following-sibling::b",
91
+ "//root/descendant-or-self::a",
92
+ ]
93
+ parts = exprs.map { |e| canon.call(doc.xpath(e)) }
94
+ ctx = Makiri::XPathContext.new(doc)
95
+ ctx.register_namespace("p", "urn:p")
96
+ ctx.register_variable("want", "9")
97
+ parts << canon.call(ctx.evaluate("//p:c/a[@v=$want]"))
98
+ parts.join("\n")
99
+ end,
100
+
101
+ # Same battery shape over an HTML5-parsed document.
102
+ "html_xpath" => lambda do
103
+ doc = Makiri::HTML::Document.parse(<<~HTML)
104
+ <html><body>
105
+ <div id="top"><p class="x">one</p><p class="y">two</p></div>
106
+ <ul><li data-n="1">a</li><li data-n="2"> b c </li></ul>
107
+ </body></html>
108
+ HTML
109
+ canon = lambda do |r|
110
+ r.is_a?(Makiri::NodeSet) ? r.map(&:to_html).join("|") : r.inspect
111
+ end
112
+ exprs = [
113
+ "//p[@class='y']",
114
+ "//li[position()=2]",
115
+ "//li[last()]",
116
+ "count(//p)",
117
+ "sum(//li/@data-n)",
118
+ "concat(string(//p[1]), '+', substring(//p[2], 1, 2))",
119
+ "translate('one', 'one', 'uno')",
120
+ "normalize-space(//li[2])",
121
+ "contains(//p[1], 'on')",
122
+ "starts-with(//p[2], 'tw')",
123
+ "//p | //li",
124
+ "//p[1]/ancestor::div",
125
+ "//p[1]/following-sibling::p",
126
+ "//div/descendant-or-self::p",
127
+ ]
128
+ parts = exprs.map { |e| canon.call(doc.xpath(e)) }
129
+ ctx = Makiri::XPathContext.new(doc)
130
+ ctx.register_variable("cls", "x")
131
+ parts << canon.call(ctx.evaluate("//p[@class=$cls]"))
132
+ parts.join("\n")
133
+ end,
134
+
135
+ # The mutation surface: create_*, insertion on every side, rename, content,
136
+ # attributes, replace, remove, and a fragment splice.
137
+ "xml_mutate" => lambda do
138
+ doc = Makiri::XML::Document.parse("<root><old>x</old><gone/></root>")
139
+ el = doc.create_element("made")
140
+ el.add_child(doc.create_text_node("inner"))
141
+ el["k"] = "v"
142
+ doc.root.add_child(el)
143
+ el.name = "renamed"
144
+ el.content = "rewritten"
145
+ el.add_previous_sibling(doc.create_element("before"))
146
+ el.add_next_sibling(doc.create_element("after"))
147
+ doc.root.at_xpath("old").replace(doc.create_element("new"))
148
+ doc.root.at_xpath("gone").remove
149
+ doc.root.add_child(doc.fragment("<f1/>tail<f2 a='b'/>"))
150
+ doc.to_xml
151
+ end,
152
+
153
+ # Serialization over a non-trivial tree (~200 elements built inside the
154
+ # lambda, so the parse is swept too), both tree and deep serializers.
155
+ "html_serialize" => lambda do
156
+ body = (1..50).map { |i|
157
+ "<div id='d#{i}' class='row'><p>cell #{i}</p><span>tail &amp; #{i}</span></div>"
158
+ }.join
159
+ doc = Makiri::HTML::Document.parse("<html><body>#{body}</body></html>")
160
+ doc.to_html + doc.at_css("body").inner_html
161
+ end,
162
+
163
+ # Full-document text extraction (exercises the text-index build, and its
164
+ # fail-closed OOM -> walk fallback).
165
+ "html_text" => lambda do
166
+ body = (1..80).map { |i| "<p>para #{i} <em>em#{i}</em> tail</p>" }.join
167
+ doc = Makiri::HTML::Document.parse("<html><body>#{body}</body></html>")
168
+ doc.text
169
+ end,
170
+
171
+ # CSS: a comma list with combinators through the reused engine, plus the
172
+ # at_css first-match path.
173
+ "css" => lambda do
174
+ doc = Makiri::HTML::Document.parse(<<~HTML)
175
+ <html><body>
176
+ <p class="c">one</p><p>skip</p><p class="c">two</p>
177
+ <div><span>in</span></div><span>out</span>
178
+ <section id="x">target</section>
179
+ </body></html>
180
+ HTML
181
+ doc.css("p.c, div > span").map { |n| n.name }.join(",") +
182
+ doc.at_css("#x")&.name.to_s
183
+ end,
184
+
185
+ # The Builder DSL (pure Ruby over create_*/add_child, so this sweeps the
186
+ # construction factories).
187
+ "xml_builder" => lambda do
188
+ Makiri::XML::Builder.new do |xml|
189
+ xml.feed("xmlns" => "urn:a", "xmlns:dc" => "urn:dc") do
190
+ xml.entry do
191
+ xml.title("Hello")
192
+ xml["dc"].id_("42")
193
+ xml.cdata("a < b")
194
+ xml.comment(" note ")
195
+ end
196
+ end
197
+ end.to_xml
198
+ end,
199
+ }.freeze
200
+
201
+ ALLOWED = [Makiri::Error, NoMemoryError].freeze
202
+ TRUNCATE = 120
203
+
204
+ def disarm = Makiri.send(:__alloc_inject, 0)
205
+
206
+ failures_total = 0
207
+
208
+ SCENARIOS.each do |name, work|
209
+ # Warm twice with injection off: process-global engines (CSS) and lazy
210
+ # builds settle, so the counted run below is representative and stable.
211
+ disarm
212
+ 2.times { work.call }
213
+
214
+ # Counted baseline run: __alloc_inject(0) also resets the counter, so the
215
+ # calls reading right after is exactly this run's allocation-attempt total.
216
+ disarm
217
+ baseline = work.call
218
+ total = Makiri.send(:__alloc_inject_calls)
219
+
220
+ ok_raised = 0
221
+ ok_identical = 0
222
+ failures = []
223
+
224
+ (1..total).each do |n|
225
+ Makiri.send(:__alloc_inject, n)
226
+ begin
227
+ result = work.call
228
+ if result == baseline
229
+ ok_identical += 1
230
+ else
231
+ failures << [n, "truncated/wrong result",
232
+ "baseline=#{baseline.to_s[0, TRUNCATE].inspect} " \
233
+ "got=#{result.to_s[0, TRUNCATE].inspect}"]
234
+ end
235
+ rescue *ALLOWED
236
+ ok_raised += 1
237
+ rescue Exception => e # rubocop:disable Lint/RescueException -- the wrong class IS the finding
238
+ failures << [n, "wrong exception class",
239
+ "#{e.class}: #{e.message.to_s[0, TRUNCATE]}"]
240
+ ensure
241
+ disarm
242
+ end
243
+ end
244
+
245
+ failures_total += failures.size
246
+ puts format("%-16s allocations=%-5d raised=%-5d identical=%-5d failed=%d",
247
+ name, total, ok_raised, ok_identical, failures.size)
248
+ failures.each do |n, kind, detail|
249
+ puts " n=#{n} #{kind}: #{detail}"
250
+ end
251
+ if total.zero?
252
+ # A scenario that never reaches a core allocation sweeps nothing - that is
253
+ # a broken scenario, not a pass.
254
+ failures_total += 1
255
+ puts " scenario performed ZERO core allocations - workload not reaching the C core"
256
+ end
257
+ end
258
+
259
+ if failures_total.zero?
260
+ puts "check_alloc_failures: OK - every injected allocation failure failed closed " \
261
+ "(clean raise or baseline-identical result)"
262
+ else
263
+ puts "check_alloc_failures: FAILED - #{failures_total} injected failure(s) " \
264
+ "produced a wrong exception or a non-baseline result"
265
+ exit 1
266
+ end
@@ -8,9 +8,26 @@ require "yaml"
8
8
  ROOT = Pathname.new(__dir__).join("..").expand_path
9
9
  ALLOWLIST_PATH = ROOT.join("script/check_c_safety_allowlist.yml")
10
10
 
11
- Rule = Struct.new(:id, :message, :regex, keyword_init: true)
11
+ # A rule may carry `paths` (an array of path globs): it then applies ONLY to
12
+ # matching files. Used for the parser-TU reader discipline, where the ban is
13
+ # meaningful only in TUs whose input reads must go through mkr_span_t.
14
+ Rule = Struct.new(:id, :message, :regex, :paths, keyword_init: true)
12
15
  Finding = Struct.new(:path, :line, :rule, :text, keyword_init: true)
13
16
 
17
+ # Byte-scanning parser TUs: every input read goes through the bounded reader
18
+ # (core/mkr_span.h) - see its header comment. The rules below turn that from a
19
+ # convention into a machine-enforced invariant for these files.
20
+ PARSER_TUS = %w[
21
+ ext/makiri/xml/mkr_xml_tree.c
22
+ ext/makiri/xml/mkr_xml_chars.c
23
+ ext/makiri/xml/mkr_xml_node.c
24
+ ext/makiri/xpath/mkr_xpath_lex.c
25
+ ext/makiri/xpath/mkr_xpath_funcs_body.h
26
+ ext/makiri/xpath/mkr_xpath_value_body.h
27
+ ext/makiri/bridge/ruby_string.c
28
+ ext/makiri/lexbor_compat/source_loc.c
29
+ ].freeze
30
+
14
31
  RULES = [
15
32
  Rule.new(
16
33
  id: "string_value_cstr",
@@ -60,7 +77,11 @@ RULES = [
60
77
  Rule.new(
61
78
  id: "verified_text_forge",
62
79
  message: "mkr_verified_text_t must be minted only by mkr_verified_text_from_view (the validated boundary)",
63
- regex: /\(\s*mkr_verified_text_t\s*\)\s*\{/
80
+ # Both forge shapes: the compound-literal cast AND the declaration
81
+ # initializer (`mkr_verified_text_t x = {...}`), which the cast-only regex
82
+ # used to miss - that gap let a fuzz harness mint over a non-NUL-terminated
83
+ # buffer unnoticed.
84
+ regex: /\(\s*mkr_verified_text_t\s*\)\s*\{|\bmkr_verified_text_t\s+\w+\s*=\s*\{/
64
85
  ),
65
86
  # --- HTML/XML representation boundary (see docs/html_xml_boundary_hardening) ---
66
87
  # These symbols assume one DOM representation; using them outside their
@@ -94,6 +115,27 @@ RULES = [
94
115
  "to dereference a node use mkr_html_node or mkr_xml_node_unwrap (kind-checked)",
95
116
  regex: /\bmkr_node_raw\s*\(/
96
117
  ),
118
+ # --- parser-TU reader discipline (see core/mkr_span.h) ---
119
+ # In the byte-scanning parser TUs every input read must go through the
120
+ # bounded reader: a raw libc scan reintroduces the "forgot the bounds check"
121
+ # class the span made structurally impossible. memcpy stays allowed (an
122
+ # explicit-length copy, not a scan).
123
+ Rule.new(
124
+ id: "raw_scan_call",
125
+ message: "parser TUs must read input through mkr_span_* / mkr_bytes_eq / mkr_utf8_* " \
126
+ "(core), not raw libc scanning",
127
+ regex: /\b(?:memchr|memcmp|strchr|strrchr|strstr|strn?cmp|strcspn|strspn|strpbrk|strtod|strtol|strtoull?|sscanf)\s*\(/,
128
+ paths: PARSER_TUS
129
+ ),
130
+ # The span's own cursor/bound fields are private to core/mkr_span.h: touching
131
+ # `.p` / `.end` in a parser TU is how a hand-rolled (uncovered) cursor starts.
132
+ Rule.new(
133
+ id: "raw_cursor_member",
134
+ message: "parser TUs must not access a span's .p/.end (or keep a raw cursor struct); " \
135
+ "use the mkr_span_* helpers (mark/since for slice capture)",
136
+ regex: /(?:->|\.)\s*(?:p|end)\b/,
137
+ paths: PARSER_TUS
138
+ ),
97
139
  ].freeze
98
140
 
99
141
  def load_config
@@ -163,6 +205,7 @@ def scan_findings(ignores)
163
205
  next [] unless code_line?(line)
164
206
 
165
207
  RULES.filter_map do |rule|
208
+ next if rule.paths && rule.paths.none? { |pat| path_matches?(pat, rel) }
166
209
  next unless line.match?(rule.regex)
167
210
  next if rule_ignored?(rel, rule.id, ignores)
168
211