makiri 0.5.1 → 0.7.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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +2 -1
- data/.github/workflows/libfuzzer.yml +1 -1
- data/.github/workflows/release.yml +28 -13
- data/.github/workflows/verify.yml +37 -0
- data/CHANGELOG.md +52 -0
- data/README.md +16 -0
- data/Rakefile +14 -7
- data/ext/makiri/bridge/bridge.h +28 -4
- data/ext/makiri/bridge/bridge_internal.h +28 -0
- data/ext/makiri/bridge/ruby_string.c +29 -221
- data/ext/makiri/bridge/xml_decode.c +237 -0
- data/ext/makiri/core/mkr_alloc.c +6 -0
- data/ext/makiri/core/mkr_alloc.h +13 -4
- data/ext/makiri/core/mkr_buf.h +1 -0
- data/ext/makiri/core/mkr_text.h +28 -9
- data/ext/makiri/core/mkr_utf8.h +8 -1
- data/ext/makiri/dom_adapter/cross_import.c +20 -7
- data/ext/makiri/dom_adapter/dom_index.c +22 -21
- data/ext/makiri/dom_adapter/post_parse.c +27 -22
- data/ext/makiri/dom_adapter/text_index.c +28 -19
- data/ext/makiri/extconf.rb +40 -3
- data/ext/makiri/fuzz/Makefile +15 -7
- data/ext/makiri/fuzz/xml_xpath_fuzz.c +87 -0
- data/ext/makiri/fuzz/xpath_fuzz.c +10 -5
- data/ext/makiri/glue/glue.h +14 -0
- data/ext/makiri/glue/ruby_doc.c +50 -24
- data/ext/makiri/glue/ruby_html_mutate.c +181 -71
- data/ext/makiri/glue/ruby_html_node.c +76 -52
- data/ext/makiri/glue/ruby_xml.c +60 -91
- data/ext/makiri/glue/ruby_xml_node.c +175 -14
- data/ext/makiri/makiri.c +8 -7
- data/ext/makiri/xml/mkr_xml.h +5 -0
- data/ext/makiri/xml/mkr_xml_chars.c +10 -0
- data/ext/makiri/xml/mkr_xml_mutate.c +228 -38
- data/ext/makiri/xml/mkr_xml_mutate.h +46 -8
- data/ext/makiri/xml/mkr_xml_node.c +21 -12
- data/ext/makiri/xml/mkr_xml_node.h +39 -9
- data/ext/makiri/xml/mkr_xml_tree.c +39 -45
- data/ext/makiri/xpath/mkr_css.c +19 -34
- data/ext/makiri/xpath/mkr_xpath.c +34 -31
- data/ext/makiri/xpath/mkr_xpath_eval_body.h +30 -31
- data/ext/makiri/xpath/mkr_xpath_funcs_body.h +76 -71
- data/ext/makiri/xpath/mkr_xpath_internal.h +1 -1
- data/ext/makiri/xpath/mkr_xpath_parse.c +125 -157
- data/ext/makiri/xpath/mkr_xpath_value_body.h +34 -45
- data/ext/makiri/xpath/mkr_xpath_xml_selftest.c +7 -10
- data/lib/makiri/attr.rb +0 -3
- data/lib/makiri/comment.rb +1 -3
- data/lib/makiri/compat_aliases.rb +1 -12
- data/lib/makiri/version.rb +1 -1
- data/script/check_c_safety.rb +11 -0
- data/script/check_c_safety_allowlist.yml +7 -16
- data/suppressions/ruby.supp +22 -0
- data/verify/Makefile +237 -0
- data/verify/cbmc_models.c +38 -0
- data/verify/harness_alloc.c +257 -0
- data/verify/harness_buf.c +167 -0
- data/verify/harness_hash.c +28 -0
- data/verify/harness_span.c +213 -0
- data/verify/harness_spanbuf.c +98 -0
- data/verify/harness_utf8.c +70 -0
- data/verify/harness_utf8_chain.c +64 -0
- data/verify/harness_utf8_words.c +54 -0
- data/verify/harness_xml_chars.c +59 -0
- data/verify/harness_xml_expand.c +72 -0
- data/verify/harness_xml_parse.c +42 -0
- data/verify/harness_xpath_lex.c +51 -0
- data/verify/harness_xpath_number.c +68 -0
- data/verify/harness_xpath_parse.c +57 -0
- data/verify/selftest_main.c +16 -0
- data/verify/stub.c +10 -0
- data/verify/verify.h +54 -0
- metadata +25 -2
- /data/ext/makiri/core/{mkr_core.c → mkr_core_selftest.c} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c178d50564ea8152e886817e6df19628c491eb7bb1213e06ab70f6ffe8665015
|
|
4
|
+
data.tar.gz: 55a69b4d93c40b130581cb078ffee01dc6355cec4843fdc0f86269d7d22a4390
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c87c6c98a58fe500d60f7775c088c655d3938e22cf614ed57b84d395d15229bd4dfbf5e61909c86804132d90dfdb31d62278e36ed7399c66102590c13867ca36
|
|
7
|
+
data.tar.gz: 2da0687466e5c2faab9d0c95d8181e529acf68b30165c9357375edb10e5dff5cd45557e7186c133fdfaf40043f7d2c2f3ee1a49d945cacea4f46c1b56b195c2c
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -12,7 +12,7 @@ jobs:
|
|
|
12
12
|
strategy:
|
|
13
13
|
fail-fast: false
|
|
14
14
|
matrix:
|
|
15
|
-
os: [ubuntu-latest, macos-latest]
|
|
15
|
+
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
16
16
|
ruby: ["3.2", "3.3", "3.4", "4.0"]
|
|
17
17
|
|
|
18
18
|
steps:
|
|
@@ -42,6 +42,7 @@ jobs:
|
|
|
42
42
|
THREADING: ${{ (matrix.os == 'ubuntu-latest' && matrix.ruby == '3.4') && '1' || '' }}
|
|
43
43
|
|
|
44
44
|
- name: Smoke-load the gem
|
|
45
|
+
shell: bash
|
|
45
46
|
run: bundle exec ruby -Ilib -r makiri -e 'p Makiri::VERSION'
|
|
46
47
|
|
|
47
48
|
sanitize:
|
|
@@ -62,10 +62,12 @@ jobs:
|
|
|
62
62
|
- x86_64-linux
|
|
63
63
|
- aarch64-linux
|
|
64
64
|
- arm64-darwin
|
|
65
|
+
- x64-mingw-ucrt
|
|
65
66
|
include:
|
|
66
|
-
- { platform: x86_64-linux,
|
|
67
|
-
- { platform: aarch64-linux,
|
|
68
|
-
- { platform: arm64-darwin,
|
|
67
|
+
- { platform: x86_64-linux, os: ubuntu-latest }
|
|
68
|
+
- { platform: aarch64-linux, os: ubuntu-24.04-arm }
|
|
69
|
+
- { platform: arm64-darwin, os: macos-14 }
|
|
70
|
+
- { platform: x64-mingw-ucrt, os: windows-latest }
|
|
69
71
|
steps:
|
|
70
72
|
- uses: actions/checkout@v6
|
|
71
73
|
with:
|
|
@@ -79,6 +81,7 @@ jobs:
|
|
|
79
81
|
- name: Compile (builds vendored Lexbor, then the ext)
|
|
80
82
|
run: bundle exec rake compile
|
|
81
83
|
- name: Smoke-load
|
|
84
|
+
shell: bash
|
|
82
85
|
run: bundle exec ruby -Ilib -r makiri -e 'p Makiri::VERSION'
|
|
83
86
|
- name: Stage the compiled binary under its ABI subdir
|
|
84
87
|
shell: bash
|
|
@@ -97,16 +100,25 @@ jobs:
|
|
|
97
100
|
run: |
|
|
98
101
|
dlext="$(ruby -e 'print RbConfig::CONFIG["DLEXT"]')"
|
|
99
102
|
bin="lib/makiri/makiri.${dlext}"
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
103
|
+
case "$(uname -s)" in
|
|
104
|
+
Darwin)
|
|
105
|
+
if otool -L "$bin" | grep -i 'libruby'; then
|
|
106
|
+
echo "::error::$bin hard-links libruby; precompiled gem would not load on other Ruby installs"; exit 1
|
|
107
|
+
fi
|
|
108
|
+
;;
|
|
109
|
+
MINGW*|MSYS*|CYGWIN*)
|
|
110
|
+
# Windows PE DLLs MUST import the Ruby DLL via the import library
|
|
111
|
+
# (no dynamic_lookup on PE/COFF). A libruby import here is by design,
|
|
112
|
+
# not a portability bug, so this assertion does not apply.
|
|
113
|
+
echo "Windows (x64-mingw-ucrt): Ruby import-library link is by design; skipping libruby assertion"
|
|
114
|
+
;;
|
|
115
|
+
*)
|
|
116
|
+
if readelf -d "$bin" 2>/dev/null | grep -iE 'NEEDED.*libruby'; then
|
|
117
|
+
echo "::error::$bin has a libruby NEEDED entry; precompiled gem may not load on a static Ruby"; exit 1
|
|
118
|
+
fi
|
|
119
|
+
;;
|
|
120
|
+
esac
|
|
121
|
+
echo "OK: portability check complete for $bin"
|
|
110
122
|
- uses: actions/upload-artifact@v7
|
|
111
123
|
with:
|
|
112
124
|
name: bin-${{ matrix.platform }}-${{ matrix.ruby }}
|
|
@@ -128,6 +140,8 @@ jobs:
|
|
|
128
140
|
os: ubuntu-24.04-arm
|
|
129
141
|
- platform: arm64-darwin
|
|
130
142
|
os: macos-14
|
|
143
|
+
- platform: x64-mingw-ucrt
|
|
144
|
+
os: windows-latest
|
|
131
145
|
steps:
|
|
132
146
|
- uses: actions/checkout@v6
|
|
133
147
|
with:
|
|
@@ -157,6 +171,7 @@ jobs:
|
|
|
157
171
|
- name: Build the native gem
|
|
158
172
|
run: ruby script/build_native_gem.rb "${{ matrix.platform }}"
|
|
159
173
|
- name: Verify it installs without recompiling
|
|
174
|
+
shell: bash
|
|
160
175
|
run: |
|
|
161
176
|
gem install "makiri-"*"-${{ matrix.platform }}.gem" --local
|
|
162
177
|
ruby -e 'require "makiri"; puts Makiri::VERSION'
|
|
@@ -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,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.0] - 2026-07-11
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
* `Document#create_document_type(name, public_id = "", system_id = "")` on both
|
|
8
|
+
`Makiri::HTML::Document` and `Makiri::XML::Document` (DOM
|
|
9
|
+
`DOMImplementation.createDocumentType`): creates a detached `DocumentType`
|
|
10
|
+
node, to be placed before the document element (e.g. with
|
|
11
|
+
`root.add_previous_sibling(doctype)`). The name is case-preserving; an omitted
|
|
12
|
+
or empty public/system id is treated as absent; an invalid name (or a `"` in an
|
|
13
|
+
id) raises. Inserting a doctype is validated against the WHATWG rules — it may
|
|
14
|
+
only be a child of the document, must precede the document element, and a
|
|
15
|
+
document holds at most one — so a misplaced or duplicate doctype raises rather
|
|
16
|
+
than producing a malformed tree.
|
|
17
|
+
|
|
18
|
+
* The XML `DocumentType` is now part of the tree: it appears in
|
|
19
|
+
`Document#children` (before the root, like a browser DOM) and carries
|
|
20
|
+
`parent`/sibling links, for both a parsed `<!DOCTYPE>` and the factory above.
|
|
21
|
+
`Document#internal_subset` still returns it. XPath is unaffected — its 1.0 data
|
|
22
|
+
model excludes the doctype (`//node()` / `/node()` never surface it), as in
|
|
23
|
+
Nokogiri/libxml2. This supports building a document with
|
|
24
|
+
`DOMImplementation.createDocument(namespace, qualifiedName, doctype)`.
|
|
25
|
+
|
|
26
|
+
* `Makiri::XML::Document#create_loose_dom_element(qualified_name, prefix,
|
|
27
|
+
local_name, namespace_uri)`, an escape hatch for browser-DOM interop that
|
|
28
|
+
creates an element whose name follows WHATWG DOM element-name rules rather
|
|
29
|
+
than the stricter XML QName rules (e.g. `"f:o:o"`, `":foo"`, `"0:a"`). The
|
|
30
|
+
regular factories stay XML-strict. Such an element is intentionally not
|
|
31
|
+
XML-serializable: `#to_xml` and `#canonicalize` raise `Makiri::Error` if the
|
|
32
|
+
tree contains one. Renaming it to a valid XML name (`#name=`) returns it to
|
|
33
|
+
strict mode.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
* Replacing an XML node with a `DocumentFragment` (`node.replace(fragment)`) is
|
|
38
|
+
now all-or-nothing: if the replacement is rejected (e.g. it would leave the
|
|
39
|
+
document with two root elements), the target node and the fragment are left
|
|
40
|
+
untouched, instead of dropping the target and splicing in only part of the
|
|
41
|
+
fragment. Replacing the root element with a single-element fragment now works.
|
|
42
|
+
|
|
43
|
+
## [0.6.0] - 2026-07-05
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
* Text and comment node content (`create_text_node`, `create_comment`,
|
|
48
|
+
`content=`) and attribute values (`[]=`, `set_attribute_ns`) now accept an
|
|
49
|
+
embedded NUL (U+0000) and store it verbatim, matching the WHATWG DOM and
|
|
50
|
+
browsers, instead of raising. Names, tag names, namespaces, CSS selectors, and
|
|
51
|
+
XPath expressions stay NUL-strict, and invalid UTF-8 is still rejected. XML
|
|
52
|
+
documents (`Makiri::XML`) are unchanged and still reject NUL everywhere, since
|
|
53
|
+
XML 1.0 has no legal U+0000 character.
|
|
54
|
+
|
|
3
55
|
## [0.5.1] - 2026-06-22
|
|
4
56
|
|
|
5
57
|
### 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,20 @@ 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
|
+
# The 10 CBMC proofs are independent processes with a lopsided cost
|
|
294
|
+
# distribution (a few ~1-2min solves dominate, the rest are trivial), so run
|
|
295
|
+
# them in parallel: wall-clock drops toward the single slowest harness with no
|
|
296
|
+
# loss of coverage. Cap -j at the CPU count (Etc.nprocessors; the runner has
|
|
297
|
+
# RAM for that many concurrent core-set solves). smoke/selftest are quick and
|
|
298
|
+
# come first as ordered goals.
|
|
299
|
+
require "etc"
|
|
300
|
+
jobs = Integer(ENV.fetch("VERIFY_JOBS", Etc.nprocessors))
|
|
301
|
+
sh "make", "-C", "verify", "-j#{jobs}", "smoke", "selftest", "cbmc"
|
|
302
|
+
end
|
|
303
|
+
|
|
290
304
|
desc "Run the performance benchmark (Makiri vs Nokogiri reference)"
|
|
291
305
|
task bench: :compile do
|
|
292
306
|
# Run outside the bundle so the bench-only gems (nokogiri, benchmark-ips)
|
|
@@ -303,13 +317,6 @@ task "bench:xml" => :compile do
|
|
|
303
317
|
end
|
|
304
318
|
end
|
|
305
319
|
|
|
306
|
-
desc "Run the CSS selector-query benchmark on a note.com-style SPA page (set BENCH_CARDS)"
|
|
307
|
-
task "bench:css" => :compile do
|
|
308
|
-
Bundler.with_unbundled_env do
|
|
309
|
-
sh "#{FileUtils::RUBY} -Ilib bench/bench_css_query.rb"
|
|
310
|
-
end
|
|
311
|
-
end
|
|
312
|
-
|
|
313
320
|
namespace :conformance do
|
|
314
321
|
desc "WHATWG HTML5 parsing conformance: run html5lib-tests through Makiri"
|
|
315
322
|
task html5: :compile do
|
data/ext/makiri/bridge/bridge.h
CHANGED
|
@@ -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
|
|
88
|
-
* (from mkr_ruby_verified_text or mkr_ruby_try_verified_text) into the token
|
|
89
|
-
* XPath engine accepts. The returned token borrows v.ptr; the caller must
|
|
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+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#ifndef MAKIRI_BRIDGE_INTERNAL_H
|
|
2
|
+
#define MAKIRI_BRIDGE_INTERNAL_H
|
|
3
|
+
|
|
4
|
+
/* Bridge-private surface shared between the string-bridge TUs (ruby_string.c and
|
|
5
|
+
* xml_decode.c). Deliberately NOT part of the public glue API in bridge.h: this
|
|
6
|
+
* is the allocation-free core of Makiri's strict text contract, consumed both by
|
|
7
|
+
* the programmatic-API validators (ruby_string.c) and by the XML input decoder
|
|
8
|
+
* (xml_decode.c). */
|
|
9
|
+
#include <ruby.h>
|
|
10
|
+
#include <stddef.h>
|
|
11
|
+
|
|
12
|
+
/* Verdict of the strict text contract (no NUL byte, valid UTF-8). Each caller
|
|
13
|
+
* maps a verdict to its own error surface (Makiri::Error, XML::SyntaxError, or a
|
|
14
|
+
* reason string). */
|
|
15
|
+
typedef enum {
|
|
16
|
+
MKR_TEXT_OK = 0,
|
|
17
|
+
MKR_TEXT_HAS_NUL,
|
|
18
|
+
MKR_TEXT_INVALID_UTF8,
|
|
19
|
+
} mkr_text_verdict_t;
|
|
20
|
+
|
|
21
|
+
/* Check [ptr,len) against the strict text contract, consulting +coderange_str+'s
|
|
22
|
+
* CACHED coderange (no scan, no alloc) for the fast path; +ptr+/+len+ may be a
|
|
23
|
+
* suffix of +coderange_str+ (see the XML BOM-strip case). ALLOCATION-FREE BY
|
|
24
|
+
* DESIGN - it runs between a caller taking a borrowed RSTRING pointer and using
|
|
25
|
+
* it, so it must not be a GC point. Defined in ruby_string.c. */
|
|
26
|
+
mkr_text_verdict_t mkr_text_check(VALUE coderange_str, const char *ptr, size_t len);
|
|
27
|
+
|
|
28
|
+
#endif /* MAKIRI_BRIDGE_INTERNAL_H */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#include "bridge.h"
|
|
2
2
|
#include "../makiri.h"
|
|
3
|
+
#include "bridge_internal.h"
|
|
3
4
|
|
|
4
5
|
#include <limits.h>
|
|
5
6
|
#include <stdio.h>
|
|
@@ -48,6 +49,8 @@ mkr_ruby_str_from_borrowed(mkr_borrowed_text_t text)
|
|
|
48
49
|
/* The shared core of Makiri's strict text contract: no NUL byte, valid UTF-8.
|
|
49
50
|
* Returns the specific violation (or MKR_TEXT_OK); each caller maps the verdict
|
|
50
51
|
* to its own error surface (Makiri::Error, XML::SyntaxError, or a reason string).
|
|
52
|
+
* The verdict enum + this signature are declared in bridge_internal.h so the XML
|
|
53
|
+
* input decoder (xml_decode.c) shares this one implementation.
|
|
51
54
|
*
|
|
52
55
|
* ALLOCATION-FREE BY DESIGN, which every caller relies on: it runs between a
|
|
53
56
|
* caller taking a borrowed RSTRING pointer and using it, so it must not be a GC
|
|
@@ -62,13 +65,7 @@ mkr_ruby_str_from_borrowed(mkr_borrowed_text_t text)
|
|
|
62
65
|
* the bytes (the BOM is one complete UTF-8 char, so a whole-string VALID
|
|
63
66
|
* coderange still proves the suffix valid). Bytes are validated as UTF-8
|
|
64
67
|
* regardless of the String's declared encoding. */
|
|
65
|
-
|
|
66
|
-
MKR_TEXT_OK = 0,
|
|
67
|
-
MKR_TEXT_HAS_NUL,
|
|
68
|
-
MKR_TEXT_INVALID_UTF8,
|
|
69
|
-
} mkr_text_verdict_t;
|
|
70
|
-
|
|
71
|
-
static mkr_text_verdict_t
|
|
68
|
+
mkr_text_verdict_t
|
|
72
69
|
mkr_text_check(VALUE coderange_str, const char *ptr, size_t len)
|
|
73
70
|
{
|
|
74
71
|
mkr_span_t sv = mkr_span(ptr, len);
|
|
@@ -115,6 +112,31 @@ mkr_ruby_verified_text(VALUE in, const char *what)
|
|
|
115
112
|
return v;
|
|
116
113
|
}
|
|
117
114
|
|
|
115
|
+
mkr_ruby_borrowed_data_t
|
|
116
|
+
mkr_ruby_verified_data(VALUE in, const char *what)
|
|
117
|
+
{
|
|
118
|
+
VALUE s = rb_String(in);
|
|
119
|
+
const char *ptr = RSTRING_PTR(s);
|
|
120
|
+
size_t len = (size_t)RSTRING_LEN(s);
|
|
121
|
+
|
|
122
|
+
/* Data-family (text/comment/CDATA content, attribute value): only invalid
|
|
123
|
+
* UTF-8 is fatal; an interior NUL is permitted so DOM data can hold U+0000.
|
|
124
|
+
* mkr_verify_text is not reused because it raises on NUL. mkr_text_check is
|
|
125
|
+
* allocation-free, so the borrow above is not held across a GC point. */
|
|
126
|
+
switch (mkr_text_check(s, ptr, len)) {
|
|
127
|
+
case MKR_TEXT_INVALID_UTF8:
|
|
128
|
+
rb_raise(mkr_eError, "%s must be valid UTF-8", what);
|
|
129
|
+
case MKR_TEXT_HAS_NUL: /* permitted for data-family */
|
|
130
|
+
case MKR_TEXT_OK:
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
mkr_ruby_borrowed_data_t v;
|
|
134
|
+
v.value = s;
|
|
135
|
+
v.ptr = ptr;
|
|
136
|
+
v.len = len;
|
|
137
|
+
return v;
|
|
138
|
+
}
|
|
139
|
+
|
|
118
140
|
mkr_ruby_borrowed_bytes_t
|
|
119
141
|
mkr_ruby_bytes_view(VALUE in)
|
|
120
142
|
{
|
|
@@ -171,220 +193,6 @@ mkr_ruby_to_utf8(VALUE str)
|
|
|
171
193
|
ECONV_INVALID_REPLACE | ECONV_UNDEF_REPLACE, Qnil);
|
|
172
194
|
}
|
|
173
195
|
|
|
174
|
-
/* rb_str_encode with no replacement flags: an undefined conversion or invalid
|
|
175
|
-
* byte sequence RAISES (Encoding::UndefinedConversionError /
|
|
176
|
-
* Encoding::InvalidByteSequenceError) instead of substituting U+FFFD. Run under
|
|
177
|
-
* rb_protect so we can remap the Ruby Encoding error to Makiri::XML::SyntaxError. */
|
|
178
|
-
static VALUE
|
|
179
|
-
mkr_xml_strict_transcode_thunk(VALUE str)
|
|
180
|
-
{
|
|
181
|
-
return rb_str_encode(str, rb_enc_from_encoding(rb_utf8_encoding()), 0, Qnil);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/* --- XML 1.0 Appendix F: byte-encoding autodetection (BOM, then declaration) ---
|
|
185
|
-
*
|
|
186
|
-
* The leading byte-order mark, or NULL; *bom_len gets its length. UTF-32 BOMs are
|
|
187
|
-
* checked before the UTF-16 LE BOM they share a prefix with.
|
|
188
|
-
*
|
|
189
|
-
* *stride / *ascii_off get the interleave geometry of the ASCII column the decl
|
|
190
|
-
* scanner later extracts (default 1/0 for a single-byte stream). It is resolved
|
|
191
|
-
* HERE, at the match, rather than re-derived downstream, because that derivation
|
|
192
|
-
* needs rb_enc_find (it can autoload an encoding = a GC point) and the decl
|
|
193
|
-
* scanner reads a borrowed RSTRING view that must not be held across one - so
|
|
194
|
-
* the scanner is kept allocation-free until its reads are done. Each span read
|
|
195
|
-
* of p still finishes before the rb_enc_find in the return. */
|
|
196
|
-
static rb_encoding *
|
|
197
|
-
mkr_xml_bom_encoding(const unsigned char *p, long len, long *bom_len, long *stride, long *ascii_off)
|
|
198
|
-
{
|
|
199
|
-
mkr_span_t s = mkr_span((const char *)p, (size_t)len);
|
|
200
|
-
*bom_len = 0;
|
|
201
|
-
*stride = 1;
|
|
202
|
-
*ascii_off = 0;
|
|
203
|
-
if (mkr_span_starts(&s, "\x00\x00\xFE\xFF", 4)) { *bom_len = 4; *stride = 4; *ascii_off = 3; return rb_enc_find("UTF-32BE"); }
|
|
204
|
-
if (mkr_span_starts(&s, "\xFF\xFE\x00\x00", 4)) { *bom_len = 4; *stride = 4; *ascii_off = 0; return rb_enc_find("UTF-32LE"); }
|
|
205
|
-
if (mkr_span_starts(&s, "\xFE\xFF", 2)) { *bom_len = 2; *stride = 2; *ascii_off = 1; return rb_enc_find("UTF-16BE"); }
|
|
206
|
-
if (mkr_span_starts(&s, "\xFF\xFE", 2)) { *bom_len = 2; *stride = 2; *ascii_off = 0; return rb_enc_find("UTF-16LE"); }
|
|
207
|
-
if (mkr_span_starts(&s, "\xEF\xBB\xBF", 3)) { *bom_len = 3; return rb_utf8_encoding(); }
|
|
208
|
-
return NULL;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
static int
|
|
212
|
-
mkr_decl_ws(int c)
|
|
213
|
-
{
|
|
214
|
-
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/* The encoding named in the '<?xml ... encoding="NAME" ?>' declaration, or NULL.
|
|
218
|
-
* The declaration is ASCII; for a UTF-16/32-detected document its bytes are
|
|
219
|
-
* stride-interleaved, so the ASCII column is extracted (stride/off resolved by
|
|
220
|
-
* the BOM matcher) before the scan, letting a BOM-vs-declaration conflict be
|
|
221
|
-
* caught even in UTF-16.
|
|
222
|
-
*
|
|
223
|
-
* p is a borrowed RSTRING view, so this stays allocation-free until every read
|
|
224
|
-
* of p is done: the stride/off geometry is passed in (rather than derived here
|
|
225
|
-
* via rb_enc_find, which can autoload = a GC point), and the only rb_enc_find -
|
|
226
|
-
* the final name lookup - runs after the bytes have been copied into head[]. */
|
|
227
|
-
static rb_encoding *
|
|
228
|
-
mkr_xml_decl_encoding(const unsigned char *p, long len, long stride, long off)
|
|
229
|
-
{
|
|
230
|
-
/* Extract the ASCII column (per the BOM stride) through bounded reads into
|
|
231
|
-
* a bounded writer - neither side trusts the loop arithmetic. */
|
|
232
|
-
mkr_span_t in = mkr_span((const char *)p, len < 0 ? 0 : (size_t)len);
|
|
233
|
-
char head[256];
|
|
234
|
-
mkr_spanbuf_t hw = mkr_spanbuf(head, sizeof(head));
|
|
235
|
-
for (size_t i = (size_t)off; hw.pos < sizeof(head); i += (size_t)stride) {
|
|
236
|
-
int c = mkr_span_at(&in, i);
|
|
237
|
-
if (c < 0) break;
|
|
238
|
-
mkr_spanbuf_putc(&hw, (char)c);
|
|
239
|
-
}
|
|
240
|
-
mkr_span_t h = mkr_span(head, hw.pos);
|
|
241
|
-
size_t hn = hw.pos;
|
|
242
|
-
|
|
243
|
-
size_t i = 0;
|
|
244
|
-
while (mkr_decl_ws(mkr_span_at(&h, i))) i++;
|
|
245
|
-
{
|
|
246
|
-
mkr_span_t t = mkr_span_tail(&h, i);
|
|
247
|
-
if (!mkr_span_starts(&t, "<?xml", 5)) return NULL;
|
|
248
|
-
}
|
|
249
|
-
i += 5;
|
|
250
|
-
/* find a whitespace-introduced "encoding" before the '?>' */
|
|
251
|
-
for (; i + 8 <= hn; i++) {
|
|
252
|
-
if (mkr_span_at(&h, i) == '?' && mkr_span_at(&h, i + 1) == '>') return NULL; /* end of decl */
|
|
253
|
-
mkr_span_t t = mkr_span_tail(&h, i);
|
|
254
|
-
if (!mkr_decl_ws(mkr_span_at(&h, i - 1)) || !mkr_span_starts(&t, "encoding", 8)) continue;
|
|
255
|
-
size_t j = i + 8;
|
|
256
|
-
while (mkr_decl_ws(mkr_span_at(&h, j))) j++;
|
|
257
|
-
if (mkr_span_at(&h, j) != '=') return NULL;
|
|
258
|
-
j++;
|
|
259
|
-
while (mkr_decl_ws(mkr_span_at(&h, j))) j++;
|
|
260
|
-
int q = mkr_span_at(&h, j);
|
|
261
|
-
if (q != '"' && q != '\'') return NULL;
|
|
262
|
-
j++;
|
|
263
|
-
size_t ns = j;
|
|
264
|
-
while (mkr_span_at(&h, j) >= 0 && mkr_span_at(&h, j) != q) j++;
|
|
265
|
-
if (j >= hn) return NULL;
|
|
266
|
-
char name[64];
|
|
267
|
-
size_t nl = j - ns;
|
|
268
|
-
if (nl == 0 || nl >= sizeof(name)) return NULL;
|
|
269
|
-
memcpy(name, head + ns, nl);
|
|
270
|
-
name[nl] = '\0';
|
|
271
|
-
return rb_enc_find(name); /* NULL for an unknown encoding name */
|
|
272
|
-
}
|
|
273
|
-
return NULL;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/* Two encodings agree for conflict purposes when identical, or when either is
|
|
277
|
-
* US-ASCII (a subset of UTF-8 and the single-byte encodings). */
|
|
278
|
-
static int
|
|
279
|
-
mkr_xml_enc_compatible(rb_encoding *a, rb_encoding *b)
|
|
280
|
-
{
|
|
281
|
-
return a == b || a == rb_usascii_encoding() || b == rb_usascii_encoding();
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
VALUE
|
|
285
|
-
mkr_xml_decode_input(VALUE str, size_t max_bytes)
|
|
286
|
-
{
|
|
287
|
-
rb_encoding *tag = rb_enc_get(str);
|
|
288
|
-
const unsigned char *raw = (const unsigned char *)RSTRING_PTR(str);
|
|
289
|
-
long rawlen = RSTRING_LEN(str);
|
|
290
|
-
|
|
291
|
-
/* Detect the byte encoding (XML 1.0 Appendix F): a BOM wins, else the
|
|
292
|
-
* declaration. The Ruby String's encoding is authoritative when it is a
|
|
293
|
-
* concrete text encoding; a BOM/declaration that disagrees is a fatal
|
|
294
|
-
* conflict. ASCII-8BIT means "raw bytes, no claimed encoding", so there the
|
|
295
|
-
* detected encoding decodes the input (a UTF-16/Shift_JIS/BOM'd file read
|
|
296
|
-
* with File.binread now parses). */
|
|
297
|
-
long bom_len = 0, bom_stride = 1, bom_off = 0;
|
|
298
|
-
rb_encoding *bom = mkr_xml_bom_encoding(raw, rawlen, &bom_len, &bom_stride, &bom_off);
|
|
299
|
-
/* rb_enc_find inside the BOM lookup can autoload an encoding (a Ruby
|
|
300
|
-
* allocation = a GC point), so re-borrow the bytes before reading them
|
|
301
|
-
* again - a borrowed RSTRING pointer must not be held across one. The
|
|
302
|
-
* interleave geometry (stride/off) is resolved by the BOM matcher and
|
|
303
|
-
* passed through, keeping the decl scanner itself allocation-free. */
|
|
304
|
-
raw = (const unsigned char *)RSTRING_PTR(str);
|
|
305
|
-
rb_encoding *decl = mkr_xml_decl_encoding(raw + bom_len, rawlen - bom_len, bom_stride, bom_off);
|
|
306
|
-
int is_binary = (tag == rb_ascii8bit_encoding());
|
|
307
|
-
|
|
308
|
-
if (bom && decl && !mkr_xml_enc_compatible(bom, decl)) {
|
|
309
|
-
rb_raise(mkr_eXmlSyntaxError,
|
|
310
|
-
"XML encoding conflict: the byte-order mark and the encoding declaration disagree");
|
|
311
|
-
}
|
|
312
|
-
if (!is_binary && bom && !mkr_xml_enc_compatible(bom, tag)) {
|
|
313
|
-
rb_raise(mkr_eXmlSyntaxError,
|
|
314
|
-
"XML encoding conflict: the byte-order mark disagrees with the string's encoding");
|
|
315
|
-
}
|
|
316
|
-
if (!is_binary && decl && !mkr_xml_enc_compatible(decl, tag)) {
|
|
317
|
-
/* A concrete String encoding is authoritative for decoding, so the
|
|
318
|
-
* declaration is not used to transcode - but a declaration that names a
|
|
319
|
-
* different encoding than the String is tagged with (e.g. a Shift_JIS
|
|
320
|
-
* String declaring encoding="UTF-8") is a self-inconsistent document and
|
|
321
|
-
* a fatal error, not a silently-ignored mismatch. */
|
|
322
|
-
rb_raise(mkr_eXmlSyntaxError,
|
|
323
|
-
"XML encoding conflict: the encoding declaration disagrees with the string's encoding");
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
rb_encoding *eff = is_binary ? (bom ? bom : (decl ? decl : rb_utf8_encoding())) : tag;
|
|
327
|
-
|
|
328
|
-
/* Decode to UTF-8 (strict). UTF-8 / US-ASCII / ASCII-8BIT are already UTF-8
|
|
329
|
-
* bytes (validated below); anything else is strict-transcoded, raising rather
|
|
330
|
-
* than substituting U+FFFD. */
|
|
331
|
-
VALUE s;
|
|
332
|
-
if (eff == rb_utf8_encoding() || eff == rb_usascii_encoding() || eff == rb_ascii8bit_encoding()) {
|
|
333
|
-
s = str;
|
|
334
|
-
} else {
|
|
335
|
-
VALUE in = str;
|
|
336
|
-
if (rb_enc_get(str) != eff) { in = rb_str_dup(str); rb_enc_associate(in, eff); }
|
|
337
|
-
int state = 0;
|
|
338
|
-
s = rb_protect(mkr_xml_strict_transcode_thunk, in, &state);
|
|
339
|
-
if (state != 0) {
|
|
340
|
-
VALUE exc = rb_errinfo();
|
|
341
|
-
rb_set_errinfo(Qnil);
|
|
342
|
-
char msg[256];
|
|
343
|
-
mkr_ruby_exception_message(exc, msg, sizeof msg);
|
|
344
|
-
rb_raise(mkr_eXmlSyntaxError,
|
|
345
|
-
"XML input could not be decoded to UTF-8: %s", msg);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
const char *ptr = RSTRING_PTR(s);
|
|
350
|
-
long len = RSTRING_LEN(s);
|
|
351
|
-
long off = 0;
|
|
352
|
-
/* §4.3.3: a leading BOM is the encoding signature, not document content -
|
|
353
|
-
* strip a U+FEFF (the transcode above turns any UTF-16/32 BOM into one). */
|
|
354
|
-
mkr_span_t sv = mkr_span(ptr, (size_t)len);
|
|
355
|
-
if (mkr_span_starts(&sv, "\xEF\xBB\xBF", 3)) {
|
|
356
|
-
off = 3; len -= 3;
|
|
357
|
-
mkr_span_skip(&sv, 3);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/* Fail closed on an over-budget input BEFORE the validation scan and the
|
|
361
|
-
* caller's GVL-release copy (an input whose UTF-8 length exceeds the arena
|
|
362
|
-
* budget can never parse). max_bytes == 0 disables the check (__decode). */
|
|
363
|
-
if (max_bytes != 0 && (size_t)len > max_bytes) {
|
|
364
|
-
rb_raise(mkr_eXmlLimitExceeded, "XML input exceeds the byte budget");
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/* Strict UTF-8 validation via the shared, allocation-free core - no GC point
|
|
368
|
-
* while `ptr` is borrowed: an embedded NUL or any invalid UTF-8 is fatal (no
|
|
369
|
-
* U+FFFD repair - unlike the HTML mkr_utf8_sanitize path). The whole-string
|
|
370
|
-
* `s` is consulted for the cached coderange (it covers the BOM-stripped
|
|
371
|
-
* suffix too - the BOM is one complete UTF-8 character), while the validated
|
|
372
|
-
* bytes are the stripped suffix `ptr + off`. */
|
|
373
|
-
switch (mkr_text_check(s, ptr + off, (size_t)len)) {
|
|
374
|
-
case MKR_TEXT_HAS_NUL:
|
|
375
|
-
rb_raise(mkr_eXmlSyntaxError, "XML input must not contain a NUL byte");
|
|
376
|
-
case MKR_TEXT_INVALID_UTF8:
|
|
377
|
-
rb_raise(mkr_eXmlSyntaxError, "XML input must be valid UTF-8");
|
|
378
|
-
case MKR_TEXT_OK:
|
|
379
|
-
break;
|
|
380
|
-
}
|
|
381
|
-
/* Build the result through the VALUE, not the borrowed ptr (rb_str_subseq
|
|
382
|
-
* allocates, so the ptr must not be what it copies from). */
|
|
383
|
-
VALUE u = rb_str_subseq(s, off, len);
|
|
384
|
-
rb_enc_associate(u, rb_utf8_encoding());
|
|
385
|
-
return u; /* validated, UTF-8-tagged, BOM-stripped */
|
|
386
|
-
}
|
|
387
|
-
|
|
388
196
|
bool
|
|
389
197
|
mkr_ruby_str_known_valid_utf8(VALUE str)
|
|
390
198
|
{
|