All findings · Lucene finding · LFV-399 · analyzingsuggester-payload-separator-check-ignores-offset

AnalyzingSuggester with payloads checks the wrong bytes for the reserved U+001F, so a valid BytesRef slice is rejected

With payloads, AnalyzingSuggester and FuzzySuggester look for the reserved byte 0x1F in the first length bytes of the backing array instead of the BytesRef slice. So the valid suggestion "ab" at offset 1 of {0x1F, 'a', 'b'} makes build() throw (since 4.3.0; Lucene's own dictionaries are unaffected).

Summary

Priority
Low, rank 634Payload-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
crash
Since
4.3.0 (2013; LUCENE-4820 added suggester payloads and this check)introduced 2013-03-13 · over 13 years
Affected
4.3.0 to 10.6.0 and main: fails on all 22 releases executed, including every release missing from the history clone; every other release tag from 4.3.0 on has the same line
Status
documented
Found by
AI tools: a bytecode scan for reads that ignore BytesRef.offset, then a slice-versus-copy harness

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

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.

Background: payloads and BytesRef slices

  1. A BytesRef is a window into a byte array: bytes, offset and length. Its value is bytes[offset, offset + length); the bytes around the window belong to other values.
  2. With payloads, AnalyzingSuggester stores 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.
Summary of the bug: AnalyzingSuggester with payloads checks the wrong bytes for the reserved 0x1F. For the slice "ab" at offset 1 of the array {0x1F, 'a', 'b'}, build() scans bytes[0, length) of the backing array instead of the slice [offset, offset + length), finds 0x1F at byte 0 and throws IllegalArgumentException on a valid suggestion; a slice that contains 0x1F gets in when bytes[0, length) holds none, and is later split at the wrong place. The fix adds surfaceForm.offset to the index.
The array {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.

The finding, step by step

  1. Build a payload-enabled AnalyzingSuggester from your own InputIterator whose 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, payload p.
  2. You expect the same result as for new BytesRef("ab"): the slice's 2 bytes are a and b, so build() accepts it and lookup("a") returns ab.
  3. You get IllegalArgumentException: surface form cannot contain unit separator character U+001F from build(), with assertions on and off, so the suggester cannot be built.
  4. Why: with payloads every entry is stored as text + 0x1F + payload, so build() rejects text that contains 0x1F. Its check scans bytes[0..1] (0x1F, a) instead of the slice bytes[1..2] (a, b), because it never adds offset, and throws at byte 0.
  5. The reverse also happens: a<0x1F>b at offset 3 of {x, x, x, a, 0x1F, b} passes (the check reads x x x), and lookup later splits it at the wrong 0x1F: lookup("a") without EXACT_FIRST returns key a with payload b<0x1F>p.

Who is affected

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

The code (main 645d4fe14a20, lucene/suggest/.../analyzing/AnalyzingSuggester.java)

  • 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), and getLookupResult splits it at the first PAYLOAD_SEP (:623-640, scanning with output2.offset at :628).
  • Input. build(InputIterator) (:389) sets hasPayloads = iterator.hasPayloads() (:394) and reads each surface form with iterator.next() (:415). It analyses the slice through toAutomaton (:417), which calls surfaceForm.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 whether build() throws.
  • FuzzySuggester extends AnalyzingSuggester (FuzzySuggester.java:66) without overriding build, so it fails the same way.

Both directions

input slicebytes the check readsbuild() todaycorrect
"ab" = offset 1, length 2 of {0x1F,'a','b'}{0x1F,'a'}throws IAEaccepted
"a<U+001F>b" = offset 3, length 3 of {'x','x','x','a',0x1F,'b'}{'x','x','x'}acceptedthrows 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).

A natural trigger

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 AnalyzingSuggester or FuzzySuggester (both @lucene.experimental, AnalyzingSuggester.java:98, FuzzySuggester.java:64) from their own InputIterator that returns BytesRef slices with offset > 0: records in one shared buffer, values from a BytesRefHash, a wrapped MemoryIndex TermsEnum.
  • 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 when bytes[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) under EXACT_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.

Minimal repro

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.

TestAnalyzingSuggesterPayloadSurfaceSlice.java (lucene/suggest, package org.apache.lucene.search.suggest.analyzing, 1 test)81 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.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());
    }
  }
}
Command
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", and lookup("a", false, 1) returns ab.
Observed
build() throws IllegalArgumentException: surface form cannot contain unit separator character U+001F; this character is reserved.

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

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

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.6.0 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (plain byte handling, nothing platform-specific)
Command (environment)
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)

Observed output

# 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

Why it looks unintended

  • A BytesRef "represents byte[], as a slice (offset + length)"; InputIterator sets no offset-0 rule, and the next lines write the slice with its offset.
  • The test framework's newBytesRef exists "to tickle latent bugs that fail to look at BytesRef.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) and BytesRefIterator.next() (BytesRefIterator.java:24-34: "The returned BytesRef may be re-used across calls to next") state no offset-0 precondition; nor do Lookup.build and the AnalyzingSuggester javadoc. 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) and sameSurfaceForm (:650-665) all use the offset.
  • Tests and practice. LuceneTestCase's newBytesRef(byte[], int, int) exists to "tickle latent bugs that fail to look at BytesRef.offset" (LuceneTestCaseParent.java:1209-1213). No suggest test uses newBytesRef, 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's AnalyzingComparator, fixed in 2017.
  • History. The check, PAYLOAD_SEP and the payload API came in one commit, 234fa47dba43 (LUCENE-4820, #5885, 2013). That commit's lookup-side split uses output2.offset, and the offset-aware writeBytes predates 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.

Suggested fix

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.

Add the offset to the reserved-byte check (one line) (validated by the checks) (recommended)

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; FuzzySuggester is 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 with TestAnalyzingSuggester (23 tests) and TestFuzzySuggester (14): 38 of 38, assertions off and on. The whole :lucene:suggest:test passes (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 MemoryIndex test and the security probe's 4 pass; the 4.3.0 history port passes with it.

Workaround on released versions: return compact copies from the iterator (validated by the checks)

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.

Downstream note

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.

How it was found

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.offset on 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 flagged AnalyzingSuggester.build at 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, a and a self-copy. Result: AnalyzingSuggester and FuzzySuggester with payloads mismatch in 87 of 310 cases each; AnalyzingSuggester without payloads, FSTCompletionLookup, WFSTCompletionLookup, TSTLookup and AnalyzingInfixSuggester show 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
Search tool (GitHub issues/PRs/commits incl. the JIRA archive, dev@, java-user@, issues@, commits@, Elasticsearch, OpenSearch, Solr, web)2026-10-26three runs, 136 queries: AnalyzingSuggester, FuzzySuggester, PAYLOAD_SEP, the exception text, BytesRef offset/slice phrasings17 hits read and classified; none about this bug
GitHub-wide, Lucene.NET, JIRA, web, git history2026-10-2631 manual searches and source reads#9017, #8127, #11090, #5885, #6094: related, none the same bug
Open PRs2026-10-26changed files of all 411 open apache/lucene PRsnone touches AnalyzingSuggester.java or FuzzySuggester.java
GitHub, re-check2026-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 mainonly old, unrelated hits (newest issue or PR mentioning AnalyzingSuggester: #12284, 2023); 0 open PRs; the line unchanged on upstream main 2f424fd428da

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails (gradle, assertions off and on; standalone -ea and -da)
10.6.0 affectedthe minimal test fails (-ea and -da)
9.1.0, 9.12.3, 10.0.0, 10.5.2, main ea57360912ed affectedthe 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 releasesAPI 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 releasesno 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 affectedno 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 affectedthe minimal test, TestAnalyzingSuggester, TestFuzzySuggester (assertions off and on) and the whole suggest module pass (237 tests, 0 failures); the 4.3.0 port passes

Independent checks

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

Each independent check and its verdict
CheckVerdictWhat it found
finder (bytecode scan + metamorphic harness) candidate87 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 realOwn 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 realBuilt 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 noneExecuted 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 keepFresh 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.0Runs 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 affectedThe 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 checkThe 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).

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,521 words

Finding record

LFV-399, snapshot of 2026-11-06

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 with TestAnalyzingSuggester and TestFuzzySuggester (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, -ea and -da (fails). The hunt ran on main ea57360912ed, where AnalyzingSuggester.java is 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 main 2f424fd428da (2026-10-30). OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

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-868
  • a 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:22
  • a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/InputIterator.java:29-31
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/BytesRefIterator.java:24-34
  • a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:473, :628, :650-665, :868
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:1209-1213
  • a 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

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

Repro files

About the diagrams

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.

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.