laskoviymishka commented on code in PR #3562:
URL: https://github.com/apache/parquet-java/pull/3562#discussion_r3557578691
##########
parquet-variant/src/main/java/org/apache/parquet/variant/Variant.java:
##########
@@ -61,6 +61,8 @@ public final class Variant {
* Lazy cache for the parsed array header.
*/
private VariantUtil.ArrayInfo cachedArrayInfo;
+ /** Nesting depth of this Variant relative to the top-level value (0 =
top-level). */
+ private final int depth;
Review Comment:
Minor: depth here counts navigation depth through Variant objects, so it
also ticks up for leaf primitives and short strings, not just container
nesting. Harmless — a one-line note on the field might just save the next
reader a double-take.
##########
parquet-variant/pom.xml:
##########
@@ -69,6 +69,17 @@
<version>${slf4j.version}</version>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.apache.parquet</groupId>
+ <artifactId>parquet-hadoop</artifactId>
Review Comment:
The Iceberg tests for this stayed at direct ByteBuffer construction, so I
was a little surprised to see the round-trip harness pull parquet-hadoop and
hadoop-client into parquet-variant's test scope. Since Parquet's binary column
encoding is just a length-prefixed byte copy, I don't think most of these cases
exercise a different construction path than wrapping the bytes directly. Might
be worth keeping the round-trip for the one or two cases where encode/decode
genuinely matters — but you'll know the parquet-java test conventions better
than I do.
##########
parquet-variant/src/main/java/org/apache/parquet/variant/VariantUtil.java:
##########
@@ -880,6 +886,158 @@ static HashMap<String, Integer> getMetadataMap(ByteBuffer
metadata) {
return result;
}
+ /**
+ * Bounds-checks a single Variant value node against its buffer slot.
Performs no recursion
+ * into nested children: child nodes are checked on demand when callers
descend into them.
+ *
+ * <p>Cost: O(1) for primitives and short strings, O(numElements) for
objects and arrays.
+ * Validation of nested structures is deferred so that opening a large
well-formed Variant
+ * is not penalized by sub-trees the caller never inspects.
+ *
+ * @param valueBuffer the variant value buffer (position/limit define the
extent of this node's slot)
+ * @param dictSize the metadata dictionary size, used to bound object field
ids
+ * @throws IllegalArgumentException if the value header or container table
does not fit within
+ * the buffer slot, or if any object field id is out of range
+ */
+ static void validateValueShallow(final ByteBuffer valueBuffer, final int
dictSize) {
+ int pos = valueBuffer.position();
+ Preconditions.checkArgument(pos >= 0 && pos < valueBuffer.limit(),
"variant value is empty");
Review Comment:
Small thing: pos is `valueBuffer.position()`, which is never negative by
ByteBuffer's contract, so the `pos >= 0` half can't fire. Might read more
clearly dropped, or with a comment on why it's kept.
##########
parquet-variant/src/main/java/org/apache/parquet/variant/VariantUtil.java:
##########
@@ -880,6 +886,158 @@ static HashMap<String, Integer> getMetadataMap(ByteBuffer
metadata) {
return result;
}
+ /**
+ * Bounds-checks a single Variant value node against its buffer slot.
Performs no recursion
+ * into nested children: child nodes are checked on demand when callers
descend into them.
+ *
+ * <p>Cost: O(1) for primitives and short strings, O(numElements) for
objects and arrays.
+ * Validation of nested structures is deferred so that opening a large
well-formed Variant
+ * is not penalized by sub-trees the caller never inspects.
+ *
+ * @param valueBuffer the variant value buffer (position/limit define the
extent of this node's slot)
+ * @param dictSize the metadata dictionary size, used to bound object field
ids
+ * @throws IllegalArgumentException if the value header or container table
does not fit within
+ * the buffer slot, or if any object field id is out of range
+ */
+ static void validateValueShallow(final ByteBuffer valueBuffer, final int
dictSize) {
+ int pos = valueBuffer.position();
+ Preconditions.checkArgument(pos >= 0 && pos < valueBuffer.limit(),
"variant value is empty");
+ long slot = (long) valueBuffer.limit() - pos;
Review Comment:
One subtlety worth a doc note (not a change, I think): `slot` is bounded by
the top-level buffer's limit, not this node's parent-declared extent, since
slice() doesn't set a limit. So a child can — truthfully per its own size —
extend into bytes that logically belong to a sibling or the parent's offset
table. It's memory-safe since it's the same backing array, and this matches the
shallow-not-full split on the Iceberg side, so I'd just note the limitation
rather than tighten it.
##########
parquet-variant/src/main/java/org/apache/parquet/variant/VariantUtil.java:
##########
@@ -880,6 +886,158 @@ static HashMap<String, Integer> getMetadataMap(ByteBuffer
metadata) {
return result;
}
+ /**
+ * Bounds-checks a single Variant value node against its buffer slot.
Performs no recursion
+ * into nested children: child nodes are checked on demand when callers
descend into them.
+ *
+ * <p>Cost: O(1) for primitives and short strings, O(numElements) for
objects and arrays.
+ * Validation of nested structures is deferred so that opening a large
well-formed Variant
+ * is not penalized by sub-trees the caller never inspects.
+ *
+ * @param valueBuffer the variant value buffer (position/limit define the
extent of this node's slot)
+ * @param dictSize the metadata dictionary size, used to bound object field
ids
+ * @throws IllegalArgumentException if the value header or container table
does not fit within
+ * the buffer slot, or if any object field id is out of range
+ */
+ static void validateValueShallow(final ByteBuffer valueBuffer, final int
dictSize) {
+ int pos = valueBuffer.position();
+ Preconditions.checkArgument(pos >= 0 && pos < valueBuffer.limit(),
"variant value is empty");
+ long slot = (long) valueBuffer.limit() - pos;
+ int header = valueBuffer.get(pos) & 0xFF;
+ int basicType = header & BASIC_TYPE_MASK;
+ int typeInfo = (header >> BASIC_TYPE_BITS) & PRIMITIVE_TYPE_MASK;
+ switch (basicType) {
+ case SHORT_STR:
+ Preconditions.checkArgument(1L + typeInfo <= slot, "variant short
string extends past buffer");
+ return;
+ case OBJECT:
+ validateContainerShallow(valueBuffer, typeInfo, pos, slot, dictSize,
true);
+ return;
+ case ARRAY:
+ validateContainerShallow(valueBuffer, typeInfo, pos, slot, dictSize,
false);
+ return;
+ default:
+ validatePrimitiveShallow(valueBuffer, typeInfo, pos, slot);
+ }
+ }
+
+ /**
+ * Shallow validation of a container.
+ *
+ * @param valueBuffer buffer with the variant data
+ * @param typeInfo type information from the metadata
+ * @param pos buffer read position
+ * @param length length of slot for this primitive.
Review Comment:
Looks like a copy-paste from validatePrimitiveShallow: the @param says
"length of slot for this primitive" but this is a container.
##########
parquet-variant/src/main/java/org/apache/parquet/variant/VariantUtil.java:
##########
@@ -190,6 +190,12 @@ class VariantUtil {
// The size (in bytes) of a UUID.
static final int UUID_SIZE = 16;
+ /**
+ * Maximum permitted nesting depth of a Variant value.
+ * same limit as in VariantJsonParser.
+ */
+ static final int MAX_VARIANT_DEPTH = 1000;
Review Comment:
I checked and the spec doesn't define a max nesting depth — the Iceberg side
treats this as a safety limit too, so I think it's a reasonable cap. The one
thing I'd flag is interop: a spec-valid variant nested deeper than 1000,
written by Spark/PyIceberg/Arrow, would be rejected here. Might be worth a
Javadoc note that it's an implementation limit rather than a spec rule, so that
expectation is clear.
##########
parquet-variant/src/main/java/org/apache/parquet/variant/VariantJsonParser.java:
##########
@@ -37,7 +39,7 @@ public final class VariantJsonParser {
private static final JsonFactory JSON_FACTORY = JsonFactory.builder()
.streamReadConstraints(StreamReadConstraints.builder()
- .maxNestingDepth(500)
+ .maxNestingDepth(MAX_VARIANT_DEPTH)
Review Comment:
This bumps the JSON parse-depth boundary from 500 to 1000 as a side effect
of sharing the constant, so documents that used to overflow at 501 now parse.
Since buildJson/buildJsonArray/buildJsonObject recurse on the real JVM stack, I
wasn't sure whether 1000 had been validated as safe on this path specifically
(as opposed to the descent path). Might be worth a release note, and maybe a
test that parses ~1000-level JSON on a realistic stack. wdyt?
##########
parquet-variant/src/main/java/org/apache/parquet/variant/Variant.java:
##########
@@ -95,29 +128,39 @@ public Variant(ByteBuffer value, ByteBuffer metadata) {
// Pre-compute dictionary size for lazy metadata cache allocation.
int pos = this.metadata.position();
int metaOffsetSize = ((this.metadata.get(pos) >> 6) & 0x3) + 1;
- if (this.metadata.remaining() > 1) {
- Preconditions.checkArgument(
- this.metadata.remaining() >= 1 + metaOffsetSize,
- "variant metadata truncated: offsetSize=" + metaOffsetSize);
- this.dictSize = VariantUtil.readUnsignedLittleEndian(this.metadata, pos
+ 1, metaOffsetSize);
- long dictTableEnd = 1L + metaOffsetSize + ((long) this.dictSize + 1) *
metaOffsetSize;
- Preconditions.checkArgument(
- dictTableEnd <= this.metadata.remaining(),
- "variant metadata dictionary extends past buffer: dictSize=" +
this.dictSize);
- } else {
- this.dictSize = 0;
- }
+ Preconditions.checkArgument(
+ this.metadata.remaining() >= 1 + metaOffsetSize,
Review Comment:
This looks like a genuine fix, but it's also a public-API behavior change
that might be worth a release note. A 1-byte metadata buffer (just the version
byte) used to construct successfully with dictSize=0 via the old `else` branch,
and now throws — so any downstream caller relying on that leniency would start
seeing IllegalArgumentException.
##########
parquet-variant/src/main/java/org/apache/parquet/variant/VariantUtil.java:
##########
@@ -880,6 +886,158 @@ static HashMap<String, Integer> getMetadataMap(ByteBuffer
metadata) {
return result;
}
+ /**
+ * Bounds-checks a single Variant value node against its buffer slot.
Performs no recursion
+ * into nested children: child nodes are checked on demand when callers
descend into them.
+ *
+ * <p>Cost: O(1) for primitives and short strings, O(numElements) for
objects and arrays.
+ * Validation of nested structures is deferred so that opening a large
well-formed Variant
+ * is not penalized by sub-trees the caller never inspects.
+ *
+ * @param valueBuffer the variant value buffer (position/limit define the
extent of this node's slot)
+ * @param dictSize the metadata dictionary size, used to bound object field
ids
+ * @throws IllegalArgumentException if the value header or container table
does not fit within
+ * the buffer slot, or if any object field id is out of range
+ */
+ static void validateValueShallow(final ByteBuffer valueBuffer, final int
dictSize) {
+ int pos = valueBuffer.position();
+ Preconditions.checkArgument(pos >= 0 && pos < valueBuffer.limit(),
"variant value is empty");
+ long slot = (long) valueBuffer.limit() - pos;
+ int header = valueBuffer.get(pos) & 0xFF;
+ int basicType = header & BASIC_TYPE_MASK;
+ int typeInfo = (header >> BASIC_TYPE_BITS) & PRIMITIVE_TYPE_MASK;
+ switch (basicType) {
+ case SHORT_STR:
+ Preconditions.checkArgument(1L + typeInfo <= slot, "variant short
string extends past buffer");
+ return;
+ case OBJECT:
+ validateContainerShallow(valueBuffer, typeInfo, pos, slot, dictSize,
true);
+ return;
+ case ARRAY:
+ validateContainerShallow(valueBuffer, typeInfo, pos, slot, dictSize,
false);
+ return;
+ default:
+ validatePrimitiveShallow(valueBuffer, typeInfo, pos, slot);
+ }
+ }
+
+ /**
+ * Shallow validation of a container.
+ *
+ * @param valueBuffer buffer with the variant data
+ * @param typeInfo type information from the metadata
+ * @param pos buffer read position
+ * @param length length of slot for this primitive.
+ * @param dictSize dictionary size
+ * @param isObject is this an object?
+ */
+ private static void validateContainerShallow(
+ final ByteBuffer valueBuffer,
+ final int typeInfo,
+ final int pos,
+ final long length,
+ final int dictSize,
+ final boolean isObject) {
+ boolean largeSize;
+ int idSize;
+ if (isObject) {
+ largeSize = ((typeInfo >> 4) & 0x1) != 0;
+ idSize = ((typeInfo >> 2) & 0x3) + 1;
+ } else {
+ largeSize = ((typeInfo >> 2) & 0x1) != 0;
+ idSize = 0;
+ }
+ int offsetSize = (typeInfo & 0x3) + 1;
+ int sizeBytes = largeSize ? U32_SIZE : 1;
+ Preconditions.checkArgument(1L + sizeBytes <= length, "variant container
header truncated");
+ int numElements = readUnsignedLittleEndian(valueBuffer, pos + 1,
sizeBytes);
+ long idStart = 1L + sizeBytes;
+ long idBytes = isObject ? (long) numElements * idSize : 0L;
+ long offsetStart = idStart + idBytes;
+ long offsetBytes = (long) (numElements + 1) * offsetSize;
Review Comment:
This is the one I'd most want a second look at, since it's where the port
seems to have drifted from the Iceberg original.
`(numElements + 1)` here is computed as int arithmetic before the `(long)`
widening applies, so a largeSize container with `numElements ==
Integer.MAX_VALUE` wraps `offsetBytes` negative — `dataStart` goes negative,
the `dataStart <= length` guard passes, and the loop below then reads ~2^31
entries out of bounds (reachable for an ARRAY, where idBytes is 0). It'd
surface as IndexOutOfBoundsException rather than the IllegalArgumentException
the Javadoc promises.
The line two above already widens the right way (`(long) numElements *
idSize`), as does Variant.java (`((long) this.dictSize + 1) * metaOffsetSize`),
so it may just be:
```java
long offsetBytes = ((long) numElements + 1) * offsetSize;
```
The Iceberg original also capped numElements with a MAX_ELEMENTS bound to
make this class unreachable, which doesn't seem to have come across in the
port. I could be missing a parquet-java-side reason it can't be hit — a
regression test at `Integer.MAX_VALUE` for ARRAY and OBJECT would confirm
either way. wdyt?
##########
parquet-variant/src/test/java/org/apache/parquet/variant/TestHardenedReader.java:
##########
@@ -0,0 +1,746 @@
+/*
+ * 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.parquet.variant;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.apache.parquet.example.data.Group;
+import org.apache.parquet.example.data.simple.SimpleGroup;
+import org.apache.parquet.hadoop.ParquetReader;
+import org.apache.parquet.hadoop.ParquetWriter;
+import org.apache.parquet.hadoop.api.ReadSupport;
+import org.apache.parquet.hadoop.example.ExampleParquetWriter;
+import org.apache.parquet.hadoop.example.GroupReadSupport;
+import org.apache.parquet.hadoop.metadata.CompressionCodecName;
+import org.apache.parquet.io.InputFile;
+import org.apache.parquet.io.OutputFile;
+import org.apache.parquet.io.PositionOutputStream;
+import org.apache.parquet.io.SeekableInputStream;
+import org.apache.parquet.io.api.Binary;
+import org.apache.parquet.schema.MessageType;
+import org.apache.parquet.schema.MessageTypeParser;
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TestName;
+
+/**
+ * Exercises the bounds checks added to {@link Variant} construction by
round-tripping a row of
+ * {@code (metadata, value)} binaries through an in-memory uncompressed
Parquet file, mutating
+ * specific bytes after writing, and asserting that the resulting buffer is
rejected with an
+ * {@link IllegalArgumentException}.
+ */
+public class TestHardenedReader {
+
+ private static final String SCHEMA_STRING =
+ "message variant_record { required binary metadata; required binary
value; }";
+
+ private static final MessageType SCHEMA =
MessageTypeParser.parseMessageType(SCHEMA_STRING);
+
+ /**
+ * Minimal well-formed empty metadata: version=1, offsetSize=1, dictSize=0,
single end-offset=0.
+ */
+ private static final byte[] EMPTY_METADATA = {0x01, 0x00, 0x00};
+
+ /**
+ * Set to {@code true} to persist the generated Parquet files under
+ * {@code target/test-hardened-reader/$testName.parquet}.
+ * This allows file to be added to the bad_data section of parquet-testing
repository.
+ */
+ private static final boolean SAVE_GENERATED_FILES = false;
+
+ /** Directory under the module's build dir where generated files are
written, if enabled. */
+ private static final Path OUTPUT_DIR = Paths.get("target",
"test-hardened-reader");
+
+ @Rule
+ public TestName testName = new TestName();
+
+ /**
+ * Happy path.
+ */
+ @Test
+ public void testWellFormedRoundTrip() throws IOException {
+ byte[] wellFormed = arrayOneNull();
+ byte[][] roundTripped = roundTrip(EMPTY_METADATA, wellFormed);
+ Variant v = new Variant(ByteBuffer.wrap(roundTripped[1]),
ByteBuffer.wrap(roundTripped[0]));
+ assertEquals(Variant.Type.ARRAY, v.getType());
+ assertEquals(1, v.numArrayElements());
+ assertEquals(Variant.Type.NULL, v.getElementAtIndex(0).getType());
+ }
+
+ /**
+ * Out-of-range child offset.
+ */
+ @Test
+ public void testOutOfRangeChildOffset() throws IOException {
+ byte[] value = arrayOneNull();
+ // Layout of arrayOneNull(): [header, numElements=1, offset[0]=0,
offset[1]=1, NULL_header]
+ // Set offset[0] to 0xFF — far outside the 1-byte data region.
+ value[2] = (byte) 0xFF;
+ expectRoundTripRaisesIllegalArgument(EMPTY_METADATA, value, "child
offset");
+ }
+
+ /**
+ * Reject numElements larger than the surrounding buffer.
+ */
+ @Test
+ public void testOversizedNumElements() throws IOException {
+ // Use a largeSize array so numElements is a 4-byte field we can grow.
+ // Layout: [arrayHeader(large=1,offsetSize=1)=0b10011, numElements(4 bytes
LE)=0,
+ // end-offset(1 byte)=0] — total 6 bytes, zero data.
+ byte[] value = new byte[] {0b10011, 0x00, 0x00, 0x00, 0x00, 0x00};
+ // Baseline must parse cleanly.
+ new Variant(ByteBuffer.wrap(value.clone()),
ByteBuffer.wrap(EMPTY_METADATA.clone()));
+ // Set numElements to a value whose offset table cannot fit in the buffer.
+ int huge = 0x10000000;
+ value[1] = (byte) (huge & 0xFF);
+ value[2] = (byte) ((huge >> 8) & 0xFF);
+ value[3] = (byte) ((huge >> 16) & 0xFF);
+ value[4] = (byte) ((huge >> 24) & 0xFF);
+ expectRoundTripRaisesIllegalArgument(EMPTY_METADATA, value, "offset
table");
+ }
+
+ /**
+ * Reject Offset arithmetic that would wrap java's signed 32-bit integer.
+ */
+ @Test
+ public void testOffsetArithmeticBoundsCheck() throws IOException {
+ // Object header that claims numElements and offsetSize values whose
product overflows int
+ // unless validated with widened arithmetic.
+ // Layout: [objectHeader(large=1, idSize=1, offsetSize=4) = 0x4E,
+ // numElements (4 bytes, little-endian) = 0x33333333,
+ // three filler bytes]
+ byte[] value = new byte[] {0x4E, 0x33, 0x33, 0x33, 0x33, (byte) 0xFF,
(byte) 0xFF, 0x3F};
Review Comment:
Comparing against the Iceberg tests, I don't think this one quite reaches
the overflow it's named for. numElements here (0x33333333, ~858M) doesn't wrap
on `+1` — so it passes via the offset-table-too-big path, which was already
correct, rather than the widened arithmetic. Passing "" for the message
substring also means the failure text isn't asserted. If the overflow in the
line above is real, this test would stay green.
If it turns out to be a real gap, retargeting at `numElements ==
Integer.MAX_VALUE` with a largeSize ARRAY header (and one for OBJECT),
asserting the specific exception type, would pin it down.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]