build() looks for 0x1F with surfaceForm.bytes[i] for i = 0 to length - 1 and never adds surfaceForm.offset, although the line right after the loop writes the slice with its offset. For a slice with offset > 0 it checks the start of the backing array, not the suggestion.
- A
BytesRefis a window into a byte array:bytes,offsetandlength. Its value isbytes[offset, offset + length); the bytes around the window belong to other values. - With payloads,
AnalyzingSuggesterstores each suggestion as its text, the byte 0x1F (U+001F) and the payload, and splits at the first 0x1F at lookup. So it refuses suggestion text that contains 0x1F.
{0x1F, 'a', 'b'} with the slice "ab" at offset 1 and the two bytes the check reads instead, the reverse case with its wrong split, and the one-line fix. Select the still to open it at full size. Generated with AI, checked against the code.- Build a payload-enabled
AnalyzingSuggesterfrom your ownInputIteratorwhose one suggestion,"ab", is a slice of a 3-byte array:new BytesRef(new byte[] {0x1F, 'a', 'b'}, 1, 2), so offset 1, length 2, payloadp. - You expect the same result as for
new BytesRef("ab"): the slice's 2 bytes areaandb, sobuild()accepts it andlookup("a")returnsab. - You get
IllegalArgumentException: surface form cannot contain unit separator character U+001Ffrombuild(), with assertions on and off, so the suggester cannot be built. - Why: with payloads every entry is stored as text + 0x1F + payload, so
build()rejects text that contains 0x1F. Its check scansbytes[0..1](0x1F,a) instead of the slicebytes[1..2](a,b), because it never addsoffset, and throws at byte 0. - The reverse also happens:
a<0x1F>bat offset 3 of{x, x, x, a, 0x1F, b}passes (the check readsx x x), and lookup later splits it at the wrong 0x1F:lookup("a")withoutEXACT_FIRSTreturns keyawith payloadb<0x1F>p.
Applications that enable payloads on AnalyzingSuggester or FuzzySuggester (both experimental) and feed them from their own InputIterator returning slices of a larger buffer, such as BytesRefHash values or a MemoryIndex TermsEnum. In a BytesRefHash, one 31-byte value stored first puts its length byte 0x1F at index 0 and fails the whole build. Lucene's own dictionaries are unaffected.
Mechanism, terms and numbers677 words
When AnalyzingSuggester (and FuzzySuggester, which inherits build()) is built with payloads, it stores each suggestion as surface form + U+001F + payload and splits at the first U+001F at lookup, so it rejects surface forms that contain that byte. The check reads surfaceForm.bytes[i] for i in [0, length) and never adds surfaceForm.offset (AnalyzingSuggester.java:466-467 on main 645d4fe14a20), while right after the loop the method writes the real slice [offset, offset + length) (:473). For a BytesRef with offset > 0 it therefore checks bytes that are not the surface form. The valid "ab" at offset 1 of {0x1F, 'a', 'b'} is rejected with IllegalArgumentException; a slice that really contains U+001F is accepted when the first length bytes of the array do not, and comes back with a truncated key and a corrupted payload, or, with the default EXACT_FIRST, is dropped (-da) or trips an assertion (-ea). The trigger is an application InputIterator that returns slices of a larger buffer, for example values from a BytesRefHash or a MemoryIndex TermsEnum, where one 31-byte value stored first puts its length prefix 0x1F at byte 0 and fails the whole build. Lucene's own payload-capable iterators return offset-0 refs, and builds without payloads never run the check. The check came with payloads in LUCENE-4820 (2013, first released in 4.3.0); it fails on all 22 releases executed, from 4.3.0 to 10.6.0, and on main, and every other release tag from 4.3.0 on has the same line. The fix is one line: surfaceForm.bytes[surfaceForm.offset + i].
- Separator.
private static final int PAYLOAD_SEP = '\u001f';(:161). With payloads, the FST output of an entry is surface form +PAYLOAD_SEP+ payload (:579-586), andgetLookupResultsplits it at the firstPAYLOAD_SEP(:623-640, scanning withoutput2.offsetat:628). - Input.
build(InputIterator)(:389) setshasPayloads = iterator.hasPayloads()(:394) and reads each surface form withiterator.next()(:415). It analyses the slice throughtoAutomaton(:417), which callssurfaceForm.utf8ToString()(:868) and honours the offset. - The check, without the offset (
:465-474):
if (hasPayloads) {
for (int i = 0; i < surfaceForm.length; i++) {
if (surfaceForm.bytes[i] == PAYLOAD_SEP) { // :467 reads bytes[0, length)
throw new IllegalArgumentException(
"surface form cannot contain unit separator character U+001F; this character is reserved");
}
}
output.writeShort((short) surfaceForm.length);
output.writeBytes(surfaceForm.bytes, surfaceForm.offset, surfaceForm.length); // :473 writes the real slice
output.writeBytes(payload.bytes, payload.offset, payload.length);- Where it runs. Inside the loop over the entry's analyzed forms (
:419), so once per analyzed form of the entry. - No out-of-bounds read. For a valid
BytesRef,offset + length <= bytes.length, so the bytes read are inside the array; they are only compared with 0x1F, never copied or returned. The only effect is whetherbuild()throws. FuzzySuggesterextendsAnalyzingSuggester(FuzzySuggester.java:66) without overridingbuild, so it fails the same way.
| input slice | bytes the check reads | build() today | correct |
|---|---|---|---|
"ab" = offset 1, length 2 of {0x1F,'a','b'} | {0x1F,'a'} | throws IAE | accepted |
"a<U+001F>b" = offset 3, length 3 of {'x','x','x','a',0x1F,'b'} | {'x','x','x'} | accepted | throws IAE, as its compact copy does |
The accepted entry is stored as a<U+001F>b<U+001F>p and split at the first U+001F. Without EXACT_FIRST, lookup("a") returns key a with payload b<U+001F>p. With the default EXACT_FIRST, the entry is missing (-da) or the lookup throws AssertionError at :808 (-ea).
BytesRefHash, which MemoryIndex uses for its terms, returns slices of a shared block: BytesRefBlockPool.fillBytesRef sets term.offset = pos + 1 after a 1-byte length prefix (BytesRefBlockPool.java:62-75, written at :101-103). If the first value in a block is 31 bytes long, byte 0 of the block is 31 = 0x1F, and the check reads byte 0 for every non-empty value from that block: the build fails at the first such value although no value contains 0x1F. Executed with a wrapped MemoryIndex TermsEnum and with BytesRefHash.get; the compact copies build.
Exposure and likelihood286 words
- Who is hit. Applications that build a payload-enabled
AnalyzingSuggesterorFuzzySuggester(both@lucene.experimental,AnalyzingSuggester.java:98,FuzzySuggester.java:64) from their ownInputIteratorthat returnsBytesRefslices withoffset > 0: records in one shared buffer, values from aBytesRefHash, a wrappedMemoryIndexTermsEnum. - Default vs opt-in. Opt-in twice: payloads must be on, and the iterator must hand out slices. Every Lucene-provided payload iterator (
DocumentDictionary,FileDictionary,BufferedInputIterator,SortedInputIterator,UnsortedInputIterator) returns offset-0 refs, and builds without payloads never run the check. A read-only code search of the default branches (not exhaustive) found that the dictionaries Solr configures are Lucene's own, and found no production use of these suggesters in Elasticsearch or OpenSearch (only test method names). - What goes wrong. Mostly a loud failure: a slice that contains no U+001F is rejected whenever a 0x1F byte sits in
bytes[0, length)outside the slice, and the suggester cannot be built or rebuilt while that value is in the input. Secondary: a slice that does contain U+001F, input the API means to reject, is accepted whenbytes[0, length)holds no 0x1F and comes back with a truncated key and a corrupted payload, or is dropped (-da) or trips an assertion (-ea) underEXACT_FIRST. - Scale. None needed: one entry is enough. Deterministic, assertions on or off.
- Workaround. Return
BytesRef.deepCopyOf(ref)from the iterator; the compact copies of valid entries used as controls in the finder's, the independent reproducer's, the devil's advocate's, the security probe's and the history repros build correctly (the minimal test has no such control). - How likely. Rare: it needs payloads plus a slice-returning application iterator, and no matching public report was found in the recorded searches.
One JUnit class, one method, public API only, for the suggest module's analyzing test package: a one-entry payload InputIterator whose surface form is "ab" at offset 1 of {0x1F, 'a', 'b'}. It fails with assertions on and off and is also the suggested regression test.
/*
* 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.analyzing;
import java.util.Set;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.search.suggest.InputIterator;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.analysis.MockAnalyzer;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
public class TestAnalyzingSuggesterPayloadSurfaceSlice extends LuceneTestCase {
public void testSurfaceFormSliceWithPayload() throws Exception {
BytesRef surface = new BytesRef(new byte[] {0x1F, 'a', 'b'}, 1, 2);
InputIterator it =
new InputIterator() {
boolean done;
@Override
public BytesRef next() {
if (done) {
return null;
}
done = true;
return surface;
}
@Override
public long weight() {
return 1;
}
@Override
public BytesRef payload() {
return new BytesRef("p");
}
@Override
public boolean hasPayloads() {
return true;
}
@Override
public Set<BytesRef> contexts() {
return null;
}
@Override
public boolean hasContexts() {
return false;
}
};
try (Directory dir = newDirectory();
Analyzer analyzer = new MockAnalyzer(random())) {
AnalyzingSuggester suggester = new AnalyzingSuggester(dir, "suggest", analyzer);
try {
suggester.build(it);
} catch (IllegalArgumentException e) {
throw new AssertionError(
"expected surface form \"ab\" to be accepted, but build rejected it", e);
}
assertEquals("ab", suggester.lookup("a", false, 1).get(0).key.toString());
}
}
}
cp $REPRO/TestAnalyzingSuggesterPayloadSurfaceSlice.java $LUCENE/lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/
cd $LUCENE && ./gradlew :lucene:suggest:test --tests org.apache.lucene.search.suggest.analyzing.TestAnalyzingSuggesterPayloadSurfaceSlice -Ptests.seed=DEADBEEF
# assertions forced on: add -Ptests.asserts=true (same failure)
- Expected
build()accepts the slice"ab", andlookup("a", false, 1)returnsab.- Observed
build()throwsIllegalArgumentException: surface form cannot contain unit separator character U+001F; this character is reserved.
More files, environment, full output251 words
Copy the test into lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/ of a Lucene checkout and run it with gradle (command below). It fails inside build(), before the lookup. With the one-line fix it passes unchanged, so it doubles as the regression test.
The seed drives MockAnalyzer and the test framework's other randomization, such as locale, time zone and whether assertions are on (DEADBEEF alone runs with tests.asserts=false; add -Ptests.asserts=true to force them on); the check depends on none of these: the test fails with two seeds and with assertions on and off. Executed: gradle on a fresh 645d4fe14a20 with assertions off and on (fails); the standalone runner on the stock main jars and on the 10.6.0 release jars, each with -ea and -da (fails, same exception).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (plain byte handling, nothing platform-specific) |
cp $REPRO/TestAnalyzingSuggesterPayloadSurfaceSlice.java $LUCENE/lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/
cd $LUCENE && ./gradlew :lucene:suggest:test --tests org.apache.lucene.search.suggest.analyzing.TestAnalyzingSuggesterPayloadSurfaceSlice -Ptests.seed=DEADBEEF
# assertions forced on: add -Ptests.asserts=true (same failure)
# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF (as drawn: tests.asserts=false; the same with -Ptests.asserts=true)
TestAnalyzingSuggesterPayloadSurfaceSlice > testSurfaceFormSliceWithPayload FAILED
java.lang.AssertionError: expected surface form "ab" to be accepted, but build rejected it
at __randomizedtesting.SeedInfo.seed([DEADBEEF:D57D816ED23036C6]:0)
at org.apache.lucene.search.suggest.analyzing.TestAnalyzingSuggesterPayloadSurfaceSlice.testSurfaceFormSliceWithPayload(TestAnalyzingSuggesterPayloadSurfaceSlice.java:75)
Caused by:
java.lang.IllegalArgumentException: surface form cannot contain unit separator character U+001F; this character is reserved
at org.apache.lucene.search.suggest.analyzing.AnalyzingSuggester.build(AnalyzingSuggester.java:468)
at org.apache.lucene.search.suggest.analyzing.TestAnalyzingSuggesterPayloadSurfaceSlice.testSurfaceFormSliceWithPayload(TestAnalyzingSuggesterPayloadSurfaceSlice.java:73)
1 test completed, 1 failed
# standalone runner, seed DEADBEEF
main 645d4fe14a20 -ea / -da tests_run=1 failures=1 same exception
10.6.0 -ea / -da tests_run=1 failures=1 same exception- A
BytesRef"represents byte[], as a slice (offset + length)";InputIteratorsets no offset-0 rule, and the next lines write the slice with its offset. - The test framework's
newBytesRefexists "to tickle latent bugs that fail to look atBytesRef.offset"; no suggest test uses it. - The check came with payloads in 2013; that commit's lookup split honours the offset.
The full argument199 words
- Contract.
BytesRef.java:22: "Represents byte[], as a slice (offset + length) into an existing byte[]".InputIterator(InputIterator.java:29-31) andBytesRefIterator.next()(BytesRefIterator.java:24-34: "The returned BytesRef may be re-used across calls to next") state no offset-0 precondition; nor doLookup.buildand theAnalyzingSuggesterjavadoc. The IAE text states the rule for the surface form, and the rest of the class treats the slice as the surface form: the write at:473,toAutomaton(:868), the lookup split (:628) andsameSurfaceForm(:650-665) all use the offset. - Tests and practice.
LuceneTestCase'snewBytesRef(byte[], int, int)exists to "tickle latent bugs that fail to look atBytesRef.offset" (LuceneTestCaseParent.java:1209-1213). No suggest test usesnewBytesRef, and none asserts the build-time U+001F message. Fixes of the same shape exist: LUCENE-10052 (FSTTermsReader.IntersectEnum"was illegally ignoring BytesRef.offset") and #9017 / LUCENE-7968, a slice bug in this class'sAnalyzingComparator, fixed in 2017. - History. The check,
PAYLOAD_SEPand the payload API came in one commit,234fa47dba43(LUCENE-4820, #5885, 2013). That commit's lookup-side split usesoutput2.offset, and the offset-awarewriteBytespredates it (LUCENE-3842). The issue thread discusses how the payload is packed, never offsets or slices.
The devil's-advocate check161 words
The strongest case for "works as designed": the class is @lucene.experimental, U+001F is deliberately reserved, and every Lucene-provided payload iterator returns offset-0 refs, so the code could be said to assume compact refs.
Why it fails: nothing documents that assumption; InputIterator is a public interface for applications to implement; the same method uses the offset right after the loop (:473); and the project treats ignoring BytesRef.offset as a bug (the newBytesRef javadoc, LUCENE-10052, LUCENE-7968). @lucene.experimental allows API changes, not wrong validation, and bugs in this class have been fixed before (LUCENE-5120, LUCENE-7968). These points limit reach, not validity. They limit it a lot: the usual symptom is a loud exception at build time, and no production caller with a slice-returning iterator was identified, hence priority low. It still counts as a bug rather than noise, because valid input is rejected through the public API with assertions off, and BytesRefHash / MemoryIndex are a natural source of such slices.
Recommended Add the offset to the reserved-byte check (one line)
Add the offset: surfaceForm.bytes[surfaceForm.offset + i] at AnalyzingSuggester.java:467, one line. With it the repro, TestAnalyzingSuggester and TestFuzzySuggester pass with assertions on and off, and so does the whole suggest test module (237 tests). Offset-0 refs behave exactly as before; FuzzySuggester is fixed too.
if (hasPayloads) {
for (int i = 0; i < surfaceForm.length; i++) {
if (surfaceForm.bytes[surfaceForm.offset + i] == PAYLOAD_SEP) { // was: surfaceForm.bytes[i]
throw new IllegalArgumentException(
"surface form cannot contain unit separator character U+001F; this character is reserved");
}
}All options and trade-offs2 options · 320 words
One code fix, validated with gradle in a fresh scratch worktree at 645d4fe14a20, and an application-side workaround for released versions.
In AnalyzingSuggester.build, read the slice's bytes, as the write right after the loop (:473) already does.
if (hasPayloads) {
for (int i = 0; i < surfaceForm.length; i++) {
if (surfaceForm.bytes[surfaceForm.offset + i] == PAYLOAD_SEP) { // was: surfaceForm.bytes[i]
throw new IllegalArgumentException(
"surface form cannot contain unit separator character U+001F; this character is reserved");
}
}Trade-offs
- No change for offset-0 refs: the expression is the same.
- Slices that really contain U+001F are now rejected, as their compact copies already are; that is the rule the exception states.
- Backportable to 10.x;
FuzzySuggesteris fixed with it.
What the checks validated
- Gradle, fresh scratch worktree at
645d4fe14a20, JDK 25,-Ptests.seed=DEADBEEF: without the fix the minimal test fails, with assertions off (as drawn) and forced on. With the fix it passes unchanged, together withTestAnalyzingSuggester(23 tests) andTestFuzzySuggester(14): 38 of 38, assertions off and on. The whole:lucene:suggest:testpasses (32 suites, 237 tests, 7 skipped, 0 failures), and so does:lucene:suggest:checkGoogleJavaFormat. Earlier runs of the same steps, one by an independent fact check, gave the same counts. - Shadow runs of the same line: the finder's 2 tests, an independent reproducer's 6 (both suggesters), the
MemoryIndextest and the security probe's 4 pass; the 4.3.0 history port passes with it.
Have the application's InputIterator.next() return BytesRef.deepCopyOf(ref) instead of the slice.
Trade-offs
No Lucene change; one allocation and copy per entry. A workaround, not a fix.
What the checks validated
The compact copies of valid entries used as controls in the finder's, the independent reproducer's, the devil's advocate's, the security probe's and the history repros build correctly.
Services that build these suggesters from user-supplied entries through a slice-returning iterator can have a rebuild fail on one ordinary 31-byte entry; returning BytesRef.deepCopyOf copies from the iterator avoids it.
A bytecode scan of every module flagged the read in build() as an array index that ignores BytesRef.offset. A harness then built each suggester from slices and from compact copies of the same bytes: the two payload suggesters disagreed in 87 of 310 cases, the four other suggesters tested and the no-payload build in none.
The method in full184 words
Method: a bytecode def-use scan plus a bounded metamorphic harness (the hunt's semi-formal tier), in a hunt unit on BytesRef offset/length handling, run on the stock main jars at ea57360912ed.
- Scan. A data-flow pass over the bytecode of every non-sandbox module flags array reads whose index ignores
BytesRef.offseton a value returned by an iterator. A mutation probe checked it: all 7 planted defective methods are flagged, and of 4 correct methods none gets an offset flag. It flaggedAnalyzingSuggester.buildat line 467. - Harness. Oracle: a suggester built from slices must behave exactly as one built from compact copies of the same bytes. Slices at offsets 1 and 7, with fill bytes 0x00, 0xFF, 0x1F,
aand a self-copy. Result:AnalyzingSuggesterandFuzzySuggesterwith payloads mismatch in 87 of 310 cases each;AnalyzingSuggesterwithout payloads,FSTCompletionLookup,WFSTCompletionLookup,TSTLookupandAnalyzingInfixSuggestershow 0. Planted offset-ignoring mutants made the harness report mismatches, as expected. - The finding was then reduced to a public-API JUnit test, later to the single test shown here. Bounded scope only; no model checker, SMT solver or prover was involved.
No prior report found: 167 recorded queries on 2026-10-26 across GitHub issues, PRs and commits (incl. the JIRA archive), the mailing lists, downstream projects and the web, re-checked on 2026-10-30. Closest: #9017 (LUCENE-7968), a different slice bug in the same class, fixed in 2017.
Searches4 searches
Related, not this bug: #9017 / LUCENE-7968 (a slice bug in AnalyzingComparator, fixed 2017; its fix did not touch this loop), #8127 / LUCENE-7071 (offset bugs in the sibling SortedInputIterator), #11090 / LUCENE-10052 (the open umbrella for randomizing BytesRef offsets in tests; it never reached the suggest tests), #5885 / LUCENE-4820 (introduced the check; its discussion covers payload packing, not offsets), #6094 / LUCENE-5030 (2013, 97fb0209ce3: moved the analyzed-form separator and hole labels to U+001F and U+001E and added the lookup-key check that rejects both; U+001F was already the payload separator from LUCENE-4820). Downstream copies of the same loop (Elasticsearch's old XAnalyzingSuggester, Lucene.NET's AnalyzingSuggester.cs) have no report. Coverage limits: GitHub full-text search is phrase-like and best effort; the issues@ list API returned almost nothing (GitHub and JIRA cover it); private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| Search tool (GitHub issues/PRs/commits incl. the JIRA archive, dev@, java-user@, issues@, commits@, Elasticsearch, OpenSearch, Solr, web) | 2026-10-26 | three runs, 136 queries: AnalyzingSuggester, FuzzySuggester, PAYLOAD_SEP, the exception text, BytesRef offset/slice phrasings | 17 hits read and classified; none about this bug |
| GitHub-wide, Lucene.NET, JIRA, web, git history | 2026-10-26 | 31 manual searches and source reads | #9017, #8127, #11090, #5885, #6094: related, none the same bug |
| Open PRs | 2026-10-26 | changed files of all 411 open apache/lucene PRs | none touches AnalyzingSuggester.java or FuzzySuggester.java |
| GitHub, re-check | 2026-10-30 (three runs) | search API: AnalyzingSuggester, "surface form cannot contain unit separator", PAYLOAD_SEP, FuzzySuggester payload, AnalyzingSuggester offset, BytesRef offset suggester; open PRs mentioning AnalyzingSuggester; the file on upstream main | only old, unrelated hits (newest issue or PR mentioning AnalyzingSuggester: #12284, 2023); 0 open PRs; the line unchanged on upstream main 2f424fd428da |
Introduced with suggester payloads by LUCENE-4820 (234fa47dba43, 2013-03-13), first released in 4.3.0; the line is unchanged since, apart from a 2021 reformat. It fails on all 22 releases executed, from 4.3.0 to 10.6.0, and on main; every other release tag from 4.3.0 on has the same line.
Versions tested7 rows
The same commit added the payload API, PAYLOAD_SEP and the check, so no before/after run is possible: 4.2.0 and 4.2.1 have no payload API, and a no-payload build of the same slice works there and on 4.3.0. The one-line fix applied to the 4.3.0 source makes the 4.3.0 port pass. git log -G 'surfaceForm\.bytes\[' finds only the introducing commit; the only later change to the loop is the LUCENE-9570 reformat (2021). All 117 release tags from 4.3.0 to 10.5.2 present in the history clone contain the expression, and so does the 10.6.0 tag (4ae946e5a9b9, line 467) in a second clone. The history clone has no tags for 8.8.2 to 8.11.4 and 10.6.0; all ten were executed from their release jars (8.8.2 to 8.11.3 with the same 8.x port as 8.11.4, during the check resolution: 2 of 2 fail on each). The other releases between the executed points are affected by reading their tag. Releases before 9.1.0 ran API ports of the scenario; old releases ran on JDK 25.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails (gradle, assertions off and on; standalone -ea and -da) |
| 10.6.0 | affected | the minimal test fails (-ea and -da) |
9.1.0, 9.12.3, 10.0.0, 10.5.2, main ea57360912ed | affected | the finder's JUnit fails 2 of 2 (9.12.3, 10.5.2 and main also with -da) |
| 4.3.0 to 9.0.0 | 9 releases | API ports fail the same way on 4.3.0 (-ea, -da), 4.5.1, 4.6.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4 and 9.0.0 |
| 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0, 8.11.1, 8.11.2, 8.11.3 | 8 releases | no tag in the history clone; the finder's JUnit with the pre-9.1 test-framework imports (as on 8.11.4 and 9.0.0) fails 2 of 2 on each release jar (-ea) |
| 4.2.0, 4.2.1 | not affected | no payload API (the payload port does not compile); a no-payload build of the same slice works |
one-line fix (scratch worktree at 645d4fe14a20; 4.3.0 source) | not affected | the minimal test, TestAnalyzingSuggester, TestFuzzySuggester (assertions off and on) and the whole suggest module pass (237 tests, 0 failures); the 4.3.0 port passes |
An independent reproducer covering both suggesters, a devil's advocate with a MemoryIndex repro, an adjudication, a security reach probe, an audit and fresh-seed re-runs all confirm it. A reviewer from another model family could run no code but agreed on the root cause. This page's gradle runs confirm the repro and the fix.
Checks and verdicts389 words
The cross-model reviewer could not run Java, so its refute-lens review voted not real at low confidence, on procedure only ("no substantive refutation was found"); a re-run of the repro for each review failed (2 of 2).
| Check | Verdict | What it found |
|---|---|---|
| finder (bytecode scan + metamorphic harness) | candidate | 87 of 310 mismatches for each payload suggester, 0 elsewhere; its JUnit fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3, and 20 of 20 over 10 seeds. |
| cross-model review (OpenAI GPT-5.6, lenses, reading only) | refute: procedural "not real" (low); intended: real (medium) | Could not run Java; agreed on the root cause and that the inputs are in contract; rated the intended-behaviour argument weak. |
| independent reproducer | real | Own 6-test public-API JUnit from another package, both suggesters, records apple<U+001F>banana in one buffer: fails 6 of 6 on main (-ea, -da), 10.5.2 and 9.12.3; passes with the fix. |
| intended-behaviour devil's advocate | real | Built the works-as-designed case and rejected it; its MemoryIndex TermsEnum repro fails on main and 10.5.2 and passes with the fix. |
| adjudication | confirmed (high) | Re-ran the reproducer's JUnit (6 of 6 fail, -ea, -da), the finder's (2 of 2) and the MemoryIndex test (1 of 1); impact class crash. |
| security assessment | none | Executed reach probe: 3 of 5 fail on main, 10.5.2 and 9.12.3; 4 of 4 pass with the fix. |
| registry re-run and audit | keep | Fresh seeds: every repro fails on main and passes with the fix; the audit's own BytesRefHash repro fails on main and 9.12.3. |
| history sweep | affected since 4.3.0 | Runs on 13 releases from 4.3.0 to 10.5.2 (API ports before 9.1.0) and main; the 4.2.x boundary; the fix on 4.3.0. |
| check resolution: untagged 8.x releases | affected | The 8.x port on the release jars of 8.8.2 to 8.11.3, which have no tag in the history clone: 2 of 2 fail on each of the 8. |
| this doc | gradle repro and fix check | The minimal test with gradle on a fresh 645d4fe14a20 fails with assertions off and on; with the one-line fix it passes, with TestAnalyzingSuggester, TestFuzzySuggester and the whole suggest module (237 tests, 0 failures); the standalone runner fails on the main and 10.6.0 jars (-ea, -da). |
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 557 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,521 words
- Registry title
- AnalyzingSuggester and FuzzySuggester check a payload-enabled surface form for the reserved byte U+001F in bytes[0, length) of the backing array instead of in the BytesRef slice [offset, offset + length), so a valid slice is rejected with IllegalArgumentException and a slice that contains U+001F is accepted and later mis-split
- Area
- suggest / analyzing suggester (AnalyzingSuggester.build payload check, inherited by FuzzySuggester)
- Priority
- Low, rank 634: Payload-enabled analyzing suggesters fed non-zero-offset slices from a custom iterator usually fail the build loudly, the mis-split is secondary and Lucene's own iterators are unaffected, so it goes with the other suggester edge cases.
- Impact class
- crash
- Impact note
- Only payload-enabled AnalyzingSuggester or FuzzySuggester builds (both @lucene.experimental; every release from 4.3.0 through 10.6.0 (10.6.0 by reading) and main) fed by an application InputIterator that returns BytesRef slices with offset > 0 are hit, for example values from a BytesRefHash, a MemoryIndex TermsEnum or a shared record buffer: build() then throws 'surface form cannot contain unit separator character U+001F' for a valid surface form whenever a 0x1F byte sits in the first length bytes of the backing array (one 31-byte term stored first in a block is enough, because its length prefix is 0x1F), and, as a secondary effect, a slice that really contains U+001F is accepted and mis-split at lookup; Lucene's own iterators return offset-0 refs and builds without payloads never run the check, so default use is not affected.
- Summary
- You build an AnalyzingSuggester or FuzzySuggester with payloads from your own InputIterator whose surface forms are BytesRef slices of a larger byte array, for example "ab" at offset 1 of {0x1F, 'a', 'b'}, and expect it to behave exactly as for compact copies of the same bytes. Instead build() looks for the reserved separator U+001F in bytes[0, length) of the backing array, ignoring the slice's offset, while two lines later it writes the real slice: the valid slice "ab" is rejected with IllegalArgumentException 'surface form cannot contain unit separator character U+001F', and "a<U+001F>b" at offset 3 of {'x','x','x','a',0x1F,'b'}, whose compact copy is rejected, is accepted. The accepted entry is then split at the wrong place: without EXACT_FIRST, lookup("a") returns key "a" with payload "b<U+001F>p" instead of payload "p"; with the default EXACT_FIRST it returns nothing for that entry with -da and throws AssertionError with -ea.
- Introduced
- 234fa47dba438452a9952f748208ce8da6eea252 (LUCENE-4820 'add optional payload to Analyzing/FuzzySuggester', by mikemccand; trunk svn r1456095, 2013-03-13; branch_4x backport d11bc054c4e2f7239ddc74db3aa88722708af79f, svn r1456098, the same day, which is the commit the 4.x and 5.x release tags contain; no pull request, Subversion era; migrated issue #5885) [read: git log -L on the loop lists only this commit and the google-java-format reformat 2cbf261032d (LUCENE-9570, 2021-01-05); git log -G 'surfaceForm\.bytes\[' on the file finds only this commit]. The same commit added the payload API, PAYLOAD_SEP and the check, and its own lookup-side split honours the offset; the offset-aware write of the surface form predates it (LUCENE-3842, f2f91bae46c, 2012-09-28). A before/after run of the commit is impossible because it introduced the API; substitutes [exec]: a no-payload build of the same slice passes on 4.2.0, 4.2.1 and 4.3.0, and the 4.3.0 AnalyzingSuggester source with only the one-line offset fix makes the 4.3.0 run pass (2013-03-13)
- First affected version
- 4.3.0 [exec] (released 2013-05-06; the first release with suggester payloads); 4.2.0 and 4.2.1 lack the payload API, so the payload repro does not compile there [exec], and their no-payload build of the same slice works [exec]
- Affected versions
- every release from 4.3.0 through 10.6.0 (10.6.0 by reading) and main ea57360912ed; not fixed on upstream main or branch_10_6 as of 2026-10-26 [exec, follow-up history sweep: the scenario fails on 4.3.0 (-ea and -da), 4.5.1, 4.6.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.1.0, 9.12.3 (-ea and -da), 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da), with correct compact-copy controls at every version; the independent reproduction's own test fails on main, 10.5.2 and 9.12.3; the intended-behaviour check's TermsEnum test fails on main and 10.5.2] [read: all 117 release tags from 4.3.0 to 10.5.2 in the history clone contain the identical expression (the clone lacks tags 8.8.2 to 8.11.4; 8.11.4 was executed); a read-only look at upstream main and branch_10_6 shows the line unchanged]. Caveats: releases before 9.1.0 ran API ports of the scenario (main() programs for 4.3-4.5, 4.6-5.5 and 6.0+ API generations, the canonical JUnit with pre-9.1 test-framework imports on 8.11.4 and 9.0.0), all on JDK 25; versions between those listed were checked by tag scan only; FuzzySuggester inherits build() unchanged (read at 4.3.0 and main) and was executed on main, 10.5.2 and 9.12.3 only; 10.6.0 was released 2026-10-29 and its branch has the unchanged line (not executed)
- 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 low-confidence “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
- Upstream and prior reports
- none found (2026-10-26, read-only; the prior-report record verdict new: 167 recorded queries, namely 136 from the scripted prior-report search in three runs over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web, plus 31 manual GitHub-wide, Lucene.NET, JIRA, web and git-history searches and source reads; 17 hits read and classified, none the same bug; the changed files of all 411 open apache/lucene PRs: none touches AnalyzingSuggester.java or FuzzySuggester.java; the independent reproduction's and the intended-behaviour check's searches found nothing either); not fixed upstream (upstream main cf398bfb142c, branch_10_6 and branch_10x, read 2026-10-26, still read surfaceForm.bytes[i] at line 467). Closest items, not reports: #9017 / LUCENE-7968 (closed 2017, an offset/length slice bug in the same class's AnalyzingComparator; its fix did not touch this loop), #8127 / LUCENE-7071 (offset-ignoring decode bugs fixed in the sibling SortedInputIterator), #11090 / LUCENE-10052 (open umbrella for randomizing BytesRef offsets in tests; never reached the suggest tests), #5885 / LUCENE-4820 (introduced the check; its discussion covers payload packing, not offsets) and #6094 / LUCENE-5030 (made 0x1E/0x1F reserved). Downstream copies with the identical loop (source reads, no reports): Elasticsearch's XAnalyzingSuggester (2.4, 5.6) and Lucene.NET AnalyzingSuggester.cs
- Lucene commit tested
- main
645d4fe14a20(11.0.0-SNAPSHOT, 2026-10-28): the minimal test with gradle in a fresh scratch worktree, assertions as drawn by the seed (off) and forced on (fails), and with the one-line fix together withTestAnalyzingSuggesterandTestFuzzySuggester(assertions off and on), the whole suggest test module (237 tests, 0 failures) and the module's format check (the worktree was removed after the runs); the standalone runner on the stock main jars and on the 10.6.0 release jars,-eaand-da(fails). The hunt ran on mainea57360912ed, whereAnalyzingSuggester.javais identical, and on the 10.5.2 and 9.12.3 jars; the history sweep ran the scenario on 13 releases from 4.3.0 to 10.5.2 (API ports before 9.1.0), and the check resolution ran the 8.x port on the release jars of 8.8.2 to 8.11.3, which have no tag in the history clone (fails on each). The line is unchanged on upstream main2f424fd428da(2026-10-30). OpenJDK 25.0.3, Linux aarch64.
- Finding record
lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:98 (at ea57360912ed) (@lucene.experimental), :161 (PAYLOAD_SEP = U+001F), :415 (surfaceForm = iterator.next()), :465-471 (hasPayloads: for i in [0, surfaceForm.length) 'surfaceForm.bytes[i] == PAYLOAD_SEP' throws 'surface form cannot contain unit separator character U+001F; this character is reserved'; no offset), :472-474 (writes length, then output.writeBytes(surfaceForm.bytes, surfaceForm.offset, surfaceForm.length), then the payload), :476 (no-payload write, offset-aware), :623-640 (getLookupResult splits at the first U+001F, offset-aware at :628), :808 ('assert results.size() == 1' in the EXACT_FIRST path)lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FuzzySuggester.java:66 (at ea57360912ed) (public final class FuzzySuggester extends AnalyzingSuggester; no build override)lucene/core/src/java/org/apache/lucene/util/BytesRef.java:22 (at ea57360912ed) ('Represents byte[], as a slice (offset + length) into an existing byte[]')
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:161, :389, :394, :415, :417, :419, :465-474, :476, :579-586, :623-640 (:628), :808, :864-868a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FuzzySuggester.java:66 (no build override)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/BytesRefBlockPool.java:62-75, :101-103
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:98 and FuzzySuggester.java:64 (@lucene.experimental)
- Minimal repro
Lucene 645d4fe14a20 build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/TestsAndRandomizationPlugin.java:240-247, :440-441 (tests.asserts drawn from the seed; -ea only when true)
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/BytesRef.java:22a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/InputIterator.java:29-31a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/BytesRefIterator.java:24-34a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:473, :628, :650-665, :868a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:1209-1213a Lucene main checkout (645d4fe14a20) grep lucene/suggest/src/test: newBytesRef 0 hits; 'surface form cannot contain' 0 hits
- History
a Lucene clone: git show releases/lucene/10.6.0 (4ae946e5a9b9) lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:467 (same expression)
- Diagrams
a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:465-474
- TestAnalyzingSuggesterPayloadSurfaceSlice.java (lucene/suggest, package org.apache.lucene.search.suggest.analyzing, 1 test):
repro/LFV-399/src/org/apache/lucene/search/suggest/analyzing/TestAnalyzingSuggesterPayloadSurfaceSlice.java(81 lines)
Made in parallel with this doc from the code at 645d4fe14a20, the minimal test and an executed probe on the stock main jars (assertions on and off) and with the one-line fix.
assets/LFV-399/bug.png(557 KB)
