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.
- 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 withca.TSTLookupis one implementation that an application picks explicitly;get(key)returns the weight stored for exactly that key. TSTLookupkeeps 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.
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.- You build a
TSTLookupfrom three entries,car/3,cat/5anddog/2, behind an as-you-type box. Typingcaworks:lookup("ca", false, 5)returns[cat/5, car/3]. - The user clears the box and your code calls
lookup("", false, 5), or it callsget(""). You expect a list, possibly empty, andnull:get's javadoc says "or null if the key does not exist", and an empty key is never stored. - 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. - 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[]andget("")returnnull.
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.
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:
xonly grows whilex < s.length() - 1, socharAt(x)stays in range. Only the empty key has no character 0. - The empty key can never be stored:
insertreturns before it creates a node whens.length() <= x(TSTAutocomplete.java:58-59), and bothbuild(throughbalancedTree,TSTAutocomplete.java:43) andaddgo through it (loadrestores whatever the stream holds; a stream thatstorewrote from such a tree holds no empty key). Soget("")should benull. - Nothing else checks the key:
lookupchecks onlycontexts,getnothing.TSTAutocomplete's constructor is package-private, soTSTLookupis the only public way in.
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
TSTLookupand pass it an empty key: typically an as-you-type box that forwards its text after the user clears it, or aget("").TSTLookupis one of severalLookupimplementations 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 bothonlyMorePopularvalues, and 50getcalls) 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() == 0before callinglookuporget.
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.
/*
* 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(""));
}
}
./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("")returnsnull, as its javadoc says for a key that does not exist.- Observed
StringIndexOutOfBoundsException: Index 0 out of bounds for length 0atTSTAutocomplete.prefixCompletion(TSTAutocomplete.java:103), assertions on and off.
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.
| lucene | main 645d4fe14a20 (gradle); the same failure on 10.6.0 and on the 13 older releases run, back to 3.3.0 |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
./gradlew :lucene:suggest:test --tests "org.apache.lucene.search.suggest.tst.TestTSTLookupEmptyKey" -Ptests.seed=DEADBEEF -Ptests.asserts=true
# 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 SUCCESSFULget'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.lookupaccepts anyCharSequenceand declares onlyIOException; no test asserts or tolerates the exception.FSTCompletionandAnalyzingSuggesterhandle""explicitly;TSTLookupfails on an unguardedcharAt.
The full argument284 words
getcontradicts 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 bybuildoradd(or restored from whatstorewrote), becauseinsertdrops it (TSTAutocomplete.java:58-59).lookuphas no precondition.Lookup.lookuptakes "lookup key. Depending on the implementation this may be a prefix, misspelling, or even infix", returns "a list of possible completions" and declares onlyIOException(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 classLookupis marked@lucene.experimental(Lookup.java:38);TSTAutocompletehas 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,
TSTLookupappears only inTestPersistence(full keys) and the ignoredTestLookupBenchmark, 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.lookupreturns an empty result forkey.isEmpty()(FSTCompletion.java:198,:221);AnalyzingSuggesterlists 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.
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 aTSTLookup(the ignoredTestLookupBenchmarkbuilds 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:testwith 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 on645d4fe14a20showslookup("", false, 5)andlookup("", true, 5)returning[],get("")returningnullandlookup("ca", false, 5)unchanged.
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.
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.
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, 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).
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(exactFirston and off,onlyMorePopularfalse) andTSTLookup(onlyMorePopulartrue);FSTCompletionLookupwas not in the model. Dictionaries: every sequence of 1 to 3 (string, weight) entries, duplicates allowed, with strings from"",a,b,aa,ab,ba,bband weights 0, 1, 2 andInteger.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 overaandb, including""(15 keys);num1 to 6: 6,146,280 checks. - Signal. One exception class,
StringIndexOutOfBoundsException, in 136,584 checks: every dictionary and everynumfor the empty key. The only otherTSTLookupclass was a duplicate-input weight difference (19,512 checks; no documented rule; not this bug).WFSTCompletionLookuphad 0 mismatches. - The class was reduced by hand to a public-API JUnit, and the repro was later minimized to the one test above.
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.
| Where | When | How | Found |
|---|---|---|---|
| scripted prior-report search | 2026-10-26 | 58 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 read | no report |
| JIRA (LUCENE, SOLR) and the lists | 2026-10-26 | text 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 lists | only precedents in other code (LUCENE-4567, LUCENE-8287, SOLR-16169) and SpellCheckCollator exceptions |
| GitHub and the web (manual) | 2026-10-26 | apache/lucene, lucene-solr, lucenenet, solr, Elasticsearch and OpenSearch; Stack Overflow; web search; the full git history | no report; no commit adds a guard |
| GitHub recheck | 2026-10-30 | apache/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 |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the one-test repro fails through gradle and the harness, assertions on and off |
| 10.6.0 | affected | the one-test repro fails on the Maven Central jars, assertions on and off |
main ea57360912ed | affected | the 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 | affected | the 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 | affected | main() probe: 4 of 4 empty-key calls throw, the non-empty control passes |
| 3.3.0 | first | the first Lucene release with the code: 4 of 4 throw |
| Solr 3.1.0 | affected | the first release of the code, under a Solr package: 4 of 4 throw |
| 3.2.0 | API absent | the probe does not compile: no suggest.tst package |
the fix on main 645d4fe14a20 | not affected | the repro and TestPersistence pass (assertions on and off), and the whole :lucene:suggest:test run (237 tests, 7 skipped, 0 failures) |
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.
| Check | Verdict | What it found |
|---|---|---|
| Independent reproducer | real | Own 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), procedural | An 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 | real | Built 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-sensitive | 13 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 test | Fresh 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 validated | Gradle 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 corrected | Two 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 469 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,425 words
- 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 withTestPersistenceand 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 withTestPersistence, the whole:lucene:suggest:testrun (237 tests, 7 skipped, 0 failures, assertions on) and:lucene:suggest:check -x test. Through the repro harness: the one-test repro on645d4fe14a20(assertions on and off, fix shadowed) and on the 10.6.0 jars from Maven Central (assertions on and off); a diagram probe on645d4fe14a20(stock and fixed, assertions on and off); the finder's JUnit, an independent 8-test JUnit and a devil's-advocate probe on mainea57360912ed(assertions on and off), 10.5.2 and 9.12.3; amain()probe on 13 releases from 3.3.0 to 10.5.2 and Solr 3.1.0. The suggest module is identical atea57360912edand645d4fe14a20. OpenJDK 25.0.3, also for the old releases, Linux aarch64 (the only platform run).
- 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) @ 645d4fe14a20lucene/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) @ 645d4fe14a20lucene/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 @ 645d4fe14a20lucene/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) @ 645d4fe14a20lucene/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 @ 645d4fe14a20lucene/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
- TestTSTLookupEmptyKey.java (one test):
repro/LFV-251/src/org/apache/lucene/search/suggest/tst/TestTSTLookupEmptyKey.java(27 lines)
It is made separately from this text; its numbers come from the executed diagram probe on main.
assets/LFV-251/bug.png(469 KB)
![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.](../assets/LFV-251/bug.png)