All findings · Lucene finding · LFV-031 · bytebuffersdatainput-small-block-bulk-reads

ByteBuffersDataInput.readLongs and readFloats can fail when blocks are smaller than 8 bytes

ByteBuffersDataInput.readLongs and readFloats can throw IllegalArgumentException: newPosition > limit at misaligned positions when blocks are under 8 bytes (longs) or 4 bytes (floats): they align a block's view by the absolute stream position, not the in-block offset. Only expert, non-default block sizes hit it; all releases since 9.0.0.

Summary

Priority
Low, rank 1151ByteBuffersDataInput.readLongs/readFloats fail on blocks smaller than 8 bytes, which only expert constructors and tests create, loud with no corruption.
Impact
crash
Since
9.0.0 (2021)introduced 2021-01-06 · about 5 years
Affected
every release from 9.0.0 to 10.6.0, and main
Status
documented
Found by
fuzzing (Jazzer, a DataOutput/DataInput round-trip fuzzer)

AI-assisted page: this finding was first signalled by a testing baseline (randomized, fuzz, property-based or stress testing, or static analysis) 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

Two lines: getLongBuffer and getFloatBuffer take the view's alignment from the absolute stream position ((int) pos & 0x7, & 0x3) instead of the offset inside the block. The two are the same only when blocks are at least 8 bytes.

Background: how ByteBuffersDataInput reads longs in bulk

  1. ByteBuffersDataInput reads a stream stored in a list of equal ByteBuffer blocks whose size is a power of two (1 KiB and up by default). Position p is in block p >> blockBits, at offset p & blockMask inside it.
  2. Bulk readLongs copies whole longs from a LongBuffer view of the current block. A view starts at one byte offset, so the code caches up to 8 views per block, one per alignment, and picks the one for the current position.
  3. A long that crosses into the next block is in no view: the view has nothing left, and readLongs reads that one long the slow way, readLong(pos).
The minimal repro: a ByteBuffersDataOutput with 2-byte blocks holding 3 zero bytes and the long 42. After skipBytes(3), readLongs builds a long view of block 1 positioned at the absolute alignment 3 & 7 = 3 instead of the in-block offset 3 & 1 = 1; the 2-byte block cannot be positioned at 3, so it throws IllegalArgumentException: newPosition > limit: (3 > 2). Expected: {42}, as readLong() returns.
The repro in 2-byte blocks: at position 3, readLongs positions block 1 at 3 & 7 = 3 instead of its in-block offset 1, past the block's end. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build a ByteBuffersDataOutput with 2-byte blocks, the smallest block size it allows: new ByteBuffersDataOutput(1, 1, ALLOCATE_BB_ON_HEAP, NO_REUSE). Write 3 zero bytes, then the long 42 (11 bytes), and call toDataInput().
  2. Skip the 3 bytes and call readLongs(dst, 0, 1). You expect dst = {42}, the value readLong() returns at the same position.
  3. Instead it throws IllegalArgumentException: newPosition > limit: (3 > 2), with assertions on and off.
  4. Why: position 3 is the second byte of block 1 (bytes 2-3), in-block offset 1. To build its long view, readLongs positions that 2-byte block at 3 & 7 = 3, the absolute position modulo 8. This throws before readLongs can see that the view holds no whole long and take its slow path for values that span blocks, and its catch clause does not handle this exception type.
  5. Blocks of 8 bytes or more (the default output allocates 1 KiB and up) and single-buffer inputs are unaffected. readFloats fails the same way on blocks under 4 bytes.

Who is affected

Only inputs built from blocks under 8 bytes: the expert ByteBuffersDataOutput constructor with minBitsPerBlock 1 or 2 (or a ByteBuffersDirectory on such outputs), or the List<ByteBuffer> constructor with 1-, 2- or 4-byte pages. Defaults (1 KiB blocks and up) are unaffected. With 2-byte blocks, opening a 200-document index with one deleted document fails. Always an exception, never a wrong value.

Mechanism, terms and numbers712 words

ByteBuffersDataInput reads a stream stored in equal, power-of-two-sized ByteBuffer blocks. Its bulk readLongs and readFloats read through cached LongBuffer/FloatBuffer views of a block, one per alignment, and compute that alignment from the absolute stream position ((int) pos & 0x7, (int) pos & 0x3) instead of the offset inside the block. For blocks of 8 bytes or more the two agree. For the 1-, 2- and 4-byte blocks Lucene allows (LIMIT_MIN_BITS_PER_BLOCK = 1, or the List<ByteBuffer> constructor), a bulk read at certain misaligned positions positions a block past its end and throws IllegalArgumentException, before the code can take its existing slow path for values that span blocks; the catch clause does not handle this type. Defaults (1 KiB blocks and up, or a single buffer) are not affected; this defect always throws, it never returns a wrong value. In every release since 9.0.0 and on main.

The code (main 645d4fe14a20)

// store/ByteBuffersDataInput.java
public void readLongs(long[] arr, int off, int len) throws EOFException {   // :328
  try {
    while (len > 0) {
      LongBuffer longBuffer = getLongBuffer(pos);                           // :331  throws here
      longBuffer.position(blockOffset(pos) >> 3);                           // :332  in-block offset
      int chunk = Math.min(len, longBuffer.remaining());
      if (chunk == 0) {
        // read a single long spanning the boundary between two buffers
        arr[off] = readLong(pos - offset);                                  // :336  slow path
        ...
  } catch (BufferUnderflowException | IndexOutOfBoundsException e) {        // :350  IAE not caught

private LongBuffer getLongBuffer(long pos) {                                // :372
  int bufferIndex = blockIndex(pos);
  int alignment = (int) pos & 0x7;                                          // :375  absolute position
  int longBufferIndex = bufferIndex * Long.BYTES + alignment;
  if (longBuffers[longBufferIndex] == null) {
    ByteBuffer dup = blocks[bufferIndex].duplicate();
    dup.position(alignment);                                                // :379  > limit if block < 8 B
    longBuffers[longBufferIndex] = dup.order(ByteOrder.LITTLE_ENDIAN).asLongBuffer();
  }
  return longBuffers[longBufferIndex];
}

getFloatBuffer (:359-369) is the same with (int) pos & 0x3 (:362) and dup.position(alignment) (:366); readFloats (:296-325) has the same catch clause (:318).

The repro, step by step (2-byte blocks, position 3)

  1. The 11 bytes (3 zeros, then the long 42) sit in 2-byte blocks. Position 3 is in block 3 >> 1 = 1, at in-block offset 3 & 1 = 1.
  2. getLongBuffer(3) computes alignment = 3 & 7 = 3 and calls position(3) on a duplicate of block 1, whose limit is 2: IllegalArgumentException: newPosition > limit: (3 > 2).
  3. The catch clause at :350 handles only BufferUnderflowException and IndexOutOfBoundsException, so the exception reaches the caller.
  4. With the in-block offset (blockOffset(3) & 7 = 1) the view would have 1 byte left, room for 0 longs, so chunk == 0 and the slow path readLong(3) would return 42.

What bounds it

  • Exact condition, for full blocks of B bytes (every block but the last): readLongs fails iff B < 8 and (pos & 7) > B; readFloats iff B < 4 and (pos & 3) > B. It follows from :375/:379 and :362/:366, and every cell of the tested matrices agrees (the study's, output blocks of 2 to 32 bytes after 0-7 prefix bytes, re-run on 645d4fe14a20; the fact check's, output blocks of 2 to 16 bytes and List pages of 1 to 16 bytes). With 2-byte blocks readLongs fails at alignments 3-7, with 4-byte blocks at 5-7; readFloats with 2-byte blocks at alignment 3.
  • This defect never returns a wrong value. For B >= 8, pos & 7 equals blockOffset(pos) & 7; single-buffer inputs have blockMask = ~0 (:65-67). For B < 8 a long view has room for 0 longs, so each read takes the slow path or throws. Every matrix cell is either correct or the exception. (Separate, and not part of this bug: the study noted, without classifying it, that the List constructor does not check the last buffer's position().)
  • Legal inputs. ByteBuffersDataOutput accepts minBitsPerBlock down to LIMIT_MIN_BITS_PER_BLOCK = 1, 2-byte blocks (ByteBuffersDataOutput.java:87-88, :154). new ByteBuffersDataInput(List<ByteBuffer>) checks for a non-empty list and, with more than one buffer, a first buffer whose position() + remaining() is a power of two and intermediate buffers of that size at position() == 0 (ByteBuffersDataInput.java:50-54, :448-479); nothing rejects 1-, 2- or 4-byte pages.
Exposure and likelihood331 words
  • How you get tiny blocks (opt-in only): new ByteBuffersDataOutput(minBitsPerBlock, maxBitsPerBlock, ...), the "Expert" constructor, with minBitsPerBlock 1 or 2, then toDataInput(); a ByteBuffersDirectory whose output supplier makes such outputs, as Lucene's own TestMultiByteBuffersDirectory does (Math.max(LIMIT_MIN_BITS_PER_BLOCK, ...)); or new ByteBuffersDataInput(List<ByteBuffer>) with 1-, 2- or 4-byte pages.
  • Defaults are safe. ByteBuffersDirectory() uses ByteBuffersDataOutput::new (ByteBuffersDirectory.java:111), whose blocks start at DEFAULT_MIN_BITS_PER_BLOCK = 10, 1 KiB; ByteBuffersDataOutput(long expectedSize) clamps to at least 10 bits (computeBlockSizeBitsFor, ByteBuffersDataOutput.java:554-565). The input's last buffer can still be shorter than 1 KiB (toBufferList flips each block, ByteBuffersDataOutput.java:236-248), and a short output gives a single-buffer input. Neither matters: with blocks of 1 KiB or more the alignment already equals the in-block offset modulo 8, and a single-buffer input has blockMask = ~0.
  • With 2-byte blocks, ordinary index reads can hit it. Live docs are read with readLongs (Lucene90LiveDocsFormat.java:133, :139), and ByteBuffersIndexInput.readLongs delegates to ByteBuffersDataInput (ByteBuffersIndexInput.java:202-206). In the study class, 200 documents with one deleted in a 2-byte-block ByteBuffersDirectory: DirectoryReader.open throws IllegalArgumentException: newPosition > limit: (3 > 2) (re-run on 645d4fe14a20). Whether an index fails depends on where its live-docs longs start: here at pos & 7 = 3, so the same index with 4- and 8-byte blocks opens (numDocs 199, fact-check probe on 645d4fe14a20).
  • Who in practice: the only known users are tests that use tiny blocks to exercise boundary handling, which is what the small limit was lowered for; downstream use was not surveyed beyond two code searches. Upstream's bulk-read tests never reach it: the aligned and unaligned readLongs/readFloats tests in BaseDirectoryTestCase (:208-258, :320-376) run on 1 KiB to 1 MiB chunks in the chunked directories (BaseChunkedDirectoryTestCase.java:47-48), and the two cross-boundary tests use 16- and 8-byte chunks (:364-381, :383-400).
  • Effect: a loud unchecked exception; no corruption, no wrong values from this defect, nothing written. Likelihood rare; priority low.

Minimal repro

One JUnit test, public API only, deterministic, about 2 s: 2-byte blocks, 3 zero bytes, the long 42, skipBytes(3), then readLongs.

TestByteBuffersDataInputSmallBlockReadLongs.java40 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.store;

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

public class TestByteBuffersDataInputSmallBlockReadLongs extends LuceneTestCase {

  public void testReadLongsWithTwoByteBlocks() throws Exception {
    ByteBuffersDataOutput out =
        new ByteBuffersDataOutput(
            1, 1, ByteBuffersDataOutput.ALLOCATE_BB_ON_HEAP, ByteBuffersDataOutput.NO_REUSE);
    out.writeBytes(new byte[3]);
    out.writeLong(42L);
    ByteBuffersDataInput in = out.toDataInput();
    in.skipBytes(3);
    long[] dst = new long[1];
    try {
      in.readLongs(dst, 0, 1);
    } catch (IllegalArgumentException e) {
      // expected: {42}, as readLong() returns; actual: IAE, a 2-byte block is positioned at 3
      throw new AssertionError("expected readLongs to return {42}, got " + e, e);
    }
    assertEquals(42L, dst[0]);
  }
}
Command
cp $REPRO/TestByteBuffersDataInputSmallBlockReadLongs.java $LUCENE/lucene/core/src/test/org/apache/lucene/store/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.store.TestByteBuffersDataInputSmallBlockReadLongs -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
Expected
readLongs(dst, 0, 1) returns {42}, as readLong() does at the same position.
Observed
IllegalArgumentException: newPosition > limit: (3 > 2) from ByteBuffersDataInput.getLongBuffer (:379), with assertions on and off.

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

More files, environment, full output226 words
  • Public API only: the Expert ByteBuffersDataOutput constructor, writeBytes, writeLong, toDataInput, skipBytes, readLongs. No index, no randomness in the input, single thread.
  • Covers readLongs through ByteBuffersDataOutput only, not readFloats or the List<ByteBuffer> route. readFloats has the same defect in getFloatBuffer and is fixed by the same diff; it is shown by the study's class and by the cross-model reviewer's own test, not by this repro. A maintainer may prefer a second, float test or one small parameterized test over both methods.
  • As the regression test: this same method, for example added to TestByteBuffersDataInput. It passes with the fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.6.0, 10.5.2 and 9.0.0
jdkOpenJDK 25.0.3
osLinux, aarch64
Command (environment)
cp $REPRO/TestByteBuffersDataInputSmallBlockReadLongs.java $LUCENE/lucene/core/src/test/org/apache/lucene/store/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.store.TestByteBuffersDataInputSmallBlockReadLongs -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on (the same with -Ptests.asserts=false)
TestByteBuffersDataInputSmallBlockReadLongs > testReadLongsWithTwoByteBlocks FAILED
    java.lang.AssertionError: expected readLongs to return {42}, got java.lang.IllegalArgumentException: newPosition > limit: (3 > 2)
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:74129CDD307F4B10]:0)
        at org.apache.lucene.store.TestByteBuffersDataInputSmallBlockReadLongs.testReadLongsWithTwoByteBlocks(TestByteBuffersDataInputSmallBlockReadLongs.java:36)

        Caused by:
        java.lang.IllegalArgumentException: newPosition > limit: (3 > 2)
            at java.base/java.nio.Buffer.createPositionException(Buffer.java:351)
            at java.base/java.nio.Buffer.position(Buffer.java:326)
            at java.base/java.nio.ByteBuffer.position(ByteBuffer.java:1561)
            at org.apache.lucene.store.ByteBuffersDataInput.getLongBuffer(ByteBuffersDataInput.java:379)
            at org.apache.lucene.store.ByteBuffersDataInput.readLongs(ByteBuffersDataInput.java:331)
            at org.apache.lucene.store.TestByteBuffersDataInputSmallBlockReadLongs.testReadLongsWithTwoByteBlocks(TestByteBuffersDataInputSmallBlockReadLongs.java:33)

# 10.5.2 and 10.6.0, assertions on and off: the same message, at getLongBuffer:379 <- readLongs:331
# with the fix: :lucene:core:test (SUCCESS): 1 test, assertions on and off

Why it looks unintended

  • Lucene documents 2-byte blocks as allowed (LIMIT_MIN_BITS_PER_BLOCK = 1, "Smallest minBitsPerBlock allowed"); readLongs documents no block-size limit, and readLong() works on the same input.
  • Lowering that limit (PR #360), rmuir wrote: "the permissible values should be ones that work correctly", while calling the constructor expert.
  • The older ByteBufferIndexInput view code guarded this case; these copies dropped the guard.
The full argument416 words
  • The size is documented and accepted. LIMIT_MIN_BITS_PER_BLOCK = 1 is javadoc'd "Smallest {@code minBitsPerBlock} allowed" (ByteBuffersDataOutput.java:87-88); the Expert constructor rejects only values below it (:154). The List constructor's javadoc asks only for "an identical remaining number of bytes in the buffer (that is a power of two)" (ByteBuffersDataInput.java:50-54), and its check (:448-479) rejects no page size under 8 bytes.
  • The contract has no precondition. DataInput.readLongs: "Read a specified number of longs"; readFloats: "Reads a specified number of floats into an array at the specified offset". Their base implementations loop readLong()/readInt() (DataInput.java:150-160, :176-188). No IllegalArgumentException is documented, and the per-value reads work on the same input (study control).
  • Intent when the limit was lowered. In the review of PR #360 (LUCENE-10155, 6c6a3bd5bd5, minimum lowered from 10 bits to 1), in a thread on the ByteBuffersDataOutput constructor's limits (dweiss asked why small blocks were restricted), rmuir wrote on 2021-10-07: "the defaults should be fast, but the permissible values should be ones that work correctly", and "It is also not efficient to use MMapDirectory with 4-byte memory mappings: but it works correctly and we take advantage of this in testing". Later that day, in the same thread, he wrote that the min/max constructor should be labelled "expert" and that Lucene should "reserve the right to change it in the future (for example, if we aligned our index files to at least 8 bytes, we could then make 1 << 3 the minimum size ...". So it supports "allowed values should work", not a promise that the minimum stays at 2 bytes.
  • Coverage was assumed. PR #363 (LUCENE-10150, 61c15c8c10f, which added readLongs): "The method is tested well by BaseDirectoryTestCase subclass already (aligned, unaligned, etc)". Those aligned and unaligned tests (BaseDirectoryTestCase.java:208-258) run on 1 KiB to 1 MiB chunks in the chunked directories (BaseChunkedDirectoryTestCase.java:47-48), and the two cross-boundary tests on 16- and 8-byte chunks (:364-400). At every block size of 8 bytes or more absolute and in-block alignment agree, so none of them can hit it.
  • The original guarded it. LUCENE-9027 (7755cdf03fc, 2019) built ByteBufferIndexInput's long views with if (i < curBuf.limit()) ... else EMPTY_LONGBUFFER and took the alignment from the position inside the current buffer.
  • Same calls, other directory: MMapDirectory with 2-byte chunks returns the right values (study control, re-run on 645d4fe14a20).
  • No javadoc, test, CHANGES entry or discussion accepts the exception.
The devil's-advocate check175 words

Strongest case for "not a bug": the trigger is an "Expert", non-default configuration (ByteBuffersDirectory and DataInput.readLongs are even @lucene.experimental); the only known users are tests; the failure is loud, not silent corruption; no report was found in about five years; and the reviewer who lowered the limit also said Lucene may raise the minimum to 8 bytes one day. "Do not use blocks that small", or a higher LIMIT_MIN_BITS_PER_BLOCK, would be an acceptable answer.

Why it does not settle it: "Expert" and @lucene.experimental warn about tuning and API stability, not about exceptions for documented values; the limit was lowered so tests could use tiny blocks, with the stated intent that permissible values work correctly; and the code already has a slow path for a value that crosses blocks, it just never gets there. The refute-lens cross-model reviewer rated this argument weak; the second cross-model reviewer, in the fact check, rated it moderate. What it does establish: the priority is low, and "won't fix" or a higher minimum would be defensible maintainer answers.

Suggested fix

Recommended A. Align by the offset inside the block

Take the alignment from the offset inside the block: blockOffset(pos) & 0x7 in getLongBuffer and blockOffset(pos) & 0x3 in getFloatBuffer. Nothing changes for blocks of 8 bytes or more; smaller blocks fall through to the existing slow path. Validated: the test passes, as do all 777 store tests and core's format, lint, forbidden-API and license checks.

All options and trade-offs3 options ยท 562 words

Three options; option A was applied in a scratch worktree of main 645d4fe14a20 and run against the minimal test, every test class in org.apache.lucene.store and core's static checks.

A. Align by the offset inside the block (validated by the checks) (recommended)

Two lines in ByteBuffersDataInput.java (the study verifier's fix). blockOffset(pos) is what readLongs/readFloats already use to position the view (:332, :300).

--- a/lucene/core/src/java/org/apache/lucene/store/ByteBuffersDataInput.java
+++ b/lucene/core/src/java/org/apache/lucene/store/ByteBuffersDataInput.java
@@ -359,7 +359,7 @@
   private FloatBuffer getFloatBuffer(long pos) {
     // This creates a separate FloatBuffer for each observed combination of ByteBuffer/alignment
     int bufferIndex = blockIndex(pos);
-    int alignment = (int) pos & 0x3;
+    int alignment = blockOffset(pos) & 0x3;
     int floatBufferIndex = bufferIndex * Float.BYTES + alignment;
     if (floatBuffers[floatBufferIndex] == null) {
       ByteBuffer dup = blocks[bufferIndex].duplicate();
@@ -372,7 +372,7 @@
   private LongBuffer getLongBuffer(long pos) {
     // This creates a separate LongBuffer for each observed combination of ByteBuffer/alignment
     int bufferIndex = blockIndex(pos);
-    int alignment = (int) pos & 0x7;
+    int alignment = blockOffset(pos) & 0x7;
     int longBufferIndex = bufferIndex * Long.BYTES + alignment;
     if (longBuffers[longBufferIndex] == null) {
       ByteBuffer dup = blocks[bufferIndex].duplicate();

Trade-offs

  • For blocks of 8 bytes or more, and single-buffer inputs (blockMask = ~0), the value is unchanged, so the fast path, the cached views and the results stay the same; the cost is one extra AND.
  • For smaller blocks the alignment is the real in-block offset, never past the block's limit; the view then has room for 0 values and every value takes the slow path. That is slow but correct, which is what tiny blocks are for.

What the checks validated

  • Scratch worktree of 645d4fe14a20, ./gradlew, OpenJDK 25.0.3: the minimal test fails on stock and passes with the fix, with assertions on and off. (The fact check re-ran the stock command in its own clone of 645d4fe14a20 with the same result.)
  • Every test class in org.apache.lucene.store (seed DEADBEEF): the 28 existing classes plus the new test, 777 tests, 17 skipped, 0 failures, including TestByteBuffersDataInput, TestByteBuffersDataOutput, TestByteBuffersDirectory, TestMultiByteBuffersDirectory, TestMultiMMap and TestMMapDirectory.
  • :lucene:core:check -x test passes (google-java-format, ecj lint, forbidden APIs, license checks). Not the full static-analysis suite: error-prone was skipped and eclint is not configured in that run.
  • Earlier, with the fix on the main jars: the study's 7-test class passes 7 of 7, including the readFloats matrix, the List constructor, the IndexInput case and the live-docs index; the cross-model reviewer's own 5 tests and the four TestByteBuffers*/TestMultiByteBuffersDirectory suites pass (256 run, 0 failed).

B. Guard the view like the older code

In getLongBuffer/getFloatBuffer, if alignment is past the block's limit, use an empty view, the LUCENE-9027 pattern in ByteBufferIndexInput.

Trade-offs

Also sends the read to the slow path, but keeps an alignment that means nothing for small blocks and adds a branch. Not validated.

Catch IllegalArgumentException in readLongs/readFloats and fall back; or raise LIMIT_MIN_BITS_PER_BLOCK to 3 and reject List pages under 8 bytes.

Trade-offs

Catching hides other errors. Forbidding changes a documented public limit and removes the tiny-chunk coverage TestMultiByteBuffersDirectory relies on; rmuir floated an 8-byte minimum in the #360 thread, but conditional on index files being 8-byte aligned, which they are not today (the live-docs longs of the 2-byte-block index start at pos & 7 = 3). Not validated.

Downstream note

No query, document or index content can choose the block size, so a downstream service is exposed only if its own code configures blocks under 8 bytes.

How it was found

Coverage-guided fuzzing with Jazzer: a typed write/read round-trip fuzzer over Lucene's DataOutput/DataInput implementations flagged the exception. An independent AI verifier then wrote its own 7-test class, derived the exact failure condition and validated the two-line fix; it was later cut to the one-test repro.

The method in full110 words
  • Finder: the prior study's non-formal baseline, the Jazzer target DataIOFuzzer: a typed write/read round trip on ByteBuffersDataOutput/Input, ByteBuffersDirectory, MMapDirectory (16- and 64-byte chunks and the default) and NIOFSDirectory, with slices, clones, skipBytes, random access and checksums; 1.5 + 20 minutes, 278 k executions.
  • Verification: an independent AI verifier wrote a from-scratch 7-test class (block-size x alignment matrices for both methods, the List constructor, a ByteBuffersIndexInput, an end-to-end live-docs index, per-value and MMapDirectory controls), derived the exact condition from the code and validated the fix.
  • Minimization: cut to the one-method test (2026-10-29). Re-verified on main 645d4fe14a20 and 10.5.2 (2026-11-01), with a cross-model review.

Prior reports, history and checks

Prior reports

No matching report found in these searches (2026-11-01): GitHub issues, PRs and commits, the JIRA archive, four Apache mailing lists, downstream projects and the web, plus a scan of all 409 open PRs. Related: #11982 (closed; refactors this view code), #11992 (alignment tests on default blocks), #16500 (another method); none fixes this.

Searches4 searches

Closest items, none this bug: #363 / #11187 and #10691 (the introducing changes); #360 / #11192 (lowered the block limit; rmuir's review quote); PR #11982 (closed, unmerged), which refactors this view-buffer code and keeps the absolute alignment; stale open PR #11992, which refactors the view code of ByteBufferIndexInput (the MMapDirectory-side class, which keeps its limit guard) and adds per-alignment readFloats/readLongs tests for ByteBuffersDataInput on default 1 KiB blocks only, without touching ByteBuffersDataInput.java; open PR #16500 (random-access readBytes bounds in the same file, a different defect); open PRs #570 and #13981 (positional bulk reads through the same path, alignment unchanged); #10070 (LUCENE-9027, the guarded original). Searches for getLongBuffer and "newPosition > limit" return nothing. No match is a search result, not proof of absence. Gaps: GitHub search returns at most 50 results per query (the open-PR file scan covers that limit); private channels cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-11-01a scripted prior-report search (16 issue/PR and 3 commit queries, incl. "newPosition > limit"), 9 direct searches (e.g. asLongBuffer, minBitsPerBlock, "view buffer"), a getLongBuffer search, and the changed files of all 409 open PRsno report; #11982 (this view code), #11992 (alignment tests on default blocks) and #16500 (another method) do not fix this
JIRA archive (LUCENE)2026-11-014 scripted queries plus 6 manual onesno report; the introducing issues (LUCENE-9652, LUCENE-10150, LUCENE-10155), LUCENE-9027 and unrelated items such as LUCENE-10394
Mailing lists (dev@, java-user@, issues@, commits@)2026-11-0120 scripted queries, 12 more, and every ByteBuffersDataInput subject on issues@ and commits@no report
Downstream projects and the web2026-11-012 downstream code searches (Solr, Elasticsearch, OpenSearch) and 6 web queriesno report

History

readLongs broke in 61c15c8c10f (#363, 2021-10-09), readFloats in 7b9f8751456 (2021-01-06); 6c6a3bd5bd5 (#360) let ByteBuffersDataOutput make 2-byte blocks. All three first ship in 9.0.0; 8.11 is not affected. It fails on all six releases run, 9.0.0 to 10.6.0, and on main; never fixed.

Versions tested7 rows
  • readLongs: 61c15c8c10f "LUCENE-10150: override readLongs() in ByteBuffersDataInput (#363)" (rmuir, 2021-10-09) added getLongBuffer with (int) pos & 0x7.
  • readFloats: 7b9f8751456 "LUCENE-9652: DataInput.readLEFloats for use by Lucene90VectorReader (#2175)" (msokolov, 2021-01-06) added getFloatBuffer with (int) pos & 0x3; renamed readFloats by LUCENE-9047 (b84e0c272b6).
  • Tiny output blocks: 6c6a3bd5bd5 "LUCENE-10155: Refactor TestMultiMMap into a BaseChunkedDirectoryTestCase (#360)" (rmuir, 2021-10-09) lowered the minimum from 10 bits (1 KiB) to LIMIT_MIN_BITS_PER_BLOCK = 1. Before it, only the List constructor (power-of-two pages, since 2018) could build blocks this small.
  • Commit-level check on the 9.0.0 jars: with the ByteBuffersDataInput from before 61c15c8c10f, readLongs passes and readFloats still fails, so the floats half is older.
  • All 34 release tags from 9.0.0 to 10.6.0 contain both alignment lines and the same catch clauses (read); upstream main f815e679bf6c (2026-11-01) still does. None of the three commits has a CHANGES entry.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedfails with assertions on and off; passes with the fix
main a9779ef3c193, b83101cdd2 affectedthe study's 7-test class fails 5 of 7 (assertions on for a9779ef3c193; flags not recorded for b83101cdd2); no fix run
10.6.0, 10.5.2 affectedfails with assertions on and off (release jars)
10.0.0, 9.12.3, 9.1.0 affectedfails (release jars)
9.0.0 affectedfails with assertions on and off; readLongs and readFloats on 2-byte List pages both fail
9.0.0 + ByteBuffersDataInput from before 61c15c8c10f bisectreadLongs passes, readFloats still fails
8.11.0, 8.11.4 not affectedno readLongs/readFloats override; readLELongs on 2-byte pages returns 42

Independent checks

Reproduced by the study verifier's own tests, a current-main verification on main and 10.5.2, the history runs from 9.0.0 to 10.6.0, and this doc's gradle runs; an independent reviewer from another model family (OpenAI GPT-5.6) reproduced it with its own test and called it real, high confidence.

Checks and verdicts289 words

All checks agree: real, an exception only, fixed by the two-line change.

Each independent check and its verdict
CheckVerdictWhat it found
study verifier realOwn 7-test class on 7cecf795236c: 8 of 40 readLongs cells and 2 of 40 readFloats cells fail, exactly the condition; List constructor, IndexInput and live-docs index fail; controls pass; fix validated.
main refreshes still reproducesThe study class on b83101cdd2 and a9779ef3c193: 7 run, 5 failed, the same set (assertions on for a9779ef3c193; no assertions-off or fix runs).
minimal repro one testFails on main with assertions on and off (two seeds); passes with the fix.
current-main verification realFails on main 645d4fe14a20 and 10.5.2, assertions on and off; the study class fails 5 of 7 and passes 7 of 7 with the fix.
cross-model review real, high confidenceOpenAI GPT-5.6, refute lens: its own 5 tests, 3 fail (2-byte List pages for both methods, 4-byte output blocks for readLongs); controls pass; with the fix 5 of 5 and 256 store tests pass.
the version-history runs since 9.0.0Release jars 9.0.0 to 10.6.0 fail; 8.11.0 control passes; commit-level class swaps separate the two halves.
this doc's gradle runs fails / fix passesScratch worktree of 645d4fe14a20: stock fails both ways; with the fix the test, 777 store tests and core's static checks pass.
fact checks real; wording correctedAn independent AI tool re-ran the repro on 645d4fe14a20 (standalone and gradle, assertions on and off), the fix, and its own block-size x alignment and live-docs probes; a second reviewer from another model family (OpenAI GPT-5.6) checked the text against the code and records. Neither changed the verdict; their corrections are applied.

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 notes551 words

Finding record

LFV-031, snapshot of 2026-11-06

Registry title
ByteBuffersDataInput.readLongs and readFloats fail when blocks are smaller than 8 bytes
Area
store / ByteBuffersDataInput
Priority
Low, rank 1151: ByteBuffersDataInput.readLongs/readFloats fail on blocks smaller than 8 bytes, which only expert constructors and tests create, loud with no corruption.
Impact class
crash
Impact note
Only ByteBuffersDataInput instances built from blocks smaller than 8 bytes; default in-tree users are unaffected; released.
Summary
You read longs or floats in bulk from a ByteBuffersDataInput built from blocks smaller than 8 bytes. You expect the same values as reading them one at a time. Instead readLongs/readFloats throw IllegalArgumentException.
Introduced
LUCENE-10150 (#11187, PR #363, 61c15c8c10f) for readLongs and LUCENE-9652 (7b9f8751456) for readFloats; LUCENE-10155 (#360, 6c6a3bd5bd5) also let ByteBuffersDataOutput produce blocks under 8 bytes [read] (2021-01-06 (7b9f8751456); 2021-10-09 (61c15c8c10f, 6c6a3bd5bd5))
First affected version
9.0.0 [exec: the minimal test fails with -ea and -da; read: git tag --contains, all three commits first ship in releases/lucene/9.0.0]; 8.11.0 and 8.11.4 not affected [exec]
Affected versions
9.0.0 to 10.6.0 (all 34 releases) and main [read: every release tag has both alignment lines]; 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 and main 645d4fe14a20 [exec]; 10.1.0 in the earlier study's release runs [exec]; 8.11.0 and 8.11.4 not affected [exec]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
Found by
non-formal baseline: fuzzing/PBT
Upstream and prior reports
none found (study searches 2026-10-22; public-list check 2026-10-20..23)
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test with assertions on and off, standalone (2026-10-29 and 2026-11-01) and through gradle in a scratch worktree (2026-11-01); the recommended fix in that worktree with the test, every org.apache.lucene.store test class and :lucene:core:check -x test. Releases 10.5.2 and 10.6.0 (assertions on and off), 9.0.0 (on and off), 9.1.0, 9.12.3 and 10.0.0; 8.11.0 and 8.11.4 as the unaffected control. Earlier: the study's 7-test class on main 7cecf795236c, b83101cdd2, a9779ef3c193 and 645d4fe14a20; the cross-model reviewer's own test on ea57360912ed. Read: upstream main f815e679bf6c (2026-11-01) still has both alignment lines. All runs used OpenJDK 25.0.3.

Lucene and upstream sources

The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/store/ByteBuffersDataInput.java:50-54, :65-67, :296-325, :328-357, :359-369, :372-382, :436-438, :448-479 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/store/ByteBuffersDataOutput.java:82, :87-88, :141-178 (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/store/ByteBuffersDirectory.java:111; lucene/core/src/java/org/apache/lucene/store/ByteBuffersDataOutput.java:82, :141-178, :236-248, :554-565; lucene/core/src/java/org/apache/lucene/store/ByteBuffersIndexInput.java:202-206 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90LiveDocsFormat.java:131-139 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/store/TestMultiByteBuffersDirectory.java:29-49; lucene/test-framework/src/java/org/apache/lucene/tests/store/BaseChunkedDirectoryTestCase.java:47-48, :364-400; lucene/test-framework/src/java/org/apache/lucene/tests/store/BaseDirectoryTestCase.java:208-258, :320-376 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/store/ByteBuffersDataOutput.java:87-88, :154; lucene/core/src/java/org/apache/lucene/store/ByteBuffersDataInput.java:50-54; lucene/core/src/java/org/apache/lucene/store/DataInput.java:150-160, :176-188; lucene/core/src/java/org/apache/lucene/store/ByteBuffersDirectory.java:48 (at 645d4fe14a20)
  • https://github.com/apache/lucene/pull/360 (rmuir review comment, 2021-10-07, re-read via the GitHub API 2026-11-01)
  • https://github.com/apache/lucene/pull/363 (PR body)
  • https://github.com/apache/lucene/pull/360 (rmuir's second comment in the same review thread, 2021-10-07, "expert" and the possible `1 << 3` minimum; re-read via the GitHub API 2026-11-01)
  • lucene/test-framework/src/java/org/apache/lucene/tests/store/BaseDirectoryTestCase.java:208-258; BaseChunkedDirectoryTestCase.java:47-48, :364-400 (at 645d4fe14a20)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/store/ByteBuffersDataInput.java:65-67, :300, :332, :436-438 (at 645d4fe14a20)
Prior reports
  • https://github.com/apache/lucene/pull/11992 (changed files, re-read via the GitHub API 2026-11-01: ByteBufferGuard, ByteBufferIndexInput, ByteBuffersDirectory, TestByteBuffersDataInput; not ByteBuffersDataInput.java)

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

Repro files

About the diagrams

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.