All findings · Lucene finding · LFV-251 · tstlookup-empty-key-string-index-out-of-bounds

The TSTLookup suggester throws StringIndexOutOfBoundsException on an empty key instead of returning null or a list

Ask the TSTLookup suggester about an empty key, for example the text of a cleared autocomplete box, and it throws StringIndexOutOfBoundsException: get("") should return null and lookup("", false, 5) a list. It fails on main and on all 14 releases run from 3.3.0 (2011) to 10.6.0.

Summary

Priority
Low, rank 618The opt-in TSTLookup throws StringIndexOutOfBoundsException on an empty key, such as a cleared autocomplete box, which is loud and immediate, so it follows LFV-329.
Impact
crash
Since
3.3.0 (2011); code from SOLR-1316 (2010)introduced 2010-08-23 · over 16 years
Affected
3.3.0 to 10.6.0, and main (14 releases run, all fail; 131 release tags source-checked)
Status
documented
Found by
bounded-exhaustive translation validation of the suggesters

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

TSTLookup.lookup and get hand the key straight to TSTAutocomplete.prefixCompletion. Its search loop starts at the tree's root node, which always exists, and its first statement reads key.charAt(0); the only length check comes after that read. A key with no characters therefore throws on the first step, whatever the dictionary holds.

Two things to know first

  1. A suggester (Lookup) returns completions for what the user has typed so far: lookup("ca", false, 5) asks for up to 5 entries that start with ca. TSTLookup is one implementation that an application picks explicitly; get(key) returns the weight stored for exactly that key.
  2. TSTLookup keeps its entries in a ternary search tree: each node holds one character and links to a lower, an equal and a higher node. A lookup walks the tree one key character per step, starting from a root node that always exists, even when the dictionary is empty.
Summary card: the TSTLookup suggester throws on an empty key instead of returning a result. Example executed on main with assertions on and off: a TSTLookup built from car/3, cat/5 and dog/2 answers lookup("ca", false, 5) with [cat/5, car/3]; after the user clears the box, lookup("", false, 5) (expected: a list) and get("") (expected null) both throw StringIndexOutOfBoundsException 'Index 0 out of bounds for length 0' at TSTAutocomplete.java:103. Why: TSTLookup passes the key unchecked to prefixCompletion, the tree's root is never null, so the loop always runs and its first step reads s.charAt(0); insert never stores the empty key, so get("") should return null, and lookup("") should return a list, not an index exception. Other suggesters answer [], the top entries, or a deliberate IllegalArgumentException. A fix sketch returns the empty list before the loop when x >= s.length(): then lookup("", false, 5) gives [] and get("") gives null.
A three-entry tree, car/3, cat/5 and dog/2, under a root node that always exists. The key ca is matched one character per step and returns [cat/5, car/3]. The empty key has no character 0, so the very first step, s.charAt(0), throws. The fix returns [] before the loop. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You build a TSTLookup from three entries, car/3, cat/5 and dog/2, behind an as-you-type box. Typing ca works: lookup("ca", false, 5) returns [cat/5, car/3].
  2. The user clears the box and your code calls lookup("", false, 5), or it calls get(""). You expect a list, possibly empty, and null: get's javadoc says "or null if the key does not exist", and an empty key is never stored.
  3. You get StringIndexOutOfBoundsException: Index 0 out of bounds for length 0, for any dictionary (also an empty or never-built one), with assertions on or off. lookup("ca", false, 5) still returns [cat/5, car/3] afterwards.
  4. Why: the search starts at the tree's root node, which always exists, and its first step reads character 0 of the key before any length check. A 4-line guard makes lookup("") return [] and get("") return null.

Who is affected

Applications that use the opt-in TSTLookup suggester and pass it an empty key, typically the text of a cleared autocomplete box, or call get(""). Every lookup is affected, with no scale needed. The call fails at once with an unchecked exception; nothing is changed and no wrong result is returned. Workaround: check for an empty key first.

Mechanism, terms and numbers519 words

TSTLookup, the ternary-search-tree implementation of Lucene's Lookup suggester API, throws java.lang.StringIndexOutOfBoundsException: Index 0 out of bounds for length 0 when it is asked about an empty key. Both public entry points fail: get(""), whose javadoc promises null for a key that does not exist, and lookup("", onlyMorePopular, num) for both onlyMorePopular values. Every TSTLookup is affected (built, restored with load(), never built, or built from an empty dictionary), with assertions on and off; non-empty keys work normally. The cause is one missing length check in TSTAutocomplete.prefixCompletion: its loop always runs, because the tree's root node always exists, and its first statement reads s.charAt(0). The code dates from SOLR-1316 (2010); all 14 Lucene releases run from 3.3.0 to 10.6.0, and main, fail, and every release tag checked has the same loop. Returning the empty list before the loop when there is no character to read fixes it; the suggest module's tests pass with that change.

The code (main 645d4fe14a20)

TSTLookup.get(key)                    TSTLookup.java:157   autocomplete.prefixCompletion(root, key, 0)
TSTLookup.lookup(key, ctx, omp, num)  TSTLookup.java:188   autocomplete.prefixCompletion(root, key, 0)
   root is never null: TSTLookup.java:43 (field), :125 (build), :277 (load): new TernaryTreeNode()

TSTAutocomplete.prefixCompletion(TernaryTreeNode root, CharSequence s, int x)      TSTAutocomplete.java:97
  :99    TernaryTreeNode p = root;
  :102   while (p != null) {
  :103     if (s.charAt(x) < p.splitchar) {          <- s = "", x = 0: StringIndexOutOfBoundsException
  :105     } else if (s.charAt(x) == p.splitchar) {
  :106       if (x == s.length() - 1) { break; }     <- the only length check, after charAt
  • The root is created as an empty node, so its character is the default '\u0000' (TernaryTreeNode.java:27-28). For keys that do not start with '\u0000' it is only the starting point and the entries hang below it; a key that starts with '\u0000' matches the root's character. Because the root is never null, the loop body runs at least once for every dictionary, including an empty or never-built one.
  • A non-empty key is safe: x only grows while x < s.length() - 1, so charAt(x) stays in range. Only the empty key has no character 0.
  • The empty key can never be stored: insert returns before it creates a node when s.length() <= x (TSTAutocomplete.java:58-59), and both build (through balancedTree, TSTAutocomplete.java:43) and add go through it (load restores whatever the stream holds; a stream that store wrote from such a tree holds no empty key). So get("") should be null.
  • Nothing else checks the key: lookup checks only contexts, get nothing. TSTAutocomplete's constructor is package-private, so TSTLookup is the only public way in.

Every form tried fails

The recorded runs cover lookup("", false, 5), lookup("", true, 5), get("") and an empty StringBuilder key, and lookups built with TSTLookup(Directory, String), restored with load(), never built (new TSTLookup()) and built from an empty dictionary, with assertions on and off. Not every combination of form and state was run; in every state the root exists, so every form reaches the same unchecked charAt at TSTAutocomplete.java:103. Non-empty keys on the same lookup answer normally, before and after the failing calls.

Exposure and likelihood251 words
  • Who is exposed. Applications that use TSTLookup and pass it an empty key: typically an as-you-type box that forwards its text after the user clears it, or a get(""). TSTLookup is one of several Lookup implementations an application chooses explicitly; Lucene has no default suggester. FSTCompletionLookup's javadoc points users to it for fine-grained weights.
  • What happens. An unchecked exception from an API that declares only IOException; if the caller does not catch it, that request fails. Nothing else: after 3,000 failing calls on a 20,000-entry dictionary, the stored bytes and 66 sampled non-empty answers (8 prefixes with both onlyMorePopular values, and 50 get calls) were unchanged, and a 4-thread run had 0 mismatches. Each failing call took about 8-9 microseconds (single runs on one host: an order of magnitude, not a benchmark).
  • Scale. None: deterministic, every dictionary, on the first loop step.
  • Versions. Lucene 3.3.0 (2011) to 10.6.0, and main: all 14 releases run in that range fail, and all 131 release tags of the history clone that contain the code have the same loop. Not every release was run; the clone has no 8.x tags after 8.8.1, so the later 8.x releases before 8.11.4 are covered by neither check. All runs used OpenJDK 25.
  • How likely. Plausible, not common: it needs an application that forwards an empty key without a check. No public report was found in the searches below, although the code dates from 2010.
  • Workaround. Check key.length() == 0 before calling lookup or get.

Minimal repro

One JUnit test for the suggest module, public API only, no input and no randomness: new TSTLookup().get("") must return null. It fails with the uncaught exception, so the failure shows the actual result.

TestTSTLookupEmptyKey.java (one test)27 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.search.suggest.tst;

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

public class TestTSTLookupEmptyKey extends LuceneTestCase {

  public void testGetEmptyKeyReturnsNull() {
    // expected: null ("or null if the key does not exist"); actual: StringIndexOutOfBoundsException
    assertNull(new TSTLookup().get(""));
  }
}
Command
./gradlew :lucene:suggest:test --tests "org.apache.lucene.search.suggest.tst.TestTSTLookupEmptyKey" -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
Expected
get("") returns null, as its javadoc says for a key that does not exist.
Observed
StringIndexOutOfBoundsException: Index 0 out of bounds for length 0 at TSTAutocomplete.prefixCompletion(TSTAutocomplete.java:103), assertions on and off.

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

More files, environment, full output197 words

Create the directory lucene/suggest/src/test/org/apache/lucene/search/suggest/tst/ (it does not exist upstream), copy the file into it and run the command. The test body is deterministic, though it runs under Lucene's randomized test framework; pass -Ptests.asserts explicitly, because by default the build picks it from the seed. With the file added, :lucene:suggest:check -x test passes.

new TSTLookup() is the no-arg constructor ("Creates a new TSTLookup with an empty Ternary Search Tree."); a lookup built with TSTLookup(Directory, String) fails the same way, and so does lookup("", false, 5), at the same line. The test is the suggested regression test as it stands.

Environment

lucenemain 645d4fe14a20 (gradle); the same failure on 10.6.0 and on the 13 older releases run, back to 3.3.0
jdkOpenJDK 25.0.3
osLinux aarch64
Command (environment)
./gradlew :lucene:suggest:test --tests "org.apache.lucene.search.suggest.tst.TestTSTLookupEmptyKey" -Ptests.seed=DEADBEEF -Ptests.asserts=true

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (the same with false)
TestTSTLookupEmptyKey > testGetEmptyKeyReturnsNull FAILED
    java.lang.StringIndexOutOfBoundsException: Index 0 out of bounds for length 0
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:AAA38CA45D33748D]:0)
        ... (9 JDK-internal frames: Preconditions, String.checkIndex, StringLatin1.charAt)
        at java.base/java.lang.String.charAt(String.java:1624)
        at org.apache.lucene.search.suggest.tst.TSTAutocomplete.prefixCompletion(TSTAutocomplete.java:103)
        at org.apache.lucene.search.suggest.tst.TSTLookup.get(TSTLookup.java:157)
        at org.apache.lucene.search.suggest.tst.TestTSTLookupEmptyKey.testGetEmptyKeyReturnsNull(TestTSTLookupEmptyKey.java:25)
1 test completed, 1 failed

# with the recommended fix, assertions on and off:
:lucene:suggest:test (SUCCESS): 1 test
BUILD SUCCESSFUL

Why it looks unintended

  • get's own javadoc: "Returns the value for the specified key, or null if the key does not exist." The empty key is never stored.
  • Lookup.lookup accepts any CharSequence and declares only IOException; no test asserts or tolerates the exception.
  • FSTCompletion and AnalyzingSuggester handle "" explicitly; TSTLookup fails on an unguarded charAt.
The full argument284 words
  • get contradicts its own javadoc. "Returns the value for the specified key, or null if the key does not exist." (TSTLookup.java:155). The empty key cannot exist in a tree made by build or add (or restored from what store wrote), because insert drops it (TSTAutocomplete.java:58-59).
  • lookup has no precondition. Lookup.lookup takes "lookup key. Depending on the implementation this may be a prefix, misspelling, or even infix", returns "a list of possible completions" and declares only IOException (Lookup.java:185-194, :209-211).
  • Public API, though experimental. The package is exported by the suggest module and neither class is deprecated or tagged internal, but TSTLookup's base class Lookup is marked @lucene.experimental (Lookup.java:38); TSTAutocomplete has no such base class. That allows API changes between releases and is a point for low priority; it does not document an index exception.
  • No test covers it. Upstream, TSTLookup appears only in TestPersistence (full keys) and the ignored TestLookupBenchmark, which looks up non-empty prefixes: the first 2-4, 6-9 or 100-200 characters of each dictionary key, or the whole key when shorter; none of its dictionary keys is empty.
  • Siblings handle the empty key explicitly. FSTCompletion.lookup returns an empty result for key.isEmpty() (FSTCompletion.java:198, :221); AnalyzingSuggester lists among its limitations "Lookups with the empty string return no results instead of all results" (AnalyzingSuggester.java:95); LUCENE-8287 (2018) made empty regex completion queries return no results.
  • History. The loop is unchanged since SOLR-1316 (2010). Its first caller, Solr's spellcheck-based Suggester, passed the text of each query token, which is normally non-empty, and it has no empty-token check, so the case was probably never considered. No commit, CHANGES or MIGRATE entry mentions it.
The devil's-advocate check173 words

The strongest case for "intended": the empty-key result is deliberately not uniform across the module. For lookup("", false, 5) (the only form run on them), WFSTCompletionLookup returns every entry, FSTCompletionLookup, AnalyzingSuggester, FuzzySuggester, AnalyzingInfixSuggester and BlendedInfixSuggester return [], and FreeTextSuggester throws IllegalArgumentException on purpose, which TestFreeTextSuggester.testEmptyString asserts. So an exception for "" is accepted in this module, Lookup is marked experimental, and a caller can guard trivially.

Why it does not change the verdict: FreeTextSuggester's exception is a deliberate check with a message ("no tokens produced by analyzer, or the only tokens were empty strings", FreeTextSuggester.java:476-479); TSTLookup's is an incidental charAt range check that nothing documents, and get("") breaks a promise in its own javadoc. The argument bears on which answer to return ([], every entry, or a documented exception), not on whether an index exception is intended. Later cross-model reviews of this doc's brief (rounds 1 and 2) each rated the case for "intended" moderate and still judged the bug real, with low priority.

Suggested fix

Recommended A. Return the empty list before the loop when there is no character to read

Return the empty list at the top of TSTAutocomplete.prefixCompletion when the key has no character left to read (4 added lines). Then lookup("") returns [] and get("") returns null, and non-empty keys are untouched. Validated: the repro passes with assertions on and off, and the suggest module's whole test run passes (237 tests, 7 skipped, 0 failures).

     TernaryTreeNode p = root;
     ArrayList<TernaryTreeNode> suggest = new ArrayList<>();
+    if (x >= s.length()) {
+      // empty (remaining) key: the loop below reads s.charAt(x)
+      return suggest;
+    }
 
     while (p != null) {
       if (s.charAt(x) < p.splitchar) {
All options and trade-offs4 options · 537 words

Option A answers "no completions", like most sibling suggesters and like get's javadoc. If maintainers prefer every entry (option C) or a documented exception (option D), the repro's expectation changes accordingly. No CHANGES entry is drafted.

A. Return the empty list before the loop when there is no character to read (validated by the checks) (recommended)

In TSTAutocomplete.prefixCompletion, before while (p != null), return the (empty) result list when x >= s.length(). One check covers both entry points, lookup and get; non-empty keys never reach it.

     TernaryTreeNode p = root;
     ArrayList<TernaryTreeNode> suggest = new ArrayList<>();
+    if (x >= s.length()) {
+      // empty (remaining) key: the loop below reads s.charAt(x)
+      return suggest;
+    }
 
     while (p != null) {
       if (s.charAt(x) < p.splitchar) {

Trade-offs

It settles the answer as "no completions". The guard sits in the shared helper, so a later caller is covered too. A null key still throws NullPointerException, now from s.length() in the guard instead of s.charAt(x) (read, not run); neither lookup nor get documents null keys.

What the checks validated

  • Gradle in a scratch worktree of main 645d4fe14a20, seed DEADBEEF, assertions on and off: the repro alone fails stock and passes with the fix.
  • The repro with TestPersistence, the only non-ignored upstream test that builds a TSTLookup (the ignored TestLookupBenchmark builds one too): stock 1 failure (the repro), with the fix 3 of 3 pass, each way. Repeated in a fresh worktree, with the module run: same results.
  • All of :lucene:suggest:test with the fix, assertions on: 32 classes (31 upstream), 237 tests, 7 skipped, 0 failures.
  • :lucene:suggest:check -x test (google-java-format, ecjLint, forbiddenApis) passes with the fix and the repro added.
  • Earlier, in the repro harness: with the fix, an independent 8-test JUnit passes 8 of 8 on main ea57360912ed (same file), and a probe on 645d4fe14a20 shows lookup("", false, 5) and lookup("", true, 5) returning [], get("") returning null and lookup("ca", false, 5) unchanged.

B. Guard in TSTLookup.lookup and TSTLookup.get (not run)

Return a new empty list from lookup (its res list is declared only after the prefixCompletion call) and null from get when key.length() == 0, before they call prefixCompletion.

Trade-offs

Same visible behaviour as A, but two guards instead of one, and prefixCompletion keeps its hidden precondition.

C. Return every entry for an empty prefix (not run)

What WFSTCompletionLookup does ([cat/5, car/3, dog/2] on the example dictionary): every key starts with the empty prefix.

Trade-offs

For "", prefixCompletion would walk the whole tree and collect a reference to every entry's node before lookup trims the result to num (with onlyMorePopular true, lookup also creates a LookupResult per match); no entries or strings are copied, and a very short prefix that matches most entries costs the same today. It also differs from five sibling suggesters and from AnalyzingSuggester's documented choice. get("") must still return null.

D. Reject the empty key with a documented IllegalArgumentException (not run)

Like FreeTextSuggester: a check with a clear message, documented in the javadoc.

Trade-offs

Better than an index exception, but callers still need their own guard, and get's javadoc, which promises null, would have to change.

Downstream note

Downstream, from reading Solr's source only (not run): Solr's suggest component passes suggest.q to Lookup.lookup after only a null check, so a Solr suggester explicitly configured with TSTLookupFactory appears to fail on an empty suggest.q (Solr's default lookup is FST).

How it was found

A bounded-exhaustive check compared WFSTCompletionLookup and TSTLookup with an exact reference on every dictionary of 1 to 3 entries drawn from 7 short strings (the empty string among them) and 4 weights, and every key of up to 3 characters, the empty key included: 6,146,280 checks. One exception class appeared, in 136,584 checks: the empty key, on every dictionary.

The method in full179 words
  • Method (semi-formal): bounded-exhaustive translation validation of two suggesters against an exact reference, on the real main jars: WFSTCompletionLookup (exactFirst on and off, onlyMorePopular false) and TSTLookup (onlyMorePopular true); FSTCompletionLookup was not in the model. Dictionaries: every sequence of 1 to 3 (string, weight) entries, duplicates allowed, with strings from "", a, b, aa, ab, ba, bb and weights 0, 1, 2 and Integer.MAX_VALUE (28 + 28^2 + 28^3 = 22,764 dictionaries; the empty dictionary is not in this scope, the independent JUnit and the probes cover it). Keys: every key of length at most 3 over a and b, including "" (15 keys); num 1 to 6: 6,146,280 checks.
  • Signal. One exception class, StringIndexOutOfBoundsException, in 136,584 checks: every dictionary and every num for the empty key. The only other TSTLookup class was a duplicate-input weight difference (19,512 checks; no documented rule; not this bug). WFSTCompletionLookup had 0 mismatches.
  • The class was reduced by hand to a public-API JUnit, and the repro was later minimized to the one test above.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26 and rechecked 2026-10-30: GitHub issues and PRs, the JIRA archive, the Lucene and Solr lists, downstream repositories, Stack Overflow, the web and the changed files of all 410 open PRs). Closest: other suggesters' empty-input fixes, LUCENE-4567 and LUCENE-8287.

Searches4 searches

Read and not this bug: LUCENE-4567 (2012, WFSTCompletionLookup NullPointerException on an empty FST, fixed); LUCENE-8287 (2018, empty regex completion queries now return no results); SOLR-16169 (2022, an empty spellcheck.q gave NegativeArraySizeException); SOLR-3608 and SOLR-13360 (open, StringIndexOutOfBoundsException from SpellCheckCollator offsets, a different code path); SOLR-1316 and SOLR-2375 (the issues that wrote the code; their discussions never mention empty keys). A recorded source reading of the Lucene.NET port (master, 2026-10-26) found the same unguarded loop (read, not run; no report there either). Gaps: GitHub search does not cover PR review comments; GitHub's search rate limit limited some tool queries on 2026-10-26 (replaced by manual checks); private reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search2026-10-2658 queries (18 GitHub, 28 mailing-list, 6 JIRA, 6 web) for the class names, prefixCompletion, empty key / prefix / string and the exception texts; 599 distinct hits, 0 high, the comments of the top 12 readno report
JIRA (LUCENE, SOLR) and the lists2026-10-26text searches for TSTLookup, TSTAutocomplete, TSTLookupFactory, prefixCompletion, TernaryTreeNode and the exception with suggest; the comments of 12 related issues searched for the class names, ternary, the exception texts and empty; lists.apache.org over the Lucene and Solr listsonly precedents in other code (LUCENE-4567, LUCENE-8287, SOLR-16169) and SpellCheckCollator exceptions
GitHub and the web (manual)2026-10-26apache/lucene, lucene-solr, lucenenet, solr, Elasticsearch and OpenSearch; Stack Overflow; web search; the full git historyno report; no commit adds a guard
GitHub recheck2026-10-30apache/lucene issues TSTLookup (9 closed feature or javadoc issues), prefixCompletion and the exception with suggest empty key; PRs TSTAutocomplete OR TSTLookup; the changed files of all 410 open PRs; repeated by the round-2 fact check (PR searches 0; none of the 6 open PRs updated since touches the code)no report; no open PR touches the code

History

Since the code was written: SOLR-1316 (2010) added the loop, and get("") fails the same way since SOLR-2375 (2011). First Lucene release 3.3.0. Never changed or fixed. Executed on 14 Lucene releases from 3.3.0 to 10.6.0, main and Solr 3.1.0: all fail.

Versions tested9 rows

Introduced by ad8bfb3bc4de "SOLR-1316 Create autosuggest component." (2010-08-23, Subversion era, no PR), which added TSTAutocomplete.prefixCompletion with this loop and TSTLookup.lookup calling it; git blame attributes every line of the loop to it, and the loop is unchanged on main after whitespace normalisation. get("") fails the same way since SOLR-2375 (e71e68235205, 2011-03-01), which made get call prefixCompletion (before, it threw UnsupportedOperationException).

Later moves, loop untouched: LUCENE-2995 (f5048293b54, 2011, a shared module), LUCENE-3135 (571ae5043c8, 2011, the backport to 3.x), LUCENE-3965 (e8e4245d9b3, 2012, the current location), LUCENE-6829 (c855dd5a33f, 2015, the Directory constructor).

First release. Lucene 3.3.0 (2011-06-30, the lucene-spellchecker contrib jar; on the trunk line 4.0.0-alpha). The code first shipped in Solr 3.1.0 under a Solr package, also affected. Lucene 3.2.0 has no such API. A source check of all 131 release tags that contain the file finds the same loop and no guard. Not every release was run: the releases between the ones run are covered by the source check only, and the clone has no 8.x tags after 8.8.1, so the later 8.x releases before 8.11.4 are covered by neither check (8.8.1 has the same loop; 8.11.4 was run from its Maven Central jars). All runs used OpenJDK 25, not each release's own JDK; the defect is plain string-index logic, and only the message text depends on the JDK.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe one-test repro fails through gradle and the harness, assertions on and off
10.6.0 affectedthe one-test repro fails on the Maven Central jars, assertions on and off
main ea57360912ed affectedthe finder's JUnit fails, an independent JUnit fails 7 of 8, the main() probe 4 of 4 (assertions on and off)
10.5.2, 9.12.3 affectedthe independent JUnit fails 7 of 8; 10.5.2 also with assertions off
10.0.0, 9.0.0, 8.11.4, 7.7.3, 6.6.6, 6.0.0, 5.5.5, 4.10.4, 4.0.0, 3.6.2 affectedmain() probe: 4 of 4 empty-key calls throw, the non-empty control passes
3.3.0 firstthe first Lucene release with the code: 4 of 4 throw
Solr 3.1.0 affectedthe first release of the code, under a Solr package: 4 of 4 throw
3.2.0 API absentthe probe does not compile: no suggest.tst package
the fix on main 645d4fe14a20 not affectedthe repro and TestPersistence pass (assertions on and off), and the whole :lucene:suggest:test run (237 tests, 7 skipped, 0 failures)

Independent checks

An independent reproducer, an intended-behaviour devil's advocate and an adjudication re-ran it and found it real; a reviewer from another model family could not run code and answered "not real" on procedural grounds only. This doc re-ran the one-test repro through gradle on 645d4fe14a20 and validated the fix.

Checks and verdicts474 words

The cross-model review was reading-only (it could not run code); every claim it read was executed by the other checks. The hunt's checks ran on main ea57360912ed, whose suggest module equals 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
Independent reproducer realOwn 8-test JUnit, public API: 7 empty-key cases (both onlyMorePopular values, an empty StringBuilder, an empty dictionary, a never-built lookup, a lookup restored with load(), get("")) fail on main (assertions on and off), 10.5.2 and 9.12.3, all at TSTAutocomplete.java:103; the control passes; 8 of 8 pass with the fix.
Cross-model review, refute lens not real (medium), proceduralAn independent reviewer from another model family (OpenAI GPT-5.6), reading only: "not real" because it could not reproduce by construction; it agreed with the root cause, judged the inputs in contract and rated the intended-behaviour case weak. Two separate runs re-ran the finder's JUnit on main and 10.5.2: it fails.
Intended-behaviour devil's advocate realBuilt the strongest works-as-designed case (no module-wide empty-key contract; FreeTextSuggester throws on purpose) and rejected it. Its probe: get("") and a never-built lookup fail on main and 10.5.2 and pass with the fix; it executed the sibling table.
Adjudication confirmed (high)Re-ran the independent reproduction's JUnit with a new seed: 7 of 8 fail with assertions on and off, 0 of 8 with the fix.
History, prior art and security screen since 3.3.0; new; not security-sensitive13 Lucene releases from 3.3.0 to 10.5.2, main and Solr 3.1.0 executed, 131 tags source-checked; no prior report; an executed side-effect probe shows no state change and no effect on concurrent callers.
Registration check and repro minimization reproduces; one testFresh seeds re-ran the finder's and the independent reproduction's JUnits (fail; pass with the fix); the repro was reduced to one get("") test on 645d4fe14a20, which fails with assertions on and off and passes with the fix shadowed.
Fix validation (this doc) fix validatedGradle in a scratch worktree of 645d4fe14a20: the repro fails stock and passes with option A, assertions on and off; with the fix TestPersistence, the whole :lucene:suggest:test run (237 tests, 7 skipped, 0 failures) and :lucene:suggest:check -x test pass. A repeat of the repro with TestPersistence and of the module run in a fresh worktree gave the same results. The repro also fails on the 10.6.0 jars.
Fact checks of this doc real; wording correctedTwo rounds. In each, an independent fact check re-ran the repro through gradle (assertions on and off), the fix and the probes, and a reviewer from another model family (OpenAI GPT-5.6) read the brief against the evidence; diagram checks compared every frame with the code and the probe. All found the bug real; their wording, precision, contrast and expected-answer findings are fixed in this version.

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 notes1,425 words

Finding record

LFV-251, snapshot of 2026-11-06

Registry title
TSTLookup.get('') and TSTLookup.lookup('') throw StringIndexOutOfBoundsException instead of returning null or an empty list: TSTAutocomplete.prefixCompletion reads key.charAt(0) before it checks the key length (every release since 3.3.0)
Area
suggest / ternary search tree suggester (TSTLookup.lookup and get, TSTAutocomplete.prefixCompletion)
Priority
Low, rank 618: The opt-in TSTLookup throws StringIndexOutOfBoundsException on an empty key, such as a cleared autocomplete box, which is loud and immediate, so it follows LFV-329.
Impact class
crash
Impact note
Hits applications that use the opt-in TSTLookup suggester and pass it an empty key, for example the text of a cleared autocomplete box: lookup('') with onlyMorePopular false or true, and get(''), throw an unchecked StringIndexOutOfBoundsException on every TSTLookup (built with TSTLookup(Directory, String), restored with load(), never built, or built from an empty dictionary), with -ea and -da, in every release since 3.3.0, instead of returning an empty list and null; the call fails at once, changes no state and returns no wrong result, no scale is needed, and callers can avoid it by checking for an empty key first.
Summary
You build a TSTLookup suggester and call get('') or lookup('', false, 5), for example because a user cleared the search box. You expect null from get(''), whose javadoc says it returns null if the key does not exist (the empty key is never stored), and a list, possibly empty, from lookup(''). Instead both throw StringIndexOutOfBoundsException 'Index 0 out of bounds for length 0', because TSTAutocomplete.prefixCompletion reads key.charAt(0) on its first loop step before it checks the key length, and the tree's root node is never null; non-empty keys work normally on the same lookup.
Introduced
ad8bfb3bc4de0d9eed8e4d44116ea216ca711766 ('SOLR-1316 Create autosuggest component.', trunk svn r988120, 2010-08-23; Subversion era, no pull request): it added TSTAutocomplete.prefixCompletion with the unguarded s.charAt(x) loop and TSTLookup.lookup calling prefixCompletion(root, key, 0) [read: git blame -w -M -C -C attributes every line of the loop on the history head to this commit, and the loop is identical to main after whitespace normalisation]; get('') fails the same way since SOLR-2375 (e71e68235205, 2011-03-01; branch_3x ab2691c7740), which made TSTLookup.get call prefixCompletion [read]; the later moves LUCENE-2995 (f5048293b54), LUCENE-3135 (571ae5043c8) and LUCENE-3965 (e8e4245d9b3) and LUCENE-6829 (c855dd5a33f) left the loop unchanged [read] (2010-08-23 (SOLR-1316, trunk); 2011-03-01 (SOLR-2375, get); first Lucene release 3.3.0 (2011-06-30))
First affected version
3.3.0 [exec] (2011-06-30; the first Lucene release with org.apache.lucene.search.suggest.tst, in the lucene-spellchecker contrib jar through LUCENE-3135; on the trunk line the first is 4.0.0-alpha, lucene-suggest module); the code first shipped in Solr 3.1.0 as org.apache.solr.spelling.suggest.tst, also affected [exec]; Lucene 3.2.0 and earlier have no such API [exec: the 3.2.0 probe does not compile]
Affected versions
every Lucene release from 3.3.0 through 10.5.2 and main ea57360912ed; never fixed. [exec] 3.3.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da), plus Solr 3.1.0's copy: the follow-up's history probe (lookup('') with onlyMorePopular false and true, get(''), lookup('') on an empty dictionary) throws in 4 of 4 calls on each while the non-empty control works; the finder's JUnit also fails on 10.5.2, 9.12.3, 8.11.4 and 6.6.6, the independent reproduction's on 10.5.2 and 9.12.3, and the intended-behaviour check's probe on 10.5.2. [read] all 131 release tags in the history clone that contain TSTAutocomplete.java have the same loop and no empty-key guard in TSTLookup (regex source check); main ea57360912e and branch_10x 1309534296e are unguarded. Not affected: Lucene 3.2.0 and earlier (API absent; 3.2.0 [exec]). All runs on aarch64 with JDK 25.0.3; the defect is plain string index logic, so the JDK changes only the message text.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record verdict new: a scripted prior-report search with 18 GitHub issue/PR queries on apache/lucene, 28 mailing-list queries over dev@, java-user@, issues@ and commits@, 6 JIRA-archive queries and 6 web queries (599 distinct hits, 0 high, 11 medium); manual JIRA searches in LUCENE and SOLR with the comments of 12 related issues grepped; lists.apache.org searches over the Lucene and Solr lists; GitHub searches in apache/lucene-solr, apache/lucenenet, apache/solr, Elasticsearch and OpenSearch; Stack Overflow; the full local git history; the independent reproduction and the intended-behaviour check searched GitHub, JIRA and the lists on their own and found nothing); not fixed upstream (main ea57360912e and branch_10x 1309534296e keep the unguarded loop, and the changed-file lists of all 413 open apache/lucene PRs show none touching suggest/tst or Lookup.java). Related context, not reports of this bug: LUCENE-4567 (2012, WFSTCompletionLookup NullPointerException on an empty FST, fixed), LUCENE-8287 (2018, empty regex completion queries now return no results), SOLR-16169 (2022, empty spellcheck.q gave NegativeArraySizeException in IndexBasedSpellChecker), SOLR-3608 and SOLR-13360 (open, StringIndexOutOfBoundsException from SpellCheckCollator offsets, a different code path), SOLR-1316 and SOLR-2375 (the issues that wrote the code; their discussions never mention empty keys)
Lucene commit tested
main 645d4fe14a20 (2026-10-28) through gradle in a scratch worktree (removed afterwards; the repro with TestPersistence and the whole module run were repeated in a fresh worktree, same results), seed DEADBEEF, assertions on and off: stock, the one-test repro fails; with the recommended fix it passes, together with TestPersistence, the whole :lucene:suggest:test run (237 tests, 7 skipped, 0 failures, assertions on) and :lucene:suggest:check -x test. Through the repro harness: the one-test repro on 645d4fe14a20 (assertions on and off, fix shadowed) and on the 10.6.0 jars from Maven Central (assertions on and off); a diagram probe on 645d4fe14a20 (stock and fixed, assertions on and off); the finder's JUnit, an independent 8-test JUnit and a devil's-advocate probe on main ea57360912ed (assertions on and off), 10.5.2 and 9.12.3; a main() probe on 13 releases from 3.3.0 to 10.5.2 and Solr 3.1.0. The suggest module is identical at ea57360912ed and 645d4fe14a20. OpenJDK 25.0.3, also for the old releases, Linux aarch64 (the only platform run).

Lucene and upstream sources

Finding record
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTAutocomplete.java:57-59 (insert returns early when s.length() <= x, so the empty key is never stored), :97-115 (prefixCompletion: s.charAt(x) at :103 inside while (p != null), before any length check; the loop breaks at x == s.length() - 1, so only an empty key is affected)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:43, :125, :277 (root is always a new TernaryTreeNode, never null), :52-57 (TSTLookup() javadoc '@see #build(InputIterator)'), :155-157 (get javadoc 'Returns the value for the specified key, or null if the key does not exist.'; prefixCompletion(root, key, 0) with no check), :183-188 (lookup passes the key unchecked), :268 and :275 (only store and load are synchronized)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:183-211 (lookup javadoc: no precondition on key, no @throws)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:95 ('Lookups with the empty string return no results instead of all results'), lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletion.java:198 and :221 (key.isEmpty() returns EMPTY_RESULT), lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FreeTextSuggester.java:476-479 (deliberate IllegalArgumentException) and lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/TestFreeTextSuggester.java:245 (testEmptyString asserts it)
The finding in 30 seconds
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTAutocomplete.java:29 (package-private constructor), :58-59 (insert drops the empty key), :97-115 (prefixCompletion; charAt at :103, the length check at :106) @ 645d4fe14a20
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:43, :125, :277 (root is always a new TernaryTreeNode), :141 (build calls balancedTree, which calls insert at TSTAutocomplete.java:43), :150 (add calls insert), :155-157 (get javadoc and call), :183-188 (lookup) @ 645d4fe14a20
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TernaryTreeNode.java:27-28 (splitchar, default '\u0000'); TSTAutocomplete.java:74-80 (a key starting with '\u0000' matches the root); TSTLookup.java:218-223 (load reads the tokens from the stream) @ 645d4fe14a20
Who is affected
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:67-68 ('use FSTCompletion directly or TSTLookup') and lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:193-194, :209-211 (throws IOException only) @ 645d4fe14a20
Minimal repro
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:53 (no-arg constructor javadoc) @ 645d4fe14a20
Why it looks unintended
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:53, :155 (javadocs) @ 645d4fe14a20; lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:184-211 @ 645d4fe14a20
  • lucene/suggest/src/java/module-info.java:28 (exports org.apache.lucene.search.suggest.tst)
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/TestPersistence.java:51, :68-69; TestLookupBenchmark.java:51 (@Ignore), :177-192 (prefix lengths 100-200, 6-9, 2-4), :214-218 (substring of each key, the whole key when shorter); Top50KWiki.utf8 (50,001 keys, none empty) @ 645d4fe14a20
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletion.java:198, :221; lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:95; lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FreeTextSuggester.java:476-479; lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/TestFreeTextSuggester.java:245 @ 645d4fe14a20
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:35-39 (class javadoc, @lucene.experimental) @ 645d4fe14a20
Suggested fix
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTAutocomplete.java:124-147 (references to all matching nodes collected) and lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:189 (res declared after the call at :188), :193-207 (trimmed to num; :198-200 a LookupResult per match with onlyMorePopular) @ 645d4fe14a20; lucene/suggest/src/test/org/apache/lucene/search/suggest/TestLookupBenchmark.java:51 (@Ignore), :145-149, :171 (builds a TSTLookup) @ 645d4fe14a20

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

Repro files

About the diagrams

It is made separately from this text; its numbers come from the executed diagram probe on main.

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.