All findings · Lucene finding · LFV-175 · storedfielddatainput-bytearray-offset-length

StoredFieldDataInput(ByteArrayDataInput) takes the input's end position as its length, so a slice or a partly read input stores extra bytes or closes the IndexWriter

new StoredFieldDataInput(new ByteArrayDataInput(bytes, 4, 8)) gets length 12, not 8: ByteArrayDataInput.length() is where the input ends, not how many bytes it holds. The stored value silently gains the 4 bytes after the slice or, at the array's end, addDocument throws and the IndexWriter closes; since 10.2.0.

Summary

Priority
Medium, rank 244Since 10.2.0 the experimental StoredFieldDataInput(ByteArrayDataInput) constructor persists extra trailing bytes (or closes IndexWriter) for any input not at position 0, but no Lucene or downstream caller is known.
Impact
silent wrong results
Since
10.2.0 (#14213, 2025-02)introduced 2025-02-19 · about 1 year
Affected
10.2.0 to 10.6.0 and main (every release to 10.5.2 run; 10.6.0 by reading)
Status
documented
Found by
code reading of the stored-fields write path, then a JUnit

AI-assisted page: this finding was first signalled by code reading or testing 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

StoredFieldDataInput stores a binary field from a DataInput plus a byte count. Its ByteArrayDataInput constructor takes that count from ByteArrayDataInput.length(), which is the input's end position (offset + len). Only when the input is at position 0 is the end position also the number of bytes, so every other input stores too many.

Diagram of the bug in three panels. 1, the example: in = new ByteArrayDataInput(buf, 4, 8) over a 16-byte array 01 to 10, with pos = 4 and limit = 12; the slice is buf[4..11], but main stores 12 bytes from pos, buf[4..15]; expected 8 bytes and, for a 12-byte array, indexed; actual 12 bytes (+4) and writer closed. 2, inside Lucene: reset sets pos = 4 and limit = 12; length() returns limit, an end position; the constructor uses it as the length 12; the writer writes 12 and copies 12 bytes from pos 4; arraycopy ignores limit; past the array's end the ArrayIndexOutOfBoundsException is aborting and IndexWriter closes; the fix length() - getPosition() = 12 - 4 = 8. 3, when it happens: any ByteArrayDataInput not at position 0 when wrapped (a slice with offset > 0, or an input partly read first), default codec and settings; it reads pos bytes too many, stored silently while pos + limit <= buf.length (persisted, kept by merges, CheckIndex clean), otherwise the exception and a closed IndexWriter that loses uncommitted documents; not affected: an input at position 0, and new StoredFieldDataInput(in, len) with an explicit length.
An 8-byte slice at offset 4 of a 16-byte array: Lucene takes the end position 12 as the length and stores 12 bytes from position 4; at the end of a 12-byte array the copy runs off the array and the IndexWriter closes. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Take a 16-byte array 01 02 ... 10 and store bytes 4 to 11 as a binary field: new StoredField("f", new StoredFieldDataInput(new ByteArrayDataInput(buf, 4, 8))). Plain defaults are enough: new IndexWriterConfig(), the default codec.
  2. You expect field f to hold those 8 bytes, 05 ... 0c, and getLength() to return 8.
  3. Instead getLength() returns 12 and the document stores 12 bytes: the slice plus 0d 0e 0f 10, with no error. ByteArrayDataInput.length() is the end position, 4 + 8 = 12, and the writer copies 12 bytes starting at position 4.
  4. The 12 bytes survive commit and forceMerge(1), CheckIndex reports the index clean, and a fixed Lucene still returns them for that document. An input whose 4-byte header was read first is hit the same way: 16 bytes stored instead of the 12 unread ones.
  5. If too few bytes follow (the same 8-byte slice of a 12-byte array), the copy runs off the array: ArrayIndexOutOfBoundsException, and the IndexWriter rolls back to its last commit and closes, dropping the documents not yet committed (one, in the probe).

Who is affected

Applications that store binary values with this @lucene.experimental constructor from a ByteArrayDataInput that is not at position 0: a slice of a larger buffer, or an input partly read first. Plain default settings are enough; every such document. No Lucene code outside tests calls the constructor, and public code search found no downstream caller.

Mechanism, terms and numbers649 words

StoredFieldDataInput (#14213, 10.2.0, @lucene.experimental) stores a binary field straight from a DataInput and a byte count. Its convenience constructor StoredFieldDataInput(ByteArrayDataInput) takes that count from ByteArrayDataInput.length(), which returns the input's end position (offset + len), not the bytes it holds. Both copy implementations (the default codec's writer and the base method other formats inherit) then copy that many bytes from the input's current position, so an input that is not at position 0 (a slice at a non-zero offset, or an input partly read first) stores as many extra bytes from the caller's array as its position, silently: the value survives merges and CheckIndex reports the index clean. When too few bytes follow, the copy throws ArrayIndexOutOfBoundsException inside the stored-fields write, which is an aborting exception, and the IndexWriter closes and rolls back to its last commit. Every release from 10.2.0 to 10.6.0 (10.6.0 by reading) and main is affected; a one-line fix (length() - getPosition()) passes the repro and the closest test suites.

Terms

  • StoredFieldDataInput: a record (DataInput in, int length), "A fixed size DataInput which includes the length of the input", with @param length the length of the data input (StoredFieldDataInput.java:22-29, @lucene.experimental). new StoredField(name, value) stores it as a binary field (StoredField.java:116-122). It was added in 10.2.0 by #14213.
  • ByteArrayDataInput: a DataInput over a byte[] with a read position pos and an end limit. new ByteArrayDataInput(bytes, offset, len) and reset(bytes, offset, len) set pos = offset and limit = offset + len (ByteArrayDataInput.java:39-41, :65-69). length() returns limit (:71-73): an end position, deliberately so since 2011, and Lucene's own readers use it that way (while (in.getPosition() < in.length())).

The bug

  1. The convenience constructor is this(byteArrayDataInput, byteArrayDataInput.length()) (StoredFieldDataInput.java:32-34): the end position becomes the byte count.
  2. The default codec's writer writes getLength() as the stored length and copies that many bytes from the input's current position (Lucene90CompressingStoredFieldsWriter.java:312-320). Formats that keep the base method do the same with readBytes (StoredFieldsWriter.java:76-82; SimpleText executed). These are the only two copy implementations in Lucene; the test framework's wrapper writers delegate to them, and index sorting first writes through a temporary default-codec writer. ByteArrayDataInput.readBytes is a plain System.arraycopy that ignores limit (ByteArrayDataInput.java:135-140).
  3. So Lucene copies [pos, limit + pos) instead of [pos, limit): pos bytes too many.
  4. If those bytes exist in the caller's array, they are stored with no error and read back as part of the value (the reader trusts the stored length, Lucene90CompressingStoredFieldsReader.java:293).
  5. If not, arraycopy throws ArrayIndexOutOfBoundsException inside the stored-fields write. IndexingChain treats any exception there as aborting (IndexingChain.java:1504-1509), which becomes a tragic event: the IndexWriter rolls back to its last commit and closes (IndexWriter.java:6098-6106).

The numbers

For a fresh slice (bytes, offset, len) the value is silently offset bytes too long when 2*offset + len <= bytes.length; otherwise the writer closes. Executed on main a9779ef3c193:

inputexpectedstored on main
new ByteArrayDataInput(buf16, 4, 8)8 bytes 05..0c12 bytes 05..10 (getLength() 12)
new ByteArrayDataInput(buf24, 0, 16) after readInt()12 bytes 05..1016 bytes 05..14
new ByteArrayDataInput(buf12, 4, 8)8 bytes, writer openArrayIndexOutOfBoundsException, writer closed, an uncommitted earlier document lost (1 of 2 left)
new ByteArrayDataInput(buf8) (control)8 bytes8 bytes

The 12-byte value of the first row survives forceMerge(1) of 2 segments into 1 and CheckIndex reports the index clean, with plain new IndexWriterConfig() defaults; an index written that way and opened with the fix still returns the 12 bytes for that document and 8 for a new one. In a probe of a shared 128-byte buffer, a 16-byte record at offset 16 stored 32 bytes, the record plus the next record of the buffer, and that value survived forceMerge(1) from 2 segments to 1; CheckIndex reported the index clean.

Exposure and likelihood355 words
  • Who is exposed. Callers of new StoredField(name, new StoredFieldDataInput(in)) where in is a ByteArrayDataInput built with new ByteArrayDataInput(bytes, offset, len) or reset(bytes, offset, len) with offset > 0 (for example from a BytesRef with a non-zero offset, or a slice of a pooled buffer), or an input that was partly read before wrapping (a header). Every such document is hit.
  • Settings. Plain defaults are enough: a probe with new IndexWriterConfig() (default codec, default merge policy) stores the 12 bytes. The JUnit pins the default codec and takes the rest of its configuration from the test framework's randomized newIndexWriterConfig(). Other stored-fields formats are hit through the base writer method: SimpleText fails the same way (executed with the hardened upstream test).
  • Silent and lasting. In the common case nothing fails: the value is pos bytes too long, made of whatever follows in the caller's array (the next record of a shared buffer, or a previous request's leftovers in a reused one, in a probe). It survives commit and merges, CheckIndex reports the index clean, and a fixed Lucene still returns the extra bytes for segments already written (executed: an index written by unfixed main, opened with the fix, returns 12 bytes for the old document and 8 for a new one).
  • Loud variant. When fewer than pos bytes follow, addDocument throws ArrayIndexOutOfBoundsException and the IndexWriter rolls back to its last commit and closes. A rollback discards every change since the last commit (IndexWriter.rollback() javadoc); the probe shows one buffered, uncommitted document lost besides the bad one (the committed index keeps 1 of the 2 documents added before it), and the next addDocument throws AlreadyClosedException.
  • Bounded. At most pos extra bytes per value, never outside the caller's own array; no query-side effect.
  • How likely. Plausible but narrow. Slicing a buffer is a natural way to build the input (the introducing PR's own test builds it from value.offset), but no non-test Lucene code calls this constructor, and public code search found no downstream caller in the repositories searched (the only hit outside Lucene, a codec wrapper, only delegates writeField; private code is not visible).

Minimal repro

The test shows that StoredFieldDataInput(ByteArrayDataInput) uses the input's end position as its length, so a slice that does not start at 0 is stored with extra trailing bytes.

TestStoredFieldDataInputSlice.java (JUnit test)48 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.index;

import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.StoredField;
import org.apache.lucene.store.ByteArrayDataInput;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.BytesRef;

public class TestStoredFieldDataInputSlice extends LuceneTestCase {

  public void testStoredValueOfSliceAtNonZeroOffset() throws IOException {
    byte[] buf = new byte[16];
    for (int i = 0; i < buf.length; i++) {
      buf[i] = (byte) i;
    }
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w =
            new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
      Document doc = new Document();
      doc.add(new StoredField("f", new StoredFieldDataInput(new ByteArrayDataInput(buf, 4, 8))));
      w.addDocument(doc);
      try (DirectoryReader r = DirectoryReader.open(w)) {
        // expected: the 8 bytes buf[4..12); actual: the 12 bytes buf[4..16)
        assertEquals(new BytesRef(buf, 4, 8), r.storedFields().document(0).getBinaryValue("f"));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-175/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.index.TestStoredFieldDataInputSlice
Expected vs actual
Expected: the stored value of new ByteArrayDataInput(buf16, 4, 8) is those 8 bytes buf[4..12); actual: 12 bytes buf[4..16), with -ea and -da.

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

More files, environment, full output52 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<[4 5 6 7 8 9 a b]> but was:<[4 5 6 7 8 9 a b c d e f]>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.index.TestStoredFieldDataInputSlice.testStoredValueOfSliceAtNonZeroOffset(TestStoredFieldDataInputSlice.java:44)

Why it looks unintended

  • The record documents length as a byte count, and StoredFieldVisitor says readers "must read StoredFieldDataInput#length bytes".
  • The introducing PR's own test builds the input from value.offset and expects exactly value back; it passes only because that offset is always 0, and fails at offset 3.
  • No javadoc asks for an input at position 0.
The full argument264 words
  • Contract. StoredFieldDataInput: "A fixed size DataInput which includes the length of the input", @param length the length of the data input, and getLength() "Returns the length of the data input" (StoredFieldDataInput.java:22-29, :41-44). StoredFieldVisitor.binaryField(FieldInfo, StoredFieldDataInput): "Implementors of this method must read StoredFieldDataInput#length bytes" (StoredFieldVisitor.java:43-45). The reader builds the record with a byte count (Lucene90CompressingStoredFieldsReader.java:293).
  • The author's test. #14213 added to BaseStoredFieldsFormatTestCase.testMergeFilterReader a field b2 = new StoredFieldDataInput(new ByteArrayDataInput(value.bytes, value.offset, value.length)) and asserts it equals value (:687-693, :724-726). value is a fresh BytesRef with offset 0. The same pattern at offset 3 fails: "b2 must equal b expected:<[61 62 63 64]> but was:<[61 62 63 64 7 7 7]>"; with a random offset of 1-5 it fails in the Lucene90 and SimpleText format tests.
  • Same PR, same intent. testNullStoredDataInputField, "test a null data input value doesn't abort the entire segment", asserts no tragic exception and that the earlier document survives (TestIndexWriterExceptions.java:1757-1783). (There the null is rejected by the StoredField constructor before indexing, so it shows intent, not this path.)
  • No precondition anywhere. The constructor's javadoc is "Creates a StoredFieldDataInput from a ByteArrayDataInput" (StoredFieldDataInput.java:31). The #14213 discussion never mentions offsets, slices or length(); CHANGES has only "GITHUB#14213: Allowing indexing stored-only StoredField directly from DataInput" under 10.2.0 (lucene/CHANGES.txt:1920).
  • Effect. Silently wrong stored bytes, or a closed IndexWriter with lost documents, for a valid input built with public constructors.
  • Precedent. LUCENE-4423 (2012), an offset/length bug on the read side of binary stored fields, was fixed as a bug.
The devil's-advocate check309 words

The strongest case for "works as designed": ByteArrayDataInput.length() returning the end position is deliberate and old (LUCENE-3224, 2011, changed limit = len to limit = offset + len; length() returns limit since LUCENE-3030), and Lucene code uses it as an end bound (Lucene104PostingsReader.java:1451). Both classes are @lucene.experimental; ByteArrayDataInput "omits all low-level checks" and even warns that rewind() "sets pos to 0, which is not right if you had called reset w/ non-zero offset!!" (ByteArrayDataInput.java:23-24, :51-55). The canonical constructor StoredFieldDataInput(DataInput, int) handles slices. So the convenience constructor just copies length(), and slice users should pass the length.

Why it fails: the record's length is documented and consumed as a byte count, nothing tells callers the convenience constructor needs position 0, and the introducing PR's own test builds the input from an offset and expects exactly len bytes back. The quirks of ByteArrayDataInput lower the severity; they do not make silently wrong stored data or a closed writer intended. Verdict: a defect in the convenience constructor, not in ByteArrayDataInput. The independent reproduction and the cross-model reviews rated the intended-behaviour argument weak; the intended-behaviour check and the adjudication rejected it without a strength rating.

The partly read input is the weaker half. The cross-model fact check of the brief (2026-10-27, reading only) rates the argument weak for a fresh slice but moderate for an input read partly before it is wrapped: for an input that starts at 0, length() as its total extent is more defensible, and no upstream test wraps a partly consumed input (the #14213 test covers only the slice form). There a documentation-only answer or removing the convenience constructor is a more plausible maintainer response. Still against it: the record's length is consumed as a byte count from the current position, and no javadoc says the input must be unread.

Suggested fix

Recommended Use the bytes remaining in the convenience constructor

In the convenience constructor, use the bytes left in the input: byteArrayDataInput.length() - byteArrayDataInput.getPosition(). One line: it fixes slices and partly read inputs and changes nothing at position 0. Values already written keep their extra bytes. It passes the repro and 226 tests of the closest suites.

-  /** Creates a StoredFieldDataInput from a ByteArrayDataInput */
+  /**
+   * Creates a StoredFieldDataInput from a ByteArrayDataInput, holding the bytes from its current
+   * position to its end
+   */
   public StoredFieldDataInput(ByteArrayDataInput byteArrayDataInput) {
-    this(byteArrayDataInput, byteArrayDataInput.length());
+    // ByteArrayDataInput#length() is the end position of the input, not the number of bytes left
+    this(byteArrayDataInput, byteArrayDataInput.length() - byteArrayDataInput.getPosition());
   }
All options and trade-offs5 options ยท 904 words

Option A was built and validated; option B (a test change) was validated with it. C and D are alternatives that were not built. Until a fix ships, pass the length explicitly, new StoredFieldDataInput(in, len), or wrap a fresh ByteArrayDataInput at position 0 over an array that holds exactly the value. A fix does not rewrite segments already written: users who used the pattern need to check and reindex.

Use the bytes remaining in the convenience constructor (validated by the checks) (recommended)

StoredFieldDataInput(ByteArrayDataInput) passes length() - getPosition() and its javadoc says it holds the bytes from the current position to the end. 1 file, +6/-2.

-  /** Creates a StoredFieldDataInput from a ByteArrayDataInput */
+  /**
+   * Creates a StoredFieldDataInput from a ByteArrayDataInput, holding the bytes from its current
+   * position to its end
+   */
   public StoredFieldDataInput(ByteArrayDataInput byteArrayDataInput) {
-    this(byteArrayDataInput, byteArrayDataInput.length());
+    // ByteArrayDataInput#length() is the end position of the input, not the number of bytes left
+    this(byteArrayDataInput, byteArrayDataInput.length() - byteArrayDataInput.getPosition());
   }

Trade-offs

  • Unchanged for every input at position 0, which covers all of Lucene's own callers (TestBinaryDocument, TestDirectoryReader, BaseStoredFieldsFormatTestCase).
  • Does not repair values already written.
  • Expected cost: one accessor call and one subtraction when the record is built; not benchmarked. The same line is on branch_10x and branch_10_6 (read), so the diff should apply there; the fix ran against the 10.2.0 jars, not the branch heads.
  • Unchanged by the fix: the record wraps the live input and takes its length once, at construction. Moving the input after wrapping makes the count stale, and adding the same Document twice re-reads a consumed input. The diff's javadoc "holding the bytes" is loose on this; "whose length is the number of bytes from its current position to its end when it is created" is closer.
  • Edge cases (read, not executed): pos == limit gives an empty value; pos > limit (possible via the unchecked setPosition and skipBytes) gives a negative length, which the base writer rejects with NegativeArraySizeException and the default codec's writer, with assertions off, writes as a negative vInt with no bytes. The record does not validate an explicit negative length either. Throwing IllegalArgumentException in the constructor when getPosition() > length() would close this before addDocument, without aborting the writer.

What the checks validated

  • Scratch worktree of a9779ef3c193, gradle, JDK 25: stock + the new test, 4 of 5 fail with assertions on and off; + the fix, the new test passes 5 of 5, and the closest suites pass with assertions on (seed DEADBEEF) and off (seed 5EED0175): TestStoredFieldDataInputPosition, TestBinaryDocument, TestDirectoryReader, the three TestLucene90StoredFieldsFormat* classes, TestCompressingStoredFieldsFormat and TestIndexSorting in lucene/core (188 tests), TestSimpleTextStoredFieldsFormat (19) and TestAssertingStoredFieldsFormat (19): 226 tests, 13 skipped, 0 failures, with option B applied and a line-wrapping draft of the new test; the final test with the fix passes in gradle (5 + 20 + 19 tests, assertions on; and 121 tests with A + B and assertions off in the fact check). On the final files google-java-format, ecjLintTest and forbiddenApisTest pass; an earlier check run on the draft passed ecjLint and forbiddenApis but failed google-java-format on the draft test's line wrapping.
  • Standalone runner: 5 of 5 on main with assertions on and off, and on the 10.2.0 jars with the class shadowed in. Earlier checks with the same line: the finder's test 3 of 3, the independent reproducer's 5 of 5, the cross-model reviewer's 4 of 4, the security probe 4 of 4. The persistence probe passes with the fix (8 bytes after forceMerge(1), CheckIndex clean).
  • Not run: the full lucene/core test suite.

Make the upstream stored-fields test use a non-zero offset (validated by the checks)

In BaseStoredFieldsFormatTestCase.testMergeFilterReader, build b2 from a copy of value at offset 1-5 of a randomly padded array (+6/-2), so every stored-fields format test covers the case.

Trade-offs

A test change only, to ship with option A. One small padded array per test document: expected negligible, not measured.

What the checks validated

On stock main it fails in TestLucene90StoredFieldsFormat and, through the base StoredFieldsWriter.writeField, in TestSimpleTextStoredFieldsFormat (ArrayIndexOutOfBoundsException: arraycopy: last source index 38 out of bounds for byte[33] for seed DEADBEEF), with assertions on and off; with option A all stored-fields format suites above pass. Why it fails on stock for any seed: with offset o (1-5) and padding p (0-5), stock reads up to index 2o + len of an array of o + len + p, so p < o throws and p >= o stores o extra bytes, which the read-back check catches; the DEADBEEF message fits o = 5, p = 0 and a 28-byte value.

Document that the input must be at position 0

Say in the constructor javadoc that the input must be fresh and at position 0, and point slice users to StoredFieldDataInput(DataInput, int). Not built.

Trade-offs

Leaves silently wrong stored data for anyone who misses the sentence, and contradicts the introducing PR's own test pattern.

Deprecate or remove the convenience constructor

The API is experimental, so it may change. Not built.

Trade-offs

API churn for the whole-array case, which works today; callers must compute the length themselves.

Not recommended: a bounds check in ByteArrayDataInput.readBytes

Throw when a read passes limit.

Trade-offs

The class deliberately "omits all low-level checks" on a hot path, and an exception inside the stored-fields write would still abort it and close the writer.

Downstream note

No downstream caller of this constructor is known; an application that slices shared or pooled buffers this way would persist, and later return to readers of the document, whatever bytes follow each slice, such as a neighbouring record.

How it was found

Code reading. While modelling the chunk arithmetic of the compressing stored-fields writer, a finder read the stored-field write paths and saw that the convenience constructor copies ByteArrayDataInput.length(); a JUnit on the stock jars confirmed it. The model did not cover this class, so this is not a formal finding.

The method in full125 words

Method: code reading, then a JUnit on the stock jars (non-formal).

  • The hunt unit on the compressing stored-fields format built a Z3 model of the writer's chunk arithmetic. That model does not mention StoredFieldDataInput.
  • While reading the write paths for the model, the finder noticed that StoredFieldDataInput(ByteArrayDataInput) copies length(), which ByteArrayDataInput defines as its limit, and wrote a 3-test public-API JUnit that failed on main, 10.5.2 and 10.2.0. It was found on 2026-10-26 in about 0.7 hours.
  • Before the candidate was filed, an independent reviewer from another model family (OpenAI GPT-5.6) tried to refute it and instead reproduced it with its own test. An independent reproducer, a devil's advocate, a second cross-model review and an adjudication followed, then history, prior-report and security follow-ups.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26, re-checked 2026-10-27): GitHub issues, PRs, commits and review threads, the mailing lists, the JIRA archive, downstream projects and the web. #14213 introduced the constructor and its discussion never mentions offsets; no open PR touches the file.

Searches4 searches

No issue, PR, review comment, list thread, JIRA ticket or downstream report describes this defect, and nobody has fixed it on any branch. Closest, not this bug: #14213 (introduced the constructor), #4297 / LUCENE-3224 (2011, made limit = offset + len), #12581 (2023, the expert DataInput stored-field methods, which take an explicit length) and LUCENE-4423 (2012, an analogous offset/length bug on the read side, in different code).

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; downstream repositories; dev@, java-user@, issues@ and commits@; JIRA archive; web2026-10-26three runs of a scripted prior-report search (the finder's and two follow-up runs), class, keyword, symptom and error-message termsonly the related items above
GitHub (apache/lucene, Elasticsearch, OpenSearch, Solr, all of GitHub)2026-10-26manual issue, PR and code searches for StoredFieldDataInput, ByteArrayDataInput length/limit/offset and related termsno report; the only code hit outside Lucene and its forks delegates writeField
Review threads and open PRs2026-10-26every comment, review comment and review of #14213; the changed files of all 413 open apache/lucene PRsno mention of offsets or length(); no open PR touches StoredFieldDataInput.java or ByteArrayDataInput.java
GitHub, re-check2026-10-27PR and issue search StoredFieldDataInput in apache/lucene; the file on main, branch_10x and branch_10_6only #14213; line 33 unchanged on upstream main 20ced71c1e6e, branch_10x and branch_10_6

History

Introduced with the API by #14213 (f12e44b6515, merged 2025-02-19) and first released in 10.2.0. Fails on every release from 10.2.0 to 10.5.2 and on main, and 10.6.0 has the same line (read); 10.1.0 and older lack the API. Not fixed on upstream main, branch_10x or branch_10_6 (2026-10-27).

Versions tested6 rows

Introduced by #14213 "Support DataInput as source for StoredField" (@Tim-Brooks, merged by @iverase on 2025-02-19, milestone 10.2.0): f12e44b6515 on main, backport 2dedd1da89a on branch_10x. The one commit added the record, this constructor and StoredField(String, StoredFieldDataInput); StoredFieldDataInput.java has no other commit on main, branch_10x or branch_10_2 to branch_10_6. git tag --contains 2dedd1da89a lists 10.2.0 to 10.6.0. CHANGES lists it under 10.2.0 as an improvement. ByteArrayDataInput.length() returning offset + len dates from 2011 (LUCENE-3030, LUCENE-3224). branch_10_6 has the same line, so the released 10.6.0 (tagged at 4ae946e5a9b) has the bug (read, not executed).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test 4 of 5 fail (gradle and the standalone runner, assertions on and off); the finder's test 3 of 3
10.5.2 affectedthis doc's test 4 of 5 fail, assertions on and off
10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1 affectedthe finder's test 3 of 3 fail on each (release jars, SHA-1 checked)
10.2.0 affectedthis doc's test 4 of 5 fail; with the fix shadowed in, 5 of 5 pass
10.1.0, 10.0.0, 9.12.3 no APIcompile error: StoredFieldDataInput does not exist
main + fix not affectedthis doc's test 5 of 5; 226 tests of the closest suites, 0 failures

Independent checks

An independent reproducer, a devil's advocate and an adjudication confirmed it with their own runs; a reviewer from another model family reproduced it with its own test, a second ran read-only. A registry re-run on a9779ef3c193 and this doc's test reproduce it, and the fix passes.

Checks and verdicts508 words

The reviews by another model family (OpenAI GPT-5.6) were one executed (refute lens) and one read-only (works-as-designed lens); the other checks were run by the hunt's AI tools, and not every run record names the model. On 2026-10-27 the brief and this doc had a fact check (executed, 0 errors), a cross-model fact check (reading only, 3 errors) and a diagram check (0 errors).

Each independent check and its verdict
CheckVerdictWhat it found
finder candidate3-test public-API JUnit fails 3 of 3 on main (-ea, -da), 10.5.2 and 10.2.0; the one-line fix passes 3 of 3.
cross-model review, refute lens real (high)Executed its own test: 3 of 4 fail on main (two seeds, also -da), 4 of 4 pass with the fix; upstream TestBinaryDocument and TestDirectoryReader binary-field tests pass with the fix.
Independent reproducer realOwn JUnit with a BytesRef oracle and 2 controls fails 3 of 5 on main (-ea, -da), 10.5.2 (-ea, -da) and 10.2.0; does not compile on 9.12.3; passes 5 of 5 with the fix.
Cross-model review, works-as-designed lens real (high), read-onlyIntended argument weak; ran no Java itself (a separate run re-ran the finder's test, which fails); not fully independent, it saw the independent reproduction's and the intended-behaviour check's verdicts.
Intended-behaviour devil's advocate realThe upstream test pattern at offset 3 fails; the explicit-length control passes; built the strongest intended case and showed why it fails.
adjudication confirmed (high)Re-ran the independent reproduction's JUnit with -ea and -da (3 of 5 fail each) and checked the root cause in the source.
security screen + 2026-10-27 re-evaluation out of scope of SECURITY.md, then not security-sensitive on re-evaluationThe screen probed shared and reused buffers, a partly read input and CheckIndex (3 of 4 fail as designed; CheckIndex clean); a re-evaluation on 2026-10-27 under the strict rule found it out of scope of SECURITY.md, so its security call is none.
registry re-run on a9779ef3c193 still reproducesThe finder's test fails 3 of 3 with byte-identical messages.
this doc reproduced, fix validatedNew 5-test JUnit fails 4 of 5 on main (gradle and standalone, -ea and -da), 10.5.2 and 10.2.0; the fix passes it and 226 tests of the closest suites.
fact check of the brief and doc (2026-10-27) pass, 0 errorsRe-ran the gradle command on a clean tree (two seeds, assertions on and off), the standalone runner with new seeds on main, 10.5.2 and 10.2.0 and 20 iterations, the fix, A + B (121 tests) and both probes; 6 wording nits, fixed.
cross-model fact check (OpenAI GPT-5.6, reading only) sound; 3 errors1 medium and 2 low errors, 7 overclaims, 6 missing caveats; all fixed except the note on hunt jargon.
diagram check (2026-10-27) pass, 0 errorsRe-ran the diagram probe on main, with assertions on and off and with the fix; 9 minor notes. The two "executed" labels it flagged were then executed on the exact example.

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

Finding record

LFV-175, snapshot of 2026-11-06

Registry title
StoredFieldDataInput(ByteArrayDataInput) takes the input's end position as its length, so a ByteArrayDataInput that does not start at position 0 (a slice at a non-zero offset, or a partly read input) silently stores extra trailing bytes, or makes addDocument fail and close IndexWriter
Area
index / stored fields (StoredFieldDataInput: DataInput-backed StoredField values, #14213)
Priority
Medium, rank 244: Since 10.2.0 the experimental StoredFieldDataInput(ByteArrayDataInput) constructor persists extra trailing bytes (or closes IndexWriter) for any input not at position 0, but no Lucene or downstream caller is known.
Impact class
silent wrong results
Impact note
Applications that store a binary value through the @lucene.experimental StoredFieldDataInput(ByteArrayDataInput) convenience constructor with an input whose position is not 0 (new ByteArrayDataInput(bytes, offset, len) or reset(bytes, offset, len) with offset > 0, or an input partly read before wrapping) are hit on every such document with the default codec and settings, in every release since 10.2.0: the stored value silently carries as many extra trailing bytes from the caller's array as the start position (persisted, kept through merges, CheckIndex clean), or, when fewer bytes than that follow the slice, addDocument throws ArrayIndexOutOfBoundsException and IndexWriter closes, losing uncommitted documents; no non-test Lucene code calls this constructor, and code search found no downstream caller.
Summary
You add new StoredField(name, new StoredFieldDataInput(new ByteArrayDataInput(bytes, offset, len))) and expect the stored value to be exactly those len bytes, because the record's length is documented as 'the length of the data input'. The convenience constructor copies ByteArrayDataInput.length(), which is the end position offset + len, so for an 8-byte slice at offset 4 of a 16-byte array the document stores 12 bytes, the slice plus the next 4, with no error. If fewer bytes than the offset follow the slice, the stored fields writer reads past the array, addDocument throws ArrayIndexOutOfBoundsException and IndexWriter closes itself.
Introduced
f12e44b6515a25933dc5e9316df72d8b093eae12 (main) and its branch_10x backport 2dedd1da89acec3d7440348a0d4fd8c1d75225d7, PR #14213 'Support DataInput as source for StoredField' (author Tim-Brooks, merged by iverase, milestone 10.2.0; CHANGES 10.2.0 'GITHUB#14213: Allowing indexing stored-only StoredField directly from DataInput'), which added the StoredFieldDataInput record together with the line 'this(byteArrayDataInput, byteArrayDataInput.length());' [read: StoredFieldDataInput.java has only this commit on main, branch_10x and branch_10_2..branch_10_6, and the line is unchanged up to ea57360912ed; exec: 10.1.0 cannot compile the repro, 10.2.0 fails it]. ByteArrayDataInput.length() has returned limit = offset + len since LUCENE-3030 (b7846ffaa1f) and LUCENE-3224 (6b41b73894d), 2011, by design [read] (2025-02-19 (merged 06:44:41 UTC; author date 2025-02-18 23:44:41 -07:00))
First affected version
10.2.0 [exec]; the API does not exist before it (10.1.0, 10.0.0 and 9.12.3 do not compile the repro) [exec]
Affected versions
10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2 and main ea57360912ed. [exec] the finder's JUnit fails 3 of 3 on every one of these releases (follow-up history runs, SHA-1-checked release jars) and on main with -ea and -da; the independent reproduction's JUnit fails 3 of 5 on main (-ea, -da), 10.5.2 (-ea, -da) and 10.2.0; the security probe fails 3 of 4 on main (-ea, -da), 10.5.2 and 10.2.0. Not affected: 10.1.0 and older, including all 9.x (9.12.3 executed), because StoredFieldDataInput does not exist there [exec: compile errors]. [read] branch_10_6 has the same constructor line, so the released 10.6.0 (4ae946e5a9b) has it too; 10.6.0 was not executed. Only the default stored fields format was executed; the default StoredFieldsWriter.writeField(FieldInfo, StoredFieldDataInput), which other codecs inherit, reads length() bytes the same way [read].
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (2026-10-26, follow-up prior-report record verdict new: three scripted prior-report searches (the finder's: 16 GitHub issue/PR, 3 commit, 2 downstream-repo and 4 JIRA queries; follow-up run 1: 8 GitHub issue/PR, 2 commit, 1 downstream-repo, 12 mailing-list queries over dev@, java-user@, issues@ and commits@, and 4 JIRA queries; follow-up run 2: 24 mailing-list, 6 JIRA and 6 web queries), plus manual GitHub issue, PR and code searches over apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr and all of GitHub, the changed-file lists of all 413 open apache/lucene PRs, every comment, review comment and review of #14213, the commit history of StoredFieldDataInput.java on main, branch_10x and branch_10_2..branch_10_6, and direct mailing-list and JIRA searches; 20 hits read, none the same bug); not fixed: upstream main HEAD ea57360912ed, branch_10x and branch_10_2..branch_10_6 still have 'this(byteArrayDataInput, byteArrayDataInput.length())', and no open PR touches StoredFieldDataInput.java or ByteArrayDataInput.java; related, not reports of this bug: #14213 (introduced the constructor; its discussion never mentions offsets, slices or length()), #4297 / LUCENE-3224 (2011, made limit = offset + len), #12581 (2023, the expert DataInput stored-field methods, which take an explicit length), LUCENE-4423 (2012, an analogous offset bug on the read side, in different code); gaps: GitHub search was rate-limited (the follow-up tool run used 8 GitHub queries; the finder's 16 and the manual queries cover the rest), multi-word queries match tokens, and private downstream code and private reports to security@lucene.apache.org are not visible
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 (assertions on and off) and 10.2.0 (assertions on) release jars with this doc's test; a main() probe with plain new IndexWriterConfig() defaults on main (assertions on and off); the finder's test on every release from 10.2.0 to 10.5.2 and on main ea57360912ed (2026-10-26). The fix was validated in the scratch worktree: the new test, 226 tests of the closest suites in lucene/core, lucene/codecs and lucene/test-framework with assertions on and off, and google-java-format, ecjLint and forbiddenApis on the final files.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/StoredFieldDataInput.java:22-34 (record javadoc 'A fixed size DataInput which includes the length of the input', '@param length the length of the data input', @lucene.experimental; :32-34 the convenience constructor 'this(byteArrayDataInput, byteArrayDataInput.length())')
  • lucene/core/src/java/org/apache/lucene/store/ByteArrayDataInput.java:65-73 (reset sets pos = offset, limit = offset + len; length() returns limit) and :129-139 (readBytes is a plain System.arraycopy from pos, 'AIOOBE not EOF if you read too much')
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingStoredFieldsWriter.java:312-320 (writeField(FieldInfo, StoredFieldDataInput) writes getLength() as the vInt length and copies that many bytes from the input) and codecs/StoredFieldsWriter.java:77-81 (the default writeField reads value.length() bytes)
  • lucene/core/src/java/org/apache/lucene/index/StoredFieldVisitor.java:43-45 ('Implementors of this method must read StoredFieldDataInput#length bytes') and codecs/lucene90/compressing/Lucene90CompressingStoredFieldsReader.java:293 (the reader builds new StoredFieldDataInput(in, length) with a byte count)
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1449-1454 (try around storedFieldsConsumer.writeField at :1450; any Throwable from storedFieldsConsumer.writeField goes to onAbortingException) and document/StoredField.java:109-120 (StoredField(String, StoredFieldDataInput), constructor at :116)
  • lucene/test-framework/src/java/org/apache/lucene/tests/index/BaseStoredFieldsFormatTestCase.java:688-693 (the #14213 test builds new StoredFieldDataInput(new ByteArrayDataInput(value.bytes, value.offset, value.length)) and expects value back; offset is 0 there); core/src/test/.../TestDirectoryReader.java:403 and document/TestBinaryDocument.java:82 (the other callers, offset 0); CHANGES.txt:1904 (under Lucene 10.2.0)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/StoredFieldDataInput.java:22-34 (at a9779ef3c193)
  • lucene/core/src/java/org/apache/lucene/store/ByteArrayDataInput.java:23-24, (at a9779ef3c193) :39-41, :65-73, :135-140
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingStoredFieldsWriter.java:312-320; (at a9779ef3c193) lucene/core/src/java/org/apache/lucene/codecs/StoredFieldsWriter.java:76-82
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingStoredFieldsReader.java:293; (at a9779ef3c193) lucene/core/src/java/org/apache/lucene/document/StoredField.java:116-122
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1504-1509; (at a9779ef3c193) IndexWriter.java:6098-6106; DocumentsWriterPerThread.java:594-605
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsReader.java:1451 (at a9779ef3c193) (length() used as an end bound)
  • lucene/test-framework/src/java/org/apache/lucene/tests/codecs/asserting/AssertingStoredFieldsFormat.java:169-173; (at a9779ef3c193) .../cranky/CrankyStoredFieldsFormat.java:151-157; lucene/core/src/java/org/apache/lucene/index/SortingStoredFieldsConsumer.java:79-81, :95-100
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:6098-6106 (at a9779ef3c193)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2567-2571 (at a9779ef3c193) (rollback javadoc), :1639
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/StoredFieldDataInput.java:22-44; (at a9779ef3c193) StoredFieldVisitor.java:43-45
  • lucene/test-framework/src/java/org/apache/lucene/tests/index/BaseStoredFieldsFormatTestCase.java:669-726 (at a9779ef3c193) (testMergeFilterReader)
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterExceptions.java:1757-1783 (at a9779ef3c193)
  • lucene/CHANGES.txt:1823 (at a9779ef3c193) (Lucene 10.2.0 header), :1920
  • lucene/core/src/java/org/apache/lucene/store/ByteArrayDataInput.java:23-24, (at a9779ef3c193) :51-55; lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsReader.java:1451
Suggested fix
  • lucene/core/src/java/org/apache/lucene/store/ByteArrayDataInput.java:23-24; (at a9779ef3c193) IndexingChain.java:1504-1509
  • gh api (read-only, 2026-10-27): StoredFieldDataInput.java on branch_10x and branch_10_6, line 33 unchanged
  • lucene/core/src/java/org/apache/lucene/store/ByteArrayDataInput.java:61-63, (at a9779ef3c193) :80-82; StoredFieldsWriter.java:79; ByteBuffersDataOutput.java:312-331; BaseStoredFieldsFormatTestCase.java:724-725
Prior reports
  • gh search prs / gh search issues / gh api contents and commits, read-only, 2026-10-27
History
  • a Lucene clone: git tag --contains 2dedd1da89a
  • lucene/CHANGES.txt:1823, (at a9779ef3c193) :1920
  • gh api (read-only, 2026-10-27): commits touching StoredFieldDataInput.java on main; the file on branch_10x and branch_10_6; no releases/lucene/10.6.0 tag

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

Repro files

About the diagrams

A three-panel picture of the repro's slice case, made in parallel with this doc from the executed repro on main a9779ef3c193 and the code. The alt text describes the PNG. Two statements on screen, the example's value surviving forceMerge(1) with CheckIndex clean and values written before a fix keeping their extra bytes, were executed on exactly this example after the diagram check (the persistence probe in the repro files).

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.