leptris 1.9.324.0-aarch64-linux → 1.9.331.0-aarch64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed267566d3e55930faa65d87747fd502b922376cd3f16d6783f04ae7de3a47e6
4
- data.tar.gz: 8184d0c615d800963d0cc817d3ede259727048e2dd17467bc2c8ef02766f9234
3
+ metadata.gz: 3d5bec9cdcf6ab2aaf75b3d2ed0e505cabf44230734514074bfc6d9e4277c7e7
4
+ data.tar.gz: 5345c0c049bd9e5aeab14492802f5a9106c5ce429ba2b92139a6260e26602351
5
5
  SHA512:
6
- metadata.gz: 0b42f07f5f59d5002dd4743f179508b1518e56f85127479d584d7856535975f403db11af37b69c04cdfaa790dac9202bf16b21d84baeaff2cf2ab8fee27a1faf
7
- data.tar.gz: 6b08a56371d60b846590531f510e9cd9fb86d94baa5099e9a2df302c7aade031317ab5fc58ef3ee86a7dd318d37945c66f5ba144f4b2fab5e140fc928f8f636c
6
+ metadata.gz: a5b7f11e204fb2f807b9d2b162ed91b2783466575aa9fbc622efc1949fb397f0ae8040a5a648e84e55a92dbd52b8efe97786ec4d48a627127da70c3fb0d5c342
7
+ data.tar.gz: bd2aac14b94807b2d72c65340ea920f735db1bfc39793aa63511f8b3fcaf0718eb715cc74c67090ca9dd9900b2a22a73c47b883bc3c11ff063d87647fdef5b77
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.331"
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.331.0"
5
5
  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,
@@ -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/lib/libleptris.so CHANGED
Binary file
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.331
5
5
  - utf8proc v2.11.0
6
6
 
7
7
  Rebuild by hand:
@@ -1,5 +1,150 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.9.331] - 2026-10-09
4
+
5
+ ### Fixed
6
+
7
+ - **a second DOCTYPE keeps the first model (PR #1613).** A
8
+ duplicate `<!DOCTYPE` re-entered the direct-parse DOCTYPE handler
9
+ and replaced the already-parsed model — the orphan's heap-grown
10
+ attribute-default array leaked (fifth nightly-fuzz find,
11
+ artifact `leak-168da36a`). The lane stays lenient about the
12
+ duplicate, but the FIRST model wins now, matching the
13
+ duplicate-attribute keep-first rule — so the first internal
14
+ subset's entities and defaults apply (previously the second
15
+ silently won). The replacement model is freed on arrival.
16
+
17
+
18
+
19
+ ## [1.9.330] - 2026-10-09
20
+
21
+ ### Fixed
22
+
23
+ - **dp fail path frees the parsed internal-subset DTD (PR #1611).**
24
+ A DOCTYPE internal subset with a default attribute declaration
25
+ grows the DTD model's heap array; the parse-success epilogue
26
+ transfers the model to the document, but a parse failure after
27
+ the DOCTYPE never did — and the fail teardown freed the diags
28
+ and line-break table but not the model (fourth nightly-fuzz
29
+ find, artifact `leak-e3246602`). The fail block frees the model
30
+ now, beside the other heap-owned pieces.
31
+
32
+
33
+
34
+ ## [1.9.329] - 2026-10-09
35
+
36
+ ### Fixed
37
+
38
+ - **fast-path declaration probe bounds against the span
39
+ (PR #1609).** `leptris_parse_string`'s fast-path `<?xml` probe
40
+ checked the ABSOLUTE length while indexing from the first
41
+ non-whitespace offset, so an input whose markup starts after
42
+ leading whitespace read one byte past the caller's buffer
43
+ (heap-buffer-overflow; third nightly-fuzz find, artifact
44
+ `crash-aac3c9bb`). The probe bounds against the remaining span
45
+ now. The regression spec reproduces the fuzzer's exact shape —
46
+ a terminator-free 9-byte heap buffer: a string literal's
47
+ implicit NUL lands the read in bounds and 32-byte stack slot
48
+ padding hides the overread from ASAN.
49
+
50
+
51
+
52
+ ## [1.9.328] - 2026-10-09
53
+
54
+ ### Fixed
55
+
56
+ - **dp fail path frees the parse-diag array (PR #1607).** A RECOVER
57
+ diagnostic (duplicate attribute) emitted before an eventual parse
58
+ failure leaked its heap-grown `parse_diags` array: the dp fail
59
+ teardown freed the line-break table and destroyed the pool but
60
+ never the diag array or its offender names. Second nightly-fuzz
61
+ find (artifact `leak-75e0ee81`, 2304 bytes). The fail path runs
62
+ `leptris_diag_free` beside the line-break free now.
63
+
64
+
65
+
66
+ ## [1.9.327] - 2026-10-09
67
+
68
+ ### Fixed
69
+
70
+ - **NUL-leading text runs keep a real content pointer — ASAN
71
+ global-buffer-overflow (PR #1605).** The text-content setter
72
+ collapsed any run whose first byte is NUL onto the shared EMPTY
73
+ sentinel regardless of its length, so a NUL-leading run stored
74
+ content `""` with its real `content_len` — the serializer then
75
+ read past the 1-byte global. Found by the nightly libFuzzer run
76
+ (artifact `crash-0a24082b`, the harness's first working session
77
+ after #1604 repaired it). The setter is length-aware now: EMPTY
78
+ only for genuinely empty runs; NUL-leading runs serialize
79
+ verbatim, consistent with the never-trimmed content contract.
80
+ - **Nightly fuzz binary lands at the documented path (PR #1604).**
81
+ `fuzz_parse` is a `test/` subdirectory target, so it built under
82
+ `build/test/` while `fuzz-nightly.yml` invoked `./build/fuzz_parse`
83
+ — every scheduled run since 2026-09-30 failed before fuzzing
84
+ started. The target now sets its runtime output directory to the
85
+ build root, where the workflow and the harness's own docs expect
86
+ it.
87
+
88
+
89
+
90
+ ## [1.9.326] - 2026-10-09
91
+
92
+ ### Added
93
+
94
+ - **XSD: tier-1 charter completion — unions, lists, groups,
95
+ attributeGroups, derivation, substitution groups, fixed values,
96
+ includes (#1075, PR #1602).** Every remaining charter feature
97
+ ships: `xs:union`/`xs:list` derivation (lists type every token,
98
+ length counts items; unions accept any named or inline member);
99
+ `xs:group` definitions with refs that expand lazily at NFA build
100
+ (forward references need no capture order, cyclic refs splice
101
+ nothing); `xs:attributeGroup` refs plus top-level attribute refs
102
+ and inline anonymous attribute simpleTypes;
103
+ complexContent/simpleContent extension and restriction through a
104
+ shared complexType capture (extension splices base content and
105
+ attribute rows, restriction replaces them, simpleContent types
106
+ element text); `xs:substitutionGroup` members bind in content
107
+ models; `@fixed` enforced on attributes and element text; and
108
+ `leptris_xsd_compile_file` resolves `xs:include`/`xs:import`/
109
+ `xs:redefine` schemaLocations against the schema's own directory
110
+ — the CLI `--xsd` path and the corpus gate now use it, so the
111
+ gate's 23 multi-file fixtures resolve their includes. Multi-
112
+ particle derivation splices normalize under a sequence (the NFA
113
+ consumes one root particle). 14 new red-first specs; ctest
114
+ 1937/1937; corpus gate green with its floor held.
115
+
116
+ ### Fixed
117
+
118
+ - **follow-up on the same PR (52a5a8d9):** the direct-content
119
+ fallback re-captured attribute rows when a derivation had already
120
+ captured them (attributes-only restriction), orphaning the chain
121
+ — 1309 bytes across 69 allocations under ASAN; the fallback now
122
+ captures only when no derivation did. `leptris_xsd_compile_file`
123
+ split the base directory on `/` only; Windows paths carry
124
+ backslashes, so includes never resolved there.
125
+
126
+
127
+
128
+ ## [1.9.325] - 2026-10-09
129
+
130
+ ### Fixed
131
+
132
+ - **XSD: local particle declarations govern nested children —
133
+ tier-1 boundary closed (#1075, PR #1600).** Children declared
134
+ only as particles of a parent's content model — no global
135
+ `xs:element` of that name — skipped attribute and content-model
136
+ validation: the instance walk resolved every child through the
137
+ global element table and at most text-typed local particles, so
138
+ `<order><item id="nan"/></order>` against an `xs:integer`
139
+ particle attribute validated clean. When no global declaration
140
+ exists, the parent's particle declaration now governs the child:
141
+ the particle's type drives the full element check — attribute
142
+ rows, content model, lexical text — through the shared
143
+ `validate_local` tail that global declarations use, and
144
+ grandchildren type through the particle chain.
145
+
146
+
147
+
3
148
  ## [1.9.324] - 2026-10-08
4
149
 
5
150
  ### 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.331
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;