All findings · Lucene finding · LFV-007 · fst-expanded-size-corruption

FSTCompiler writes a corrupt FST when a node would expand to 2^31 bytes or more

FSTCompiler computes a fixed-length-arc node's padded size as an int, which wraps at 2^31 for a wide, sparse node with one large output; with assertions off, compile() returns normally, every lookup through that node throws and enumeration can return a never-added key. Silent in most releases since 4.2.0 (2013).

Summary

Priority
Low, rank 990FSTCompiler can silently write a corrupt FST when one padded fixed-length node reaches 2 GiB, which needs custom FSTs with huge outputs off the index path, unreported in about 13 years.
Impact
data corruption
Since
4.2.0introduced 2013-01-12 · over 13 years
Affected
4.2.0 to 10.5.2, branch_10_6 and main (9.9.x only with suffixRAMLimitMB(0))
Status
documented
Found by
SMT (Z3) on the node-size arithmetic, then a witness on the real jar

AI-assisted page: this finding was first signalled by a formal tool's own output 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

Result panel of the diagram, with assertions off: compile() returns normally; 2,048 of 2,048 Util.get lookups throw; FST.save() writes the bad bytes, so the damage persists; after a save and an off-heap reload, IntsRefFSTEnum.next() returns key 17,188, which was never added; both controls (a 65,530-byte value, and allowFixedLengthArcs(false)) give all 2,048 keys correctly; and the cliff: a 1,048,569-byte value builds a correct 2,147,481,607-byte FST, while one byte more gives a 1,058,812-byte FST on which every lookup throws.
The compiler on the wide root: the list it writes first, the wrapping size product, the skipped padding, the header over arc 0 and the failing reads. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Build a BYTE2 FST from 2,048 sparse keys (labels 0, 32, ..., 65,504) with ByteSequenceOutputs: key 0 maps to a 1,048,570-byte value, the rest to 1 byte. The root gets fixed-length arcs (on by default); arc 0 is exactly 2^20 bytes.
  2. You expect a correct FST or an error. One byte less gives a correct 2,147,481,607-byte FST with all 2,048 lookups right.
  3. Instead the padded size 2,048 × 2^20 = 2^31 wraps to -2,147,483,648 as an int. The compiler skips the padding but still writes the binary-search header over the first 6 bytes of arc 0.
  4. With assertions off, compile() returns normally. Every Util.get throws (on heap, Index -32704 out of bounds for length 33), and with patterned value bytes, after save() and an off-heap reload, enumeration returns key 17,188, never added.
  5. At build time only assert destPos >= srcPos catches it, and only with assertions on. With allowFixedLengthArcs(false) the same keys read back correctly.

Who is affected

Applications that build their own FST<BytesRef> or FST<CharsRef> with a wide, sparse node and one large output; in-tree, StemmerOverrideFilter.Builder with 2,048 one-code-point words and a 1,048,570-char stem. Not the default index format: no core codec on main uses util.fst, and no way in through the pre-10.3 default terms index is known. No field report; very low reach. Workaround: allowFixedLengthArcs(false).

Mechanism, terms and numbers625 words

FSTCompiler computes the padded size of a node with fixed-length arcs as a Java int. When one node has many arcs and one large output, the size reaches 2^31 and wraps; with assertions off the compiler skips the padding but still writes the header that claims it. compile() returns normally, lookups through that node throw, and enumeration can return a key that was never added. Silent in most releases since 4.2.0 (2013). This is about FSTs that applications build; the default index format on main does not use util.fst.

Terms

  • An FST (finite state transducer) is Lucene's compact map from keys to values. A key is a sequence of integer labels (bytes BYTE1, 16-bit chars BYTE2, or ints BYTE4); the values are outputs. The FST is a graph of nodes, and each arc leaving a node carries one label. FSTCompiler builds it from sorted keys; Util.get, FSTEnum and the arc readers in FST read it.
  • FSTCompiler writes each node in two passes. Pass 1 writes the arcs back to back, each only as long as it needs (flags, label, output, target): a list node that a reader scans from the start. Pass 2 runs for nodes that qualify for fixed-length arcs (depth at most 3 with at least 5 arcs, or at least 10 arcs at any depth; on by default): it pads every arc to the size of the largest one, maxBytesPerArc, so a reader can jump to arc i and binary search the labels. A small header in front says "binary-search node, numArcs arcs, maxBytesPerArc bytes each".

The bug

The padded size is computed as an int: numArcs * maxBytesPerArc (main FSTCompiler.java:665, int destPos = headerLen + nodeIn.numArcs * maxBytesPerArc;). If one arc is big and the node has many arcs, the product reaches 2^31 and wraps. In the binary-search writer the end position destPos then comes out too small, the guard if (destPos > srcPos) (:667) is false, so the arcs are not padded, and the header is written anyway (:695) over the first 6 bytes of arc 0.

The line before the guard, assert destPos >= srcPos (:666), catches it only with assertions on (-ea), while the FST is built. Java runs with assertions off unless told otherwise. Under -da, compile() returns normally and the FST holds the unpadded list plus a header that claims padding:

  • every Util.get under that node throws (IndexOutOfBoundsException or ArrayIndexOutOfBoundsException on heap, IllegalArgumentException: read negative position through MMapDirectory);
  • enumeration (IntsRefFSTEnum.next()) can return a key that was never added, because it decodes the big output's own bytes as an arc (it depends on those bytes and the shape);
  • FST.save() writes the bad bytes as they are, so the corruption survives a save and reload.

The test's numbers

BYTE2, ByteSequenceOutputs, 2,048 sparse root arcs (labels 0, 32, ..., 65,504), arc 0 with a 1,048,570-byte output: arc 0 is 1 + 2 + 3 + 1,048,570 = 2^20 bytes, so 2048 * 1048576 = 2^31, which is -2,147,483,648 as an int. destPos = 6 + (-2,147,483,648) = -2,147,483,642, below srcPos = 1,058,811, so no expansion happens. Util.get(key 32) then probes arc 1,023 about 1 GB before the start of a 1 MB FST, which on heap is block -32,704: Index -32704 out of bounds for length 33.

With allowFixedLengthArcs(false) the same keys build and read back correctly. With a 1,048,569-byte output (product 2,147,481,600) the compiler builds a correct 2,147,481,607-byte FST and all 2,048 lookups are right; one byte more gives a 1,058,812-byte FST on which every lookup throws. The failure is a cliff, not a limit.

Exposure and likelihood280 words
  • Who could hit it. Applications that build their own FST<BytesRef> or FST<CharsRef> maps with a wide node and one large output; #9132 (LUCENE-8084) describes that data shape ("one of the outputs is very long, all the others are small", "root level fanout is huge") but reports only wasted space. One in-tree public API is silent at build time: StemmerOverrideFilter.Builder with 2,048 one-code-point words and one 1,048,570-char stem builds, then every lookup throws. There is no field report.
  • Not the default index format. On main and branch_10_6 no core codec imports util.fst (the default codec is Lucene104). Releases before 10.3 built the default terms index with FSTCompiler, as BYTE1 FSTs with small outputs; no way to reach this bug through them is known.
  • Silent where it matters. Production runs with assertions off, so compile() succeeds and the damage shows only on read. In a deep node other keys keep working and the corruption stays hidden until a key under that node is read (301 of 301 other keys correct, 4,096 of 4,096 under the bad node throw, in one repro).
  • Shape. It needs the binary-search layout, which the compiler picks for sparse labels: for BYTE2, at most 4,096 sparse arcs and one arc of at least 512 KiB; for BYTE1, at most about 33 arcs and one arc of 62-64 MiB or more; for BYTE4, for example 1,048,576 arcs at stride 1024 and one arc of 2 KiB. Contiguous label sets get other layouts and fail loudly (out of memory or ArrayIndexOutOfBoundsException, tried at -Xmx2g and -Xmx8g, not with larger heaps).
  • Reach is very low (registry: data corruption, opt-in shape, extreme scale). Workaround: allowFixedLengthArcs(false).

Minimal repro

The test shows that a default BYTE2 FSTCompiler with 2048 sparse root arcs, one of them 2^20 bytes (a 1,048,570-byte output), so the fixed-length root node would take 2048 x 2^20 = 2^31 bytes and the int size wraps.

TestFSTNodeExpandedSizeOverflow.java (JUnit test)37 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.fst;

import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.IntsRef;

public class TestFSTNodeExpandedSizeOverflow extends LuceneTestCase {

  public void testRootNodeOf2048ArcsWithOneMiBArc() throws Exception {
    FSTCompiler<BytesRef> compiler =
        new FSTCompiler.Builder<>(FST.INPUT_TYPE.BYTE2, ByteSequenceOutputs.getSingleton()).build();
    BytesRef big = new BytesRef(new byte[(1 << 20) - 6]);
    for (int i = 0; i < 2048; i++) {
      BytesRef output = i == 0 ? big : new BytesRef(new byte[] {1});
      compiler.add(new IntsRef(new int[] {i * 32}, 0, 1), output);
    }
    FST<BytesRef> fst = FST.fromFSTReader(compiler.compile(), compiler.getFSTReader());
    // 2048 arcs x 2^20 bytes = 2^31: expected key 0's output, actual a corrupt FST that throws
    assertEquals(big, Util.get(fst, new IntsRef(new int[] {0}, 0, 1)));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-007/, 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.fst.TestFSTNodeExpandedSizeOverflow
Expected vs actual
Expected: Util.get returns key 0's output (or compile() fails). Actual: with -da, compile() returns a corrupt FST and the lookup throws IndexOutOfBoundsException: Index -32704 out of bounds for length 33; with -ea, Lucene's assert destPos >= srcPos (FSTCompiler.java:666) fires inside compile().

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

More files, environment, full output32 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
       org.apache.lucene.util.fst.FSTCompiler.writeNodeForBinarySearch(FSTCompiler.java:666)
       org.apache.lucene.util.fst.FSTCompiler.addNode(FSTCompiler.java:553)
       org.apache.lucene.util.fst.FSTSuffixNodeCache.add(FSTSuffixNodeCache.java:154)
       org.apache.lucene.util.fst.FSTCompiler.compileNode(FSTCompiler.java:388)
       org.apache.lucene.util.fst.FSTCompiler.compile(FSTCompiler.java:1014)
       org.apache.lucene.util.fst.TestFSTNodeExpandedSizeOverflow.testRootNodeOf2048ArcsWithOneMiBArc(TestFSTNodeExpandedSizeOverflow.java:33)

Why it looks unintended

  • The writer's own assertion states the rule that breaks: assert destPos >= srcPos.
  • No size limit is documented; fixed-length arcs are documented as an optimization, and with it off ("smaller but slower to traverse") the same input is correct.
  • It is a cliff, not a limit: one byte less builds a correct 2 GB FST.
The full argument141 words
  • The writer's own assertion states the invariant that breaks: assert destPos >= srcPos.
  • No size limit is documented, and the FSTCompiler class javadoc advertises "FSTs larger than 2.1GB are now possible (as of Lucene 4.2)".
  • Fixed-length arcs are documented as an optimization: "Pass false to disable the fixed length arc optimization ... smaller but slower to traverse". An optimization that changes the result is a bug by the API's own description, and with the optimization off the same input is correct.
  • It is a cliff: one byte less builds a correct 2 GB FST.
  • Precedent: FST size overflows were fixed as bugs before, for example #5804 / LUCENE-4739 (type:bug, "FSTs more than ~1.1GB" could not be saved and loaded) and #5146 / LUCENE-4074 (type:bug, an int overflow in the FST sorter).
The devil's-advocate check201 words

The intended-behaviour checker argued every point for "not a bug": the classes are @lucene.experimental (but that means the API may change, not that output may be corrupt); #12624's review assumed single nodes are small (those comments are about shrinking a scratch buffer, and 105 review comments, 20 conversation comments and 61 reviews never mention overflow, destPos or a (long) cast); an assert guards it (FSTCompiler checks caller contracts with asserts, but this one is an internal invariant that legal input breaks, and about 25% of main's test seeds run with assertions off); it is a pathological shape and a known space limitation (#5747, #9132 are about wasted space only); the format cannot hold a 2 GiB fixed-length node (true, but the input does not need one: the list encoding is 1 MB); and nobody has hit it in about 13 years (that speaks to reach, not intent).

Verdict: not intended. Not documented, not an unsupported configuration, and no documented limit is exceeded; a real correctness bug with very low practical reach. Not a security issue: SECURITY.md says reports that feed arbitrary data into low-level APIs are generally not vulnerabilities but "are still valid bug reports", so a public issue is right.

Suggested fix

Recommended Keep the variable-length list for nodes whose fixed-length area would exceed 1 GiB

Keep the variable-length arc list the compiler already wrote when a node's fixed-length arc area, computed as a long, would exceed 1 GiB: one guard before all three fixed-length writers, no reader or format change. The test then passes with assertions on and off. Cost: 1-2 GiB nodes stay lists, so lookups there are a linear scan.

private static final int FIXED_LENGTH_ARC_MAX_NODE_BYTES = 1 << 30;
...
// if the expanded node would be too large, keep the variable length arcs written above
if (doFixedLengthArcs
    && (long) nodeIn.numArcs * maxBytesPerArc <= FIXED_LENGTH_ARC_MAX_NODE_BYTES) {
All options and trade-offs5 options · 460 words

The checks validated keeping oversized nodes as lists; the alternatives come from the brief and the fix README.

Keep the variable-length list for nodes whose fixed-length area would exceed 1 GiB (validated by the checks) (recommended)

Local branch fix-fst-expanded-size-overflow, commit 13e3e5e3f5 on main b4383f066b (3 files, +37/-2). A node whose fixed-length arc area would be larger than 1 GiB keeps the variable-length list that pass 1 already wrote, which is a valid encoding. It uses the hook the commented-out LUCENE-4682 heuristic already reserves, one guard before all three fixed-length writers (continuous, direct addressing, binary search), with no reader or format change. The cap also keeps the other size products and the reader's bytesPerArc * mid in int range.

private static final int FIXED_LENGTH_ARC_MAX_NODE_BYTES = 1 << 30;
...
// if the expanded node would be too large, keep the variable length arcs written above
if (doFixedLengthArcs
    && (long) nodeIn.numArcs * maxBytesPerArc <= FIXED_LENGTH_ARC_MAX_NODE_BYTES) {

Trade-offs

  • A node whose arc area is between 1 GiB and 2 GiB used to be expanded correctly; now it stays a list, so lookups there are a linear scan (slower, but it saves at least 1 GiB of build heap).
  • Nodes over the cap keep pass-1 arcs without BIT_TARGET_NEXT: valid, slightly larger than a pure list. Output is byte-identical whenever the arc area is at most 2^30.
  • The CHANGES entry sits under 10.7.0 with GITHUB#TBD placeholders, which is probably stale (10.6 is expected to be the last 10.x); let the committer choose. The commit was written by an AI tool.

What the checks validated

  • The fix hunk applies cleanly to main and to branch_10_6; the test passes 5/5 with -ea and 5/5 with -da on both.
  • org.apache.lucene.util.fst.*: 38 tests, 0 failures; :lucene:core:check -x test and error-prone pass on main.
  • All probes and repros are correct on the fixed jars, including shapes that used to fail loudly. Boundary: at exactly 2^30 the node still expands (1,073,741,831 bytes, 2048/2048 correct); one byte more per arc keeps a 534,525-byte list (2048/2048 correct).

Restore the (long) cast

Compute the product as a long again, as 9.4.2 did.

Trade-offs

Not enough on its own: the scratch buffer is a byte[], so the bug only turns into another exception; 9.4.2-9.8.0 also show that a 2 GB node hit a second truncation.

Throw an IllegalArgumentException when the node is too large

Fail loudly in the compiler.

Trade-offs

Loud, but it rejects input that has a valid encoding (the list).

An exact bound near MAX_ARRAY_LENGTH

Use the largest size the scratch array can hold as the cap.

Trade-offs

More code and little headroom.

A waste heuristic (#5747 / #9132)

Decide fixed-length arcs by wasted space, as those issues propose.

Trade-offs

A format-affecting design change, out of scope for this bug.

How it was found

SMT solving (Z3): while modelling another finding, an AI tool transcribed FSTCompiler's encoding-decision arithmetic into Z3; a second model's query for the silent path (padding skipped, header still written) was satisfiable with default settings. A concrete witness then compiled under -da on the real jar, and its lookups threw. Not a blind discovery.

The method in full284 words

Method: SMT (Z3) on the transcribed size arithmetic of FSTCompiler, then a concrete witness on the real jar (the study's "formal" tier). Run 3, experiment "fst-verification" (2026-10-22, main 7cecf79523).

  • The task targeted util/fst, named FSTCompiler, the binary-search, direct and continuous arc encodings, fixed-length arcs and their thresholds, and asked for Alloy/Dafny models and bounded-exhaustive checks; the shared preamble named SMT (Z3/cvc5, bit-vector and floating-point theories) as a general technique. It said nothing about size arithmetic.
  • while modelling a different finding, the AI tool transcribed the encoding-decision arithmetic into a Z3 bit-vector and floating-point model that contains int sizeForBinarySearch.
  • 06:10: a Java probe for "expanded size exceeds 2^31" gave only loud failures. 06:11: the AI tool wrote smt/expand_overflow.py, whose query Q6 encodes the silent path: binary search chosen, destPos <= srcPos, expansion skipped, header still written.
  • 06:36: after bounding srcPos correctly, Z3 reported Q6d satisfiable (default factor, credit 0) for 5, 10 and 64 arcs; the final log (06:47) has it unsatisfiable for 200 and 255 arcs within a 512 MB first-pass bound, where direct addressing is chosen instead.
  • 06:37: a concrete re-execution of the same arithmetic picked a witness (64 arcs, one 33,554,427-byte output). On the real jar under -da it compiled and the lookups threw: the first observation of the silent corruption. 06:38: a JUnit repro.
  • The experiment's bounded-exhaustive harness (7.0M FSTs, no discrepancy) cannot reach nodes of 2^31 bytes. A separate AI verifier wrote its own tests and ran 9.3.0, 9.4.2 and 9.9.2.

So the AI tool formed the mechanism while reading and modelling the code, Z3 showed the silent branch is reachable with default settings, and a concrete scan picked the sizes. Not "blind".

Prior reports, history and checks

Prior reports

No prior report in GitHub issues and PRs (about 110 searches; all 410 open PRs), the dev@, java-user@ and issues@ lists, or Elasticsearch, OpenSearch and Solr, searched 2026-10-23. The closest, #5747 and #9132, are about wasted space, not corruption.

Searches5 searches

Nothing reports this bug. The closest items are about wasted space in fixed-length arcs, not corruption.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs, incl. migrated JIRA (apache/lucene)2026-10-23about 75 search/issues phrasings (open and closed, titles, bodies, comments), for example FSTCompiler overflow, maxBytesPerArc, writeNodeForBinarySearch, sizeForBinarySearch, destPos, allowFixedLengthArcs, "array arcs", "never added", findTargetArc, NarrowCalculation; other AI tools ran 35 more, about 110 in all; all 95 items created since 2026-10-10 listedno report; related, not duplicates: #5747 (LUCENE-4682, wasted bytes from array arcs, open) and #9132 (LUCENE-8084, the same shape, space only); the only "FSTCompiler overflow" hit, #11643 (LUCENE-10607), is unrelated
#12624 review2026-10-23105 review comments, 20 conversation comments, 61 reviewsno mention of overflow, destPos or a (long) cast; single-node size discussed as a buffer question
Open PRs2026-10-23all 410 open PRsnone touches FSTCompiler.java
Mailing lists (lists.apache.org)2026-10-23dev@, java-user@ and issues@ archivesno report
Downstream repos2026-10-23Elasticsearch, OpenSearch and Solrno report

History

Silent since 4.2.0 (LUCENE-4678, 2013); the current form comes from #12624 (9.10.0). 9.4.2-9.8.0 are silent in another form; 9.9.x (by default) and 3.6.0-4.1.0 fail loudly. All 133 releases from 3.6.0 to 10.5.2 were run.

Versions tested7 rows

The current form comes from #12624 (@dungba88, merged by @mikemccand 2023-12-07, b6999818474; 9.10.0 ships the backport 193d8f14107), which replaced long destPos = startAddress + headerLen + nodeIn.numArcs * (long) maxBytesPerArc; with int destPos = headerLen + nodeIn.numArcs * maxBytesPerArc;. But it is not a 9.10.0 regression: the silent form starts in 4.2.0 with LUCENE-4678 (2220c6e8ba9, @mikemccand, 2013-01-12, paged bytes), which added the if (destPos > srcPos) skip while the product stayed int. #11923 (9.4.2) cast the product to long, but a second int truncation in BytesStore.reverse kept 9.4.2-9.8.0 wrong; #12738 (9.9.0) made the build fail loudly, but only while the suffix cache is on. The int destPos line is identical at every release tag from 9.10.0 to 10.5.2.

Versions and branches executed, with results
Version or commitVerdictResult
main b83101cdd2, branch_10_6 d04ee73177 affected-da: silent build, every read through the node throws; -ea: AssertionError in writeNodeForBinarySearch
all 19 releases 9.10.0-10.5.2 affectedsilent, the same as main (all jar sha1 = Maven Central)
9.9.0-9.9.2, default settings fails loudlyArithmeticException: integer overflow at NodeHash.add for the test shape; silent with the non-default suffixRAMLimitMB(0)
9.4.2-9.8.0 silent, other formcompile() returns a 2,148,532,360-byte FST; with a zero-filled value 2047/2048 lookups return null and 1 an empty output, no exception; on 9.4.2 with the verifier's value bytes (big value byte k = 31k+5, small values 1 + i % 100) one lookup did not return within 120 s
4.2.0-9.4.1 (101 releases) affectedsilent, the same mechanism as main
3.6.0-4.1.0 fails loudlyArrayIndexOutOfBoundsException: arraycopy ... in FST.addNode
fix 13e3e5e3f5 not affectedevery probe and repro correct, including the shapes that used to fail loudly

Independent checks

Independent AI checkers and a claim re-check checked brief v1: the test fails without the fix and passes with it on main and branch_10_6, a run on all 133 releases moved the start back from 9.10.0 to 4.2.0, and 4 of 35 claims were refuted and corrected. The study's verifier confirmed a new bug.

Checks and verdicts273 words

Independent AI checkers each took a lens on brief v1, and a claim re-check re-checked the disputed claims, also on old releases; brief v2 applies the result.

Each independent check and its verdict
CheckVerdictWhat it found
exec-current confirmedThe test fails 8/8 under -da and 3/3 under -ea on main, and 3/3 each way on branch_10_6; the fix passes 5/5 both ways; its own BYTE4 deep-node repro is silent in three stores and returns a never-added key; a shape matrix of which layouts are silent; heap is a few MB, not 1 MB.
exec-versions history wrong in two placesRan one probe on all 133 releases 3.6.0-10.5.2: 9.4.2-9.8.0 are silent too, and the silent form goes back to 4.2.0, so it is not a 9.10.0 regression.
code-history holds; AI summary wrongRoot cause, lines, stack traces and fix hold on both refs; the AI summary's last sentence was wrong (destPos was already long before #11923).
intended-behavior not intendedArgued every point for "not a bug" and showed each fails; found the cliff at one byte and the precedents.
dups-policy no duplicates; provenance correctedAbout 75 phrasings, the lists, downstream repos and all open PRs: nothing; the task did name util/fst and fixed-length arcs, and Z3 as a general technique.
Claim re-check status readyRe-ran 4.1.0, 4.2.0, 9.4.1, 9.4.2, 9.8.0 and 9.9.2 itself; 35 claim verdicts, 4 refuted (two version-history claims, the AI summary's history sentence, the task description).
study verifier new bugFST verifier wrote 9 + 2 tests (BYTE2, BYTE4 with 1M arcs, a deep node, an off-heap reload, the analysis path).

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 notes471 words

Finding record

LFV-007, snapshot of 2026-11-06

Registry title
FSTCompiler silently writes a corrupt FST when a fixed-length-arc node would expand to 2^31 bytes or more
Area
util / FST
Priority
Low, rank 990: FSTCompiler can silently write a corrupt FST when one padded fixed-length node reaches 2 GiB, which needs custom FSTs with huge outputs off the index path, unreported in about 13 years.
Impact class
data corruption
Impact note
Only applications that build FSTs with a wide node and huge outputs (e.g. a ~1 MiB output on a 2048-arc BYTE2 node; in-tree StemmerOverrideFilter.Builder with a 1 MiB stem); the default index format on main does not use util.fst; very low practical reach.
Summary
You build an FST in which one node has many arcs and at least one large output, so that its fixed-length arc area numArcs*maxBytesPerArc reaches 2^31 bytes. You expect a correct FST or an error. Instead, with assertions off, compile() returns an FST whose node header was written over the unexpanded arc list: lookups through that node throw, and enumeration can return a key that was never added.
Introduced
silent form since LUCENE-4678 (2220c6e8ba9) in 4.2.0; current form #12624 (b6999818474; 9.10.0 ships the backport 193d8f14107), which dropped the long arithmetic (2013-01-12 (LUCENE-4678); 2023-12-07 (#12624 merged))
First affected version
4.2.0
Affected versions
silent: 4.2.0-9.4.1 (101 releases) and all 19 releases 9.10.0-10.5.2, branch_10_6 and main [exec]; 9.4.2-9.8.0: silent build, reads return null/empty or hang [exec]; 9.9.0-9.9.2: loud by default, silent with suffixRAMLimitMB(0) [exec]; 3.6.0-4.1.0 loud [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; not yet triaged by a person.
Found by
formal: Z3
Upstream and prior reports
none found (2026-10-23: about 110 phrasings, mailing lists, downstream repos, 410 open PRs); #9132 (LUCENE-8084) is the same shape for space only
Lucene commit tested
main b83101cdd2 and branch_10_6 d04ee73177 (both run 2026-10-23); all 133 lucene-core releases from 3.6.0 to 10.5.2 (sha1-checked, one reflection probe), the full-history clone for the code history, and fix commit 13e3e5e3f5. The public-list rerun reproduced the repro on main b83101cdd2; the hunt's main ea57360912ed adds only #16883, which narrows LFV-004 alone; util/fst is unchanged between the two commits, so the line numbers cited below hold on both.

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

Repro files

About the diagrams

The PNG is the result panel.

Current version v5 (2026-10-24): text and layout from v3, which applied every item of the v2 visual check.

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.