Skip to content

MINOR: Bounds check delta dictionary data copy in VectorAppender - #1364

Open
Arawoof06 wants to merge 2 commits into
apache:mainfrom
Arawoof06:delta-dictionary-append-bounds
Open

Arawoof06 wants to merge 2 commits into
apache:mainfrom
Arawoof06:delta-dictionary-append-bounds

Conversation

@Arawoof06

@Arawoof06 Arawoof06 commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

What's Changed

VectorAppender.visit(BaseVariableWidthVector) and visit(BaseLargeVariableWidthVector) copy the delta's value bytes with MemoryUtil.copyMemory, starting at the delta's first offset for lastOffset - firstOffset bytes, and nothing checks that range against the delta data buffer. ArrowReader.loadDictionary passes IPC delta dictionary batches straight to VectorBatchAppender.batchAppend, so a stream whose VarChar delta dictionary has offsets [0, 4, 4096] over an 8-byte data buffer appends a 4092-byte dictionary value filled with adjacent native memory, with bounds checking enabled.

Both visits now validate the delta before touching the target: the validity and offset buffers must cover the value count, and the offsets must start at a non-negative value, never decrease, and end within the data buffer. The target data size is computed with Math.addExact. These checks don't depend on BoundsChecking, and a rejected delta leaves the target unchanged. The data copy also uses ArrowBuf.setBytes(index, src, srcIndex, length).

Tests in TestArrowReaderWriter, for both VarChar and LargeVarChar: malformed delta offsets (past the data buffer, decreasing, last before first, negative start) throw IndexOutOfBoundsException and leave the original dictionary intact, and a valid delta with a non-zero start offset appends correctly.

@jbonofre jbonofre added the enhancement PRs that add or improve features. label Oct 16, 2026
@apache apache deleted a comment from github-actions Bot Oct 16, 2026
@github-actions github-actions Bot added this to the 20.0.0 milestone Oct 16, 2026
deltaVector.getDataBuffer().memoryAddress() + deltaDataStart,
targetVector.getDataBuffer().memoryAddress() + targetDataSize,
deltaDataSize);
targetVector

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice improvement, this turns a raw out-of-bounds native copy into a checked one. One concern is ordering. By the time this setBytes runs, the target's data buffer has been reallocated for deltaDataSize and concatBits has already written the delta's validity bits into the target.

If the check throws, the target is left with extra validity bits while valueCount, offsets and lastSet are unchanged.

Could we validate the delta (last offset against its data buffer capacity) before any realloc or write, so a rejected delta leaves the target untouched?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Added a validateDelta pass at the top of both visits that runs before any realloc or concatBits, so a rejected delta now leaves the target as it was. The test checks that too (see below).

deltaVector.getDataBuffer().memoryAddress() + deltaDataStart,
targetVector.getDataBuffer().memoryAddress() + targetDataSize,
deltaDataSize);
targetVector

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, only the data copy is checked now. The validity copy via concatBits, the offset buffer MemoryUtil.copyMemory, and the offset-rebasing loop still read the delta without bounds checks. A delta whose offset buffer is shorter than (valueCount + 1) * 4 bytes, or whose validity buffer is shorter than valueCount bits, would still read out-of-bounds native memory.

Is it worth validating those buffer sizes up front too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, worth it. validateDelta now checks the validity buffer against getValidityBufferSizeFromCount(valueCount) and the offset buffer against (valueCount + 1) * OFFSET_WIDTH before anything reads them, so concatBits, the offset copyMemory and the rebasing loop only run on buffers that cover the value count.

deltaDataSize);
targetVector
.getDataBuffer()
.setBytes(targetDataSize, deltaVector.getDataBuffer(), deltaDataStart, deltaDataSize);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deltaDataSize is derived from the delta's offsets without validation. A last offset smaller than the first gives a negative size, and a very large one can overflow the int when added to targetDataSize.
Intermediate offsets are not checked against the data size either, so later reads of the appender vector could go out of bounds even when the final offset passes.

It would be good to guard 0 <= start <= end <= dataBuffer.capacity() and use Math.addExact or similar.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. It walks the offsets once and requires 0 <= first, non-decreasing offsets throughout, and last <= data buffer capacity, so intermediate offsets are covered too. targetDataSize + deltaDataSize uses Math.addExact now.

deltaVector.getDataBuffer().memoryAddress() + deltaDataStart,
targetVector.getDataBuffer().memoryAddress() + targetDataSize,
deltaDataSize);
targetVector

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same comments applies here 😄

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same changes on the large path, with long offsets and Math.addExact(long, long).


try (ArrowStreamReader reader =
new ArrowStreamReader(
new ByteArrayReadableSeekableByteChannel(outStream.toByteArray()), allocator)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setBytes bounds checking is gated by BoundsChecking.BOUNDS_CHECKING_ENABLED. With arrow.enable_unsafe_memory_access=true the check is skipped, no exception is thrown, and this test fails (and the fix does nothing in that mode). Consider skipping the test with an assumeTrue when bounds checking is off. Also, asserting only the exception type lets the partial-mutation issue go unnoticed. After the failure, please assert that the dictionary vector still has its original two values.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new checks are explicit capacity/offset comparisons rather than relying on ArrowBuf bounds checking, so they apply with arrow.enable_unsafe_memory_access=true as well, and I left the test unconditional instead of adding an assumeTrue. After the failure it now asserts the dictionary still has 2 values, foo and bar, and that validity bits 2 and 3 are unset. With only the earlier setBytes change, that validity assertion fails.


try (ArrowStreamReader reader =
new ArrowStreamReader(
new ByteArrayReadableSeekableByteChannel(outStream.toByteArray()), allocator)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only covers the VarChar path.

Could we add a LargeVarChar case for the BaseLargeVariableWidthVector visit? A positive case with a valid delta whose data start offset is non-zero would also help, since that exercises the deltaDataStart argument to setBytes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added. The malformed case is parameterized over VarChar and LargeVarChar and over four offset shapes: past the data buffer, decreasing intermediate offset, last before first, and negative start. There is also a positive test for both types with offsets {3, 6, 9} over xxxbazqux, which expects foo, bar, baz, qux.

new ByteArrayReadableSeekableByteChannel(outStream.toByteArray()), allocator)) {
assertThrows(IndexOutOfBoundsException.class, reader::loadNextBatch);
}
AutoCloseables.close(closeableList);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If assertThrows fails, closeableList is never closed and the allocator leaks. Consider wrapping it in a finally so the original assertion failure isn't masked by a leak error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The stream setup moved into a helper that closes everything in a finally, and each test only holds the reader in try-with-resources.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement PRs that add or improve features.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants