UTF32ToUTF8 turns a code-point automaton into the byte automaton that regexp, wildcard and automaton queries run on terms. When a range spans UTF-8 lengths, it also lets E0 and F0 be followed by any continuation byte, so overlong 3- and 4-byte sequences, which are not UTF-8, are accepted.
convert() builds for . next to what UTF-8 allows, and the search that counts 2 documents instead of 1. Select the still to open it at full size. Generated with AI, checked against the code.- Index the 3 bytes
E0 80 80as a binary term (new StringField("f", new BytesRef(bytes), Store.NO)) next to the terma. These bytes are not UTF-8: they are an overlong spelling of U+0000, whose real encoding is00. The smallest real 3-byte character isE0 A0 80(U+0800). - Search with
RegexpQuery(".")(exactly one character),WildcardQuery("?")or anAutomatonQueryfor [U+0001, U+10FFFF]. You expect 1 hit,a. You get 2 hits for each query, and no error. - Why: Lucene turns the code-point range into a byte automaton with
UTF32ToUTF8, and itsall()lets any continuation byte80..BFfollow the lead byteE0orF0. Real UTF-8 allows onlyA0..BFafterE0and90..BFafterF0. - One code point decides it: [U+007F, U+0FFF] compiles to 3,969 byte strings for 3,969 code points, but [U+007F, U+1000] to 6,018 for 3,970. Every range from below U+0800 to U+1000 or above gains the 2,048 strings
E0 80..9F xx; from below U+10000 to U+40000 or above, the 65,536 stringsF0 80..8F xx xx. - #12472 (9.8.0) fixed only ranges whose last code point starts with
E0orF0. The rest dates from 2010. Terms indexed from Java strings are always valid UTF-8 and are never affected.
Only applications that index binary terms that are not UTF-8 (for example StringField with a BytesRef) and search them with RegexpQuery, WildcardQuery or AutomatonQuery. Default settings, no scale, no error: the queries silently match extra terms; [^/]* ("no slash") matches bytes Lucene decodes to a/b. No affected application is known.
Mechanism, terms and numbers1,024 words
UTF32ToUTF8 converts a code-point automaton into the byte automaton that RegexpQuery, WildcardQuery, AutomatonQuery and ByteRunAutomaton run against indexed terms, and promises an "equivalent UTF-8 representation". For a code-point range that starts below U+0800 and ends at or above U+1000, the byte automaton also accepts the 2,048 overlong 3-byte sequences E0 80..9F xx; for a range that starts below U+10000 and ends at or above U+40000, also the 65,536 overlong 4-byte sequences F0 80..8F xx xx. makeAnyChar(), the regexp ., the wildcard ? and negated classes such as [^/] are such ranges. Valid UTF-8 terms are not affected, but binary terms that are not UTF-8 are matched by Unicode queries that should reject them: next to the term a, the binary term E0 80 80 makes RegexpQuery("."), WildcardQuery("?") and an AutomatonQuery for [U+0001, U+10FFFF] each count 2 documents instead of 1. #12458 reported this bug class in 2023 and its fix #12472 (9.8.0) handled only ranges whose last code point has lead byte exactly E0 or F0; the rest has been there since the class was written in 2010 (LUCENE-2265, first released in 4.0.0-ALPHA).
- UTF-8 lengths: U+0000..U+007F take 1 byte, U+0080..U+07FF 2, U+0800..U+FFFF 3 (lead byte
E0..EF), U+10000..U+10FFFF 4 (lead byteF0..F4). Each further byte is a continuation byte80..BF. - Overlong sequence: a longer spelling of a code point that has a shorter one. UTF-8 forbids them, so each Unicode scalar value (every code point except the surrogates U+D800..U+DFFF) has exactly one encoding. That is why the byte after
E0must beA0..BF(E0 80 80would be U+0000; the first real 3-byte code point U+0800 isE0 A0 80) and the byte afterF0must be90..BF(U+10000 isF0 90 80 80). For 2 bytes the same rule makesC0andC1illegal lead bytes. UTF32ToUTF8: converts each code-point range of an automaton into byte transitions.CompiledAutomaton(:237) andByteRunAutomaton(:45) use it, soRegexpQuery,WildcardQueryandAutomatonQuery(defaultisBinary = false) run its output against the term dictionary.
- When the two ends of a range have different UTF-8 lengths,
build()takes its last branch (195-212):start()for the lower end, a middle loop for each whole length class in between (200-208), andend()for the upper end (211). all()(268-282) emits "a lead byte in a range, thenleftcontinuation bytes80..BF" (lines 276, 280). It never narrows the byte afterE0orF0.- The middle loop calls
all(E0, EF, 2)for the whole 3-byte class (whenlo < U+0800andhi >= U+10000), which includesE0 80..9F xx. The 2-byte class starts atC2(startCodes, line 34), soC0/C1stay out. end()atupto == 0covers the lead bytes below the upper end's lead byte fromstartCode = lead & ~mask(257), which isE0orF0, throughall(...)(259-261). This runs when the upper end's lead byte isE1..EF(hi >= U+1000) orF1..F4(hi >= U+40000).- The two special cases #12472 added (248-255) apply only at
upto == 1and only when the upper end's lead byte is exactlyE0(second byte fromA0) orF0(from90). The 2-byte case beside them (243-247) assertsupto == 0and starts the lead byte atC2; it is the 2010 special case, rewritten by #12472 with the same effect.
For [U+007F, U+1000] (7F to E1 80 80): start gives 7F; the middle loop gives C2..DF 80..BF; end computes startCode = E1 & ~0F = E0 and emits all(E0, E0, 2) = E0 80..BF 80..BF, of which only E0 A0..BF 80..BF is real UTF-8, so 32 x 64 = 2,048 overlong strings get in; then E1 80 80. For [U+007F, U+0FFF] the upper end is E0 BF BF, step 4 emits nothing and the E0 special case applies: exact.
makeAnyChar() = [U+0000, U+10FFFF] hits both sources (the middle loop's E0 box and end()'s all(F0, F3, 3)), and so do the regexp . (RegExp.java:1447), the wildcard ? (WildcardQuery.java:83) and negated classes, which are makeAnyChar() minus the excluded characters (RegExp.java:1302): [^/]* accepts 61 E0 80 AF 62, which Lucene's lenient decoder turns into a/b.
For one range [lo, hi], the byte language is exactly the shortest-form encodings of lo..hi, plus the box E0 80..9F 80..BF iff lo < U+0800 and hi >= U+1000, plus the box F0 80..8F 80..BF 80..BF iff lo < U+10000 and hi >= U+40000, and nothing else. (Under Lucene's convention, shared by the study's oracle, a surrogate code point in the range counts with its mechanical encoding ED A0..BF xx; RFC 3629 does not allow those either, but they are not overlong.) Checked by the study verifier on 828 ranges (all 259.8M canonical encodings plus an exact count of accepted strings); re-run here with the study's exact checker on all 9,472,128 ranges with ends below U+1100 (524,288 bad = 2,048 x 256) and on 262,177 enumerated whole automata (176,200 differ from a reference encoder). JBMC on a verbatim extraction (the extracted code, checked equal to the real class on 8,000,000 pairs; accepted strings of up to 4 bytes): complete for all ranges, proved sound for same-length ranges and 1->2 bytes, and a counterexample in each of the length classes (1,3), (1,4), (2,3), (2,4) and (3,4). Not every range in those classes is unsound: a 2->3-byte range with hi < U+1000 is exact, as the conditions above say.
Valid UTF-8 is safe: a valid term never has 80..9F after E0 or 80..8F after F0, and the automaton is built one code point per edge, so a valid term only goes through shortest-form pieces (reasoning, backed by the single-range checks). The surrogate block ED A0..BF xx is also accepted for makeAnyChar(), but those are the encodings of U+D800..U+DFFF, which are in the range: not part of this report.
Exposure and likelihood457 words
- Who is exposed. Applications that index non-UTF-8 terms through a binary route, such as
StringField(String, BytesRef, Store)(StringField.java:69-79; the route that was run),KeywordField(String, BytesRef, Store)(KeywordField.java:78), a token stream with aBytesTermAttribute(TermToBytesRefAttribute.java:46-48), a customIndexableField, or segments holding such terms copied in withaddIndexes, and query the same field withRegexpQuery,WildcardQuery,AutomatonQuerywith the defaultisBinary = false, or anything else that compiles a code-point automaton (CompiledAutomaton.java:236-237,ByteRunAutomaton.java:27-29). Lucene expects such terms: "it can be provided with a binary (not necessarily UTF8!) term" (CompiledAutomaton.java:231-233). - Not exposed. Terms indexed from Java strings: Lucene's encoder always writes valid UTF-8 and replaces unpaired surrogates with
EF BF BD(UnicodeUtil.java:172-176,:233).TermQuery, which builds no automaton and seeks the exact term bytes (TermQuery.java:141, 212-213).PrefixQueryandTermRangeQuery, which use binary automata (PrefixQuery.java:40,TermRangeQuery.java:88-92). - Reachable in the source, not run end to end:
FuzzyQuery(FuzzyAutomatonBuilder.java:63, 73use the 3-argumentCompiledAutomatonconstructor, which passesisBinary = false,CompiledAutomaton.java:142-143),Intervals.wildcardandIntervals.regexp(Intervals.java:206, 239),SpanMultiTermQueryWrapper(rewrites through the wrapped query,SpanMultiTermQueryWrapper.java:120-121), and the doc-values rewrite, which runs the query's compiled automaton on doc-values terms (DocValuesRewriteMethod.java:98-99). Whether each can match an overlong term depends on the automaton it builds. - Direct
convert()callers get an automaton with too many strings (6,018 for the 3,970 code points of [U+007F, U+1000]).FuzzySuggesterandFuzzyCompletionQuerycall it directly but intersect it with suggestions built by the analyzer, so no wrong suggestion is expected when those terms are valid UTF-8 (reasoning; their tests pass with and without the fix). An analyzer that emits binary terms was not analysed. - No setting needed, and silent. Once the prerequisite exists (non-UTF-8 terms in a field that also gets Unicode queries, itself uncommon), no setting is involved, a one-character pattern triggers it, and nothing fails: the query just matches extra terms. In exclusion patterns the extra term is the one the filter meant to exclude (
[^/]*accepts the overlong/in61 E0 80 AF 62). - How likely. Low. A Unicode pattern can reach the overlong bytes only if the rest of the term is valid UTF-8, and random binary keys almost never are. Java's modified UTF-8 (
C0 80for U+0000) and CESU-8 (ED ..surrogate pairs) do not produce these bytes. The #12472 CHANGES entry calls the scenario "extremely rare" (lucene/CHANGES.txt:3425-3427). "Low" is a judgement; prevalence was not measured. - Inconsistent. Ranges one code point apart differ ([U+007F, U+0FFF] is exact, [U+007F, U+1000] is not), and 2-byte overlongs (
C0 80) are rejected while 3- and 4-byte ones are accepted.
The test shows that the UTF-8 automaton that UTF32ToUTF8 builds (via ByteRunAutomaton) for the code-point range [U+0000, U+1000] accepts the overlong 3-byte sequence E0 80 80.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.util.automaton;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestUTF32ToUTF8Overlong extends LuceneTestCase {
public void testRangeFromOneToThreeBytesRejectsOverlong() {
ByteRunAutomaton a = new ByteRunAutomaton(Automata.makeCharRange(0, 0x1000));
byte[] overlong = {(byte) 0xE0, (byte) 0x80, (byte) 0x80};
// Expected: rejected (E0 80 80 is an overlong U+0000, not UTF-8); actual: accepted.
assertFalse(
"expected [U+0000, U+1000] to reject E0 80 80, but it accepted", a.run(overlong, 0, 3));
}
}
# in a copy of this finding's repro folder (repro/LFV-057/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.util.automaton.TestUTF32ToUTF8Overlong
- Expected vs actual
- Expected: rejected (E0 80 80 is an overlong U+0000 and the UTF-8 encoding of no code point); actual (-ea and -da): accepted.
More files, environment, full output41 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: expected [U+0000, U+1000] to reject E0 80 80, but it accepted
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.assertTrue(Assert.java:42)
org.junit.Assert.assertFalse(Assert.java:65)
org.apache.lucene.util.automaton.TestUTF32ToUTF8Overlong.testRangeFromOneToThreeBytesRejectsOverlong(TestUTF32ToUTF8Overlong.java:27)- The class promises the "equivalent UTF-8 representation" (
UTF32ToUTF8.java:27), and UTF-8 forbids overlong forms. - Upstream already fixed one sub-case as a bug: #12458 (
type:bug) and #12472, under 9.8.0 "Bug Fixes": "would sometimes accept extra invalid UTF-8 binary sequences". - Visible without indexing or matching any invalid input:
convert()of [U+007F, U+1000] yields 6,018 strings for 3,970 code points.
The full argument240 words
- Contract.
UTF32ToUTF8.java:27: "Converts UTF-32 automata to the equivalent UTF-8 representation.":287: "Converts an incoming utf32 automaton to an equivalent utf8 one."Automata.java:104:makeAnyChar()"accepts any single codepoint";:137-138:makeCharRange"accepts a single codepoint whose value is in the given interval (including both end points)". - #12458 (gsmiller, 2023-07-24,
type:bug): "This happens when a transition in the input automaton contains a code-point range that spans different UTF8 byte counts"; its example was U+FFFF/U+10000 acceptingF0 80..8F xx xx. mikemccand replied: "This likely also occurs for the other UTF-8 byte transitions (1->2, 2->3, 3->4, 1->3, etc.)", and quoted thebuild()middle loop that still emits one of the boxes. - #12472 (tang-hi;
ec1367862d0, 9.x20748b7a505, first released in 9.8.0) is under "Bug Fixes" (lucene/CHANGES.txt:3409, 3425-3427). Its regression testTestUnicodeUtil#testUTF8SpanMultipleBytes(:195-220) asserts the exact string count (6) for the adjacent pairs [U+007F, U+0080], [U+07FF, U+0800], [U+FFFF, U+10000] only. - Tests cannot see it today.
TestUTF32ToUTF8.testRandomRangesdoes test ranges that span lengths (both ends from all four length buckets,TestUTF32ToUTF8.java:121-163), but it only feeds shortest-form encodings (matches(),:41-46), so it cannot see extra accepted strings; no test feeds such a range overlong bytes or counts its strings, except the #12472 test for the three adjacent pairs. - Stated intent elsewhere. In review of the open PR #13072 its author wrote that after
UTF32ToUTF8"transitions intentionally have gaps for illegal UTF-8 byte sequences".
The devil's-advocate check483 words
The strongest case for "not a bug": (1) ByteRunAutomaton is "Automaton representation for matching UTF-8 byte[]" (ByteRunAutomaton.java:19) and BytesRef.utf8ToString() "May throw ... if the data is not well-formed UTF-8" (BytesRef.java:134-135), so overlong input is outside the documented domain. (2) The 2010 code knowingly allowed the extra edges: "special case -- avoid created unused edges (utf8 doesn't accept certain byte sequences) -- there are other cases we could optimize too", and the Python prototype in the tree lists extra paths under "could be more minimal" (UTF32ToUTF8.py:23-26). (3) In the #12472 review mikemccand wrote: "Net/net I don't think this will impact any users ... users who index binary content would search it with binary terms (not via conversion from Unicode, where this bug lurks) unless they had a truly exotic use case." (4) The fix makes automata bigger, and terms that are valid UTF-8 are never mis-matched today. (5) For total automata such as .*, CompiledAutomaton already matches every term, UTF-8 or not (type ALL, CompiledAutomaton.java:179-199, 352-353). (6) UTF32ToUTF8 is @lucene.internal; AutomatonQuery and CompiledAutomaton are @lucene.experimental. (7) #12458 was closed after #12472, which could be read as upstream accepting what was left.
Why it fails: the conversion's own output is not equivalent to its input (6,018 strings for 3,970 code points), found by enumerating its output, without indexing or matching any invalid input, which is what #12472's regression test checks. Upstream filed and fixed the same symptom as a bug, and a committer expected the other transitions to be affected. In the #12472 review mikemccand asked whether "there are other cases we could optimize too" was "still true" and asked for "the three-to-four and one-to-two cases" in the test; the comment was then deleted and no leftover case was named. The discussion contains no explicit decision about the leftover cases; #12458 closed as completed when #12472 merged, after the reviewer called the fix "fundamentally correct", which more likely means it was believed complete (point 7). Point 3 is about impact, not intent: the same comment proposed the CHANGES wording that names this exact scenario. Point 5 is the separate shortcut for total automata; apart from the two boxes, non-total ones such as . or [^/]* reject the other invalid forms (C0/C1, sequences above U+10FFFF, lone continuation bytes); they accept the encoded surrogates ED A0..BF xx, because U+D800..U+DFFF are in the code-point range (not part of this report). Point 6 limits who may call the class, not what "equivalent" means. Verdict: a real, long-standing defect with low practical impact; because the fix has a size cost, upstream may prefer to fix or to document (option C). Terms that are valid UTF-8 are never mis-matched. The cross-model refute review rated the "intended" argument weak; the later cross-model fact check rated it moderate, while still finding the evidence against intent stronger.
Recommended Option A: give lead byte E0/F0 its own, narrower second byte in all()
In all(), give a lead byte E0 or F0 its own state whose next byte is A0..BF or 90..BF (+12 lines). The repro and 357 related tests pass, the exact checker finds 0 bad ranges, and the same logic was model-checked for all ranges. Cost: regexp . grows from 14 to 19 run-automaton states (option B: 16).
All options and trade-offs3 options ยท 767 words
Both fixes change only all(), the one place that emits the boxes: it is called with lead byte E0 and 2 continuation bytes, or F0 and 3, only from the two buggy paths (start() and the same-length middle pass a lead byte + 1 or continuation bytes; lines 184-191, 227-229). Both were validated in a scratch worktree at ea57360912ed with gradle and JDK 25; the worktree was removed afterwards. Automaton sizes (run automaton / minimal DFA states):
| automaton | main | option A | option B |
|---|---|---|---|
regexp . | 14 / 6 | 19 / 8 | 16 / 8 |
WildcardQuery("*foo*") | 104 / 12 | 144 / 16 | 120 / 16 |
WildcardQuery("f?o") | 16 / 8 | 21 / 10 | 18 / 10 |
convert() of 528 boundary ranges, summed (states / minimal) | 4,973 / 3,432 | 5,537 / 3,676 | 5,209 / 3,676 |
The minimal sizes are the same for both fixes because their languages are the same; no correct fix can avoid that growth. No query-speed benchmark was run.
Option A: give lead byte E0/F0 its own, narrower second byte in all() (validated by the checks) (recommended)
+12 lines at the top of all(): when the lead-byte range starts at E0 with 2 continuation bytes left (or F0 with 3), emit that lead byte on its own, followed by A0..BF (or 90..BF) and the remaining 80..BF bytes, then continue the range from E1 (or F1). It is the study verifier's patch with a new comment, and the same logic as the study's JBMC-checked version.
private void all(int start, int end, int startCode, int endCode, int left) {
// The shortest 3-byte sequence is E0 A0 80 (U+0800) and the shortest 4-byte one is F0 90 80 80
// (U+10000): after a lead byte E0 or F0, a second byte below A0 or 90 would be an overlong
// (invalid) encoding, so that lead byte gets its own, narrower second-byte range.
if ((left == 2 && startCode == 0xE0) || (left == 3 && startCode == 0xF0)) {
int n = utf8.createState();
utf8.addTransition(start, n, startCode);
all(n, end, startCode == 0xE0 ? 0xA0 : 0x90, 0xBF, left - 1);
if (startCode == endCode) {
return;
}
startCode++;
}
... (unchanged: emit [startCode, endCode] followed by `left` bytes 80..BF)
}Trade-offs
- Run automata grow: regexp
.14 -> 19 states,*foo*104 -> 144 (+38%), 528 boundary ranges +11% (table above). - Smallest diff of the two, and the logic is covered by the study's JBMC proof.
What the checks validated
- Repro: 9 of 9 pass with
-eaand-da; the plain program exits 0. - gradle, seed
DEADBEEF::lucene:core:testoverorg.apache.lucene.util.automaton.*(with the repro and the 2 regression tests),TestUnicodeUtil,TestRegexp*,TestWildcard*,TestAutomatonQuery*,TestPrefix*,TestFuzzy*,TestTermRangeQuery,TestTermsEnum*, and:lucene:suggest:testTestFuzzySuggester,TestFuzzyCompletionQuery: 357 tests, 0 failures, 3 skipped (stock: 10 failures, all in the new tests);checkGoogleJavaFormatpasses. - gradle with
-Ptests.asserts=false -Ptests.seed=1234ABCDonTestUTF32ToUTF8*: 17 of 17 pass. - The study's exact checker: 0 bad among all 9,472,128 ranges with ends below U+1100, 994,755 structural and 300,000 random ranges; 0 of 262,177 whole automata differ from a reference encoder. The verifier's 828-range characterization: no overlong and no other extra string.
- Study: JBMC proves the same logic (in the extracted code, accepted strings of up to 4 bytes) sound and complete for all 0 <= lo <= hi <= U+10FFFF.
+35/-9 lines: all() is restructured so that E0 (or F0) gets its own state, which joins the shared chain of continuation states after its narrow second byte, instead of a copy of the whole chain.
Trade-offs
- About 60% fewer extra run-automaton states than option A (regexp
.: 16 instead of 19;*foo*: 120 instead of 144; 528 ranges +4.7% instead of +11%); the same minimal DFAs. - A larger diff to review, and not covered by a model-checking proof.
What the checks validated
- The same runs as option A, all passing: repro 9 of 9 (
-ea,-da), plain program, gradle 357 tests with 0 failures andcheckGoogleJavaFormat, the assertions-off gradle run, the exact checker (0 bad ranges, 0 of 262,177 automata) and the 828-range characterization.
State in the UTF32ToUTF8, ByteRunAutomaton and AutomatonQuery javadocs that the byte automaton is equivalent only on valid UTF-8, and that Unicode queries can match some non-UTF-8 binary terms.
Trade-offs
No size cost, but it leaves the #12458 class half fixed, contradicts "equivalent", and keeps ranges one code point apart behaving differently. Not recommended.
Semi-formal verification in the 2026-10-22 study: an exact UTF-8 range checker compared UTF32ToUTF8 on the real jar with a reference encoder over millions of ranges, and JBMC model checking of a verbatim extraction gave counterexamples for 5 length classes and proved the fix. The AI tool read #12458 only after the checker had found it.
The method in full207 words
Method: bounded-exhaustive translation validation plus bounded model checking (the study's "semi-formal" tier). Study run 3, experiment "automaton-verification", finding F3 (2026-10-22, main 7cecf79523).
- An AI tool wrote an exact reference semantics for UTF-8 ranges and compared the real
UTF32ToUTF8+ByteRunAutomatonwith it on every range with ends below U+1100 (9.47M ranges), 994,755 structural ranges, 300,000 random ranges and 262,177 enumerated whole automata. - It then ran JBMC 6.11 on a verbatim extraction of
UTF32ToUTF8.java:33-283(checked equal to the real class on 8,000,000 range and byte-string pairs), split by the UTF-8 lengths of the two ends. Main: complete for all ranges, with a counterexample in each of the length classes (1,3), (1,4), (2,3), (2,4), (3,4); every counterexample was replayed on the real jar. The fixed version: sound and complete for all ranges (for the extracted code and accepted strings of up to 4 bytes). - The AI tool reports that it saw #12458 and #12472 only after the harness had found the problem (README section 10).
- A second, independent AI tool re-checked it with its own RFC 3629 oracle, test and patch, and the public-list rerun reproduced it on
b83101cdd2. On 2026-10-26 the hunt reduced it to the 9-test JUnit and re-ran it on main and old releases.
No report of the leftover cases (searched 2026-10-26: 27 GitHub, 48 mailing-list, 6 JIRA and 8 web queries, plus all 413 open PRs). The closed parent #12458 describes the same bug class, and its fix #12472 covered one sub-case, so upstream will likely read this as an incomplete fix.
Searches6 searches
Verdict of the prior-report follow-up: partially known. Nobody reported the leftover cases and upstream has not fixed them, but #12458 describes the class in general terms and mikemccand expected the other transitions to be affected. #12458 is closed, so a new issue should link #12458 and #12472. Users could have hit this without recognizing it, so no report is not proof that nobody was affected.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-26 | 27 tool queries: UTF32ToUTF8, TestUTF32ToUTF8, testUTF8SpanMultipleBytes, makeAnyChar, makeCharRange, overlong, "invalid UTF-8", non-shortest, E0 80, F0 80, 12458, 12472, and items created after the fix; 18 related threads read in full | no report of the leftover cases; parent #12458 (closed) and fix #12472; #12451, #12461, #12470, #13310 are the same episode or refactors |
| Open PRs | 2026-10-26 | changed files of all 413 open apache/lucene PRs | none changes UTF32ToUTF8.java; #13072 and #16564 (match-all-suffix optimizations) are related context |
| Mailing lists (lists.apache.org) | 2026-10-26 | 48 tool queries on dev@, java-user@, issues@ and commits@; 32 manual queries on dev@, java-user@, issues@ and users@, limited to 2023-07-01 .. 2026-10-26 | no report; issues@ hits mirror the GitHub items |
| JIRA archive | 2026-10-26 | 6 tool queries and 6 manual JQL searches (LUCENE, SOLR, LUCENENET); the LUCENE-2265 comments | nothing about overlong acceptance |
| Web and downstream trackers | 2026-10-26 | 14 web queries (2 throttled), 4 searches on downstream search-engine trackers and apache/solr | no report; only items showing that some fields hold non-UTF-8 terms |
| Upstream fix status | 2026-10-26 | history of UTF32ToUTF8.java on 11 branches; git log --grep over the full history | not fixed; newest commit ce50d3a135d (2025-02-24, #14228, a modifier change) |
Present since UTF32ToUTF8 was written (LUCENE-2265, 5b74a4ec611, 2010), so in every release from 4.0.0-ALPHA to 10.5.2 and on main; #12472 (9.8.0) removed only the E0/F0-end sub-case. All 130 GA releases were run.
Versions tested6 rows
Introduced by 5b74a4ec611 (LUCENE-2265, 2010-05-03; committed by mikemccand, issue opened by rmuir), which created UTF32ToUTF8 and ByteRunAutomaton and moved term matching from decoded UTF-16 to raw bytes. The 2014 port to the new Automaton API (LUCENE-5752, 4.10.0) kept the logic. Narrowed by #12472 (ec1367862d0 on main, 20748b7a505 on 9.x, first released in 9.8.0). The history probe has 14 sanity checks, 12 leftover checks and the 2 inputs #12472 fixed; all sanity checks passed on every version.
| Version or commit | Verdict | Result |
|---|---|---|
| 1.x to 3.6.2 | no automaton package | no util/automaton source; nothing to test |
5b74a4ec611 (LUCENE-2265), built from source | introducing commit | 10 of 10 leftover inputs accepted (no CompiledAutomaton yet); the 2 #12472 inputs accepted |
| 4.0.0-ALPHA, 4.0.0-BETA, 4.0.0 to 9.7.0 | affected | 12 of 12 leftover inputs accepted; the 2 #12472 inputs accepted (wider form) |
| 9.8.0 to 10.5.2 | after #12472 | 12 of 12 leftover inputs accepted; the 2 #12472 inputs rejected; the JUnit fails 8 of 9 on 9.8.0, 9.12.3 and 10.5.2 |
main ea57360912ed | affected | 12 of 12 (assertions on and off); the JUnit fails 8 of 9; the plain repro fails |
| main with fix A or fix B | not affected | 0 of 12; JUnit 9 of 9 pass; exact checker 0 bad |
Reproduced on main ea57360912ed with and without assertions and on 10.5.2, 9.12.3, 9.8.0 and 9.7.0; confirmed by the study's checker and JBMC, an independent verifier with its own oracle, the public-list rerun, and a cross-model refute review with its own test; both fixes validated with gradle.
Checks and verdicts294 words
Each check worked from the code or its own runs.
| Check | Verdict | What it found |
|---|---|---|
| Study experiment (automaton-verification, F3) | found | Exact range checker on the real jar and JBMC on a verbatim extraction: counterexamples for 5 length classes, replayed on the real jar; the fix proved sound and complete. |
| Study verifier | new bug, low severity | Own RFC 3629 oracle, test and patch: 7 of 9 tests fail on main; exact characterization on 828 ranges; found that a fix adds states rather than saving them. |
| Public-list rerun | reproduces on b83101cdd2 | 9 tests, 7 failures. |
| Hunt verification | real | The 9-test JUnit fails 8 of 9 on main with -ea and -da, on 10.5.2 with -ea and -da, on 9.12.3 and 9.8.0; 9 of 9 on 9.7.0. |
| Cross-model refute review | real, high confidence | An independent reviewer from another model family (OpenAI GPT-5.6) wrote and ran its own 4-test suite: 3 failures on main (-ea and -da) and on 10.5.2, 0 with the fix (re-run by the repro driver). Intended-behaviour argument rated weak. |
| History follow-up | since 2010, first release 4.0.0-ALPHA | Probe run on every release from 4.0.0-ALPHA to 10.5.2 and on the introducing commit; #12472 changes only the 2 inputs it fixed. |
| Prior-report follow-up | partially known (#12458) | No report of the leftover cases; the closed parent describes the class. |
| Fix validation (this brief) | both fixes pass | gradle: 357 tests, 0 failures with fix A and with fix B (stock: 10 failures, all in the new tests); exact checker 0 bad for both; sizes measured. |
| Correction to an input | corrected | The experiment README says the bug costs extra states; it saves them, and a fix adds them (the verifier's and this brief's size runs). |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 538 KB
Finding record, Lucene and upstream sources, repro files and diagram notes634 words
- Registry title
- UTF32ToUTF8 still accepts overlong UTF-8 for ranges that span into 3- and 4-byte code points
- Area
- util / automaton
- Priority
- Low, rank 1186: UTF32ToUTF8 still accepts some overlong UTF-8 for ranges spanning into 3- and 4-byte code points, reachable only with invalid-UTF-8 binary terms that real text never contains.
- Impact class
- silent wrong results
- Impact note
- Only binary terms that are not valid UTF-8, queried with Unicode automata (makeAnyChar, regex '.'), or direct convert() users; released (residual of #12458).
- Summary
- You convert a code-point range that starts below U+0800 and ends at or above U+1000 into a UTF-8 automaton, as makeAnyChar and regex '.' do, and match it against binary terms that are not valid UTF-8. You expect only valid UTF-8 to match. Instead the overlong sequences E0 80..9F xx are accepted.
- Introduced
- LUCENE-2265 (5b74a4ec611), which created UTF32ToUTF8; narrowed, not fixed, by #12472 (ec1367862d0, 9.8.0) [exec] (2010-05-03)
- First affected version
- 4.0.0-ALPHA [exec]
- Affected versions
- every release from 4.0.0-ALPHA to 10.5.2 and main (all run) [exec]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
- Found by
- semi-formal: JBMC, exhaustive check
- Upstream and prior reports
- none found as a report of the residual (study searches 2026-10-22); closed #12458 is the parent
- Lucene commit tested
- main
ea57360912ed(commit 2026-10-24; tested 2026-10-26): the plain-Java repro and the JUnit class through the repro runner with -ea and -da; the JUnit class and two regression tests through gradle in a scratch worktree, stock and with fixes A and B (the worktree was removed after the runs); the study's exact UTF-8 checker and the automaton size probes, stock and with both fixes. Official jars (SHA-1 checked): 10.5.2, 9.12.3, 9.8.0 and 9.7.0 for the JUnit class, 10.5.2 and 9.8.0 for the plain repro, and every release from 4.0.0-ALPHA to 10.5.2 for the history probe.UTF32ToUTF8.javaatea57360912edis byte-identical to the study base7cecf79523; the public-list rerun reproduced the bug onb83101cdd2.
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/UTF32ToUTF8.java:27, 34, 153-157, 159-213, 215-232, 234-266, 268-282, 287Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:236-237; ByteRunAutomaton.java:19, 27-29, 45; Automata.java:104-107Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java:696, 1302, 1447; Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/WildcardQuery.java:83
- Who is affected
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/document/StringField.java:69-79Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:231-237; ByteRunAutomaton.java:27-29, 45Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/UnicodeUtil.java:172-176, 233Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/PrefixQuery.java:40; TermRangeQuery.java:88-92; AutomatonQuery.java:59-67; TermQuery.java:141, 212-213; DocValuesRewriteMethod.java:98-99Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/document/KeywordField.java:78; .../analysis/tokenattributes/TermToBytesRefAttribute.java:46-48; .../search/FuzzyAutomatonBuilder.java:63, 73; .../util/automaton/CompiledAutomaton.java:142-143; lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:206, 239; .../queries/spans/SpanMultiTermQueryWrapper.java:120-121Lucene main ea57360912ed: lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FuzzySuggester.java:226; .../document/FuzzyCompletionQuery.java:157Lucene main ea57360912ed: lucene/CHANGES.txt:3425-3427
- Why it looks unintended
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/UTF32ToUTF8.java:27, 29, 287; UTF32ToUTF8.py:23-26; Automata.java:104, 137-138; ByteRunAutomaton.java:19; CompiledAutomaton.java:42, 179-199, 352-353; .../search/AutomatonQuery.java:41Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/BytesRef.java:134-135Lucene main ea57360912ed: lucene/CHANGES.txt:3409, 3425-3427Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/util/TestUnicodeUtil.java:195-220; .../util/automaton/TestUTF32ToUTF8.java:41-46, 121-165https://github.com/apache/lucene/issues/12458 (body; comments of 2023-07-27; read 2026-10-26)https://github.com/apache/lucene/pull/12472 (review comments of 2023-07-30 and 2023-08-01; read 2026-10-26)
- Suggested fix
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/UTF32ToUTF8.java:184-191, 227-229, 268-282
- TestUTF32ToUTF8Overlong.java (JUnit test):
repro/LFV-057/src/org/apache/lucene/util/automaton/TestUTF32ToUTF8Overlong.java(30 lines)
A summary diagram (PNG), made separately from this text from an executed probe on main ea57360912ed. The alt text was checked against the PNG.
An independent diagram check compared every value, API name, line number and code excerpt with the code and its own probe runs; every fact held. It found one layout error in the diagram (section 3: the bar for the one edge of . touched the caption above it).
assets/LFV-057/bug.png(538 KB)
![Summary diagram in three panels. 1: two indexed terms, doc 0 "a" (61) and doc 1 the binary term E0 80 80, whose payload bits are all zero, so it is an overlong spelling of U+0000 (Lucene decodes it to U+0000 too); also, [^/]* accepts 61 E0 80 AF 62, which decodes to "a/b". 2: what UTF32ToUTF8.convert() builds for ".": E0-EF 80-BF 80-BF from the middle loop (lines 203-208) and F0-F3 80-BF 80-BF 80-BF from end() (line 257), where shortest-form UTF-8 needs E0 A0-BF 80-BF plus E1-EF 80-BF 80-BF; all() (lines 268-282) allows 80-BF after every lead byte, and #12472 special-cased only an end lead byte of exactly E0 or F0. 3: the compiled DFA of "." accepts E0 80 80, so RegexpQuery("."), WildcardQuery("?") and AutomatonQuery(makeCharRange(1, 0x10FFFF)) each count 2 documents instead of 1, with no exception; terms that are valid UTF-8 are not affected. Footer: affected are ranges with lo < U+0800 and hi >= U+1000 (2,048 byte strings) or lo < U+10000 and hi >= U+40000 (65,536); every release from 4.0.0-ALPHA to 10.5.2, and main, accepts these bytes.](../assets/LFV-057/bug.png)