All findings · Lucene finding · LFV-057 · utf32toutf8-overlong-residual

UTF32ToUTF8 still accepts overlong UTF-8 for ranges that span into 3- and 4-byte code points

Queries built from code points, like RegexpQuery(".") or WildcardQuery("?"), also match binary terms holding overlong UTF-8 such as E0 80 80: the byte automaton of a range from below U+0800 to U+1000 or above accepts 2,048 such strings. #12472 (9.8.0) fixed one sub-case; valid UTF-8 terms are unaffected.

Summary

Priority
Low, rank 1186UTF32ToUTF8 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
silent wrong results
Since
4.0.0-ALPHA (LUCENE-2265, 2010); narrowed by #12472 in 9.8.0introduced 2010-05-03 · over 16 years
Affected
every release from 4.0.0-ALPHA to 10.5.2 (all run) and main
Status
documented
Found by
exact UTF-8 range checker and JBMC model checking (study)

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

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.
Two indexed terms, what 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.
  1. Index the 3 bytes E0 80 80 as a binary term (new StringField("f", new BytesRef(bytes), Store.NO)) next to the term a. These bytes are not UTF-8: they are an overlong spelling of U+0000, whose real encoding is 00. The smallest real 3-byte character is E0 A0 80 (U+0800).
  2. Search with RegexpQuery(".") (exactly one character), WildcardQuery("?") or an AutomatonQuery for [U+0001, U+10FFFF]. You expect 1 hit, a. You get 2 hits for each query, and no error.
  3. Why: Lucene turns the code-point range into a byte automaton with UTF32ToUTF8, and its all() lets any continuation byte 80..BF follow the lead byte E0 or F0. Real UTF-8 allows only A0..BF after E0 and 90..BF after F0.
  4. 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 strings F0 80..8F xx xx.
  5. #12472 (9.8.0) fixed only ranges whose last code point starts with E0 or F0. The rest dates from 2010. Terms indexed from Java strings are always valid UTF-8 and are never affected.

Who is 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).

Terms

  • 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 byte F0..F4). Each further byte is a continuation byte 80..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 E0 must be A0..BF (E0 80 80 would be U+0000; the first real 3-byte code point U+0800 is E0 A0 80) and the byte after F0 must be 90..BF (U+10000 is F0 90 80 80). For 2 bytes the same rule makes C0 and C1 illegal lead bytes.
  • UTF32ToUTF8: converts each code-point range of an automaton into byte transitions. CompiledAutomaton (:237) and ByteRunAutomaton (:45) use it, so RegexpQuery, WildcardQuery and AutomatonQuery (default isBinary = false) run its output against the term dictionary.

The bug, step by step (code at ea57360912ed, UTF32ToUTF8.java)

  1. 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), and end() for the upper end (211).
  2. all() (268-282) emits "a lead byte in a range, then left continuation bytes 80..BF" (lines 276, 280). It never narrows the byte after E0 or F0.
  3. The middle loop calls all(E0, EF, 2) for the whole 3-byte class (when lo < U+0800 and hi >= U+10000), which includes E0 80..9F xx. The 2-byte class starts at C2 (startCodes, line 34), so C0/C1 stay out.
  4. end() at upto == 0 covers the lead bytes below the upper end's lead byte from startCode = lead & ~mask (257), which is E0 or F0, through all(...) (259-261). This runs when the upper end's lead byte is E1..EF (hi >= U+1000) or F1..F4 (hi >= U+40000).
  5. The two special cases #12472 added (248-255) apply only at upto == 1 and only when the upper end's lead byte is exactly E0 (second byte from A0) or F0 (from 90). The 2-byte case beside them (243-247) asserts upto == 0 and starts the lead byte at C2; 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.

Exact extent

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 a BytesTermAttribute (TermToBytesRefAttribute.java:46-48), a custom IndexableField, or segments holding such terms copied in with addIndexes, and query the same field with RegexpQuery, WildcardQuery, AutomatonQuery with the default isBinary = 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). PrefixQuery and TermRangeQuery, which use binary automata (PrefixQuery.java:40, TermRangeQuery.java:88-92).
  • Reachable in the source, not run end to end: FuzzyQuery (FuzzyAutomatonBuilder.java:63, 73 use the 3-argument CompiledAutomaton constructor, which passes isBinary = false, CompiledAutomaton.java:142-143), Intervals.wildcard and Intervals.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]). FuzzySuggester and FuzzyCompletionQuery call 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 / in 61 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 80 for 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.

Minimal repro

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.

TestUTF32ToUTF8Overlong.java (JUnit test)30 lines
/*
 * 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));
  }
}
Command
# 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.

All files of this repro: repro/LFV-057/

More files, environment, full output41 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# 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)

Why it looks unintended

  • 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 accepting F0 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 the build() middle loop that still emits one of the boxes.
  • #12472 (tang-hi; ec1367862d0, 9.x 20748b7a505, first released in 9.8.0) is under "Bug Fixes" (lucene/CHANGES.txt:3409, 3425-3427). Its regression test TestUnicodeUtil#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.testRandomRanges does 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.

Suggested fix

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):

automatonmainoption Aoption B
regexp .14 / 619 / 816 / 8
WildcardQuery("*foo*")104 / 12144 / 16120 / 16
WildcardQuery("f?o")16 / 821 / 1018 / 10
convert() of 528 boundary ranges, summed (states / minimal)4,973 / 3,4325,537 / 3,6765,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 -ea and -da; the plain program exits 0.
  • gradle, seed DEADBEEF: :lucene:core:test over org.apache.lucene.util.automaton.* (with the repro and the 2 regression tests), TestUnicodeUtil, TestRegexp*, TestWildcard*, TestAutomatonQuery*, TestPrefix*, TestFuzzy*, TestTermRangeQuery, TestTermsEnum*, and :lucene:suggest:test TestFuzzySuggester, TestFuzzyCompletionQuery: 357 tests, 0 failures, 3 skipped (stock: 10 failures, all in the new tests); checkGoogleJavaFormat passes.
  • gradle with -Ptests.asserts=false -Ptests.seed=1234ABCD on TestUTF32ToUTF8*: 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.

Option B: same rule, sharing the remaining continuation states (validated by the checks)

+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 and checkGoogleJavaFormat, the assertions-off gradle run, the exact checker (0 bad ranges, 0 of 262,177 automata) and the 828-range characterization.

Option C: keep the behaviour and document it

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.

How it was found

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 + ByteRunAutomaton with 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-2627 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 fullno report of the leftover cases; parent #12458 (closed) and fix #12472; #12451, #12461, #12470, #13310 are the same episode or refactors
Open PRs2026-10-26changed files of all 413 open apache/lucene PRsnone changes UTF32ToUTF8.java; #13072 and #16564 (match-all-suffix optimizations) are related context
Mailing lists (lists.apache.org)2026-10-2648 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-26no report; issues@ hits mirror the GitHub items
JIRA archive2026-10-266 tool queries and 6 manual JQL searches (LUCENE, SOLR, LUCENENET); the LUCENE-2265 commentsnothing about overlong acceptance
Web and downstream trackers2026-10-2614 web queries (2 throttled), 4 searches on downstream search-engine trackers and apache/solrno report; only items showing that some fields hold non-UTF-8 terms
Upstream fix status2026-10-26history of UTF32ToUTF8.java on 11 branches; git log --grep over the full historynot fixed; newest commit ce50d3a135d (2025-02-24, #14228, a modifier change)

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
1.x to 3.6.2 no automaton packageno util/automaton source; nothing to test
5b74a4ec611 (LUCENE-2265), built from source introducing commit10 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 affected12 of 12 leftover inputs accepted; the 2 #12472 inputs accepted (wider form)
9.8.0 to 10.5.2 after #1247212 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 affected12 of 12 (assertions on and off); the JUnit fails 8 of 9; the plain repro fails
main with fix A or fix B not affected0 of 12; JUnit 9 of 9 pass; exact checker 0 bad

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Study experiment (automaton-verification, F3) foundExact 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 severityOwn 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 b83101cdd29 tests, 7 failures.
Hunt verification realThe 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 confidenceAn 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-ALPHAProbe 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 passgradle: 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 correctedThe 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).

Diagram file

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.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes634 words

Finding record

LFV-057, snapshot of 2026-11-06

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.java at ea57360912ed is byte-identical to the study base 7cecf79523; the public-list rerun reproduced the bug on b83101cdd2.

Lucene and upstream sources

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, 287
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:236-237; ByteRunAutomaton.java:19, 27-29, 45; Automata.java:104-107
  • Lucene 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-79
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:231-237; ByteRunAutomaton.java:27-29, 45
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/UnicodeUtil.java:172-176, 233
  • Lucene 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-99
  • Lucene 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-121
  • Lucene main ea57360912ed: lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FuzzySuggester.java:226; .../document/FuzzyCompletionQuery.java:157
  • Lucene 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:41
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/BytesRef.java:134-135
  • Lucene main ea57360912ed: lucene/CHANGES.txt:3409, 3425-3427
  • Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/util/TestUnicodeUtil.java:195-220; .../util/automaton/TestUTF32ToUTF8.java:41-46, 121-165
  • https://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

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

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).

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.