makiri 0.6.0 → 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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +2 -1
  3. data/.github/workflows/release.yml +28 -13
  4. data/CHANGELOG.md +40 -0
  5. data/Rakefile +9 -8
  6. data/ext/makiri/bridge/bridge_internal.h +28 -0
  7. data/ext/makiri/bridge/ruby_string.c +4 -221
  8. data/ext/makiri/bridge/xml_decode.c +237 -0
  9. data/ext/makiri/dom_adapter/cross_import.c +20 -7
  10. data/ext/makiri/dom_adapter/dom_index.c +22 -21
  11. data/ext/makiri/dom_adapter/post_parse.c +27 -22
  12. data/ext/makiri/dom_adapter/text_index.c +28 -19
  13. data/ext/makiri/extconf.rb +40 -3
  14. data/ext/makiri/fuzz/Makefile +1 -1
  15. data/ext/makiri/fuzz/xpath_fuzz.c +10 -5
  16. data/ext/makiri/glue/glue.h +14 -0
  17. data/ext/makiri/glue/ruby_doc.c +50 -24
  18. data/ext/makiri/glue/ruby_html_mutate.c +176 -66
  19. data/ext/makiri/glue/ruby_html_node.c +76 -52
  20. data/ext/makiri/glue/ruby_xml.c +60 -91
  21. data/ext/makiri/glue/ruby_xml_node.c +175 -14
  22. data/ext/makiri/makiri.c +8 -7
  23. data/ext/makiri/xml/mkr_xml.h +5 -0
  24. data/ext/makiri/xml/mkr_xml_chars.c +10 -0
  25. data/ext/makiri/xml/mkr_xml_mutate.c +228 -38
  26. data/ext/makiri/xml/mkr_xml_mutate.h +46 -8
  27. data/ext/makiri/xml/mkr_xml_node.c +21 -12
  28. data/ext/makiri/xml/mkr_xml_node.h +39 -9
  29. data/ext/makiri/xml/mkr_xml_tree.c +39 -45
  30. data/ext/makiri/xpath/mkr_css.c +19 -34
  31. data/ext/makiri/xpath/mkr_xpath.c +34 -31
  32. data/ext/makiri/xpath/mkr_xpath_eval_body.h +27 -29
  33. data/ext/makiri/xpath/mkr_xpath_funcs_body.h +29 -33
  34. data/ext/makiri/xpath/mkr_xpath_internal.h +1 -1
  35. data/ext/makiri/xpath/mkr_xpath_parse.c +125 -157
  36. data/ext/makiri/xpath/mkr_xpath_value_body.h +34 -45
  37. data/lib/makiri/attr.rb +0 -3
  38. data/lib/makiri/comment.rb +1 -3
  39. data/lib/makiri/compat_aliases.rb +1 -12
  40. data/lib/makiri/version.rb +1 -1
  41. data/script/check_c_safety_allowlist.yml +4 -10
  42. data/verify/Makefile +1 -1
  43. metadata +4 -2
  44. /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: ad4966a3bf44b4cbc01822bab034227c3c5a5c18347135bfc5cfbc13963a42eb
4
- data.tar.gz: 4669d45c028deb66343788e8adb27c72819fb78c71a3f6efa14f8c5acc07efc4
3
+ metadata.gz: c178d50564ea8152e886817e6df19628c491eb7bb1213e06ab70f6ffe8665015
4
+ data.tar.gz: 55a69b4d93c40b130581cb078ffee01dc6355cec4843fdc0f86269d7d22a4390
5
5
  SHA512:
6
- metadata.gz: a076b9c0c2f8e790d6bf433912f8f27cf1c9439de66dbcd403a010736ee26e3716531c975969ea03274bb0fdac34e4c46ab25dbc6777eb50ec753e41a0c95e90
7
- data.tar.gz: ad0d15f5fc4b6de3bf8850b21f69b0e66848c0dfe9f19b1304e9f0acde2754979506ca618a2af0449c9191052491fddc3b9ace9f226beed4b94349c48b4c09cc
6
+ metadata.gz: c87c6c98a58fe500d60f7775c088c655d3938e22cf614ed57b84d395d15229bd4dfbf5e61909c86804132d90dfdb31d62278e36ed7399c66102590c13867ca36
7
+ data.tar.gz: 2da0687466e5c2faab9d0c95d8181e529acf68b30165c9357375edb10e5dff5cd45557e7186c133fdfaf40043f7d2c2f3ee1a49d945cacea4f46c1b56b195c2c
@@ -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, os: ubuntu-latest }
67
- - { platform: aarch64-linux, os: ubuntu-24.04-arm }
68
- - { platform: arm64-darwin, os: macos-14 }
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
- if [ "$(uname)" = "Darwin" ]; then
101
- if otool -L "$bin" | grep -i 'libruby'; then
102
- echo "::error::$bin hard-links libruby; precompiled gem would not load on other Ruby installs"; exit 1
103
- fi
104
- else
105
- if readelf -d "$bin" 2>/dev/null | grep -iE 'NEEDED.*libruby'; then
106
- echo "::error::$bin has a libruby NEEDED entry; precompiled gem may not load on a static Ruby"; exit 1
107
- fi
108
- fi
109
- echo "OK: $bin has no libruby dependency"
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'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
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
+
3
43
  ## [0.6.0] - 2026-07-05
4
44
 
5
45
  ### Changed
data/Rakefile CHANGED
@@ -290,7 +290,15 @@ end
290
290
  desc "CBMC proofs over the Ruby/Lexbor-free carve-out (core + XML + XPath front; " \
291
291
  "needs cbmc; see docs/formal_verification.ja.md and verify/Makefile)"
292
292
  task :verify do
293
- sh "make -C verify smoke selftest cbmc"
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"
294
302
  end
295
303
 
296
304
  desc "Run the performance benchmark (Makiri vs Nokogiri reference)"
@@ -309,13 +317,6 @@ task "bench:xml" => :compile do
309
317
  end
310
318
  end
311
319
 
312
- desc "Run the CSS selector-query benchmark on a note.com-style SPA page (set BENCH_CARDS)"
313
- task "bench:css" => :compile do
314
- Bundler.with_unbundled_env do
315
- sh "#{FileUtils::RUBY} -Ilib bench/bench_css_query.rb"
316
- end
317
- end
318
-
319
320
  namespace :conformance do
320
321
  desc "WHATWG HTML5 parsing conformance: run html5lib-tests through Makiri"
321
322
  task html5: :compile do
@@ -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
- typedef enum {
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);
@@ -196,220 +193,6 @@ mkr_ruby_to_utf8(VALUE str)
196
193
  ECONV_INVALID_REPLACE | ECONV_UNDEF_REPLACE, Qnil);
197
194
  }
198
195
 
199
- /* rb_str_encode with no replacement flags: an undefined conversion or invalid
200
- * byte sequence RAISES (Encoding::UndefinedConversionError /
201
- * Encoding::InvalidByteSequenceError) instead of substituting U+FFFD. Run under
202
- * rb_protect so we can remap the Ruby Encoding error to Makiri::XML::SyntaxError. */
203
- static VALUE
204
- mkr_xml_strict_transcode_thunk(VALUE str)
205
- {
206
- return rb_str_encode(str, rb_enc_from_encoding(rb_utf8_encoding()), 0, Qnil);
207
- }
208
-
209
- /* --- XML 1.0 Appendix F: byte-encoding autodetection (BOM, then declaration) ---
210
- *
211
- * The leading byte-order mark, or NULL; *bom_len gets its length. UTF-32 BOMs are
212
- * checked before the UTF-16 LE BOM they share a prefix with.
213
- *
214
- * *stride / *ascii_off get the interleave geometry of the ASCII column the decl
215
- * scanner later extracts (default 1/0 for a single-byte stream). It is resolved
216
- * HERE, at the match, rather than re-derived downstream, because that derivation
217
- * needs rb_enc_find (it can autoload an encoding = a GC point) and the decl
218
- * scanner reads a borrowed RSTRING view that must not be held across one - so
219
- * the scanner is kept allocation-free until its reads are done. Each span read
220
- * of p still finishes before the rb_enc_find in the return. */
221
- static rb_encoding *
222
- mkr_xml_bom_encoding(const unsigned char *p, long len, long *bom_len, long *stride, long *ascii_off)
223
- {
224
- mkr_span_t s = mkr_span((const char *)p, (size_t)len);
225
- *bom_len = 0;
226
- *stride = 1;
227
- *ascii_off = 0;
228
- if (mkr_span_starts(&s, "\x00\x00\xFE\xFF", 4)) { *bom_len = 4; *stride = 4; *ascii_off = 3; return rb_enc_find("UTF-32BE"); }
229
- if (mkr_span_starts(&s, "\xFF\xFE\x00\x00", 4)) { *bom_len = 4; *stride = 4; *ascii_off = 0; return rb_enc_find("UTF-32LE"); }
230
- if (mkr_span_starts(&s, "\xFE\xFF", 2)) { *bom_len = 2; *stride = 2; *ascii_off = 1; return rb_enc_find("UTF-16BE"); }
231
- if (mkr_span_starts(&s, "\xFF\xFE", 2)) { *bom_len = 2; *stride = 2; *ascii_off = 0; return rb_enc_find("UTF-16LE"); }
232
- if (mkr_span_starts(&s, "\xEF\xBB\xBF", 3)) { *bom_len = 3; return rb_utf8_encoding(); }
233
- return NULL;
234
- }
235
-
236
- static int
237
- mkr_decl_ws(int c)
238
- {
239
- return c == ' ' || c == '\t' || c == '\r' || c == '\n';
240
- }
241
-
242
- /* The encoding named in the '<?xml ... encoding="NAME" ?>' declaration, or NULL.
243
- * The declaration is ASCII; for a UTF-16/32-detected document its bytes are
244
- * stride-interleaved, so the ASCII column is extracted (stride/off resolved by
245
- * the BOM matcher) before the scan, letting a BOM-vs-declaration conflict be
246
- * caught even in UTF-16.
247
- *
248
- * p is a borrowed RSTRING view, so this stays allocation-free until every read
249
- * of p is done: the stride/off geometry is passed in (rather than derived here
250
- * via rb_enc_find, which can autoload = a GC point), and the only rb_enc_find -
251
- * the final name lookup - runs after the bytes have been copied into head[]. */
252
- static rb_encoding *
253
- mkr_xml_decl_encoding(const unsigned char *p, long len, long stride, long off)
254
- {
255
- /* Extract the ASCII column (per the BOM stride) through bounded reads into
256
- * a bounded writer - neither side trusts the loop arithmetic. */
257
- mkr_span_t in = mkr_span((const char *)p, len < 0 ? 0 : (size_t)len);
258
- char head[256];
259
- mkr_spanbuf_t hw = mkr_spanbuf(head, sizeof(head));
260
- for (size_t i = (size_t)off; hw.pos < sizeof(head); i += (size_t)stride) {
261
- int c = mkr_span_at(&in, i);
262
- if (c < 0) break;
263
- mkr_spanbuf_putc(&hw, (char)c);
264
- }
265
- mkr_span_t h = mkr_span(head, hw.pos);
266
- size_t hn = hw.pos;
267
-
268
- size_t i = 0;
269
- while (mkr_decl_ws(mkr_span_at(&h, i))) i++;
270
- {
271
- mkr_span_t t = mkr_span_tail(&h, i);
272
- if (!mkr_span_starts(&t, "<?xml", 5)) return NULL;
273
- }
274
- i += 5;
275
- /* find a whitespace-introduced "encoding" before the '?>' */
276
- for (; i + 8 <= hn; i++) {
277
- if (mkr_span_at(&h, i) == '?' && mkr_span_at(&h, i + 1) == '>') return NULL; /* end of decl */
278
- mkr_span_t t = mkr_span_tail(&h, i);
279
- if (!mkr_decl_ws(mkr_span_at(&h, i - 1)) || !mkr_span_starts(&t, "encoding", 8)) continue;
280
- size_t j = i + 8;
281
- while (mkr_decl_ws(mkr_span_at(&h, j))) j++;
282
- if (mkr_span_at(&h, j) != '=') return NULL;
283
- j++;
284
- while (mkr_decl_ws(mkr_span_at(&h, j))) j++;
285
- int q = mkr_span_at(&h, j);
286
- if (q != '"' && q != '\'') return NULL;
287
- j++;
288
- size_t ns = j;
289
- while (mkr_span_at(&h, j) >= 0 && mkr_span_at(&h, j) != q) j++;
290
- if (j >= hn) return NULL;
291
- char name[64];
292
- size_t nl = j - ns;
293
- if (nl == 0 || nl >= sizeof(name)) return NULL;
294
- memcpy(name, head + ns, nl);
295
- name[nl] = '\0';
296
- return rb_enc_find(name); /* NULL for an unknown encoding name */
297
- }
298
- return NULL;
299
- }
300
-
301
- /* Two encodings agree for conflict purposes when identical, or when either is
302
- * US-ASCII (a subset of UTF-8 and the single-byte encodings). */
303
- static int
304
- mkr_xml_enc_compatible(rb_encoding *a, rb_encoding *b)
305
- {
306
- return a == b || a == rb_usascii_encoding() || b == rb_usascii_encoding();
307
- }
308
-
309
- VALUE
310
- mkr_xml_decode_input(VALUE str, size_t max_bytes)
311
- {
312
- rb_encoding *tag = rb_enc_get(str);
313
- const unsigned char *raw = (const unsigned char *)RSTRING_PTR(str);
314
- long rawlen = RSTRING_LEN(str);
315
-
316
- /* Detect the byte encoding (XML 1.0 Appendix F): a BOM wins, else the
317
- * declaration. The Ruby String's encoding is authoritative when it is a
318
- * concrete text encoding; a BOM/declaration that disagrees is a fatal
319
- * conflict. ASCII-8BIT means "raw bytes, no claimed encoding", so there the
320
- * detected encoding decodes the input (a UTF-16/Shift_JIS/BOM'd file read
321
- * with File.binread now parses). */
322
- long bom_len = 0, bom_stride = 1, bom_off = 0;
323
- rb_encoding *bom = mkr_xml_bom_encoding(raw, rawlen, &bom_len, &bom_stride, &bom_off);
324
- /* rb_enc_find inside the BOM lookup can autoload an encoding (a Ruby
325
- * allocation = a GC point), so re-borrow the bytes before reading them
326
- * again - a borrowed RSTRING pointer must not be held across one. The
327
- * interleave geometry (stride/off) is resolved by the BOM matcher and
328
- * passed through, keeping the decl scanner itself allocation-free. */
329
- raw = (const unsigned char *)RSTRING_PTR(str);
330
- rb_encoding *decl = mkr_xml_decl_encoding(raw + bom_len, rawlen - bom_len, bom_stride, bom_off);
331
- int is_binary = (tag == rb_ascii8bit_encoding());
332
-
333
- if (bom && decl && !mkr_xml_enc_compatible(bom, decl)) {
334
- rb_raise(mkr_eXmlSyntaxError,
335
- "XML encoding conflict: the byte-order mark and the encoding declaration disagree");
336
- }
337
- if (!is_binary && bom && !mkr_xml_enc_compatible(bom, tag)) {
338
- rb_raise(mkr_eXmlSyntaxError,
339
- "XML encoding conflict: the byte-order mark disagrees with the string's encoding");
340
- }
341
- if (!is_binary && decl && !mkr_xml_enc_compatible(decl, tag)) {
342
- /* A concrete String encoding is authoritative for decoding, so the
343
- * declaration is not used to transcode - but a declaration that names a
344
- * different encoding than the String is tagged with (e.g. a Shift_JIS
345
- * String declaring encoding="UTF-8") is a self-inconsistent document and
346
- * a fatal error, not a silently-ignored mismatch. */
347
- rb_raise(mkr_eXmlSyntaxError,
348
- "XML encoding conflict: the encoding declaration disagrees with the string's encoding");
349
- }
350
-
351
- rb_encoding *eff = is_binary ? (bom ? bom : (decl ? decl : rb_utf8_encoding())) : tag;
352
-
353
- /* Decode to UTF-8 (strict). UTF-8 / US-ASCII / ASCII-8BIT are already UTF-8
354
- * bytes (validated below); anything else is strict-transcoded, raising rather
355
- * than substituting U+FFFD. */
356
- VALUE s;
357
- if (eff == rb_utf8_encoding() || eff == rb_usascii_encoding() || eff == rb_ascii8bit_encoding()) {
358
- s = str;
359
- } else {
360
- VALUE in = str;
361
- if (rb_enc_get(str) != eff) { in = rb_str_dup(str); rb_enc_associate(in, eff); }
362
- int state = 0;
363
- s = rb_protect(mkr_xml_strict_transcode_thunk, in, &state);
364
- if (state != 0) {
365
- VALUE exc = rb_errinfo();
366
- rb_set_errinfo(Qnil);
367
- char msg[256];
368
- mkr_ruby_exception_message(exc, msg, sizeof msg);
369
- rb_raise(mkr_eXmlSyntaxError,
370
- "XML input could not be decoded to UTF-8: %s", msg);
371
- }
372
- }
373
-
374
- const char *ptr = RSTRING_PTR(s);
375
- long len = RSTRING_LEN(s);
376
- long off = 0;
377
- /* §4.3.3: a leading BOM is the encoding signature, not document content -
378
- * strip a U+FEFF (the transcode above turns any UTF-16/32 BOM into one). */
379
- mkr_span_t sv = mkr_span(ptr, (size_t)len);
380
- if (mkr_span_starts(&sv, "\xEF\xBB\xBF", 3)) {
381
- off = 3; len -= 3;
382
- mkr_span_skip(&sv, 3);
383
- }
384
-
385
- /* Fail closed on an over-budget input BEFORE the validation scan and the
386
- * caller's GVL-release copy (an input whose UTF-8 length exceeds the arena
387
- * budget can never parse). max_bytes == 0 disables the check (__decode). */
388
- if (max_bytes != 0 && (size_t)len > max_bytes) {
389
- rb_raise(mkr_eXmlLimitExceeded, "XML input exceeds the byte budget");
390
- }
391
-
392
- /* Strict UTF-8 validation via the shared, allocation-free core - no GC point
393
- * while `ptr` is borrowed: an embedded NUL or any invalid UTF-8 is fatal (no
394
- * U+FFFD repair - unlike the HTML mkr_utf8_sanitize path). The whole-string
395
- * `s` is consulted for the cached coderange (it covers the BOM-stripped
396
- * suffix too - the BOM is one complete UTF-8 character), while the validated
397
- * bytes are the stripped suffix `ptr + off`. */
398
- switch (mkr_text_check(s, ptr + off, (size_t)len)) {
399
- case MKR_TEXT_HAS_NUL:
400
- rb_raise(mkr_eXmlSyntaxError, "XML input must not contain a NUL byte");
401
- case MKR_TEXT_INVALID_UTF8:
402
- rb_raise(mkr_eXmlSyntaxError, "XML input must be valid UTF-8");
403
- case MKR_TEXT_OK:
404
- break;
405
- }
406
- /* Build the result through the VALUE, not the borrowed ptr (rb_str_subseq
407
- * allocates, so the ptr must not be what it copies from). */
408
- VALUE u = rb_str_subseq(s, off, len);
409
- rb_enc_associate(u, rb_utf8_encoding());
410
- return u; /* validated, UTF-8-tagged, BOM-stripped */
411
- }
412
-
413
196
  bool
414
197
  mkr_ruby_str_known_valid_utf8(VALUE str)
415
198
  {