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.
- checksums.yaml +4 -4
- data/.github/workflows/conformance.yml +22 -0
- data/.github/workflows/libfuzzer.yml +83 -0
- data/.github/workflows/security.yml +88 -3
- data/.github/workflows/valgrind.yml +135 -0
- data/CHANGELOG.md +60 -2
- data/README.md +81 -77
- data/Rakefile +194 -3
- data/lib/makiri/3.2/makiri.so +0 -0
- data/lib/makiri/3.3/makiri.so +0 -0
- data/lib/makiri/3.4/makiri.so +0 -0
- data/lib/makiri/4.0/makiri.so +0 -0
- data/lib/makiri/cdata_section.rb +1 -3
- data/lib/makiri/comment.rb +1 -3
- data/lib/makiri/document.rb +8 -0
- data/lib/makiri/element.rb +1 -3
- data/lib/makiri/processing_instruction.rb +1 -3
- data/lib/makiri/text.rb +1 -3
- data/lib/makiri/version.rb +1 -1
- data/lib/makiri/xml/builder.rb +263 -0
- data/lib/makiri/xml/node_methods.rb +47 -0
- data/lib/makiri.rb +1 -0
- data/script/check_alloc_failures.rb +266 -0
- data/script/check_c_safety.rb +45 -2
- data/script/check_c_safety_allowlist.yml +19 -0
- data/script/check_leaks.rb +64 -0
- data/script/leaks_harness.rb +64 -0
- metadata +7 -1
|
@@ -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&A x\r\ny">
|
|
44
|
+
<!-- a comment -->
|
|
45
|
+
<p:branch><leaf depth="2">text & A 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 & #{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
|
data/script/check_c_safety.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|