leptris 1.9.375.0 → 1.9.377.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa27f3dd652c356d4b8b939f40aba24b30866302e3d4c3cfb7fa62c032de7415
4
- data.tar.gz: fe4921f2e63255893c97cb83bbfa845a2fe92c023c7b1a2a900794e183b30ba8
3
+ metadata.gz: eaa5ce5dfd171e7a4931f40dcd4ec8e901bd4895f20776df74daee75595e2294
4
+ data.tar.gz: d843a2dac00c737e4e203ea1375f0575fd1df0ec2b854ad0134e87bfcad3493f
5
5
  SHA512:
6
- metadata.gz: e67786d718aa5a26dcb59e67cbd8144f0de8b4c0327ee64ec6a58d0ff89e2bd1512bb54e480e2c649252011361e77f6d811afdaf7ce6f070aa4d77a940fea7f9
7
- data.tar.gz: 879fe1f675823fb7f8bd880f005abc0f1287b1f9ebd48f63bcf914d3bf326a7dc5b125071738cecb3e8228f1965c361c813c3a6d7ac474cc34ae0324d0d72f1e
6
+ metadata.gz: 3d81ecd1df498f7fd65db0531affc0a61ab9df1f79123a19b05759c77d0a64c91299e771a2b314347a3565a3f721b63685b01723f7afa21e40063eb94a2c0774
7
+ data.tar.gz: 1aa12f5eaad5f1566a9cc2b070f09db64433714ced23cdac8c16a1d34056b2f43748b45fecdbc8c590a1084e694cd8ec38549af406e8e68aee9e69d9203edec1
data/Rakefile CHANGED
@@ -8,7 +8,7 @@ RSpec::Core::RakeTask.new(:spec)
8
8
  # Pin for `rake compile` and the platform-gem builds. Keep in lockstep
9
9
  # with .github/workflows/build.yml (which calls `rake compile`) and the
10
10
  # CHANGELOG when libleptris releases.
11
- LIBLEPTRIS_VERSION = "1.9.375"
11
+ LIBLEPTRIS_VERSION = "1.9.377"
12
12
  # Vendored alongside libleptris for fn:normalize-unicode (TODO
13
13
  # .restructure/20): built per platform with a RELOCATABLE @rpath
14
14
  # install name, loaded by ffi.rb before libleptris so the
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.375.0"
4
+ VERSION = "1.9.377.0"
5
5
  end
data/vendor-src/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  Sources vendored in this gem (recompile rights; the ruby
2
2
  variant builds them at install via extconf.rb):
3
3
 
4
- - libleptris v1.9.375
4
+ - libleptris v1.9.377
5
5
  - utf8proc v2.11.0
6
6
 
7
7
  Rebuild by hand:
@@ -1,5 +1,66 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.9.377] - 2026-10-20
4
+
5
+ ### Added
6
+
7
+ - **html: verdict parity 1508 to 1532 of 1555 (PR #1797).** Five
8
+ emission slices, each grounded in html5lib's parser/tokenizer
9
+ source and gated by the #1771 corpus harness with zero want=clean
10
+ regressions: misplaced in-body caption/col/…/tr and `<frame>`
11
+ (unexpected-start-tag-ignored) plus the stray `</p>` phase codes;
12
+ after-html/after-body/after-frameset character codes
13
+ (expected-eof-but-got-char, unexpected-char-after-body,
14
+ unexpected-char-after-frameset); unterminated-comment tokenizer
15
+ codes (eof-in-comment, eof-in-comment-double-dash);
16
+ plaintext-at-EOF and the `</` tokenizer EOF codes; and a
17
+ phase-aware depth-0 stray-end-tag branch covering raw-text names,
18
+ void elements and menuitem (end-tag-after-implied-root before
19
+ html, after-body/-frameset/after-html variants, otherwise
20
+ unexpected-end-tag). Tree parity stays 1555/1555; the verdict
21
+ floor rose from 1508 to 1532.
22
+
23
+
24
+ ## [1.9.376] - 2026-10-20
25
+
26
+ ### Fixed
27
+
28
+ - **serialize: decoded entity-shaped text is escaped
29
+ unconditionally (leptris-ruby#515, PR #1793).** Parse expands
30
+ entities into text (named references that survive under
31
+ `KEEP_ENTITY_REFS` become `ENTITY_REF` nodes), but both text
32
+ emitters kept an entity lookahead that re-emitted decoded data
33
+ verbatim: `&#x41;` reparsed as `A`, and `&lt;UK&gt;` minted a
34
+ `<UK>` element that swallowed siblings. Every `&` in text is now
35
+ escaped; `ENTITY_REF` nodes still serialize as markup.
36
+ - **parse: strict loads validate the effective encoding
37
+ (leptris-ruby#514, PR #1793).** Non-RECOVER parses reject
38
+ invalid UTF-8 under XML 1.0 §4.3.3 (RFC 3629 strictness:
39
+ overlongs, surrogates, >U+10FFFF, truncation are fatal), for
40
+ documents that are UTF-8 by declaration or default. A declared
41
+ non-UTF-8 encoding is exempt (legacy encodings still transcode
42
+ through the iconv lane), and RECOVER keeps the #1660
43
+ best-effort-tree contract. The ASCII fast path scans
44
+ word-at-a-time (~14 GB/s, ~0.2% of a full parse).
45
+
46
+ ### Added
47
+
48
+ - **html: verdict parity 1446 to 1508 of 1555 (PR #1794).** Seven
49
+ emission slices, each grounded in html5lib's source and gated by
50
+ the #1771 corpus harness with zero want=clean regressions:
51
+ depth-0 stray raw-text end tags (`</script>`, `</style>`, …);
52
+ later `<html>` tokens (non-html-root); the end-tag-too-early
53
+ family (heading mismatch, implied-end crossings, and the same
54
+ rule on li/dd/dt/p implied closes); after-body/frameset phase
55
+ codes; InBody frameset, duplicate body/head, and AfterHead
56
+ out-of-my-head tags; select/table/foreign codes
57
+ (unexpected-input-in-select, hidden-input-in-table,
58
+ unexpected-html-element-in-foreign-content); and tokenizer
59
+ need-space-after-doctype. Tree parity stays 1555/1555; the
60
+ verdict floor rose from 1409 to 1508.
61
+
62
+
63
+
3
64
  ## [1.9.375] - 2026-10-20
4
65
 
5
66
  ### Added
@@ -3,7 +3,7 @@
3
3
  cmake_minimum_required(VERSION 3.20)
4
4
 
5
5
  project(leptris
6
- VERSION 1.9.375
6
+ VERSION 1.9.377
7
7
  DESCRIPTION "Fast XML parser and XPath evaluator in pure C"
8
8
  LANGUAGES C CXX
9
9
  )
@@ -838,6 +838,11 @@ LEPTRIS_API LeptrisStatus leptris_document_set_root_ex(
838
838
  /**
839
839
  * Parse XML string into document
840
840
  *
841
+ * Encoding: the input must be valid in its effective encoding. With
842
+ * no XML declaration (or a UTF-8/US-ASCII one) that is UTF-8 (XML
843
+ * 1.0 §4.3.3) and invalid byte sequences are a fatal load error. A
844
+ * declared legacy encoding is transcoded to UTF-8 before parsing.
845
+ *
841
846
  * @param xml XML string (must be valid UTF-8)
842
847
  * @param length Length of XML string in bytes
843
848
  * @param status Output status code (can be NULL)
@@ -979,6 +984,11 @@ LEPTRIS_API int leptris_rng_error_line(LeptrisRelaxNG rng, size_t i);
979
984
  LEPTRIS_API int leptris_rng_error_column(LeptrisRelaxNG rng, size_t i);
980
985
 
981
986
 
987
+ /* Parse with explicit flags. Strict (non-RECOVER) parses validate
988
+ * the input against its effective encoding (see leptris_parse_string);
989
+ * RECOVER keeps the best-effort #1660 contract and skips the fatal
990
+ * encoding check. A declared non-UTF-8 encoding is exempt from UTF-8
991
+ * validation and passes through raw in this lane. */
982
992
  LEPTRIS_API LeptrisDocument leptris_parse_string_flags(const char* xml,
983
993
  size_t length,
984
994
  LeptrisParseFlags flags,
@@ -43,6 +43,7 @@
43
43
  #include <string.h>
44
44
  #include <stdlib.h>
45
45
  #include <stdio.h>
46
+ #include <stdint.h>
46
47
 
47
48
  /* File-scope externs: engine config TLS defined in core.c (declared
48
49
  * in leptris_internal.h; this TU reads it without the umbrella) and
@@ -3415,6 +3416,123 @@ static struct leptris_document* dp_il_try(const char* xml, size_t len,
3415
3416
  return d;
3416
3417
  }
3417
3418
 
3419
+ /* ---- Load-time encoding validation (leptris-ruby#514) ----
3420
+ *
3421
+ * XML 1.0 §4.3.3: a document without a declaration is UTF-8; a
3422
+ * declared non-UTF-8 encoding is exempt (its bytes are legitimately
3423
+ * not UTF-8 — leptris_parse_string transcodes declared legacy
3424
+ * encodings through iconv before they reach this funnel). Structural
3425
+ * UTF-8 (RFC 3629) is strict: overlongs, surrogates, >U+10FFFF and
3426
+ * truncated sequences are fatal, matching libxml2's load-time
3427
+ * "Invalid bytes in character encoding". */
3428
+
3429
+ static int dp_ascii_case_eq(const char* a, const char* b, size_t n) {
3430
+ for (size_t i = 0; i < n; i++) {
3431
+ char x = a[i];
3432
+ char y = b[i];
3433
+ if (x >= 'A' && x <= 'Z') x += 32;
3434
+ if (y >= 'A' && y <= 'Z') y += 32;
3435
+ if (x != y) return 0;
3436
+ }
3437
+ return 1;
3438
+ }
3439
+
3440
+ /* 1 when the XML declaration names an encoding other than UTF-8 /
3441
+ * US-ASCII. No (or unreadable) declaration -> 0, the UTF-8 default. */
3442
+ static int dp_declares_non_utf8(const char* s, size_t len) {
3443
+ size_t i = 0;
3444
+ if (len >= 3 && (unsigned char)s[0] == 0xEF &&
3445
+ (unsigned char)s[1] == 0xBB && (unsigned char)s[2] == 0xBF) {
3446
+ i = 3;
3447
+ }
3448
+ if (len - i < 5 || memcmp(s + i, "<?xml", 5) != 0) return 0;
3449
+ if (len - i > 5) {
3450
+ char c = s[i + 5];
3451
+ if (c != ' ' && c != '\t' && c != '\r' && c != '\n' && c != '?')
3452
+ return 0;
3453
+ }
3454
+ size_t j = i + 5;
3455
+ size_t end = j;
3456
+ while (end + 1 < len && !(s[end] == '?' && s[end + 1] == '>')) end++;
3457
+ if (end + 1 >= len) return 0;
3458
+ while (j < end) {
3459
+ if (end - j >= 8 && memcmp(s + j, "encoding", 8) == 0 &&
3460
+ (j == i + 5 || s[j - 1] == ' ' || s[j - 1] == '\t' ||
3461
+ s[j - 1] == '\r' || s[j - 1] == '\n')) {
3462
+ size_t k = j + 8;
3463
+ while (k < end && (s[k] == ' ' || s[k] == '\t' ||
3464
+ s[k] == '\r' || s[k] == '\n')) k++;
3465
+ if (k < end && s[k] == '=') {
3466
+ k++;
3467
+ while (k < end && (s[k] == ' ' || s[k] == '\t' ||
3468
+ s[k] == '\r' || s[k] == '\n')) k++;
3469
+ if (k < end && (s[k] == '"' || s[k] == '\'')) {
3470
+ char q = s[k++];
3471
+ size_t v = k;
3472
+ while (k < end && s[k] != q) k++;
3473
+ size_t vl = k - v;
3474
+ if (vl == 5 && dp_ascii_case_eq(s + v, "UTF-8", 5)) return 0;
3475
+ if (vl == 4 && dp_ascii_case_eq(s + v, "UTF8", 4)) return 0;
3476
+ if (vl == 8 && dp_ascii_case_eq(s + v, "US-ASCII", 8))
3477
+ return 0;
3478
+ return 1;
3479
+ }
3480
+ }
3481
+ }
3482
+ j++;
3483
+ }
3484
+ return 0;
3485
+ }
3486
+
3487
+ static int dp_utf8_valid(const char* s, size_t len) {
3488
+ const unsigned char* p = (const unsigned char*)s;
3489
+ size_t i = 0;
3490
+ /* ASCII fast path: 8 bytes per iteration. The DFA below runs
3491
+ * only when a high byte is present; ordinary documents pay one
3492
+ * memory-bandwidth pass. */
3493
+ while (i + 8 <= len) {
3494
+ uint64_t w;
3495
+ memcpy(&w, p + i, 8);
3496
+ if (w & UINT64_C(0x8080808080808080)) break;
3497
+ i += 8;
3498
+ }
3499
+ while (i < len && p[i] < 0x80) i++;
3500
+ while (i < len) {
3501
+ unsigned c = p[i];
3502
+ if (c < 0x80) {
3503
+ i++;
3504
+ continue;
3505
+ }
3506
+ size_t n;
3507
+ unsigned lo = 0x80;
3508
+ unsigned hi = 0xBF;
3509
+ if (c >= 0xC2 && c <= 0xDF) {
3510
+ n = 1;
3511
+ } else if (c >= 0xE0 && c <= 0xEF) {
3512
+ n = 2;
3513
+ if (c == 0xE0) lo = 0xA0;
3514
+ else if (c == 0xED) hi = 0x9F;
3515
+ } else if (c >= 0xF0 && c <= 0xF4) {
3516
+ n = 3;
3517
+ if (c == 0xF0) lo = 0x90;
3518
+ else if (c == 0xF4) hi = 0x8F;
3519
+ } else {
3520
+ return 0;
3521
+ }
3522
+ if (len - i <= n) return 0;
3523
+ if (p[i + 1] < lo || p[i + 1] > hi) return 0;
3524
+ for (size_t k = 2; k <= n; k++) {
3525
+ if (p[i + k] < 0x80 || p[i + k] > 0xBF) return 0;
3526
+ }
3527
+ i += n + 1;
3528
+ }
3529
+ return 1;
3530
+ }
3531
+
3532
+ static int dp_input_encoding_ok(const char* s, size_t len) {
3533
+ return dp_declares_non_utf8(s, len) || dp_utf8_valid(s, len);
3534
+ }
3535
+
3418
3536
  /* Public: copy the input then parse (standard path).
3419
3537
  *
3420
3538
  * The copy and the arena-sizing pre-scan are ONE fused SIMD pass
@@ -3424,6 +3542,7 @@ static struct leptris_document* dp_il_try(const char* xml, size_t len,
3424
3542
  * arena holds only nodes and strings. */
3425
3543
  struct leptris_document* direct_parse(const char* xml, size_t len) {
3426
3544
  if (!xml || len == 0) return NULL;
3545
+ if (!dp_input_encoding_ok(xml, len)) return NULL;
3427
3546
  {
3428
3547
  struct leptris_document* d = dp_il_try(xml, len, 0, 0, 0);
3429
3548
  if (d) return d;
@@ -3435,6 +3554,12 @@ struct leptris_document* direct_parse(const char* xml, size_t len) {
3435
3554
  struct leptris_document* direct_parse_flags(const char* xml, size_t len,
3436
3555
  unsigned parse_flags) {
3437
3556
  if (!xml || len == 0) return NULL;
3557
+ /* RECOVER keeps the #1660 best-effort contract (never NULL for
3558
+ * malformed input); the fatal encoding check is strict-lane. */
3559
+ if (!(parse_flags & LEPTRIS_PARSE_RECOVER) &&
3560
+ !dp_input_encoding_ok(xml, len)) {
3561
+ return NULL;
3562
+ }
3438
3563
  /* The interleaved lane has no recovery support (#1660) — RECOVER
3439
3564
  * parses take the default lane. */
3440
3565
  if (!(parse_flags & LEPTRIS_PARSE_RECOVER)) {
@@ -3462,6 +3587,7 @@ struct leptris_document* direct_parse_flags(const char* xml, size_t len,
3462
3587
  /* Public: parse a caller-owned writable buffer without copying. */
3463
3588
  struct leptris_document* direct_parse_inplace(char* buf, size_t len) {
3464
3589
  if (!buf || len == 0) return NULL;
3590
+ if (!dp_input_encoding_ok(buf, len)) return NULL;
3465
3591
  buf[len] = '\0'; /* Ensure NUL termination */
3466
3592
  return direct_parse_internal(buf, len, 0, 0, 0, 0, 0, 0);
3467
3593
  }
@@ -2767,6 +2767,9 @@ typedef struct {
2767
2767
  * switch, so the divert stays armed. Re-armed by each
2768
2768
  * </body>/</html> token. */
2769
2769
  int after_body_done;
2770
+ /* Verdict bookkeeping only (never gates tree placement): the
2771
+ * AfterBody startTagOther error has fired for this phase. */
2772
+ int after_body_diag_done;
2770
2773
  /* #659 two-mode split: leptris_parse_html_string is the WHATWG
2771
2774
  * engine (full "in head" set: script/style/noscript/template
2772
2775
  * ... lift into the implied head); the new
@@ -3299,6 +3302,53 @@ static int h_is_formatting(const char* n) {
3299
3302
  return h_fmt4_lut[h_tag_id(n)];
3300
3303
  }
3301
3304
 
3305
+ /* Raw-text tokenizer targets. In body they have no dedicated end-tag
3306
+ * handler: html5lib's endTagOther walks the stack and reports
3307
+ * unexpected-end-tag at the first special node, so every such token
3308
+ * reaching the generic walk is stray (the tokenizer consumes the
3309
+ * matching terminator when the element is actually open). */
3310
+ static int h_is_rawtext_name(const char* n) {
3311
+ return strcmp(n, "script") == 0 || strcmp(n, "style") == 0 ||
3312
+ strcmp(n, "title") == 0 || strcmp(n, "textarea") == 0 ||
3313
+ strcmp(n, "xmp") == 0 || strcmp(n, "iframe") == 0 ||
3314
+ strcmp(n, "noembed") == 0 || strcmp(n, "noframes") == 0;
3315
+ }
3316
+
3317
+ /* html5lib generateImpliedEndTags set (§13.2.4.5): closing a block
3318
+ * or list item may pop these silently; anything else above the
3319
+ * target makes the close "too early". */
3320
+ static int h_is_implied_end_name(const char* n) {
3321
+ return strcmp(n, "p") == 0 || strcmp(n, "li") == 0 ||
3322
+ strcmp(n, "dd") == 0 || strcmp(n, "dt") == 0 ||
3323
+ strcmp(n, "option") == 0 || strcmp(n, "optgroup") == 0 ||
3324
+ strcmp(n, "rb") == 0 || strcmp(n, "rt") == 0 ||
3325
+ strcmp(n, "rtc") == 0 || strcmp(n, "rp") == 0;
3326
+ }
3327
+
3328
+ /* End tags html5lib implements with endTagBlock / endTagListItem /
3329
+ * endTagAppletMarqueeObject — the handlers that raise
3330
+ * end-tag-too-early when the target is not the current node after
3331
+ * implied-end generation. */
3332
+ static int h_is_tooearly_target(const char* n) {
3333
+ return strcmp(n, "p") == 0 || strcmp(n, "li") == 0 ||
3334
+ strcmp(n, "dd") == 0 || strcmp(n, "dt") == 0 ||
3335
+ strcmp(n, "applet") == 0 || strcmp(n, "marquee") == 0 ||
3336
+ strcmp(n, "object") == 0 ||
3337
+ strcmp(n, "address") == 0 || strcmp(n, "article") == 0 ||
3338
+ strcmp(n, "aside") == 0 || strcmp(n, "blockquote") == 0 ||
3339
+ strcmp(n, "button") == 0 || strcmp(n, "center") == 0 ||
3340
+ strcmp(n, "details") == 0 || strcmp(n, "dialog") == 0 ||
3341
+ strcmp(n, "dir") == 0 || strcmp(n, "div") == 0 ||
3342
+ strcmp(n, "dl") == 0 || strcmp(n, "fieldset") == 0 ||
3343
+ strcmp(n, "figcaption") == 0 || strcmp(n, "figure") == 0 ||
3344
+ strcmp(n, "footer") == 0 || strcmp(n, "header") == 0 ||
3345
+ strcmp(n, "hgroup") == 0 || strcmp(n, "listing") == 0 ||
3346
+ strcmp(n, "main") == 0 || strcmp(n, "menu") == 0 ||
3347
+ strcmp(n, "nav") == 0 || strcmp(n, "ol") == 0 ||
3348
+ strcmp(n, "pre") == 0 || strcmp(n, "section") == 0 ||
3349
+ strcmp(n, "summary") == 0 || strcmp(n, "ul") == 0;
3350
+ }
3351
+
3302
3352
  /* WHATWG "special" category (13.2.4.2) — the adoption agency's
3303
3353
  * furthest-block candidates. */
3304
3354
  /* Generated tag classifier (#1218 hot path): one first-char
@@ -3985,6 +4035,13 @@ static void h_append(HBuilder* b, LeptrisNodeRef n) {
3985
4035
  if (*q != ' ' && *q != '\t' && *q != '\n' &&
3986
4036
  *q != '\r') { ws = 0; break; }
3987
4037
  if (!ws) {
4038
+ /* AfterFrameset processCharacters reports
4039
+ * unexpected-char-after-frameset; the after-</html>
4040
+ * flavor is reported by the expected-eof block below. */
4041
+ if (!b->after_html)
4042
+ h_diag(b, "unexpected-char-after-frameset",
4043
+ b->err_line_memo_off,
4044
+ "characters after </frameset>");
3988
4045
  /* Every whitespace character inserts, every
3989
4046
  * non-whitespace one drops (tests2:7: " te st" ->
3990
4047
  * " "). Filter in place, like after-frameset. */
@@ -4007,7 +4064,20 @@ static void h_append(HBuilder* b, LeptrisNodeRef n) {
4007
4064
  for (const char* q = tt; *q; q++)
4008
4065
  if (*q != ' ' && *q != '\t' && *q != '\n' &&
4009
4066
  *q != '\r') { nonws = 1; break; }
4010
- if (nonws) b->after_body_done = 1;
4067
+ if (nonws) {
4068
+ b->after_body_done = 1;
4069
+ /* AfterAfterBody/AfterAfterFrameset processCharacters
4070
+ * report expected-eof-but-got-char; AfterBody reports
4071
+ * unexpected-char-after-body (tests2:55, tests19:41). */
4072
+ if (b->after_html)
4073
+ h_diag(b, "expected-eof-but-got-char",
4074
+ b->err_line_memo_off,
4075
+ "characters after </html>");
4076
+ else
4077
+ h_diag(b, "unexpected-char-after-body",
4078
+ b->err_line_memo_off,
4079
+ "characters after </body>");
4080
+ }
4011
4081
  }
4012
4082
  /* Doc-start mixed run: the ws prefix is "before html"
4013
4083
  * whitespace and drops (doctype01:31, tests19:79). Tightest
@@ -4133,6 +4203,17 @@ static void h_append(HBuilder* b, LeptrisNodeRef n) {
4133
4203
  b->open_ns[b->depth - 2] == H_NS_HTML &&
4134
4204
  b->open_id[b->depth - 2] == h_id_table)
4135
4205
  tbl_ctx = 1;
4206
+ /* InTable startTagInput with type=hidden: html5lib reports
4207
+ * unexpected-hidden-input-in-table and inserts AT the spot
4208
+ * (no fostering — h_fosterable exempts it, tests7:16-20). */
4209
+ if (b->whatwg_foster && tbl_ctx && b->input_hidden_tag &&
4210
+ leptris_node_get_type(n) == LEPTRIS_NODE_TYPE_ELEMENT &&
4211
+ h_tag_id(leptris_element_name((LeptrisElement)n)) ==
4212
+ h_id_input) {
4213
+ h_diag(b, "unexpected-hidden-input-in-table",
4214
+ b->err_line_memo_off,
4215
+ "hidden input inserted in table context");
4216
+ }
4136
4217
  /* #659 foster (WHATWG only): text/elements in table context
4137
4218
  * go before the nearest open table in ITS parent. */
4138
4219
  if (b->whatwg_foster && tbl_ctx && h_fosterable(b, n)) {
@@ -5736,12 +5817,25 @@ static LeptrisDocument html_parse_shared(
5736
5817
  size_t clen = cclose
5737
5818
  ? (size_t)(ce - cs)
5738
5819
  : (size_t)(end - cs);
5739
- if (!cclose)
5820
+ if (!cclose) {
5821
+ /* Tokenizer EOF states: a trailing "--" is
5822
+ * comment-end-double-dash (tests2:59), anything
5823
+ * else is a plain eof-in-comment (tests2:35/43). */
5824
+ if (b.whatwg) {
5825
+ int dd = clen >= 2 && cs[clen - 1] == '-' &&
5826
+ cs[clen - 2] == '-';
5827
+ h_diag(&b,
5828
+ dd ? "eof-in-comment-double-dash"
5829
+ : "eof-in-comment",
5830
+ (size_t)(end - b.input_base),
5831
+ "comment reaches end of input");
5832
+ }
5740
5833
  /* Unterminated at EOF: html5lib drops the
5741
5834
  * trailing dash run (tests2:59: <!--x-- keeps
5742
5835
  * data "x"). */
5743
5836
  while (clen > 0 && cs[clen - 1] == '-')
5744
5837
  clen--;
5838
+ }
5745
5839
  p = cclose ? ce + cclose : end;
5746
5840
  LeptrisCommentNode* c = leptris_comment_create(
5747
5841
  cs, clen, b.pool);
@@ -5851,6 +5945,13 @@ static LeptrisDocument html_parse_shared(
5851
5945
  }
5852
5946
  if (is_dt) {
5853
5947
  q += 7;
5948
+ /* Tokenizer: need-space-after-doctype when the
5949
+ * keyword is not followed by whitespace
5950
+ * (doctype01:3, tests2:26-28). */
5951
+ if (b.whatwg && q < end && *q != '>' && !h_is_ws(*q))
5952
+ h_diag(&b, "need-space-after-doctype",
5953
+ (size_t)(q - b.input_base),
5954
+ "no space after <!doctype");
5854
5955
  while (q < end && h_is_ws(*q)) q++;
5855
5956
  const char* nstart = q;
5856
5957
  while (q < end && !h_is_ws(*q) && *q != '>') q++;
@@ -6043,6 +6144,18 @@ static LeptrisDocument html_parse_shared(
6043
6144
  {
6044
6145
  const char* ns2 = p + 2;
6045
6146
  if (b.whatwg && ns2 >= end) {
6147
+ /* Tokenizer: EOF in the tag-open state emits
6148
+ * expected-closing-tag-but-got-eof and the two
6149
+ * characters as text (tests1:38); in the
6150
+ * initial mode that text also reports the
6151
+ * missing doctype. */
6152
+ h_diag(&b, "expected-closing-tag-but-got-eof",
6153
+ (size_t)(p - b.input_base),
6154
+ "end tag at end of input");
6155
+ if (!b.left_initial && !doc->doctype)
6156
+ h_diag(&b, "expected-doctype-but-got-chars",
6157
+ (size_t)(p - b.input_base),
6158
+ "characters in the initial mode");
6046
6159
  if (text < p) {
6047
6160
  LeptrisTextNode* t =
6048
6161
  h_text_node(&b, text, p);
@@ -6191,6 +6304,41 @@ static LeptrisDocument html_parse_shared(
6191
6304
  text = p;
6192
6305
  continue;
6193
6306
  }
6307
+ /* Stray end tags with no open stack. The raw-text family
6308
+ * completes during start-tag handling; void elements and
6309
+ * menuitem have no in-body end handler. html5lib's
6310
+ * endTagOther reports by phase: before-html is
6311
+ * end-tag-after-implied-root; AfterBody/AfterFrameset
6312
+ * and AfterAfter* have their own codes; InHead/InBody/
6313
+ * AfterHead report unexpected-end-tag. */
6314
+ if (b.whatwg && nlen && b.depth == 0) {
6315
+ char rn[16];
6316
+ size_t rl = nlen < sizeof(rn) - 1 ? nlen : sizeof(rn) - 1;
6317
+ for (size_t i = 0; i < rl; i++) rn[i] = h_lower(ns[i]);
6318
+ rn[rl] = 0;
6319
+ int stray = h_is_rawtext_name(rn) ||
6320
+ (h_is_void(rn) && strcmp(rn, "br") != 0) ||
6321
+ strcmp(rn, "menuitem") == 0;
6322
+ if (stray) {
6323
+ const char* code = "unexpected-end-tag";
6324
+ if (b.after_html)
6325
+ code = "expected-eof-but-got-end-tag";
6326
+ else if (b.after_body)
6327
+ code = "unexpected-end-tag-after-body";
6328
+ else if (b.frameset && b.after_frameset)
6329
+ code = "unexpected-end-tag-after-frameset";
6330
+ else if (b.frameset)
6331
+ code = "unexpected-end-tag-in-frameset";
6332
+ else if (!b.top_tail && !b.head_tag_seen &&
6333
+ !b.body_seen && !b.body_tag_seen)
6334
+ code = "end-tag-after-implied-root";
6335
+ h_diag(&b, code, (size_t)(p - b.input_base),
6336
+ "</%s> has no matching open element", rn);
6337
+ p = q;
6338
+ text = p;
6339
+ continue;
6340
+ }
6341
+ }
6194
6342
  if (nlen && (b.depth > 0 ||
6195
6343
  (b.whatwg &&
6196
6344
  ((nlen == 4 &&
@@ -6206,6 +6354,19 @@ static LeptrisDocument html_parse_shared(
6206
6354
  for (size_t i = 0; i < cl; i++)
6207
6355
  lname[i] = h_lower(ns[i]);
6208
6356
  lname[cl] = 0;
6357
+ /* InBody endTagOther: a VOID element has no end
6358
+ * handler, so a stray end tag reports
6359
+ * unexpected-end-tag (tests25:15-20: </keygen>).
6360
+ * </br> is endTagBr and acts as <br> above. */
6361
+ if (b.whatwg && h_is_void(lname) &&
6362
+ strcmp(lname, "br") != 0) {
6363
+ h_diag(&b, "unexpected-end-tag",
6364
+ (size_t)(p - b.input_base),
6365
+ "</%s> is a void element", lname);
6366
+ p = q;
6367
+ text = p;
6368
+ continue;
6369
+ }
6209
6370
  if (!h_is_void(lname)) {
6210
6371
  if (b.whatwg &&
6211
6372
  strcmp(lname, "menuitem") == 0) {
@@ -6274,6 +6435,29 @@ static LeptrisDocument html_parse_shared(
6274
6435
  const char* hn =
6275
6436
  leptris_element_name(b.open[d - 1]);
6276
6437
  if (hn && h_is_heading(hn)) {
6438
+ /* html5lib endTagHeading: after the
6439
+ * implied-end pass, a current
6440
+ * heading of a different name makes
6441
+ * the close too early (tests19:24:
6442
+ * </h2> over an open h3), and so
6443
+ * does a non-implied element above
6444
+ * the heading. */
6445
+ int early = strcmp(hn, lname) != 0;
6446
+ for (size_t k = d; k < b.depth; k++) {
6447
+ const char* an2 = leptris_element_name(
6448
+ b.open[k]);
6449
+ if (an2 &&
6450
+ b.open_ns[k] == H_NS_HTML &&
6451
+ !h_is_implied_end_name(an2)) {
6452
+ early = 1;
6453
+ break;
6454
+ }
6455
+ }
6456
+ if (early)
6457
+ h_diag(&b, "end-tag-too-early",
6458
+ (size_t)(p - b.input_base),
6459
+ "</%s> closes <%s> early",
6460
+ lname, hn);
6277
6461
  h_pop_to(&b, d - 1);
6278
6462
  /* Old-suite rule (tests1:21/91):
6279
6463
  * </table> also closes open
@@ -6314,7 +6498,23 @@ static LeptrisDocument html_parse_shared(
6314
6498
  text = p;
6315
6499
  continue;
6316
6500
  }
6501
+ /* No heading in scope: html5lib still checks
6502
+ * the current node, which is never the token
6503
+ * (tests19:23: </h3> after <p>). */
6504
+ h_diag(&b, "end-tag-too-early",
6505
+ (size_t)(p - b.input_base),
6506
+ "</%s> with no open heading", lname);
6317
6507
  }
6508
+ /* InHead/AfterHead endTagOther: a </p> before
6509
+ * body content is dropped with unexpected-end-tag
6510
+ * and inserts nothing (tests19:3). */
6511
+ if (b.whatwg && strcmp(lname, "p") == 0 &&
6512
+ !b.body_seen && (b.head_tag_seen ||
6513
+ b.head_end_seen) &&
6514
+ !b.frameset && !b.after_body && !b.after_html)
6515
+ h_diag(&b, "unexpected-end-tag",
6516
+ (size_t)(p - b.input_base),
6517
+ "</p> outside the body phase");
6318
6518
  /* #659 13.2.6.4.17: </p> with no p in button
6319
6519
  * scope inserts an EMPTY <p> and closes it
6320
6520
  * (tests1:29) — the stray tag still leaves a
@@ -6342,6 +6542,13 @@ static LeptrisDocument html_parse_shared(
6342
6542
  break;
6343
6543
  }
6344
6544
  if (!p_open) {
6545
+ /* InBody endTagP: no p in button scope
6546
+ * is a parse error even though the
6547
+ * empty p is still inserted
6548
+ * (tests2:60). */
6549
+ h_diag(&b, "unexpected-end-tag",
6550
+ (size_t)(p - b.input_base),
6551
+ "</p> with no p in scope");
6345
6552
  LeptrisElement pe =
6346
6553
  h_open_element(&b, "p");
6347
6554
  (void)pe;
@@ -6623,7 +6830,14 @@ static LeptrisDocument html_parse_shared(
6623
6830
  strcmp(lname, "dd") == 0 ||
6624
6831
  strcmp(lname, "dt") == 0) &&
6625
6832
  (h_ieq_raw(on, "ul") ||
6626
- h_ieq_raw(on, "ol")));
6833
+ h_ieq_raw(on, "ol"))) ||
6834
+ /* html5lib endTagOther: a
6835
+ * raw-text name has no
6836
+ * in-body handler, so the
6837
+ * walk errors at the first
6838
+ * special node. */
6839
+ (h_is_rawtext_name(lname) &&
6840
+ h_is_special_ww(on));
6627
6841
  }
6628
6842
  if (fenced) {
6629
6843
  /* #1771: html5lib's walk reports
@@ -6677,6 +6891,27 @@ static LeptrisDocument html_parse_shared(
6677
6891
  (int)(d - 1) <= ti)
6678
6892
  break;
6679
6893
  }
6894
+ /* html5lib endTagBlock / endTagListItem /
6895
+ * endTagAppletMarqueeObject: after the
6896
+ * implied-end pass, a non-implied element
6897
+ * above the target makes the close too
6898
+ * early (tests3:11: </pre> over <div>). */
6899
+ if (b.whatwg && b.open_ns[d - 1] == H_NS_HTML &&
6900
+ h_is_tooearly_target(lname)) {
6901
+ for (size_t k = d; k < b.depth; k++) {
6902
+ const char* an2 =
6903
+ leptris_element_name(b.open[k]);
6904
+ if (an2 &&
6905
+ b.open_ns[k] == H_NS_HTML &&
6906
+ !h_is_implied_end_name(an2)) {
6907
+ h_diag(&b, "end-tag-too-early",
6908
+ (size_t)(p - b.input_base),
6909
+ "</%s> closes over <%s>",
6910
+ lname, an2);
6911
+ break;
6912
+ }
6913
+ }
6914
+ }
6680
6915
  h_pop_to(&b, d - 1);
6681
6916
  /* Old-suite </table> rule: closes
6682
6917
  * formatting BELOW the table - only
@@ -6977,6 +7212,47 @@ static LeptrisDocument html_parse_shared(
6977
7212
  while (q < end && !h_is_ws(*q) && *q != '>' && *q != '/') q++;
6978
7213
  size_t nlen = (size_t)(q - ns);
6979
7214
  char* name = h_pooled_lower(b.pool, ns, nlen);
7215
+ if (name && b.whatwg && strcmp(name, "html") != 0) {
7216
+ if (b.after_html &&
7217
+ !(b.frameset && strcmp(name, "noframes") == 0)) {
7218
+ /* AfterAfterBody / AfterAfterFrameset startTagOther
7219
+ * (only html — and noframes in the frameset flavor —
7220
+ * is accepted). */
7221
+ h_diag(&b, "expected-eof-but-got-start-tag",
7222
+ (size_t)(p - b.input_base),
7223
+ "<%s> after </html>", name);
7224
+ } else if (b.after_body && !b.after_body_done &&
7225
+ !b.frameset && !b.after_body_diag_done) {
7226
+ /* AfterBody startTagOther: the first non-html start
7227
+ * tag after </body> errors and moves the phase back
7228
+ * to in-body (tests15:4/6, tests9:21, tests10:20).
7229
+ * Bookkeeping is diagnostic-only: after_body_done
7230
+ * also gates comment placement. */
7231
+ h_diag(&b, "unexpected-start-tag-after-body",
7232
+ (size_t)(p - b.input_base),
7233
+ "<%s> after </body>", name);
7234
+ b.after_body_diag_done = 1;
7235
+ }
7236
+ /* AfterHead startTagFromHead: a head-only start tag in
7237
+ * the window after </head> and before body content is
7238
+ * processed in head but reported out-of-my-head
7239
+ * (tests7:3/4). */
7240
+ if (b.head_end_seen && !b.body_seen && !b.body_tag_seen &&
7241
+ !b.frameset && !b.after_body &&
7242
+ (strcmp(name, "base") == 0 ||
7243
+ strcmp(name, "basefont") == 0 ||
7244
+ strcmp(name, "bgsound") == 0 ||
7245
+ strcmp(name, "link") == 0 ||
7246
+ strcmp(name, "meta") == 0 ||
7247
+ strcmp(name, "noframes") == 0 ||
7248
+ strcmp(name, "script") == 0 ||
7249
+ strcmp(name, "style") == 0 ||
7250
+ strcmp(name, "title") == 0)) {
7251
+ h_diag(&b, "unexpected-start-tag-out-of-my-head",
7252
+ (size_t)(p - b.input_base),
7253
+ "<%s> after </head>", name);
7254
+ }
7255
+ }
6980
7256
  if (!name) goto done;
6981
7257
  /* #659 (13.2.6.4.7): <image> is renamed <img> and
6982
7258
  * reprocessed (tests1:90). */
@@ -7070,6 +7346,13 @@ static LeptrisDocument html_parse_shared(
7070
7346
  if (t) h_append(&b, (LeptrisNodeRef)t);
7071
7347
  }
7072
7348
  if (strcmp(name, "body") == 0) {
7349
+ /* InBody startTagBody always reports
7350
+ * unexpected-start-tag for a second <body>
7351
+ * (tests2:16); the first one is the phase entry. */
7352
+ if (b.whatwg && (b.body_tag_seen || b.body_seen))
7353
+ h_diag(&b, "unexpected-start-tag",
7354
+ (size_t)(p - b.input_base),
7355
+ "<body> after the body exists");
7073
7356
  b.body_tag_seen = 1;
7074
7357
  if (b.whatwg) b.frameset_ok = 0;
7075
7358
  /* Only the FIRST structural <body> marks the
@@ -7081,6 +7364,15 @@ static LeptrisDocument html_parse_shared(
7081
7364
  b.lift_boundary = b.top_tail;
7082
7365
  }
7083
7366
  } else {
7367
+ /* InBody/AfterHead startTagHead: any <head> after
7368
+ * the head phase started is an unexpected start tag
7369
+ * (tests6:1: </head> <head>). */
7370
+ if (b.whatwg &&
7371
+ (b.head_tag_seen || b.head_end_seen ||
7372
+ b.body_tag_seen || b.body_seen))
7373
+ h_diag(&b, "unexpected-start-tag",
7374
+ (size_t)(p - b.input_base),
7375
+ "<head> after the head phase");
7084
7376
  /* Before-head ends here: later comments are head
7085
7377
  * content; earlier ones stay html-prefix children. */
7086
7378
  b.head_tag_seen = 1;
@@ -7109,18 +7401,18 @@ static LeptrisDocument html_parse_shared(
7109
7401
  /* frameset is NOT in the 13.2.6.5 breakout list: inside
7110
7402
  * foreign content it is a plain foreign element
7111
7403
  * (plain-text-unsafe:16-23). */
7112
- /* #1771: html5lib's verdict — a <frameset> token that
7113
- * reaches the body phase after body content is
7114
- * unexpected-start-tag whether it then converts or is
7115
- * ignored (webkit01:51); head-phase converters and
7116
- * in-template framesets stay clean. body_seen is not
7117
- * set by the frameset token itself (it is in the
7118
- * head-only list), so it reads PRIOR content only. */
7119
- if (b.body_seen && !b.frameset &&
7404
+ /* #1771: html5lib's InBody startTagFrameset always
7405
+ * reports unexpected-start-tag — the frameset either
7406
+ * converts (body element absent, frameset-ok set) or is
7407
+ * ignored. frameset_ok==0 marks the InBody phase: any
7408
+ * body tag/content (or a frameset-ok-clearing start)
7409
+ * has already been seen. AfterHead converters keep
7410
+ * frameset_ok==1 and stay clean (webkit01:51). */
7411
+ if (!b.frameset_ok && !b.frameset &&
7120
7412
  h_template_idx(&b) < 0)
7121
7413
  h_diag(&b, "unexpected-start-tag",
7122
7414
  (size_t)(p - b.input_base),
7123
- "frameset after body content");
7415
+ "frameset in the body phase");
7124
7416
  /* depth<=1: at most the (explicit or synthesized) html
7125
7417
  * element is open; frameset-ok is the spec gate and the
7126
7418
  * body is removed wholesale when it converts. */
@@ -7195,6 +7487,20 @@ static LeptrisDocument html_parse_shared(
7195
7487
  strcmp(name, "frame") != 0 &&
7196
7488
  strcmp(name, "noframes") != 0 &&
7197
7489
  strcmp(name, "html") != 0) {
7490
+ /* html5lib phase verdicts for the dropped token (the
7491
+ * after-</html> flavor is emitted at the start-tag
7492
+ * dispatch): AfterFrameset names its own code; inside an
7493
+ * open frameset it is InFrameset's. */
7494
+ if (!b.after_html) {
7495
+ if (b.after_frameset)
7496
+ h_diag(&b, "unexpected-start-tag-after-frameset",
7497
+ (size_t)(p - b.input_base),
7498
+ "<%s> after </frameset>", name);
7499
+ else
7500
+ h_diag(&b, "unexpected-start-tag-in-frameset",
7501
+ (size_t)(p - b.input_base),
7502
+ "<%s> inside <frameset>", name);
7503
+ }
7198
7504
  if (text < p) {
7199
7505
  LeptrisTextNode* t = h_text_node(&b, text, p);
7200
7506
  if (t) {
@@ -7329,6 +7635,12 @@ static LeptrisDocument html_parse_shared(
7329
7635
  (strcmp(name, "input") == 0 ||
7330
7636
  strcmp(name, "keygen") == 0 ||
7331
7637
  strcmp(name, "textarea") == 0)) {
7638
+ /* InSelect startTagInput (shared by input/keygen/
7639
+ * textarea) reports before popping the select
7640
+ * (tests7:14). */
7641
+ h_diag(&b, "unexpected-input-in-select",
7642
+ (size_t)(p - b.input_base),
7643
+ "<%s> inside <select>", name);
7332
7644
  for (size_t d2 = b.depth; d2 > 0; d2--)
7333
7645
  if (strcmp(leptris_element_name(b.open[d2 - 1]),
7334
7646
  "select") == 0) {
@@ -7383,6 +7695,17 @@ static LeptrisDocument html_parse_shared(
7383
7695
  continue;
7384
7696
  }
7385
7697
 
7698
+ /* html5lib startTagHtml: an <html> token processed once the
7699
+ * html/head/body phases exist is not the first start tag —
7700
+ * parse error non-html-root, then the attributes merge onto
7701
+ * the root (tests14:3/4/6, tests19:37/38/88, tests2:52-54). */
7702
+ if (b.whatwg && strcmp(name, "html") == 0 &&
7703
+ (b.html_seen || b.head_tag_seen || b.body_tag_seen ||
7704
+ b.after_body || b.body_seen)) {
7705
+ h_diag(&b, "non-html-root",
7706
+ (size_t)(p - b.input_base),
7707
+ "<html> after the root element exists");
7708
+ }
7386
7709
  /* 13.2.6.3: an <html> start tag inside content (never
7387
7710
  * seen yet, deep in a table/foreign subtree) merges attrs
7388
7711
  * and is ignored - no element (domjs-unsafe:36:
@@ -7460,6 +7783,13 @@ static LeptrisDocument html_parse_shared(
7460
7783
  }
7461
7784
  }
7462
7785
  if (!tbl_ctx) {
7786
+ /* InBody startTagMisplaced: caption/col/colgroup/
7787
+ * head/tbody/td/tfoot/th/thead/tr in body is a
7788
+ * parse error and the token is ignored
7789
+ * (tests25:7 <body><col>A). */
7790
+ h_diag(&b, "unexpected-start-tag-ignored",
7791
+ (size_t)(p - b.input_base),
7792
+ "<%s> ignored in the body phase", name);
7463
7793
  /* The TOKEN is ignored - surrounding character
7464
7794
  * tokens keep flowing into body (the html-merge
7465
7795
  * flush pattern). */
@@ -7478,6 +7808,20 @@ static LeptrisDocument html_parse_shared(
7478
7808
  * an ignored <frameset> vanishes). */
7479
7809
  if (b.whatwg && strcmp(name, "frame") == 0 &&
7480
7810
  (!b.frameset || b.after_frameset)) {
7811
+ /* InBody reports the frame as a misplaced start tag
7812
+ * (tests25:10); the after-</frameset> phase names its
7813
+ * own code (the after-</html> phase is reported at the
7814
+ * start-tag dispatch). */
7815
+ if (!b.after_html) {
7816
+ if (!b.frameset)
7817
+ h_diag(&b, "unexpected-start-tag-ignored",
7818
+ (size_t)(p - b.input_base),
7819
+ "<frame> ignored in the body phase");
7820
+ else
7821
+ h_diag(&b, "unexpected-start-tag-after-frameset",
7822
+ (size_t)(p - b.input_base),
7823
+ "<frame> after </frameset>");
7824
+ }
7481
7825
  while (q < end && *q != '>') q++;
7482
7826
  p = (q < end) ? q + 1 : end;
7483
7827
  text = p;
@@ -7502,7 +7846,13 @@ static LeptrisDocument html_parse_shared(
7502
7846
  if (h_is_breakout(name) ||
7503
7847
  (strcmp(name, "font") == 0 && h_font_break(q, end))) {
7504
7848
  /* Breakout: pop the foreign scope, reprocess
7505
- * under HTML rules below. */
7849
+ * under HTML rules below. html5lib's foreign
7850
+ * startTagOther reports the HTML element as
7851
+ * unexpected in foreign content
7852
+ * (tests9:15, tests10:14). */
7853
+ h_diag(&b, "unexpected-html-element-in-foreign-content",
7854
+ (size_t)(p - b.input_base),
7855
+ "<%s> breaks out of foreign content", name);
7506
7856
  while (b.depth > 0 &&
7507
7857
  b.open_ns[b.depth - 1] != H_NS_HTML &&
7508
7858
  !h_is_int_point(&b, b.depth - 1))
@@ -7520,6 +7870,12 @@ static LeptrisDocument html_parse_shared(
7520
7870
  /* Implied end tags this start tag triggers (HTML rules
7521
7871
  * only — foreign content has none). */
7522
7872
  if (elem_ns == H_NS_HTML) {
7873
+ /* html5lib startTagListItem/startTagBlock: an implied
7874
+ * close whose target is buried under a non-implied
7875
+ * element (div, article, ...) reports end-tag-too-early
7876
+ * on this START tag (tests2:11, tests3:20). One per
7877
+ * token, like the reference walk. */
7878
+ const char* implied_early = NULL;
7523
7879
  /* WHATWG: a dd/dt start closes the NEAREST open dd/dt
7524
7880
  * in default scope — sibling definition items even
7525
7881
  * with content in between (13.2.6.4.7, tests19:30:
@@ -7529,6 +7885,15 @@ static LeptrisDocument html_parse_shared(
7529
7885
  for (size_t d = b.depth; d > 0; d--) {
7530
7886
  uint8_t oid = b.open_id[d - 1];
7531
7887
  if (oid == h_id_dd || oid == h_id_dt) {
7888
+ for (size_t k = d; k < b.depth; k++) {
7889
+ const char* an2 =
7890
+ leptris_element_name(b.open[k]);
7891
+ if (an2 && b.open_ns[k] == H_NS_HTML &&
7892
+ !h_is_implied_end_name(an2)) {
7893
+ implied_early = an2;
7894
+ break;
7895
+ }
7896
+ }
7532
7897
  b.depth = d - 1;
7533
7898
  break;
7534
7899
  }
@@ -7550,6 +7915,17 @@ static LeptrisDocument html_parse_shared(
7550
7915
  if ((tid == h_id_li && oid == h_id_li) ||
7551
7916
  (tid != h_id_li &&
7552
7917
  (oid == h_id_dd || oid == h_id_dt))) {
7918
+ if (!implied_early) {
7919
+ for (size_t k = d; k < b.depth; k++) {
7920
+ const char* an2 =
7921
+ leptris_element_name(b.open[k]);
7922
+ if (an2 && b.open_ns[k] == H_NS_HTML &&
7923
+ !h_is_implied_end_name(an2)) {
7924
+ implied_early = an2;
7925
+ break;
7926
+ }
7927
+ }
7928
+ }
7553
7929
  b.depth = d - 1;
7554
7930
  break;
7555
7931
  }
@@ -7570,6 +7946,17 @@ static LeptrisDocument html_parse_shared(
7570
7946
  for (size_t d = b.depth; d > 0; d--) {
7571
7947
  uint8_t oid = b.open_id[d - 1];
7572
7948
  if (oid == h_id_p) {
7949
+ if (!implied_early) {
7950
+ for (size_t k = d; k < b.depth; k++) {
7951
+ const char* an2 =
7952
+ leptris_element_name(b.open[k]);
7953
+ if (an2 && b.open_ns[k] == H_NS_HTML &&
7954
+ !h_is_implied_end_name(an2)) {
7955
+ implied_early = an2;
7956
+ break;
7957
+ }
7958
+ }
7959
+ }
7573
7960
  b.depth = d - 1;
7574
7961
  break;
7575
7962
  }
@@ -7578,6 +7965,10 @@ static LeptrisDocument html_parse_shared(
7578
7965
  break;
7579
7966
  }
7580
7967
  }
7968
+ if (b.whatwg && implied_early)
7969
+ h_diag(&b, "end-tag-too-early",
7970
+ (size_t)(p - b.input_base),
7971
+ "implied close crosses <%s>", implied_early);
7581
7972
  uint8_t tmpl_last_popped = H_ID_UNKNOWN;
7582
7973
  while (b.depth > 0) {
7583
7974
  uint8_t oid = b.open_id[b.depth - 1];
@@ -8250,6 +8641,13 @@ static LeptrisDocument html_parse_shared(
8250
8641
  "input",
8251
8642
  name);
8252
8643
  }
8644
+ /* PLAINTEXT has no close form: it is still open at
8645
+ * EOF, so InBody processEOF reports the unclosed
8646
+ * element (tests18:2-7, tests19:6). */
8647
+ if (b.whatwg && cq >= end && strcmp(name, "plaintext") == 0)
8648
+ h_diag(&b, "expected-closing-tag-but-got-eof",
8649
+ (size_t)(end - b.input_base),
8650
+ "plaintext reaches end of input");
8253
8651
  b.depth--; /* the raw element is complete */
8254
8652
  text = p;
8255
8653
  continue;
@@ -203,10 +203,12 @@ int buffer_has_error(SerializeBuffer* buf) {
203
203
 
204
204
 
205
205
  /* Inline escaped-text emitter (TODO 194d): writes into a caller-
206
- * reserved worst-case buffer (6 bytes per input byte), preserving
207
- * the entity semantics of the previous per-run walkers — entity
208
- * references emit as-is (text mode), bare & escapes, quotes are
209
- * ordinary in text mode and escaped in attribute mode. */
206
+ * reserved worst-case buffer (6 bytes per input byte). Every '&' is
207
+ * escaped — entity references are parse-time markup and either
208
+ * already decoded into the text (predefined/char refs) or carried as
209
+ * ENTITY_REF nodes; a decoded text value shaped like "&#x26;" is
210
+ * data, not markup (leptris-ruby#515). Quotes are ordinary in text
211
+ * mode and escaped in attribute mode. */
210
212
  /* Escape-class table (serialize endgame): the run scan was 3-4
211
213
  * compares per byte — the dominant serializer cost at 57% of the
212
214
  * text-heavy profile. One indexed load + one test per byte, the
@@ -300,23 +302,9 @@ static char* emit_escaped_inline(char* out, const char* content,
300
302
  }
301
303
 
302
304
  if (content[i] == '&') {
303
- size_t j = i + 1;
304
- int found_semicolon = 0;
305
- while (j < len && j < i + 12) {
306
- if (content[j] == ';') { found_semicolon = 1; break; }
307
- if (!isalnum((unsigned char)content[j]) && content[j] != '#' && content[j] != '-') break;
308
- j++;
309
- }
310
- if (!attr_mode && found_semicolon && j > i + 1) {
311
- size_t n = j - i + 1;
312
- memcpy(out, &content[i], n);
313
- out += n;
314
- i = j + 1;
315
- } else {
316
- memcpy(out, "&amp;", 5);
317
- out += 5;
318
- i++;
319
- }
305
+ memcpy(out, "&amp;", 5);
306
+ out += 5;
307
+ i++;
320
308
  continue;
321
309
  }
322
310
  if (content[i] == '<') { memcpy(out, "&lt;", 4); out += 4; }
@@ -716,8 +704,8 @@ void serialize_text_internal(LeptrisTextNode* text, SerializeBuffer* buf) {
716
704
  }
717
705
 
718
706
  /* Run-batched (TODO 194b): bulk-append ordinary bytes up to the
719
- * next special character; the entity lookahead only runs at '&'.
720
- * Quotes and apostrophes are ordinary in text content. */
707
+ * next special character. Quotes and apostrophes are ordinary in
708
+ * text content. */
721
709
  size_t i = 0;
722
710
  while (i < content_len) {
723
711
  size_t run = i;
@@ -733,26 +721,10 @@ void serialize_text_internal(LeptrisTextNode* text, SerializeBuffer* buf) {
733
721
  }
734
722
 
735
723
  if (content[i] == '&') {
736
- /* Look ahead for ';' to detect entity reference */
737
- size_t j = i + 1;
738
- int found_semicolon = 0;
739
- while (j < text->content_len && j < i + 12) {
740
- if (content[j] == ';') {
741
- found_semicolon = 1;
742
- break;
743
- }
744
- if (!isalnum((unsigned char)content[j]) && content[j] != '#' && content[j] != '-') {
745
- break;
746
- }
747
- j++;
748
- }
749
- if (found_semicolon && j > i + 1) {
750
- buffer_append_len(buf, &content[i], j - i + 1);
751
- i = j + 1;
752
- } else {
753
- buffer_append_len(buf, "&amp;", 5);
754
- i++;
755
- }
724
+ /* Always escape: decoded text is data. Named references
725
+ * that survived parsing live in ENTITY_REF nodes. */
726
+ buffer_append_len(buf, "&amp;", 5);
727
+ i++;
756
728
  continue;
757
729
  }
758
730
 
@@ -977,9 +977,27 @@ TEST(Html5LibCorpus, ErrorVerdictParity) {
977
977
  for (size_t i = 0; i < failures.size() && i < 20; i++)
978
978
  printf(" VERDICT %s\n", failures[i].c_str());
979
979
  EXPECT_GT(total, (size_t)1500);
980
- /* Verdict-parity floor — set at the slice-2 baseline
981
- * (1409/1555; was 190 before the #1771 emission slices —
982
- * the doctype-less initial-mode family, trailing chars, EOF with
983
- * open elements; slice 3 adds the raw-text EOF variants). Only raises. */
984
- EXPECT_GE(parity, (size_t)1409);
980
+ /* Verdict-parity floor — main measured 1446/1555 before these
981
+ * slices (1409 was the slice-2 baseline; was 190 before the
982
+ * #1771 emission slices). Raw-text stray end tags add 12 depth-0
983
+ * cases; non-html-root adds the 9 later-<html> cases; the
984
+ * end-tag-too-early slices add heading name mismatches, block
985
+ * closes over non-implied elements, and the same rule on the
986
+ * implied close of li/dd/dt/p start tags; the after-body and
987
+ * frameset phase slices add the AfterBody/AfterFrameset/
988
+ * AfterAfter* start-tag codes; the start-tag phase slice adds
989
+ * InBody frameset, duplicate body/head, and AfterHead
990
+ * out-of-my-head tags; the select/table/foreign slice adds
991
+ * unexpected-input-in-select, hidden-input-in-table, and
992
+ * unexpected-html-element-in-foreign-content; the tokenizer
993
+ * slice adds need-space-after-doctype; the misplaced-tag slice
994
+ * adds in-body caption/col/…/tr and frame (ignored), plus the
995
+ * stray </p> phase codes; the character-phase slice adds
996
+ * expected-eof-but-got-char and unexpected-char-after-body/
997
+ * -frameset; the tokenizer EOF slice adds eof-in-comment and
998
+ * eof-in-comment-double-dash; the plaintext/EOF slice adds the
999
+ * plaintext open-at-EOF and the "</" tokenizer EOF codes; the
1000
+ * stray-end-tag slice makes the depth-0 branch phase-aware for
1001
+ * raw-text, void and menuitem names. Only raises. */
1002
+ EXPECT_GE(parity, (size_t)1532);
985
1003
  }
@@ -257,6 +257,93 @@ TEST(EncodingEdgeCases, EmptyXmlElementRoundTrips) {
257
257
  leptris_document_free(doc);
258
258
  }
259
259
 
260
+ /* leptris-ruby#514: strict load validates the input against the
261
+ * effective encoding. Without a declaration (or with a UTF-8 one)
262
+ * the entity is UTF-8 in XML 1.0; invalid byte sequences are a
263
+ * fatal load error, matching libxml2's "Invalid bytes in character
264
+ * encoding". A declared non-UTF-8 encoding is exempt — those bytes
265
+ * are legitimately not UTF-8. */
266
+ TEST(EncodingUtf8Strict, RejectsInvalidBytesWithoutDeclaration) {
267
+ const char bad[] = "<r>\xC3\x28</r>";
268
+ LeptrisStatus st = LEPTRIS_OK;
269
+ LeptrisDocument doc = leptris_parse_string(bad, sizeof(bad) - 1, &st);
270
+ EXPECT_EQ(doc, nullptr);
271
+ EXPECT_EQ(st, LEPTRIS_ERROR_PARSE);
272
+ }
273
+
274
+ TEST(EncodingUtf8Strict, RejectsInvalidBytesWithUtf8Declaration) {
275
+ const char bad[] =
276
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?><r>\xC3\x28</r>";
277
+ LeptrisStatus st = LEPTRIS_OK;
278
+ LeptrisDocument doc = leptris_parse_string(bad, sizeof(bad) - 1, &st);
279
+ EXPECT_EQ(doc, nullptr);
280
+ EXPECT_EQ(st, LEPTRIS_ERROR_PARSE);
281
+
282
+ const char bad_lower[] =
283
+ "<?xml version='1.0' encoding='utf-8'?><r>\xC3\x28</r>";
284
+ LeptrisDocument doc2 = leptris_parse_string(
285
+ bad_lower, sizeof(bad_lower) - 1, &st);
286
+ EXPECT_EQ(doc2, nullptr);
287
+ }
288
+
289
+ TEST(EncodingUtf8Strict, RejectsEveryInvalidSequenceClass) {
290
+ struct { const char* xml; size_t len; } cases[] = {
291
+ {"<r>\xC0\x80</r>", 0}, /* overlong NUL */
292
+ {"<r>\xED\xA0\x80</r>", 0}, /* surrogate U+D800 */
293
+ {"<r>\xF5\x80\x80\x80</r>", 0}, /* > U+10FFFF */
294
+ {"<r>\x80</r>", 0}, /* lone continuation */
295
+ {"<r>abc\xC3", 0}, /* truncated at EOF */
296
+ {"<r a='\xC3\x28'/>", 0}, /* invalid in attribute */
297
+ };
298
+ for (auto& c : cases) {
299
+ size_t len = c.len ? c.len : std::strlen(c.xml);
300
+ LeptrisStatus st = LEPTRIS_OK;
301
+ LeptrisDocument doc = leptris_parse_string(c.xml, len, &st);
302
+ EXPECT_EQ(doc, nullptr) << "accepted invalid bytes";
303
+ if (doc) leptris_document_free(doc);
304
+ }
305
+ }
306
+
307
+ TEST(EncodingUtf8Strict, AcceptsValidMultibyte) {
308
+ const char xml[] = "<r>caf\xC3\xA9 \xE4\xB8\xAD \xF0\x9F\x98\x80</r>";
309
+ LeptrisStatus st = LEPTRIS_OK;
310
+ LeptrisDocument doc = leptris_parse_string(xml, std::strlen(xml), &st);
311
+ ASSERT_NE(doc, nullptr);
312
+ EXPECT_STREQ(leptris_element_text(leptris_document_root(doc)),
313
+ "caf\xC3\xA9 \xE4\xB8\xAD \xF0\x9F\x98\x80");
314
+ leptris_document_free(doc);
315
+ }
316
+
317
+ TEST(EncodingUtf8Strict, DeclaredNonUtf8IsExempt) {
318
+ /* ISO-8859-1 0xE9 is not valid UTF-8 but is valid in the
319
+ * declared encoding: the load must not reject it. Depending on
320
+ * the iconv build it either transcodes to UTF-8 ("café") or
321
+ * passes the raw declared byte through — both are documented
322
+ * shapes (#514); only the bogus UTF-8 rejection would fail. */
323
+ const char xml[] =
324
+ "<?xml version='1.0' encoding='ISO-8859-1'?><r>caf\xE9</r>";
325
+ LeptrisStatus st = LEPTRIS_OK;
326
+ LeptrisDocument doc = leptris_parse_string(xml, sizeof(xml) - 1, &st);
327
+ ASSERT_NE(doc, nullptr);
328
+ const char* text = leptris_element_text(leptris_document_root(doc));
329
+ ASSERT_NE(text, nullptr);
330
+ EXPECT_TRUE(std::string(text) == "caf\xC3\xA9" ||
331
+ std::string(text) == "caf\xE9")
332
+ << "unexpected content byte(s)";
333
+ leptris_document_free(doc);
334
+ }
335
+
336
+ TEST(EncodingUtf8Strict, RecoverLaneKeepsBestEffortTree) {
337
+ /* #1660: RECOVER never returns NULL for malformed input; the
338
+ * encoding check must not break that contract. */
339
+ const char bad[] = "<r>\xC3\x28</r>";
340
+ LeptrisStatus st = LEPTRIS_OK;
341
+ LeptrisDocument doc = leptris_parse_string_flags(
342
+ bad, sizeof(bad) - 1, LEPTRIS_PARSE_RECOVER, &st);
343
+ ASSERT_NE(doc, nullptr);
344
+ leptris_document_free(doc);
345
+ }
346
+
260
347
  } // namespace
261
348
 
262
349
  /* Issue #613: with_encoding on declared ISO-8859-1 must deliver
@@ -39,6 +39,71 @@ TEST(SerializeRoundTrip, EscapesBareAmpersandInText) {
39
39
  leptris_document_free(doc);
40
40
  }
41
41
 
42
+ TEST(SerializeRoundTrip, EscapesEntityShapedTextUnconditionally) {
43
+ // leptris-ruby#515: parse decodes entities into text; a decoded
44
+ // text value that LOOKS like an entity reference ("&#x26;",
45
+ // "&amp;", "&lt;UK&gt;") must still escape its '&' on output.
46
+ // The old serializer passed entity-shaped runs through verbatim,
47
+ // so the next parse produced a different value (&#x41; -> A,
48
+ // &lt;UK&gt; -> a literal <UK> start tag).
49
+ struct { const char* xml; const char* want; const char* want_text; } cases[] = {
50
+ {"<r>a &amp;#x26; b</r>", "<r>a &amp;#x26; b</r>", "a &#x26; b"},
51
+ {"<r>a &amp;amp; b</r>", "<r>a &amp;amp; b</r>", "a &amp; b"},
52
+ {"<r>a &amp;lt;UK&amp;gt; b</r>", "<r>a &amp;lt;UK&amp;gt; b</r>",
53
+ "a &lt;UK&gt; b"},
54
+ {"<r>a &amp;#x41; b</r>", "<r>a &amp;#x41; b</r>", "a &#x41; b"},
55
+ // Attribute forces the non-fused text emitter; the first four
56
+ // cases take the fused <name>text</name> fast path.
57
+ {"<r k='v'>a &amp;#x26; b</r>", "<r k=\"v\">a &amp;#x26; b</r>",
58
+ "a &#x26; b"},
59
+ };
60
+ for (auto& c : cases) {
61
+ LeptrisStatus st = LEPTRIS_OK;
62
+ LeptrisDocument doc = leptris_parse_string(c.xml, std::strlen(c.xml), &st);
63
+ ASSERT_NE(doc, nullptr) << c.xml;
64
+ char* serialized = leptris_document_serialize(doc, nullptr);
65
+ ASSERT_NE(serialized, nullptr) << c.xml;
66
+ EXPECT_STREQ(serialized, c.want) << c.xml;
67
+
68
+ // The emitted bytes must reparse to the same string value.
69
+ LeptrisDocument again = leptris_parse_string(
70
+ serialized, std::strlen(serialized), &st);
71
+ ASSERT_NE(again, nullptr) << serialized;
72
+ LeptrisElement root = leptris_document_root(again);
73
+ ASSERT_NE(root, nullptr) << serialized;
74
+ const char* content = leptris_element_text(root);
75
+ ASSERT_NE(content, nullptr) << serialized;
76
+ EXPECT_STREQ(content, c.want_text) << c.xml;
77
+ if (std::string(c.xml).find("UK") != std::string::npos) {
78
+ // No <UK> element may have been minted.
79
+ EXPECT_EQ(leptris_element_first_child(root, "UK"), nullptr) << c.xml;
80
+ }
81
+
82
+ leptris_document_free(again);
83
+ leptris_free_string(serialized);
84
+ leptris_document_free(doc);
85
+ }
86
+ }
87
+
88
+ TEST(SerializeRoundTrip, KeepsEntityRefNodesUnescaped) {
89
+ // KEEP_ENTITY_REFS stores named references as ENTITY_REF nodes;
90
+ // those still serialize as &name; (they are markup, not text).
91
+ const char xml[] =
92
+ "<!DOCTYPE r [<!ENTITY foo \"x\">]><r>a &foo; b</r>";
93
+ LeptrisStatus st = LEPTRIS_OK;
94
+ LeptrisDocument doc = leptris_parse_string_flags(
95
+ xml, std::strlen(xml), LEPTRIS_PARSE_KEEP_ENTITY_REFS, &st);
96
+ ASSERT_NE(doc, nullptr);
97
+ char* serialized = leptris_document_serialize(doc, nullptr);
98
+ ASSERT_NE(serialized, nullptr);
99
+ EXPECT_NE(std::string(serialized).find("&foo;"), std::string::npos)
100
+ << serialized;
101
+ EXPECT_EQ(std::string(serialized).find("&amp;foo;"), std::string::npos)
102
+ << serialized;
103
+ leptris_free_string(serialized);
104
+ leptris_document_free(doc);
105
+ }
106
+
42
107
  TEST(SerializeRoundTrip, GrowsBufferForHugeTextContent) {
43
108
  // Regression for TODO 08: buffer_ensure_capacity must grow without
44
109
  // integer overflow or silent realloc-failure corruption.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg/master/scripts/vcpkg.schema.json",
3
3
  "name": "leptris",
4
- "version": "1.9.375",
4
+ "version": "1.9.377",
5
5
  "description": "High-performance XML parser and XPath 1.0 engine in C (libleptris)",
6
6
  "homepage": "https://github.com/leptris/leptris",
7
7
  "license": "MIT",
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: leptris
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.375.0
4
+ version: 1.9.377.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.