leptris 1.9.324.0 → 1.9.333.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/Rakefile +1 -1
  4. data/lib/leptris/vendor/aarch64-linux/libleptris.so +0 -0
  5. data/lib/leptris/vendor/aarch64-linux-musl/libleptris.so +0 -0
  6. data/lib/leptris/vendor/arm-linux/libleptris.so +0 -0
  7. data/lib/leptris/vendor/arm-linux-musl/libleptris.so +0 -0
  8. data/lib/leptris/vendor/arm64-darwin/libleptris.dylib +0 -0
  9. data/lib/leptris/vendor/ppc64le-linux/libleptris.so +0 -0
  10. data/lib/leptris/vendor/s390x-linux-musl/libleptris.so +0 -0
  11. data/lib/leptris/vendor/x86_64-darwin/libleptris.dylib +0 -0
  12. data/lib/leptris/vendor/x86_64-linux/libleptris.so +0 -0
  13. data/lib/leptris/vendor/x86_64-linux-musl/libleptris.so +0 -0
  14. data/lib/leptris/version.rb +1 -1
  15. data/lib/leptris/xml/cdata.rb +4 -0
  16. data/lib/leptris/xml/comment.rb +4 -0
  17. data/lib/leptris/xml/entity_reference.rb +4 -0
  18. data/lib/leptris/xml/ffi.rb +5 -0
  19. data/lib/leptris/xml/node.rb +1 -0
  20. data/lib/leptris/xml/processing_instruction.rb +4 -0
  21. data/lib/leptris/xml/text.rb +4 -0
  22. data/lib/leptris/xml/xsd.rb +18 -0
  23. data/vendor-src/README.md +1 -1
  24. data/vendor-src/libleptris/CHANGELOG.md +181 -0
  25. data/vendor-src/libleptris/CMakeLists.txt +1 -1
  26. data/vendor-src/libleptris/cli/commands/validate.c +5 -7
  27. data/vendor-src/libleptris/src/include/leptris.h +8 -0
  28. data/vendor-src/libleptris/src/leptris/dom/node_public.c +1 -1
  29. data/vendor-src/libleptris/src/leptris/dom/text.c +4 -2
  30. data/vendor-src/libleptris/src/leptris/dom/text.h +7 -2
  31. data/vendor-src/libleptris/src/leptris/flat/direct_parse.c +32 -4
  32. data/vendor-src/libleptris/src/leptris/html/html_parse.c +2 -1
  33. data/vendor-src/libleptris/src/leptris/leptris.c +7 -2
  34. data/vendor-src/libleptris/src/leptris/xsd/compile.c +1000 -76
  35. data/vendor-src/libleptris/src/leptris/xsd/content_model.c +35 -2
  36. data/vendor-src/libleptris/src/leptris/xsd/validate.c +154 -23
  37. data/vendor-src/libleptris/src/leptris/xsd/xsd_internal.h +95 -3
  38. data/vendor-src/libleptris/src/leptris/xslt/xslt_exec.c +1 -1
  39. data/vendor-src/libleptris/test/CMakeLists.txt +5 -0
  40. data/vendor-src/libleptris/test/parser/test_parser.cpp +116 -0
  41. data/vendor-src/libleptris/test/xsd/test_xsd_compile.cpp +676 -4
  42. data/vendor-src/libleptris/vcpkg.json +1 -1
  43. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20ca01027af6d2d9c7dd54b359eb38fb8243682d85499c82fa021f22605718f6
4
- data.tar.gz: 67293f668204144634cac5a732f48376f218ac225378f1139809900ccd459c06
3
+ metadata.gz: b49c473e39e2876b99fafc9c9bf9c6025fe52640699d0eac17a7054a699fdb5d
4
+ data.tar.gz: 42f69045bf2379d40d51b8503bec81e7e7bbf5dbc5e89eaef959e7b6a15d5db6
5
5
  SHA512:
6
- metadata.gz: 8a6421039f5259c8887348842d639ab31c270fb628b22538b51d5426093aee61dce662de3a5c1386a00faa8fbd6cc2ddc715fe856c14ed3dc0e073c369491858
7
- data.tar.gz: 17a7df64ddb31b03471b14fcbe99c15ced17574da99709fa316de42b8176b62c8cc6b6722f6c8d6f3529981e6b53b4e7b28ec1474046bd33fb2dceebe33ca8f7
6
+ metadata.gz: fd6fcd375d425f8dcaf8069c83c9d169acc45dd60fff80e7ff3661b6d119b06f61d3285f79b788fe693ba061c884d06469639976d9b9ac807012a475d70a5217
7
+ data.tar.gz: f8145e11531fd836f617a233634ec23fefc5faca631d28fd475f281951cbeaf838f522fbc338157490932e80ec04fba2fa9f096fdecd87ce5439e5762470e3ed
data/CHANGELOG.md CHANGED
@@ -1,3 +1,45 @@
1
+ ## [1.9.331.0] - 2026-10-10
2
+
3
+ ### Added — engine sync (libleptris 1.9.325-1.9.331)
4
+
5
+ - **XSD.compile_file (1.9.326)**: the file-path compile face —
6
+ unreadable files raise; malformed schemas compile to
7
+ error-carrying handles, exactly like the string face.
8
+ Audit 382/381.
9
+ - **XSD charter completion (1.9.326)**: xs:union / xs:list
10
+ derivation (typed items, length semantics), groups,
11
+ attributeGroups, complexContent/simpleContent derivation,
12
+ substitution groups, fixed values, includes — pinned for
13
+ unions (any-member acceptance, lexical + instance) and lists
14
+ (per-item typing). KNOWN GAP filed upstream (leptris#1615):
15
+ memberTypes + an inline anonymous member together over-accept;
16
+ each spelling alone is correct and pinned.
17
+
18
+ ### Fixed — engine sync
19
+
20
+ - 1.9.325: local particle declarations govern nested children —
21
+ particle-only children's attributes and content models validate
22
+ (the `<item qty="nan"/>` shape), pinned.
23
+ - 1.9.327-1.9.331: five nightly-fuzz finds (NUL-leading text
24
+ runs, two dp-fail-path leaks, the fast-path declaration probe
25
+ read, duplicate DOCTYPE model replacement) — the observable one
26
+ pinned: a duplicate DOCTYPE keeps the FIRST internal subset
27
+ (keep-first, matching the duplicate-attribute rule).
28
+
29
+ ## [1.9.324.0] - 2026-10-09
30
+
31
+ ### Added — engine sync (libleptris 1.9.324)
32
+
33
+ - **XSD identity constraints — tier-1 scope complete (#1075)**:
34
+ `xs:key` / `xs:unique` / `xs:keyref` capture on top-level
35
+ declarations (incl. inline complexTypes) and two-pass instance
36
+ validation: key/unique tuple collection with uniqueness +
37
+ non-empty-field enforcement, then keyref resolution — attribute
38
+ fields included, selectors through the engine's own XPath.
39
+ Completes compile → datatypes/facets → content models →
40
+ instance validation → identity constraints. Pinned: resolved
41
+ refs, duplicate tuples, dangling refs, empty fields.
42
+
1
43
  ## [1.9.323.0] - 2026-10-08
2
44
 
3
45
  ### Fixed — engine sync (libleptris 1.9.323)
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.324"
11
+ LIBLEPTRIS_VERSION = "1.9.333"
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.324.0"
4
+ VERSION = "1.9.333.0"
5
5
  end
@@ -31,4 +31,8 @@ class Leptris::XML::CDATA < Leptris::XML::Text
31
31
  Leptris::XML::FFI.leptris_cdata_node_set_content(c_ptr, new_content.to_s))
32
32
  new_content
33
33
  end
34
+
35
+ def to_s
36
+ "<![CDATA[#{content}]]>"
37
+ end
34
38
  end
@@ -31,4 +31,8 @@ class Leptris::XML::Comment < Leptris::XML::Node
31
31
  Leptris::XML::FFI.leptris_comment_node_set_content(c_ptr, new_content.to_s))
32
32
  new_content
33
33
  end
34
+
35
+ def to_s
36
+ "<!--#{content}-->"
37
+ end
34
38
  end
@@ -10,4 +10,8 @@ class Leptris::XML::EntityReference < Leptris::XML::Node
10
10
  Leptris::XML::FFI.leptris_entity_ref_node_name(c_ptr)
11
11
  end
12
12
  alias_method :node_name, :name
13
+
14
+ def to_s
15
+ "&#{name};"
16
+ end
13
17
  end
@@ -1289,6 +1289,11 @@ attach_function :leptris_parse_string,
1289
1289
  # schema-level errors. Validation is a later tier.
1290
1290
  attach_function :leptris_xsd_compile,
1291
1291
  [:string, :size_t, :pointer], :leptris_xsd_schema
1292
+ # libleptris 1.9.326: compile from a file path — NULL only
1293
+ # when unreadable; malformed schemas yield error-carrying
1294
+ # handles, exactly like the string face.
1295
+ attach_function :leptris_xsd_compile_file,
1296
+ [:string, :pointer], :leptris_xsd_schema
1292
1297
  attach_function :leptris_xsd_free,
1293
1298
  [:leptris_xsd_schema], :void
1294
1299
  attach_function :leptris_xsd_declaration_count,
@@ -8,6 +8,7 @@ class Leptris::XML::Node
8
8
  @c_ptr ||= ::FFI::Pointer.new(@c_address)
9
9
  end
10
10
 
11
+
11
12
  # The Integer twin of #c_ptr — reading it never materializes a
12
13
  # Pointer (the native faces take addresses).
13
14
  def c_address
@@ -50,6 +50,10 @@ class Leptris::XML::ProcessingInstruction < Leptris::XML::Node
50
50
  new_data
51
51
  end
52
52
 
53
+ def to_s
54
+ content.to_s.empty? ? "<?#{name}?>" : "<?#{name} #{content}?>"
55
+ end
56
+
53
57
  # Document-level PIs have no tree parent for leptris_node_unlink
54
58
  # — route through the document-level removal (libleptris 1.9.9,
55
59
  # #612), identity-matched by index so the right same-target PI
@@ -36,4 +36,8 @@ class Leptris::XML::Text < Leptris::XML::Node
36
36
  Leptris::XML::FFI.leptris_text_node_set_content(c_ptr, new_content.to_s))
37
37
  new_content
38
38
  end
39
+
40
+ def to_s
41
+ Leptris::XML::Serialization.escape_text(content)
42
+ end
39
43
  end
@@ -9,6 +9,13 @@ module Leptris::XML::XSD
9
9
  Schema.compile(schema_xml)
10
10
  end
11
11
 
12
+ # Compile from a file path (libleptris >= 1.9.326): unreadable
13
+ # files raise; malformed schemas compile to an error-carrying
14
+ # handle, exactly like .compile.
15
+ def compile_file(path)
16
+ Schema.compile_file(path)
17
+ end
18
+
12
19
  # Slice 2 (libleptris 1.9.319, #1075): built-in lexical table.
13
20
  # +builtin+ takes the "xs:NAME" reference spelling. Returns
14
21
  # true/false; an unknown table name raises — a silent false
@@ -61,6 +68,17 @@ module Leptris::XML::XSD
61
68
  @handle = handle
62
69
  end
63
70
 
71
+ # Compile from a file path: the engine reads it directly.
72
+ def self.compile_file(path)
73
+ status = ::FFI::MemoryPointer.new(:int)
74
+ raw = Leptris::XML::FFI.leptris_xsd_compile_file(path.to_s, status)
75
+ if raw.null?
76
+ raise Leptris::XML::Error,
77
+ "schema file unreadable (status #{status.read_int}): #{path}"
78
+ end
79
+ new(Handle.new(raw))
80
+ end
81
+
64
82
  # Number of top-level schema declarations the compiler
65
83
  # recognized (elements, types, groups, ...).
66
84
  def declaration_count
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.324
4
+ - libleptris v1.9.333
5
5
  - utf8proc v2.11.0
6
6
 
7
7
  Rebuild by hand:
@@ -1,5 +1,186 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.9.333] - 2026-10-10
4
+
5
+ ### Added
6
+
7
+ - **Regression pin: union `memberTypes` + inline anonymous members
8
+ (#1615, PR #1618).** The reported over-acceptance was not
9
+ reproducible on any engine artifact (main, the v1.9.331 tag,
10
+ every published gem binary — including through the full Ruby
11
+ binding path). The exact schema now asserts in CI through both
12
+ `leptris_xsd_simple_valid` and instance validation, so the
13
+ mixed-member semantics are verifiable binary-independent of any
14
+ binding environment.
15
+
16
+
17
+ ## [1.9.332] - 2026-10-09
18
+
19
+ ### Added
20
+
21
+ - **XSD 1.1 — tier 2 (#1075, PR #1616).** Gated on
22
+ `xs:schema @version="1.1"`: `xs:assert` on complexTypes (direct
23
+ and derivation-nested; compiled once, evaluated per instance
24
+ through the engine's XPath 2 lane; evaluation errors are
25
+ failures); `xs:assertion` on simpleTypes (`$value` binds the
26
+ lexical, element context); `xs:openContent` interleave/suffix
27
+ (wildcard self-loops at the Thompson NFA's junction states;
28
+ `xs:defaultOpenContent` schema-wide with `appliesToEmpty`) and
29
+ `xs:alternative` conditional type assignment (first passing
30
+ test types the instance, declared type as fallback). Ten
31
+ red-first specs; ctest 1951/1951; 1.0 schemas unaffected.
32
+
33
+ ### Fixed
34
+
35
+ - free the defaultOpenContent wildcard with the schema (xsd)
36
+
37
+
38
+
39
+ ## [1.9.331] - 2026-10-09
40
+
41
+ ### Fixed
42
+
43
+ - **a second DOCTYPE keeps the first model (PR #1613).** A
44
+ duplicate `<!DOCTYPE` re-entered the direct-parse DOCTYPE handler
45
+ and replaced the already-parsed model — the orphan's heap-grown
46
+ attribute-default array leaked (fifth nightly-fuzz find,
47
+ artifact `leak-168da36a`). The lane stays lenient about the
48
+ duplicate, but the FIRST model wins now, matching the
49
+ duplicate-attribute keep-first rule — so the first internal
50
+ subset's entities and defaults apply (previously the second
51
+ silently won). The replacement model is freed on arrival.
52
+
53
+
54
+
55
+ ## [1.9.330] - 2026-10-09
56
+
57
+ ### Fixed
58
+
59
+ - **dp fail path frees the parsed internal-subset DTD (PR #1611).**
60
+ A DOCTYPE internal subset with a default attribute declaration
61
+ grows the DTD model's heap array; the parse-success epilogue
62
+ transfers the model to the document, but a parse failure after
63
+ the DOCTYPE never did — and the fail teardown freed the diags
64
+ and line-break table but not the model (fourth nightly-fuzz
65
+ find, artifact `leak-e3246602`). The fail block frees the model
66
+ now, beside the other heap-owned pieces.
67
+
68
+
69
+
70
+ ## [1.9.329] - 2026-10-09
71
+
72
+ ### Fixed
73
+
74
+ - **fast-path declaration probe bounds against the span
75
+ (PR #1609).** `leptris_parse_string`'s fast-path `<?xml` probe
76
+ checked the ABSOLUTE length while indexing from the first
77
+ non-whitespace offset, so an input whose markup starts after
78
+ leading whitespace read one byte past the caller's buffer
79
+ (heap-buffer-overflow; third nightly-fuzz find, artifact
80
+ `crash-aac3c9bb`). The probe bounds against the remaining span
81
+ now. The regression spec reproduces the fuzzer's exact shape —
82
+ a terminator-free 9-byte heap buffer: a string literal's
83
+ implicit NUL lands the read in bounds and 32-byte stack slot
84
+ padding hides the overread from ASAN.
85
+
86
+
87
+
88
+ ## [1.9.328] - 2026-10-09
89
+
90
+ ### Fixed
91
+
92
+ - **dp fail path frees the parse-diag array (PR #1607).** A RECOVER
93
+ diagnostic (duplicate attribute) emitted before an eventual parse
94
+ failure leaked its heap-grown `parse_diags` array: the dp fail
95
+ teardown freed the line-break table and destroyed the pool but
96
+ never the diag array or its offender names. Second nightly-fuzz
97
+ find (artifact `leak-75e0ee81`, 2304 bytes). The fail path runs
98
+ `leptris_diag_free` beside the line-break free now.
99
+
100
+
101
+
102
+ ## [1.9.327] - 2026-10-09
103
+
104
+ ### Fixed
105
+
106
+ - **NUL-leading text runs keep a real content pointer — ASAN
107
+ global-buffer-overflow (PR #1605).** The text-content setter
108
+ collapsed any run whose first byte is NUL onto the shared EMPTY
109
+ sentinel regardless of its length, so a NUL-leading run stored
110
+ content `""` with its real `content_len` — the serializer then
111
+ read past the 1-byte global. Found by the nightly libFuzzer run
112
+ (artifact `crash-0a24082b`, the harness's first working session
113
+ after #1604 repaired it). The setter is length-aware now: EMPTY
114
+ only for genuinely empty runs; NUL-leading runs serialize
115
+ verbatim, consistent with the never-trimmed content contract.
116
+ - **Nightly fuzz binary lands at the documented path (PR #1604).**
117
+ `fuzz_parse` is a `test/` subdirectory target, so it built under
118
+ `build/test/` while `fuzz-nightly.yml` invoked `./build/fuzz_parse`
119
+ — every scheduled run since 2026-09-30 failed before fuzzing
120
+ started. The target now sets its runtime output directory to the
121
+ build root, where the workflow and the harness's own docs expect
122
+ it.
123
+
124
+
125
+
126
+ ## [1.9.326] - 2026-10-09
127
+
128
+ ### Added
129
+
130
+ - **XSD: tier-1 charter completion — unions, lists, groups,
131
+ attributeGroups, derivation, substitution groups, fixed values,
132
+ includes (#1075, PR #1602).** Every remaining charter feature
133
+ ships: `xs:union`/`xs:list` derivation (lists type every token,
134
+ length counts items; unions accept any named or inline member);
135
+ `xs:group` definitions with refs that expand lazily at NFA build
136
+ (forward references need no capture order, cyclic refs splice
137
+ nothing); `xs:attributeGroup` refs plus top-level attribute refs
138
+ and inline anonymous attribute simpleTypes;
139
+ complexContent/simpleContent extension and restriction through a
140
+ shared complexType capture (extension splices base content and
141
+ attribute rows, restriction replaces them, simpleContent types
142
+ element text); `xs:substitutionGroup` members bind in content
143
+ models; `@fixed` enforced on attributes and element text; and
144
+ `leptris_xsd_compile_file` resolves `xs:include`/`xs:import`/
145
+ `xs:redefine` schemaLocations against the schema's own directory
146
+ — the CLI `--xsd` path and the corpus gate now use it, so the
147
+ gate's 23 multi-file fixtures resolve their includes. Multi-
148
+ particle derivation splices normalize under a sequence (the NFA
149
+ consumes one root particle). 14 new red-first specs; ctest
150
+ 1937/1937; corpus gate green with its floor held.
151
+
152
+ ### Fixed
153
+
154
+ - **follow-up on the same PR (52a5a8d9):** the direct-content
155
+ fallback re-captured attribute rows when a derivation had already
156
+ captured them (attributes-only restriction), orphaning the chain
157
+ — 1309 bytes across 69 allocations under ASAN; the fallback now
158
+ captures only when no derivation did. `leptris_xsd_compile_file`
159
+ split the base directory on `/` only; Windows paths carry
160
+ backslashes, so includes never resolved there.
161
+
162
+
163
+
164
+ ## [1.9.325] - 2026-10-09
165
+
166
+ ### Fixed
167
+
168
+ - **XSD: local particle declarations govern nested children —
169
+ tier-1 boundary closed (#1075, PR #1600).** Children declared
170
+ only as particles of a parent's content model — no global
171
+ `xs:element` of that name — skipped attribute and content-model
172
+ validation: the instance walk resolved every child through the
173
+ global element table and at most text-typed local particles, so
174
+ `<order><item id="nan"/></order>` against an `xs:integer`
175
+ particle attribute validated clean. When no global declaration
176
+ exists, the parent's particle declaration now governs the child:
177
+ the particle's type drives the full element check — attribute
178
+ rows, content model, lexical text — through the shared
179
+ `validate_local` tail that global declarations use, and
180
+ grandchildren type through the particle chain.
181
+
182
+
183
+
3
184
  ## [1.9.324] - 2026-10-08
4
185
 
5
186
  ### Added
@@ -3,7 +3,7 @@
3
3
  cmake_minimum_required(VERSION 3.20)
4
4
 
5
5
  project(leptris
6
- VERSION 1.9.324
6
+ VERSION 1.9.333
7
7
  DESCRIPTION "Fast XML parser and XPath evaluator in pure C"
8
8
  LANGUAGES C CXX
9
9
  )
@@ -266,17 +266,15 @@ static cli_result_t validate_execute(int argc, char** argv) {
266
266
  leptris_rng_free(rng);
267
267
  }
268
268
  if (xsd_path) {
269
- size_t xsd_len = 0;
270
- char* xsd_content = read_file(xsd_path, &xsd_len);
271
- if (!xsd_content) {
269
+ /* compile_file resolves schemaLocations relative to the
270
+ * schema's own directory */
271
+ LeptrisStatus st = LEPTRIS_OK;
272
+ LeptrisXsdSchema xsd = leptris_xsd_compile_file(xsd_path, &st);
273
+ if (!xsd) {
272
274
  cli_error("cannot read XSD: %s", xsd_path);
273
275
  leptris_document_free(doc);
274
276
  return CLI_ERROR_IO;
275
277
  }
276
- LeptrisStatus st = LEPTRIS_OK;
277
- LeptrisXsdSchema xsd =
278
- leptris_xsd_compile(xsd_content, xsd_len, &st);
279
- free(xsd_content);
280
278
  /* compile returns an error-carrying handle on failure — the
281
279
  * status, not the pointer, says whether it succeeded. */
282
280
  if (st != LEPTRIS_OK) {
@@ -900,6 +900,14 @@ LEPTRIS_API void leptris_rng_free(LeptrisRelaxNG rng);
900
900
  * @return compiled schema; on failure an error-carrying handle
901
901
  * (leptris_xsd_error says why — free it the same way)
902
902
  */
903
+ /* Compile a schema from a file: relative xs:include/xs:import/
904
+ * xs:redefine schemaLocations resolve against the schema's own
905
+ * directory. Returns NULL only when the file cannot be read; a
906
+ * malformed schema yields an error-carrying handle, exactly like
907
+ * leptris_xsd_compile. (Memory: free with leptris_xsd_free.) */
908
+ LEPTRIS_API LeptrisXsdSchema leptris_xsd_compile_file(const char* path,
909
+ LeptrisStatus* status);
910
+
903
911
  LEPTRIS_API LeptrisXsdSchema leptris_xsd_compile(const char* xsd_text,
904
912
  size_t len,
905
913
  LeptrisStatus* status);
@@ -416,7 +416,7 @@ LEPTRIS_API LeptrisStatus leptris_text_node_set_content(LeptrisNodeRef node,
416
416
  if (!doc || !doc->pool) return LEPTRIS_ERROR_INVALID_ARG;
417
417
  char* copy = node_public_pool_strdup(doc->pool, content, len);
418
418
  if (!copy) return LEPTRIS_ERROR_MEMORY;
419
- leptris_textnode_set_content_ptr_doc(t, copy, doc);
419
+ leptris_textnode_set_content_ptr_doc(t, copy, len, doc);
420
420
  t->content_len = (uint32_t)len;
421
421
  return LEPTRIS_OK;
422
422
  }
@@ -42,7 +42,8 @@ LeptrisTextNode* leptris_text_create(const char* content,
42
42
  /* Contiguous with the node in the same pool alloc — the offset
43
43
  * is a small positive delta; doc tags the oversized-content
44
44
  * fallback should it ever leave the inline window. */
45
- leptris_textnode_set_content_ptr_doc(node, content_storage, doc);
45
+ leptris_textnode_set_content_ptr_doc(node, content_storage,
46
+ content_len, doc);
46
47
  node->content_len = (uint32_t)content_len;
47
48
 
48
49
  return node;
@@ -76,7 +77,8 @@ LeptrisTextNode* leptris_text_create_borrowed(const char* content,
76
77
  * lifetime + termination (#1285 slice 4). The buffer-to-node
77
78
  * distance crosses allocations — the doc-tagged stamp is
78
79
  * MANDATORY here (TLS is unset on parse worker threads). */
79
- leptris_textnode_set_content_ptr_doc(node, content, doc);
80
+ leptris_textnode_set_content_ptr_doc(node, content, content_len,
81
+ doc);
80
82
  node->content_len = (uint32_t)content_len;
81
83
  node->parent_off = 0;
82
84
  node->next_sibling_off = 0;
@@ -162,10 +162,15 @@ static inline const char* leptris_textnode_content(const LeptrisTextNode* t) {
162
162
  * use this form; the pooled-create contiguous path never spills but
163
163
  * takes doc anyway so the oversized-content fallback is covered. */
164
164
  static inline void leptris_textnode_set_content_ptr_doc(
165
- LeptrisTextNode* t, const char* p, struct leptris_document* doc) {
165
+ LeptrisTextNode* t, const char* p, size_t len,
166
+ struct leptris_document* doc) {
166
167
  if (!t) return;
167
168
  if (!p) { t->content_off = 0; return; }
168
- if (*p == '\0') { t->content_off = LEPTRIS_COMPACT_INT32_EMPTY; return; }
169
+ /* EMPTY only for genuinely empty runs. A NUL FIRST byte is
170
+ * content (nightly-fuzz 2026-10-09: NUL-leading runs collapsed
171
+ * here while content_len kept the length — the serializer then
172
+ * walked past the 1-byte EMPTY global). */
173
+ if (len == 0) { t->content_off = LEPTRIS_COMPACT_INT32_EMPTY; return; }
169
174
  ptrdiff_t d = (char*)p - (char*)t;
170
175
  if (d < INT32_MIN || d >= INT32_MAX)
171
176
  t->content_off = leptris_compact_int32_encode_doc(
@@ -845,7 +845,16 @@ static LEPTRIS_NOINLINE int dp_parse_doctype(char** pos_io, char* end,
845
845
  subset_start, (size_t)(subset_end - subset_start),
846
846
  pool);
847
847
  if (parsed) {
848
- dtd = parsed;
848
+ extern void leptris_dtd_free(struct LeptrisDTD*);
849
+ if (dtd) {
850
+ /* a second <!DOCTYPE: the first model wins (the
851
+ * duplicate-attribute keep-first rule); the
852
+ * replacement's heap default_decls array would
853
+ * leak otherwise (nightly-fuzz leak-168da36a) */
854
+ leptris_dtd_free(parsed);
855
+ } else {
856
+ dtd = parsed;
857
+ }
849
858
  }
850
859
  }
851
860
  *pos_io = pos;
@@ -1260,7 +1269,8 @@ static inline LeptrisTextNode* dp_text_create(DParser* p,
1260
1269
  * carve (dispatch-entry dp_nul / walker '&' NUL / EOF sentinel)
1261
1270
  * — the terminator write lives with the byte's last reader, not
1262
1271
  * here. */
1263
- leptris_textnode_set_content_ptr_doc(tn, content, p->doc);
1272
+ leptris_textnode_set_content_ptr_doc(tn, content, content_len,
1273
+ p->doc);
1264
1274
  tn->content_len = (uint32_t)content_len;
1265
1275
  tn->parent_off = 0;
1266
1276
  tn->next_sibling_off = 0;
@@ -2336,7 +2346,24 @@ fail:
2336
2346
  }
2337
2347
  /* The eagerly-built line-break table is malloc'd, not pool-owned
2338
2348
  * — the fail path never reaches leptris_document_free. Free it
2339
- * BEFORE pool_destroy reclaims the doc struct. */
2349
+ * BEFORE pool_destroy reclaims the doc struct. The parse-diag
2350
+ * array is the same: heap-realloc'd (leptris_diag_emit), and a
2351
+ * RECOVER diag (e.g. duplicate attribute) can precede the
2352
+ * failure — nightly-fuzz leak-75e0ee81, 2304 bytes. */
2353
+ {
2354
+ extern void leptris_diag_free(struct LeptrisDiag**, int*, int*);
2355
+ leptris_diag_free(&doc->parse_diags, &doc->parse_diag_count,
2356
+ &doc->parse_diag_cap);
2357
+ }
2358
+ /* The parsed internal-subset DTD is heap-modelled
2359
+ * (default_decls realloc, ttdtd_add_attribute) and never
2360
+ * transferred to the doc on this path — the success epilogue
2361
+ * assigns doc->dtd; without this free the model leaks
2362
+ * (nightly-fuzz leak-e3246602, 8 bytes). */
2363
+ {
2364
+ extern void leptris_dtd_free(struct LeptrisDTD*);
2365
+ if (p.dtd) leptris_dtd_free(p.dtd);
2366
+ }
2340
2367
  free(doc->line_breaks);
2341
2368
  /* Round 10: the elem_pos journal + lazy index are pool-carved —
2342
2369
  * pool_destroy below reclaims them. */
@@ -2959,7 +2986,8 @@ static struct leptris_document* dp_il_build(
2959
2986
  tn->base.type = LEPTRIS_NODE_TYPE_TEXT;
2960
2987
  tn->base.frozen = 1;
2961
2988
  tn->base.line = r->line;
2962
- leptris_textnode_set_content_ptr_doc(tn, content, doc);
2989
+ leptris_textnode_set_content_ptr_doc(tn, content,
2990
+ content_len, doc);
2963
2991
  tn->content_len = (uint32_t)content_len;
2964
2992
  /* (slice 4: pool/borrowed fields gone; the il scratch
2965
2993
  * run is already NUL-terminated at carve) */
@@ -4005,7 +4005,8 @@ static void h_append(HBuilder* b, LeptrisNodeRef n) {
4005
4005
  w9++;
4006
4006
  if (t9 && w9 && t9[w9]) {
4007
4007
  LeptrisTextNode* tn9 = (LeptrisTextNode*)n;
4008
- leptris_textnode_set_content_ptr_doc(tn9, t9 + w9, b->doc);
4008
+ leptris_textnode_set_content_ptr_doc(
4009
+ tn9, t9 + w9, (size_t)(tn9->content_len - w9), b->doc);
4009
4010
  tn9->content_len -= w9;
4010
4011
  } else if (t9 && w9 && !t9[w9]) {
4011
4012
  return; /* pure before-head ws: dropped (tests7:7) */
@@ -471,8 +471,13 @@ LEPTRIS_API LeptrisDocument leptris_parse_string(const char* xml, size_t length,
471
471
  (d[i] == 0xFE && d[i + 1] == 0xFF))) {
472
472
  /* Fall through to slow path. */
473
473
  } else if (i < length && d[i] == '<' &&
474
- !(length >= 5 && d[i + 1] == '?' &&
475
- d[i + 2] == 'x' && d[i + 3] == 'm' && d[i + 4] == 'l')) {
474
+ /* bound against the REMAINING span: i indexes
475
+ * past leading whitespace, so an absolute-length
476
+ * check read d[i+4] one past the buffer
477
+ * (nightly-fuzz crash-aac3c9bb) */
478
+ !(length - i >= 5 && d[i + 1] == '?' &&
479
+ d[i + 2] == 'x' && d[i + 3] == 'm' &&
480
+ d[i + 4] == 'l')) {
476
481
  /* Not a "<?xml" declaration. Check for embedded NUL bytes
477
482
  * in the first 64 bytes (UTF-16 without BOM indicator). */
478
483
  int has_nul = 0;