This is an automated email from the ASF dual-hosted git repository.

RyanSkraba pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/avro.git


The following commit(s) were added to refs/heads/main by this push:
     new 61270d45bf AVRO-4300: [java] Bound array/map allocation and skipping 
for zero-byte elements and on the fast reader path (#3865)
61270d45bf is described below

commit 61270d45bf53d2b05f88d8b02c503e868e23fcaf
Author: Ismaël Mejía <[email protected]>
AuthorDate: Sun Jul 19 15:30:34 2026 +0200

    AVRO-4300: [java] Bound array/map allocation and skipping for zero-byte 
elements and on the fast reader path (#3865)
    
    * AVRO-4300: [java] Bound zero-byte array element allocation
    
    An array whose element schema encodes to zero bytes (null, a zero-length
    fixed, or a record with only zero-byte fields) consumes no input per 
element,
    so the number of elements a block declares cannot be bounded by the bytes
    remaining in the stream. ensureAvailableCollectionBytes therefore skips the
    check for such elements, and the collection-length cap is 
Integer.MAX_VALUE-8
    (a VM array-size ceiling, not a memory budget). A tiny payload declaring a 
huge
    block count of such elements (e.g. {"type":"array","items":"null"} with a
    count of 200,000,000) drives an unbounded backing-array allocation and 
exhausts
    the heap. This affects both the classic GenericDatumReader.readArray path 
and
    the default fast-reader path (FastReaderBuilder), which had no collection 
guard
    at all.
    
    Add SystemLimitException.checkMaxCollectionAllocation, a heap-aware 
cumulative
    cap (default: maxMemory()/4/8 elements, overridable via the
    org.apache.avro.limits.collectionItems.maxAllocation system property, 
mirroring
    the existing decompression limit). Enforce it before allocating in both 
reader
    paths, keyed on GenericDatumReader.isZeroByteSchema so only the unbounded
    zero-byte case is affected; all other element types remain bounded by
    ensureAvailableCollectionBytes and are unchanged. Maps are already bounded
    because each entry carries a string key of at least one byte.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Apply the available-bytes check on the fast reader path
    
    The fast reader (FastReaderBuilder, the default decode path) never received 
the
    AVRO-4241 bytes-remaining guard: that change only touched the classic
    GenericDatumReader. As a result an array of non-zero-byte elements with a 
huge
    declared block count and no data (e.g. array<long>/array<int> with a count 
of
    200,000,000) still pre-allocated new GenericData.Array<>((int) count) on the
    default path and exhausted the heap.
    
    Expose GenericDatumReader.ensureAvailableCollectionBytes and apply it, 
together
    with the zero-byte allocation cap, before allocating each array block in
    FastReaderBuilder, so the fast and classic readers enforce identical guards.
    Maps were already safe on both paths (each entry carries a >=1-byte key).
    
    Verified with a matrix of array<null|long|int> and map<null|long> at a huge
    count under -Xmx256m: every combination is now rejected 
(SystemLimitException
    for zero-byte elements, EOFException otherwise) on both reader paths 
instead of
    OOM.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Cover all collection/reader combinations in tests
    
    Add a matrix test asserting every collection kind is rejected (never OOM) 
with
    a huge block count and no data, on both the fast (default) and classic 
reader:
    array<null> via the heap-aware allocation cap (SystemLimitException) and
    array<long>, array<int>, map<null>, map<long> via the bytes-remaining check
    (EOFException) -- the full 5x2 set of combinations. Keep a cumulative
    multi-block null test and a positive within-limit decode test on both 
readers.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Test negative block count is bounded on both readers
    
    The C and Python collection-limit tests cover a negative block count 
(abs(count)
    zero-byte elements preceded by a block byte-size); the Java tests did not. 
The
    decoder normalizes the negative count to a positive one, which must still be
    bounded by the heap-aware allocation cap. Add a test asserting this on both 
the
    fast and classic reader paths.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Test Long.MIN_VALUE block count is handled safely
    
    Mirror the C SDK's INT64_MIN edge case. Long.MIN_VALUE as a block count is 
the
    pathological overflow: negating it overflows back to a negative value. 
Java's
    decoder normalizes this to an empty collection (no allocation) rather than
    rejecting it as the C SDK does, which is equally non-exploitable. Add a test
    asserting the safe, allocation-free result on both the fast and classic 
reader
    paths.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Bound the array/map skip path
    
    The skip path was unbounded: BinaryDecoder.skipArray()/skipMap() returned
    doSkipItems() without calling checkMaxCollectionLength, and
    GenericDatumReader.skip() looped over the returned count. Skipping a huge 
block
    of zero-byte elements (e.g. a writer array<null> field absent from the 
reader
    schema, skipped during projection) could therefore loop unboundedly -- a CPU
    exhaustion even though skipping reads and allocates nothing.
    
    Two complementary bounds:
     - BinaryDecoder.skipArray()/skipMap() now apply the structural collection 
cap
       (checkMaxCollectionLength), mirroring readArrayStart()/readMapStart() and
       covering the resolving-decoder projection skip path on both reader types.
     - GenericDatumReader.skip() additionally bounds the cumulative count, 
using the
       heap-aware allocation cap for zero-byte element arrays and the 
structural cap
       otherwise, matching the read path and the other language SDKs.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Fix limit Javadoc markup; clamp allocation cap to VM 
limit
    
    Addresses review feedback:
     - Fix the malformed <li> markup in the limit-properties list (each item 
closed
       </li> prematurely after the <tt> tag) and correct the bytes property name
       (org.apache.avro.limits.bytes.maxLength) so the Javadoc renders 
correctly.
     - Clamp maxCollectionAllocation to MAX_ARRAY_VM_LIMIT when refreshing 
limits, so
       a configured (or large-heap-derived) zero-byte allocation cap stays 
consistent
       with the other collection caps and cannot exceed the VM array ceiling.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Fix limit Javadoc grammar; make heap-cap test 
deterministic
    
    Addresses review feedback:
     - Javadoc: "read at once single sequence" -> "read in a single sequence".
     - testCheckMaxCollectionAllocation asserts with MAX_ARRAY_VM_LIMIT + 1 
instead
       of Integer.MAX_VALUE - 8. Since the default allocation cap is derived 
from the
       heap and then clamped to MAX_ARRAY_VM_LIMIT, a value equal to that limit 
may
       not exceed the computed default on a very large heap; +1 guarantees it 
does,
       keeping the test deterministic across environments.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Reword zero-byte Javadoc; tighten skip test assertions
    
    Addresses review feedback:
     - Javadoc: "a self-referencing record" is not inherently zero-byte (the 
code
       only computes a 0-byte minimum for some recursive schemas to break 
recursion).
       Reword the three occurrences to describe actual zero-byte encodings: 
null, a
       zero-length fixed, or a record whose fields all encode to zero bytes.
     - skipMapRejectsHugeCount now asserts UnsupportedOperationException (a 
count of
       Integer.MAX_VALUE deterministically hits the VM structural-limit path), 
and
       resolvingSkipOfHugeNullArrayFieldIsBounded asserts SystemLimitException, 
so the
       tests pin the intended exception rather than a generic RuntimeException.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Apply spotless formatting to reworded Javadoc
    
    The zero-byte Javadoc rewording did not match the Eclipse formatter's
    line-wrapping, failing the spotless check. Reflowed via `mvn 
spotless:apply`.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Clarify zero-byte comments to match the actual predicate
    
    Addresses review feedback: the "encodes to zero bytes" wording in
    GenericDatumReader (the array cap comment and isZeroByteSchema Javadoc) and
    FastReaderBuilder is imprecise. The guard is minBytesPerElement(schema) == 
0,
    which is also true for recursive schemas whose cycle is broken by returning 
a 0
    minimum. Reword to describe the "minimum encoded size is zero" predicate so 
the
    docs match the actual condition under which the heap-aware cap applies.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Reject Long.MIN_VALUE block count as malformed
    
    Addresses review feedback: doReadItemCount() negates a negative block 
count, but
    Long.MIN_VALUE negates back to Long.MIN_VALUE (still negative). The 
single-arg
    checkMaxCollectionLength does not reject negatives, so it was truncated via
    (int) cast to 0, silently ending the collection without consuming the
    end-of-array/map marker and desynchronizing decoding of subsequent fields.
    
    Reject Long.MIN_VALUE outright as malformed, matching the C SDK, instead of
    normalizing to an empty collection. Update the test to assert the rejection.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Clamp collection preallocation from declared block count
    
    The array/map readers passed the declared block count straight to newArray/
    newMap as the initial capacity. On a stream source the bytes-available 
guard is
    skipped (BinaryDecoder.remainingBytes() returns -1 for sources other than
    ByteArrayInputStream/ByteBufferInputStream), so a large declared count 
reaches
    the allocation path directly and drives a huge up-front allocation (e.g.
    new Object[count]) before a single element is read.
    
    Clamp the initial capacity to a modest bound (MAX_COLLECTION_PREALLOC) via
    initialCollectionCapacity(); the backing array/map still grows on demand as
    elements are decoded, so a truncated or hostile stream now fails with
    EOFException after a bounded allocation instead of attempting to preallocate
    hundreds of millions of slots. Applies to both the classic and fast readers.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Reject out-of-range union and enum indices with 
AvroTypeException
    
    The union branch index and enum symbol index were used to index the branch/
    symbol tables without an explicit range check on several paths, surfacing a
    malformed index as a generic IndexOutOfBoundsException instead of a clear
    Avro-specific error:
    
    - Union: Symbol.Alternative.getSymbol (used by both the validating and 
resolving
      decoders, and therefore by GenericDatumReader and the fast reader) indexed
      symbols[] directly. Add a [0, size) check throwing AvroTypeException.
    - Enum: ResolvingDecoder.readEnum returned the raw index in the 
no-adjustments
      case and indexed the adjustment table otherwise, both without validation
      (ValidatingDecoder.readEnum already checked). Add the range checks.
    - The fast reader reads from a plain decoder (resolution is pre-baked), so 
its
      union and enum readers need their own [0, length) checks.
    
    Adds tests covering negative and too-large union and enum indices on both 
the
    classic and fast reader paths.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Reword remaining "zero-byte" docs to the actual 
predicate
    
    The maxAllocation limit is applied when a schema's minimum encoded size is 
zero
    (GenericDatumReader.isZeroByteSchema, i.e. minBytesPerElement == 0), which 
also
    covers recursive schemas whose cycle is conservatively broken with a 0 
minimum.
    Reword the remaining "encodes to zero bytes" wording -- the class Javadoc, 
the
    MAX_COLLECTION_ALLOCATION_PROPERTY Javadoc, the SystemLimitException 
message, and
    the skipArray comment -- to say "minimum encoded size is zero" so the docs 
match
    the condition under which the cap actually applies.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Half-open range in enum messages; finish zero-byte 
reword
    
    Address review feedback:
    - The enum out-of-range messages said "max is <size>", implying <size> is a
      valid index. Reword to the half-open "must be in [0, <size>)" to match the
      actual check and the union messages, in ResolvingDecoder (both the
      no-adjustments and adjustments branches), FastReaderBuilder, and
      ValidatingDecoder.
    - Reword the remaining "encodes to zero bytes" wording in the
      checkMaxCollectionAllocation Javadoc to "minimum encoded size is zero"
      (including the recursive-schema case), matching isZeroByteSchema.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Enforce structural cap when skipping zero-byte arrays; 
tidy
    
    Address review feedback:
    - skip(ARRAY) bounded zero-byte-element arrays only by the heap-aware 
allocation
      cap, so the configurable structural collection limit
      (MAX_COLLECTION_LENGTH_PROPERTY) was not enforced cumulatively and a large
      count split into many blocks could drive a long skip loop. Always enforce
      checkMaxCollectionLength cumulatively, and additionally
      checkMaxCollectionAllocation for zero-byte schemas.
    - FastReaderBuilder.checkArrayBlock: skip the ensureAvailableCollectionBytes
      call for zero-byte elements (it recomputes minBytesPerElement and no-ops),
      applying the allocation cap directly instead.
    - Reword the checkMaxCollectionAllocation summary line from 
"zero-byte-encoded"
      to "minimum encoded size is zero".
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Reword defaultMaxCollectionAllocation Javadoc
    
    Update the last "zero-byte-encoded" wording (the private
    defaultMaxCollectionAllocation helper) to "minimum encoded size is zero",
    consistent with the public property/Javadoc and the isZeroByteSchema 
predicate.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Reword last zero-byte field doc; sort test imports
    
    - Reword the maxCollectionAllocation field Javadoc from "zero-byte-encoded" 
to
      "minimum encoded size is zero".
    - Sort the java.io imports in TestGenericDatumReader (BufferedInputStream 
before
      the ByteArray* imports) to satisfy Spotless.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Reject Long.MIN_VALUE and negative byte-size in 
doSkipItems
    
    doSkipItems accepted a Long.MIN_VALUE block count (treating it as a 
byte-sized
    block and continuing to skip), inconsistent with doReadItemCount which 
rejects
    it. Reject Long.MIN_VALUE, and also reject a negative block byte-size, so 
the
    skip path fails fast on malformed input like the read path.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Reject negative block byte-size in doReadItemCount
    
    doReadItemCount consumed the block byte-size for a negative-count block 
without
    validating it. doSkipItems now rejects a negative byte-size, so the read 
path
    does the same for consistency and to reject malformed encodings.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Clarify test Javadoc: trailing 0L varint, not "no data"
    
    The huge-collection matrix payload includes a trailing 0L varint (an element
    value for arrays, a 0-length key for maps); reword "no element data" to 
reflect
    that.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Reword heap-fraction Javadoc to the actual predicate
    
    Reword the DEFAULT_MAX_COLLECTION_ALLOCATION_HEAP_FRACTION Javadoc from
    "zero-byte elements" to "elements whose minimum encoded size is zero".
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Make array preallocation-clamp test regression-proof
    
    arrayHugeCountOnStreamClampsPreallocation only asserted an EOFException, 
which a
    reintroduced new Object[(int) count] preallocation could still satisfy on a
    large-heap JVM (allocating ~200M slots then hitting EOF). Override newArray 
to
    record the largest requested capacity and assert it stays clamped to
    initialCollectionCapacity, and assert remainingBytes() == -1 to confirm the
    unknown-remaining-bytes precondition.
    
    Assisted-by: GitHub Copilot:claude-opus-4.8
    
    * AVRO-4300: [java] Address review nits: use isZeroByteSchema, tidy 
block-count guards
    
    Apply RyanSkraba's review feedback:
    - GenericDatumReader.readArray uses the isZeroByteSchema helper instead
      of inlining minBytesPerElement == 0, matching skip().
    - BinaryDecoder.doReadItemCount rejects Long.MIN_VALUE before consuming
      the block byte-size, so no readLong runs once the count is invalid.
    - BinaryDecoder.doSkipItems drops the readLong before throwing on an
      invalid Long.MIN_VALUE count, consistent with doReadItemCount.
    - FastReaderBuilder.createArrayReader drops the redundant comment;
      the rationale is documented on checkArrayBlock.
---
 .../java/org/apache/avro/SystemLimitException.java | 108 +++++-
 .../apache/avro/generic/GenericDatumReader.java    |  85 ++++-
 .../java/org/apache/avro/io/BinaryDecoder.java     |  30 +-
 .../java/org/apache/avro/io/FastReaderBuilder.java |  44 ++-
 .../java/org/apache/avro/io/ResolvingDecoder.java  |  11 +
 .../java/org/apache/avro/io/ValidatingDecoder.java |   2 +-
 .../java/org/apache/avro/io/parsing/Symbol.java    |   5 +
 .../org/apache/avro/TestSystemLimitException.java  |  44 +++
 .../avro/generic/TestGenericDatumReader.java       | 383 +++++++++++++++++++++
 9 files changed, 694 insertions(+), 18 deletions(-)

diff --git 
a/lang/java/avro/src/main/java/org/apache/avro/SystemLimitException.java 
b/lang/java/avro/src/main/java/org/apache/avro/SystemLimitException.java
index 886a939735..5ce0b25ba6 100644
--- a/lang/java/avro/src/main/java/org/apache/avro/SystemLimitException.java
+++ b/lang/java/avro/src/main/java/org/apache/avro/SystemLimitException.java
@@ -28,13 +28,20 @@ import org.slf4j.LoggerFactory;
  * The following system properties can be set to limit the size of bytes,
  * strings and collection types to be allocated:
  * <ul>
- * <li><tt>org.apache.avro.limits.byte.maxLength</tt></li> limits the maximum
- * size of <tt>byte</tt> types.</li>
- * <li><tt>org.apache.avro.limits.collectionItems.maxLength</tt></li> limits 
the
- * maximum number of <tt>map</tt> and <tt>list</tt> items that can be read at
- * once single sequence.</li>
- * <li><tt>org.apache.avro.limits.string.maxLength</tt></li> limits the maximum
- * size of <tt>string</tt> types.</li>
+ * <li><tt>org.apache.avro.limits.bytes.maxLength</tt> limits the maximum size
+ * of <tt>bytes</tt> types.</li>
+ * <li><tt>org.apache.avro.limits.collectionItems.maxLength</tt> limits the
+ * maximum number of <tt>map</tt> and <tt>list</tt> items that can be read in a
+ * single sequence.</li>
+ * <li><tt>org.apache.avro.limits.string.maxLength</tt> limits the maximum size
+ * of <tt>string</tt> types.</li>
+ * <li><tt>org.apache.avro.limits.collectionItems.maxAllocation</tt> limits the
+ * number of <tt>array</tt> elements whose minimum encoded size is zero (such 
as
+ * <tt>null</tt>, a zero-length <tt>fixed</tt>, a record whose fields are all
+ * zero-byte, or a recursive schema whose cycle is conservatively broken with a
+ * 0 minimum) that may be allocated at once. Unlike other element types, these
+ * cannot be bounded by the number of bytes remaining in the stream, so the
+ * limit defaults to a fraction of the maximum heap.</li>
  * </ul>
  *
  * The default is to permit sizes up to {@link #MAX_ARRAY_VM_LIMIT}.
@@ -83,6 +90,35 @@ public class SystemLimitException extends 
AvroRuntimeException {
   public static final long MAX_DECOMPRESS_LENGTH = 
getLongLimitFromProperty(MAX_DECOMPRESS_LENGTH_PROPERTY,
       defaultMaxDecompressLength());
 
+  /**
+   * System property declaring the maximum number of array elements whose 
minimum
+   * encoded size is zero (e.g. {@code null}, a zero-length fixed, a record 
whose
+   * fields are all zero-byte, or a recursive schema conservatively treated as 
a 0
+   * minimum) to allocate at once: {@value}.
+   */
+  public static final String MAX_COLLECTION_ALLOCATION_PROPERTY = 
"org.apache.avro.limits.collectionItems.maxAllocation";
+
+  /**
+   * Fraction of the maximum heap a single decoded collection of elements whose
+   * minimum encoded size is zero may occupy by default. Keeps the backing
+   * allocation below the heap so a small payload declaring a huge block count
+   * cannot exhaust the JVM.
+   */
+  private static final long DEFAULT_MAX_COLLECTION_ALLOCATION_HEAP_FRACTION = 
4;
+
+  /**
+   * Estimated bytes retained per pre-allocated collection slot (a single 
object
+   * reference), used to translate the heap budget into an element count.
+   */
+  private static final long BYTES_PER_COLLECTION_SLOT = 8;
+
+  /**
+   * Maximum number of array elements whose minimum encoded size is zero to
+   * allocate at once. Recomputed from the system property (or the heap) by
+   * {@link #resetLimits()}.
+   */
+  private static long maxCollectionAllocation = 
defaultMaxCollectionAllocation();
+
   static {
     resetLimits();
   }
@@ -153,6 +189,20 @@ public class SystemLimitException extends 
AvroRuntimeException {
         Math.max(1L, Runtime.getRuntime().maxMemory() / 
DEFAULT_MAX_DECOMPRESS_HEAP_FRACTION));
   }
 
+  /**
+   * Calculate the default maximum number of array elements whose minimum 
encoded
+   * size is zero to allocate at once, as a fraction of the maximum heap. Such
+   * elements consume no guaranteed input bytes, so the usual "bytes remaining"
+   * bound does not apply and the allocation must instead be capped relative to
+   * the available memory.
+   *
+   * @return the calculated default max element count.
+   */
+  private static long defaultMaxCollectionAllocation() {
+    long heapBudget = Math.max(1L, Runtime.getRuntime().maxMemory() / 
DEFAULT_MAX_COLLECTION_ALLOCATION_HEAP_FRACTION);
+    return Math.max(1L, heapBudget / BYTES_PER_COLLECTION_SLOT);
+  }
+
   /**
    * Check to ensure that reading the bytes is within the specified limits.
    *
@@ -246,6 +296,44 @@ public class SystemLimitException extends 
AvroRuntimeException {
     return (int) items;
   }
 
+  /**
+   * Check to ensure that allocating storage for the specified number of array
+   * elements whose minimum encoded size is zero remains within the heap-aware
+   * limit.
+   * <p>
+   * Elements whose minimum encoded size is zero (e.g. {@code null}, a 
zero-length
+   * fixed, a record whose fields are all zero-byte, or a recursive schema 
whose
+   * cycle is conservatively broken with a 0 minimum) consume no guaranteed 
input
+   * bytes, so the number that may be declared is not bounded by the bytes
+   * remaining in the stream. Without a cap, a tiny payload can declare an
+   * enormous block count and drive an unbounded backing-array allocation. This
+   * limit is derived from the maximum heap (see
+   * {@link #MAX_COLLECTION_ALLOCATION_PROPERTY}).
+   *
+   * @param existing The number of elements already allocated for the 
collection.
+   * @param items    The next number of elements to allocate.
+   * @return The cumulative element count if and only if it is within the 
limit.
+   * @throws SystemLimitException if the cumulative allocation would exceed the
+   *                              limit.
+   * @throws AvroRuntimeException if either argument is negative.
+   */
+  public static long checkMaxCollectionAllocation(long existing, long items) {
+    if (existing < 0) {
+      throw new AvroRuntimeException("Malformed data. Length is negative: " + 
existing);
+    }
+    if (items < 0) {
+      throw new AvroRuntimeException("Malformed data. Length is negative: " + 
items);
+    }
+    long total = existing + items;
+    if (total < existing || total > maxCollectionAllocation) {
+      throw new SystemLimitException("Cannot allocate " + (total < existing ? 
"more than Long.MAX_VALUE" : total)
+          + " collection elements whose minimum encoded size is zero: exceeds 
the maximum allowed of "
+          + maxCollectionAllocation + " (configure with the system property " 
+ MAX_COLLECTION_ALLOCATION_PROPERTY
+          + ")");
+    }
+    return total;
+  }
+
   /**
    * Check to ensure that reading the string size is within the specified 
limits.
    *
@@ -294,5 +382,11 @@ public class SystemLimitException extends 
AvroRuntimeException {
     maxBytesLength = getLimitFromProperty(MAX_BYTES_LENGTH_PROPERTY, 
MAX_ARRAY_VM_LIMIT);
     maxCollectionLength = getLimitFromProperty(MAX_COLLECTION_LENGTH_PROPERTY, 
MAX_ARRAY_VM_LIMIT);
     maxStringLength = getLimitFromProperty(MAX_STRING_LENGTH_PROPERTY, 
MAX_ARRAY_VM_LIMIT);
+    maxCollectionAllocation = 
getLongLimitFromProperty(MAX_COLLECTION_ALLOCATION_PROPERTY,
+        defaultMaxCollectionAllocation());
+    // A collection cannot hold more than MAX_ARRAY_VM_LIMIT elements, so keep 
the
+    // zero-byte allocation cap consistent with the other collection limits 
even
+    // when it is configured (or derived from a very large heap) above that.
+    maxCollectionAllocation = Math.min(maxCollectionAllocation, 
MAX_ARRAY_VM_LIMIT);
   }
 }
diff --git 
a/lang/java/avro/src/main/java/org/apache/avro/generic/GenericDatumReader.java 
b/lang/java/avro/src/main/java/org/apache/avro/generic/GenericDatumReader.java
index 178ca50cca..8a80d3ab64 100644
--- 
a/lang/java/avro/src/main/java/org/apache/avro/generic/GenericDatumReader.java
+++ 
b/lang/java/avro/src/main/java/org/apache/avro/generic/GenericDatumReader.java
@@ -37,6 +37,7 @@ import org.apache.avro.Conversions;
 import org.apache.avro.LogicalType;
 import org.apache.avro.Schema;
 import org.apache.avro.Schema.Field;
+import org.apache.avro.SystemLimitException;
 import org.apache.avro.io.DatumReader;
 import org.apache.avro.io.Decoder;
 import org.apache.avro.io.DecoderFactory;
@@ -114,6 +115,26 @@ public class GenericDatumReader<D> implements 
DatumReader<D> {
   private static final ThreadLocal<Map<Schema, Map<Schema, ResolvingDecoder>>> 
RESOLVER_CACHE = ThreadLocalWithInitial
       .of(WeakIdentityHashMap::new);
 
+  /**
+   * Upper bound on the initial capacity eagerly allocated for a collection 
from
+   * its declared block count. The backing array/map grows on demand as 
elements
+   * are read, so this is only a starting hint: it prevents a large declared 
count
+   * from driving a huge up-front allocation before any element is decoded. 
This
+   * matters most for stream sources, where the decoder cannot know how many 
bytes
+   * remain and so cannot otherwise bound the declared count against the input.
+   */
+  private static final int MAX_COLLECTION_PREALLOC = 1024;
+
+  /**
+   * Clamp a declared collection block count to a safe initial allocation size.
+   *
+   * @param count the declared (already limit-checked) block count
+   * @return {@code count} capped at {@link #MAX_COLLECTION_PREALLOC}
+   */
+  public static int initialCollectionCapacity(long count) {
+    return (int) Math.min(count, MAX_COLLECTION_PREALLOC);
+  }
+
   /**
    * Gets a resolving decoder for use by this GenericDatumReader. Unstable API.
    * Currently uses a thread local cache to prevent constructing the resolvers 
too
@@ -296,9 +317,20 @@ public class GenericDatumReader<D> implements 
DatumReader<D> {
     long base = 0;
     if (l > 0) {
       ensureAvailableCollectionBytes(in, l, expectedType);
+      // Elements whose minimum encoded size is zero (null, a zero-length 
fixed, a
+      // record whose fields are all zero-byte, or a recursive schema where the
+      // cycle is broken with a 0 minimum) consume no guaranteed input, so
+      // ensureAvailableCollectionBytes cannot bound their count from the bytes
+      // remaining. Cap such collections against a heap-aware limit so a tiny
+      // payload cannot declare a huge block count and drive an unbounded
+      // backing-array allocation.
+      boolean zeroByteElements = isZeroByteSchema(expectedType);
+      if (zeroByteElements) {
+        SystemLimitException.checkMaxCollectionAllocation(base, l);
+      }
       LogicalType logicalType = expectedType.getLogicalType();
       Conversion<?> conversion = getData().getConversionFor(logicalType);
-      Object array = newArray(old, (int) l, expected);
+      Object array = newArray(old, initialCollectionCapacity(l), expected);
       do {
         if (logicalType != null && conversion != null) {
           for (long i = 0; i < l; i++) {
@@ -311,7 +343,11 @@ public class GenericDatumReader<D> implements 
DatumReader<D> {
           }
         }
         base += l;
-      } while ((l = arrayNext(in, expectedType)) > 0);
+        l = arrayNext(in, expectedType);
+        if (zeroByteElements && l > 0) {
+          SystemLimitException.checkMaxCollectionAllocation(base, l);
+        }
+      } while (l > 0);
       return pruneArray(array);
     } else {
       return pruneArray(newArray(old, 0, expected));
@@ -364,7 +400,7 @@ public class GenericDatumReader<D> implements 
DatumReader<D> {
     LogicalType logicalType = eValue.getLogicalType();
     Conversion<?> conversion = getData().getConversionFor(logicalType);
     ensureAvailableMapBytes(in, l, eValue);
-    Object map = newMap(old, (int) l);
+    Object map = newMap(old, initialCollectionCapacity(l));
     if (l > 0) {
       do {
         if (logicalType != null && conversion != null) {
@@ -441,6 +477,23 @@ public class GenericDatumReader<D> implements 
DatumReader<D> {
     return minBytesPerElement(schema, Collections.newSetFromMap(new 
IdentityHashMap<>()));
   }
 
+  /**
+   * Whether the minimum encoded size of the given schema is zero, i.e.
+   * {@link #minBytesPerElement(Schema)} is {@code 0}. This is true for values
+   * that always encode to zero bytes (e.g. {@code null}, a zero-length
+   * {@code fixed}, or a record whose fields are all zero-byte), and
+   * conservatively for recursive schemas, where the cycle is broken by 
returning
+   * a 0 minimum. Such elements cannot be bounded by the number of bytes 
remaining
+   * in the stream, so a collection of them must be bounded by a heap-aware
+   * allocation limit instead.
+   *
+   * @param schema the element (or map value) schema
+   * @return {@code true} if the schema's minimum encoded size is zero
+   */
+  public static boolean isZeroByteSchema(Schema schema) {
+    return minBytesPerElement(schema) == 0;
+  }
+
   private static int minBytesPerElement(Schema schema, Set<Schema> visited) {
     switch (schema.getType()) {
     case NULL:
@@ -481,9 +534,11 @@ public class GenericDatumReader<D> implements 
DatumReader<D> {
    * reports fewer remaining bytes than required.
    * <p>
    * This check prevents out-of-memory errors from pre-allocating huge backing
-   * arrays when the source data is truncated or malicious.
+   * arrays when the source data is truncated or malicious. It is exposed so 
the
+   * fast reader ({@code FastReaderBuilder}) can apply the same guard as this
+   * classic reader.
    */
-  private static void ensureAvailableCollectionBytes(Decoder decoder, long 
count, Schema elementSchema)
+  public static void ensureAvailableCollectionBytes(Decoder decoder, long 
count, Schema elementSchema)
       throws EOFException {
     if (count <= 0) {
       return;
@@ -747,7 +802,23 @@ public class GenericDatumReader<D> implements 
DatumReader<D> {
       break;
     case ARRAY:
       Schema elementType = schema.getElementType();
+      // Bound the cumulative element count: skipping a huge block of elements
+      // whose minimum encoded size is zero (e.g. null) would otherwise loop
+      // unboundedly (a CPU exhaustion) even though it reads nothing. Such
+      // elements use the heap-aware allocation cap; others the structural
+      // collection cap.
+      boolean zeroByteElements = isZeroByteSchema(elementType);
+      long arrayTotal = 0;
       for (long l = in.skipArray(); l > 0; l = in.skipArray()) {
+        // Always enforce the cumulative structural cap, then additionally the
+        // heap-aware allocation cap for zero-byte elements (which the 
structural
+        // cap alone does not bound tightly), so a huge count split across 
blocks
+        // cannot drive an unbounded skip loop.
+        SystemLimitException.checkMaxCollectionLength(arrayTotal, l);
+        if (zeroByteElements) {
+          SystemLimitException.checkMaxCollectionAllocation(arrayTotal, l);
+        }
+        arrayTotal += l;
         for (long i = 0; i < l; i++) {
           skip(elementType, in);
         }
@@ -755,7 +826,11 @@ public class GenericDatumReader<D> implements 
DatumReader<D> {
       break;
     case MAP:
       Schema value = schema.getValueType();
+      // Map entries always carry a >= 1 byte key, so the structural cap 
applies.
+      long mapTotal = 0;
       for (long l = in.skipMap(); l > 0; l = in.skipMap()) {
+        SystemLimitException.checkMaxCollectionLength(mapTotal, l);
+        mapTotal += l;
         for (long i = 0; i < l; i++) {
           in.skipString();
           skip(value, in);
diff --git a/lang/java/avro/src/main/java/org/apache/avro/io/BinaryDecoder.java 
b/lang/java/avro/src/main/java/org/apache/avro/io/BinaryDecoder.java
index 77fc849076..3974eb5c62 100644
--- a/lang/java/avro/src/main/java/org/apache/avro/io/BinaryDecoder.java
+++ b/lang/java/avro/src/main/java/org/apache/avro/io/BinaryDecoder.java
@@ -408,8 +408,21 @@ public class BinaryDecoder extends Decoder {
   protected long doReadItemCount() throws IOException {
     long result = readLong();
     if (result < 0L) {
-      // Consume byte-count if present
-      readLong();
+      if (result == Long.MIN_VALUE) {
+        // Long.MIN_VALUE cannot be negated (-Long.MIN_VALUE overflows back to
+        // Long.MIN_VALUE), so it is not a valid block count. Reject it rather
+        // than letting it fall through as a negative "count" that would later 
be
+        // truncated to 0 and silently terminate the collection without 
consuming
+        // the end marker, desynchronizing decoding of subsequent fields.
+        throw new AvroRuntimeException("Malformed data. Block count is 
invalid: " + result);
+      }
+      // A negative block count is followed by a block byte-size; consume it.
+      final long bytecount = readLong();
+      if (bytecount < 0L) {
+        // The block byte-size is a byte count and must be non-negative, 
matching
+        // doSkipItems().
+        throw new AvroRuntimeException("Malformed data. Block byte-size is 
negative: " + bytecount);
+      }
       result = -result;
     }
     return result;
@@ -436,7 +449,16 @@ public class BinaryDecoder extends Decoder {
   private long doSkipItems() throws IOException {
     long result = readLong();
     while (result < 0L) {
+      if (result == Long.MIN_VALUE) {
+        // Consistent with doReadItemCount: Long.MIN_VALUE is not a valid block
+        // count (it cannot be negated), so reject it rather than treating it 
as
+        // a byte-sized block and continuing to skip.
+        throw new AvroRuntimeException("Malformed data. Block count is 
invalid: " + result);
+      }
       final long bytecount = readLong();
+      if (bytecount < 0L) {
+        throw new AvroRuntimeException("Malformed data. Block byte-size is 
negative: " + bytecount);
+      }
       doSkipBytes(bytecount);
       result = readLong();
     }
@@ -458,7 +480,7 @@ public class BinaryDecoder extends Decoder {
 
   @Override
   public long skipArray() throws IOException {
-    return doSkipItems();
+    return SystemLimitException.checkMaxCollectionLength(doSkipItems());
   }
 
   @Override
@@ -476,7 +498,7 @@ public class BinaryDecoder extends Decoder {
 
   @Override
   public long skipMap() throws IOException {
-    return doSkipItems();
+    return SystemLimitException.checkMaxCollectionLength(doSkipItems());
   }
 
   @Override
diff --git 
a/lang/java/avro/src/main/java/org/apache/avro/io/FastReaderBuilder.java 
b/lang/java/avro/src/main/java/org/apache/avro/io/FastReaderBuilder.java
index 512c9ebf34..8c61acf201 100644
--- a/lang/java/avro/src/main/java/org/apache/avro/io/FastReaderBuilder.java
+++ b/lang/java/avro/src/main/java/org/apache/avro/io/FastReaderBuilder.java
@@ -40,6 +40,7 @@ import org.apache.avro.Resolver.Skip;
 import org.apache.avro.Resolver.WriterUnion;
 import org.apache.avro.Schema;
 import org.apache.avro.Schema.Field;
+import org.apache.avro.SystemLimitException;
 import org.apache.avro.generic.GenericArray;
 import org.apache.avro.generic.GenericData;
 import org.apache.avro.generic.GenericData.InstanceSupplier;
@@ -419,6 +420,10 @@ public class FastReaderBuilder {
   private FieldReader createUnionReader(FieldReader[] unionReaders) {
     return reusingReader((reuse, decoder) -> {
       final int selection = decoder.readIndex();
+      if (selection < 0 || selection >= unionReaders.length) {
+        throw new AvroTypeException(
+            "Union branch index out of range: must be in [0, " + 
unionReaders.length + "), but received " + selection);
+      }
       return unionReaders[selection].read(null, decoder);
     });
 
@@ -469,39 +474,76 @@ public class FastReaderBuilder {
   @SuppressWarnings("unchecked")
   private FieldReader createArrayReader(Schema readerSchema, Container action) 
throws IOException {
     FieldReader elementReader = getReaderFor(action.elementAction, null);
+    Schema elementType = readerSchema.getElementType();
+
+    boolean zeroByteElements = 
GenericDatumReader.isZeroByteSchema(elementType);
 
     return reusingReader((reuse, decoder) -> {
       if (reuse instanceof GenericArray) {
         GenericArray<Object> reuseArray = (GenericArray<Object>) reuse;
         long l = decoder.readArrayStart();
+        long total = 0;
+        checkArrayBlock(decoder, elementType, zeroByteElements, total, l);
         reuseArray.clear();
 
         while (l > 0) {
           for (long i = 0; i < l; i++) {
             reuseArray.add(elementReader.read(reuseArray.peek(), decoder));
           }
+          total += l;
           l = decoder.arrayNext();
+          checkArrayBlock(decoder, elementType, zeroByteElements, total, l);
         }
         return reuseArray;
       } else {
         long l = decoder.readArrayStart();
+        long total = 0;
+        checkArrayBlock(decoder, elementType, zeroByteElements, total, l);
         List<Object> array = (reuse instanceof List) ? (List<Object>) reuse
-            : new GenericData.Array<>((int) l, readerSchema);
+            : new 
GenericData.Array<>(GenericDatumReader.initialCollectionCapacity(l), 
readerSchema);
         array.clear();
         while (l > 0) {
           for (long i = 0; i < l; i++) {
             array.add(elementReader.read(null, decoder));
           }
+          total += l;
           l = decoder.arrayNext();
+          checkArrayBlock(decoder, elementType, zeroByteElements, total, l);
         }
         return array;
       }
     });
   }
 
+  /**
+   * Validates an array block count before its elements are allocated, applying
+   * the same guards as the classic {@code GenericDatumReader}: the
+   * bytes-remaining check for elements with a positive minimum size, and the
+   * heap-aware allocation cap for zero-byte elements (which the bytes check
+   * cannot bound).
+   */
+  private static void checkArrayBlock(Decoder decoder, Schema elementType, 
boolean zeroByteElements, long total,
+      long count) throws IOException {
+    if (count <= 0) {
+      return;
+    }
+    if (zeroByteElements) {
+      // The bytes-remaining check cannot bound zero-byte elements (minBytes is
+      // 0, so ensureAvailableCollectionBytes would no-op after recomputing 
it);
+      // apply the heap-aware allocation cap instead.
+      SystemLimitException.checkMaxCollectionAllocation(total, count);
+    } else {
+      GenericDatumReader.ensureAvailableCollectionBytes(decoder, count, 
elementType);
+    }
+  }
+
   private FieldReader createEnumReader(EnumAdjust action) {
     return reusingReader((reuse, decoder) -> {
       int index = decoder.readEnum();
+      if (index < 0 || index >= action.values.length) {
+        throw new AvroTypeException(
+            "Enumeration out of range: must be in [0, " + action.values.length 
+ "), but received " + index);
+      }
       Object resultObject = action.values[index];
       if (resultObject == null) {
         throw new AvroTypeException("No match for " + 
action.writer.getEnumSymbols().get(index));
diff --git 
a/lang/java/avro/src/main/java/org/apache/avro/io/ResolvingDecoder.java 
b/lang/java/avro/src/main/java/org/apache/avro/io/ResolvingDecoder.java
index 6bdb16a332..0b124579f7 100644
--- a/lang/java/avro/src/main/java/org/apache/avro/io/ResolvingDecoder.java
+++ b/lang/java/avro/src/main/java/org/apache/avro/io/ResolvingDecoder.java
@@ -260,8 +260,19 @@ public class ResolvingDecoder extends ValidatingDecoder {
     Symbol.EnumAdjustAction top = (Symbol.EnumAdjustAction) parser.popSymbol();
     int n = in.readEnum();
     if (top.noAdjustments) {
+      // n is used directly as an index into the reader enum's symbols, so it
+      // must fall within the reader symbol count.
+      if (n < 0 || n >= top.size) {
+        throw new AvroTypeException("Enumeration out of range: must be in [0, 
" + top.size + "), but received " + n);
+      }
       return n;
     }
+    // Otherwise n indexes the writer-to-reader adjustment table; reject an 
index
+    // outside it rather than letting the array access throw.
+    if (n < 0 || n >= top.adjustments.length) {
+      throw new AvroTypeException(
+          "Enumeration out of range: must be in [0, " + top.adjustments.length 
+ "), but received " + n);
+    }
     Object o = top.adjustments[n];
     if (o instanceof Integer) {
       return (Integer) o;
diff --git 
a/lang/java/avro/src/main/java/org/apache/avro/io/ValidatingDecoder.java 
b/lang/java/avro/src/main/java/org/apache/avro/io/ValidatingDecoder.java
index 26f79a16ff..3c5e084218 100644
--- a/lang/java/avro/src/main/java/org/apache/avro/io/ValidatingDecoder.java
+++ b/lang/java/avro/src/main/java/org/apache/avro/io/ValidatingDecoder.java
@@ -164,7 +164,7 @@ public class ValidatingDecoder extends ParsingDecoder 
implements Parser.ActionHa
     Symbol.IntCheckAction top = (Symbol.IntCheckAction) parser.popSymbol();
     int result = in.readEnum();
     if (result < 0 || result >= top.size) {
-      throw new AvroTypeException("Enumeration out of range: max is " + 
top.size + " but received " + result);
+      throw new AvroTypeException("Enumeration out of range: must be in [0, " 
+ top.size + "), but received " + result);
     }
     return result;
   }
diff --git 
a/lang/java/avro/src/main/java/org/apache/avro/io/parsing/Symbol.java 
b/lang/java/avro/src/main/java/org/apache/avro/io/parsing/Symbol.java
index b5dcbeb68f..039b1517fd 100644
--- a/lang/java/avro/src/main/java/org/apache/avro/io/parsing/Symbol.java
+++ b/lang/java/avro/src/main/java/org/apache/avro/io/parsing/Symbol.java
@@ -26,6 +26,7 @@ import java.util.Map;
 import java.util.NoSuchElementException;
 import java.util.Set;
 
+import org.apache.avro.AvroTypeException;
 import org.apache.avro.Schema;
 
 /**
@@ -457,6 +458,10 @@ public abstract class Symbol {
     }
 
     public Symbol getSymbol(int index) {
+      if (index < 0 || index >= symbols.length) {
+        throw new AvroTypeException(
+            "Union branch index out of range: must be in [0, " + 
symbols.length + "), but received " + index);
+      }
       return symbols[index];
     }
 
diff --git 
a/lang/java/avro/src/test/java/org/apache/avro/TestSystemLimitException.java 
b/lang/java/avro/src/test/java/org/apache/avro/TestSystemLimitException.java
index 0da3917950..a0b59f55e2 100644
--- a/lang/java/avro/src/test/java/org/apache/avro/TestSystemLimitException.java
+++ b/lang/java/avro/src/test/java/org/apache/avro/TestSystemLimitException.java
@@ -46,6 +46,7 @@ public class TestSystemLimitException {
     System.clearProperty(MAX_BYTES_LENGTH_PROPERTY);
     System.clearProperty(MAX_COLLECTION_LENGTH_PROPERTY);
     System.clearProperty(MAX_STRING_LENGTH_PROPERTY);
+    System.clearProperty(MAX_COLLECTION_ALLOCATION_PROPERTY);
     resetLimits();
   }
 
@@ -110,6 +111,49 @@ public class TestSystemLimitException {
         "String length 1024 exceeds maximum allowed");
   }
 
+  @Test
+  void testCheckMaxCollectionAllocation() {
+    // With a small custom limit, cumulative allocations beyond it are 
rejected.
+    System.setProperty(MAX_COLLECTION_ALLOCATION_PROPERTY, "1000");
+    resetLimits();
+
+    // Values within the limit pass through and return the running total.
+    assertEquals(0L, checkMaxCollectionAllocation(0L, 0L));
+    assertEquals(1000L, checkMaxCollectionAllocation(0L, 1000L));
+    assertEquals(1000L, checkMaxCollectionAllocation(400L, 600L));
+
+    // A single block over the limit is rejected.
+    SystemLimitException ex = assertThrows(SystemLimitException.class, () -> 
checkMaxCollectionAllocation(0L, 1001L));
+    assertTrue(ex.getMessage().contains("exceeds the maximum allowed of 
1000"), ex.getMessage());
+
+    // Cumulative blocks that cross the limit are rejected.
+    ex = assertThrows(SystemLimitException.class, () -> 
checkMaxCollectionAllocation(600L, 401L));
+    assertTrue(ex.getMessage().contains("exceeds the maximum allowed of 
1000"), ex.getMessage());
+
+    // Negative arguments are rejected as malformed.
+    Exception nex = assertThrows(AvroRuntimeException.class, () -> 
checkMaxCollectionAllocation(-1L, 10L));
+    assertEquals(ERROR_NEGATIVE, nex.getMessage());
+    nex = assertThrows(AvroRuntimeException.class, () -> 
checkMaxCollectionAllocation(10L, -1L));
+    assertEquals(ERROR_NEGATIVE, nex.getMessage());
+
+    // Additive overflow is rejected rather than wrapping to a small value.
+    assertThrows(SystemLimitException.class, () -> 
checkMaxCollectionAllocation(Long.MAX_VALUE, 10L));
+  }
+
+  @Test
+  void testCheckMaxCollectionAllocationDefaultsToHeapFraction() {
+    // With no property set, the default is derived from the heap and is well
+    // below Integer.MAX_VALUE on a normally sized JVM, yet generous enough for
+    // legitimate small collections.
+    resetLimits();
+    assertEquals(1024L, checkMaxCollectionAllocation(0L, 1024L));
+    // A pathologically large zero-byte collection is rejected without 
allocating.
+    // Use MAX_ARRAY_VM_LIMIT + 1 so it exceeds the cap regardless of heap size
+    // (the default is derived from the heap and then clamped to 
MAX_ARRAY_VM_LIMIT,
+    // so Integer.MAX_VALUE - 8 alone would not exceed it on a very large 
heap).
+    assertThrows(SystemLimitException.class, () -> 
checkMaxCollectionAllocation(0L, (long) MAX_ARRAY_VM_LIMIT + 1L));
+  }
+
   @Test
   void testCheckMaxCollectionLengthFromNonZero() {
     // Correct values pass through
diff --git 
a/lang/java/avro/src/test/java/org/apache/avro/generic/TestGenericDatumReader.java
 
b/lang/java/avro/src/test/java/org/apache/avro/generic/TestGenericDatumReader.java
index 5586b82899..211692c4d7 100644
--- 
a/lang/java/avro/src/test/java/org/apache/avro/generic/TestGenericDatumReader.java
+++ 
b/lang/java/avro/src/test/java/org/apache/avro/generic/TestGenericDatumReader.java
@@ -19,19 +19,26 @@ package org.apache.avro.generic;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.EOFException;
 import java.io.IOException;
+import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Random;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 
+import org.apache.avro.AvroRuntimeException;
 import org.apache.avro.Schema;
+import org.apache.avro.SystemLimitException;
 import org.apache.avro.io.BinaryDecoder;
 import org.apache.avro.io.BinaryEncoder;
 import org.apache.avro.io.DecoderFactory;
@@ -216,6 +223,49 @@ public class TestGenericDatumReader {
     assertThrows(EOFException.class, () -> reader.read(null, decoder));
   }
 
+  /**
+   * On a stream source the decoder cannot know how many bytes remain, so the
+   * bytes-available guard is skipped and a large declared array count reaches 
the
+   * allocation path directly. The initial backing-array capacity must be 
clamped
+   * so a hostile count cannot drive a huge up-front allocation; a truncated
+   * stream therefore fails with {@link EOFException} (once the declared 
elements
+   * cannot be read) rather than attempting to preallocate hundreds of 
millions of
+   * slots.
+   */
+  @Test
+  void arrayHugeCountOnStreamClampsPreallocation() throws Exception {
+    Schema schema = Schema.createArray(Schema.create(Schema.Type.LONG));
+    // Record the largest capacity ever requested from newArray so we can 
assert
+    // the preallocation is clamped rather than sized to the declared block 
count.
+    // Without this, a regression that reintroduces new Object[(int) count] 
could
+    // allocate ~200M slots and still pass (with an EOFException) on a 
large-heap
+    // JVM, hiding the regression.
+    final int[] maxRequestedCapacity = { 0 };
+    GenericDatumReader<Object> reader = new GenericDatumReader<Object>(schema) 
{
+      @Override
+      protected Object newArray(Object old, int size, Schema schema) {
+        maxRequestedCapacity[0] = Math.max(maxRequestedCapacity[0], size);
+        return super.newArray(old, size, schema);
+      }
+    };
+
+    // A huge (non-zero-byte) block count followed by no element data. 
Wrapping in
+    // a BufferedInputStream keeps the source from being a 
ByteArrayInputStream, so
+    // it cannot report its remaining byte count (remainingBytes() == -1) and 
the
+    // bytes-available guard is disabled -- exercising the preallocation clamp.
+    byte[] data = encodeVarints(200_000_000L);
+    InputStream stream = new BufferedInputStream(new 
ByteArrayInputStream(data));
+    BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(stream, null);
+    // Confirm the guard-disabling precondition: the decoder cannot report how
+    // many bytes remain, so this really is the unknown-remaining-bytes path.
+    assertEquals(-1, decoder.remainingBytes());
+    assertThrows(EOFException.class, () -> reader.read(null, decoder));
+    // The declared count is 200,000,000 but the initial allocation must stay
+    // clamped to GenericDatumReader.initialCollectionCapacity (<= 1024).
+    assertTrue(maxRequestedCapacity[0] <= 
GenericDatumReader.initialCollectionCapacity(200_000_000L),
+        "preallocation was not clamped: requested capacity " + 
maxRequestedCapacity[0]);
+  }
+
   /**
    * Verify that reading an array of nulls with a large count SUCCEEDS because
    * null elements are 0 bytes each, so the byte check is correctly skipped.
@@ -303,4 +353,337 @@ public class TestGenericDatumReader {
     BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(data, null);
     assertThrows(EOFException.class, () -> reader.read(null, decoder));
   }
+
+  // --- Zero-byte element collection allocation limit (AVRO-4300) ---
+
+  /**
+   * An array of {@code null} elements encodes each element as 0 bytes, so the
+   * bytes-remaining check cannot bound the block count. A huge count must be
+   * rejected by the heap-aware allocation limit rather than driving an 
unbounded
+   * backing-array allocation.
+   */
+  @Test
+  void arrayOfNullsRejectsCountAboveAllocationLimit() throws Exception {
+    
System.setProperty(SystemLimitException.MAX_COLLECTION_ALLOCATION_PROPERTY, 
"1000");
+    org.apache.avro.TestSystemLimitException.resetLimits();
+    try {
+      Schema schema = Schema.createArray(Schema.create(Schema.Type.NULL));
+      GenericDatumReader<Object> reader = new GenericDatumReader<>(schema);
+
+      // A single block declaring 200,000 null elements: only ~4 payload bytes,
+      // but would allocate a 200,000-slot backing array. Exceeds the limit of
+      // 1000, so it must be rejected before allocating.
+      byte[] data = encodeVarints(200_000L, 0L);
+      BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(data, null);
+      assertThrows(SystemLimitException.class, () -> reader.read(null, 
decoder));
+
+      // Cumulative growth across multiple blocks is also rejected: two blocks 
of
+      // 600 nulls each (1200 > 1000) must throw on the second block.
+      byte[] cumulative = encodeVarints(600L, 600L, 0L);
+      BinaryDecoder decoder2 = DecoderFactory.get().binaryDecoder(cumulative, 
null);
+      assertThrows(SystemLimitException.class, () -> reader.read(null, 
decoder2));
+    } finally {
+      
System.clearProperty(SystemLimitException.MAX_COLLECTION_ALLOCATION_PROPERTY);
+      org.apache.avro.TestSystemLimitException.resetLimits();
+    }
+  }
+
+  /**
+   * A legitimate array of {@code null} elements within the allocation limit 
still
+   * decodes correctly, so the guard does not reject valid data.
+   */
+  @Test
+  void arrayOfNullsWithinAllocationLimitStillDecodes() throws Exception {
+    
System.setProperty(SystemLimitException.MAX_COLLECTION_ALLOCATION_PROPERTY, 
"1000");
+    org.apache.avro.TestSystemLimitException.resetLimits();
+    try {
+      Schema schema = Schema.createArray(Schema.create(Schema.Type.NULL));
+      GenericDatumReader<Object> reader = new GenericDatumReader<>(schema);
+
+      byte[] data = encodeVarints(1000L, 0L);
+      BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(data, null);
+      GenericData.Array<?> result = (GenericData.Array<?>) reader.read(null, 
decoder);
+      assertEquals(1000, result.size());
+    } finally {
+      
System.clearProperty(SystemLimitException.MAX_COLLECTION_ALLOCATION_PROPERTY);
+      org.apache.avro.TestSystemLimitException.resetLimits();
+    }
+  }
+
+  /**
+   * An array whose element is a record with only {@code null} fields also 
encodes
+   * to 0 bytes per element and must be bounded by the allocation limit.
+   */
+  @Test
+  void arrayOfAllNullRecordsRejectsCountAboveAllocationLimit() throws 
Exception {
+    
System.setProperty(SystemLimitException.MAX_COLLECTION_ALLOCATION_PROPERTY, 
"1000");
+    org.apache.avro.TestSystemLimitException.resetLimits();
+    try {
+      Schema recWithNull = Schema.createRecord("AllNull", null, "test", false);
+      recWithNull.setFields(Collections.singletonList(new Schema.Field("n", 
Schema.create(Schema.Type.NULL))));
+      Schema schema = Schema.createArray(recWithNull);
+      GenericDatumReader<Object> reader = new GenericDatumReader<>(schema);
+
+      byte[] data = encodeVarints(200_000L, 0L);
+      BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(data, null);
+      assertThrows(SystemLimitException.class, () -> reader.read(null, 
decoder));
+    } finally {
+      
System.clearProperty(SystemLimitException.MAX_COLLECTION_ALLOCATION_PROPERTY);
+      org.apache.avro.TestSystemLimitException.resetLimits();
+    }
+  }
+
+  private static GenericDatumReader<Object> arrayReader(Schema elementType, 
boolean fastReader) {
+    return readerFor(Schema.createArray(elementType), fastReader);
+  }
+
+  private static GenericDatumReader<Object> readerFor(Schema schema, boolean 
fastReader) {
+    GenericData data = new GenericData();
+    data.setFastReaderEnabled(fastReader);
+    return new GenericDatumReader<>(schema, schema, data);
+  }
+
+  /**
+   * Full matrix: every collection kind must be rejected (never OOM) with a 
huge
+   * declared block count and effectively no element data (only a trailing 
{@code
+   * 0L} varint, used as a single element value or a 0-length map key), on both
+   * the fast (default) and the classic reader. Zero-byte-element arrays are
+   * bounded by the heap-aware allocation cap (SystemLimitException); every 
other
+   * kind is bounded by the bytes-remaining check (EOFException). Maps always
+   * carry a >=1-byte key so they fall in the latter group regardless of the 
value
+   * type.
+   */
+  @Test
+  void hugeCollectionsRejectedOnBothReaderPaths() throws Exception {
+    // element/value type -> expected exception when the count is huge and no 
data
+    Schema nullType = Schema.create(Schema.Type.NULL);
+    Schema longType = Schema.create(Schema.Type.LONG);
+    Schema intType = Schema.create(Schema.Type.INT);
+
+    // (schema, expected exception). array<null> is the only zero-byte case 
here.
+    Object[][] cases = { { Schema.createArray(nullType), 
SystemLimitException.class },
+        { Schema.createArray(longType), EOFException.class }, { 
Schema.createArray(intType), EOFException.class },
+        { Schema.createMap(nullType), EOFException.class }, { 
Schema.createMap(longType), EOFException.class }, };
+
+    // Small allocation limit so the zero-byte array<null> case is rejected
+    // deterministically regardless of the test JVM heap size.
+    
System.setProperty(SystemLimitException.MAX_COLLECTION_ALLOCATION_PROPERTY, 
"1000");
+    org.apache.avro.TestSystemLimitException.resetLimits();
+    try {
+      for (Object[] c : cases) {
+        Schema schema = (Schema) c[0];
+        @SuppressWarnings("unchecked")
+        Class<? extends Throwable> expected = (Class<? extends Throwable>) 
c[1];
+        for (boolean fast : new boolean[] { true, false }) {
+          GenericDatumReader<Object> reader = readerFor(schema, fast);
+          byte[] data = encodeVarints(200_000_000L, 0L);
+          BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(data, 
null);
+          assertThrows(expected, () -> reader.read(null, decoder), () -> 
schema + " fast=" + fast);
+        }
+      }
+    } finally {
+      
System.clearProperty(SystemLimitException.MAX_COLLECTION_ALLOCATION_PROPERTY);
+      org.apache.avro.TestSystemLimitException.resetLimits();
+    }
+  }
+
+  /**
+   * The fast reader (the default decode path) must apply the same guards as 
the
+   * classic reader. A non-zero-byte element array with a huge count and no 
data
+   * must be rejected by the bytes-remaining check rather than pre-allocating a
+   * huge backing array. Also verifies the cumulative allocation cap across
+   * multiple blocks for the zero-byte case, which the single-block matrix does
+   * not exercise.
+   */
+  @Test
+  void fastAndClassicReaderRejectCumulativeNullBlocks() throws Exception {
+    
System.setProperty(SystemLimitException.MAX_COLLECTION_ALLOCATION_PROPERTY, 
"1000");
+    org.apache.avro.TestSystemLimitException.resetLimits();
+    try {
+      for (boolean fast : new boolean[] { true, false }) {
+        GenericDatumReader<Object> reader = 
arrayReader(Schema.create(Schema.Type.NULL), fast);
+        // Two blocks of 600 nulls each (1200 > 1000) must throw on the second.
+        byte[] data = encodeVarints(600L, 600L, 0L);
+        BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(data, null);
+        assertThrows(SystemLimitException.class, () -> reader.read(null, 
decoder), "fastReader=" + fast);
+      }
+    } finally {
+      
System.clearProperty(SystemLimitException.MAX_COLLECTION_ALLOCATION_PROPERTY);
+      org.apache.avro.TestSystemLimitException.resetLimits();
+    }
+  }
+
+  /**
+   * A negative block count encodes {@code abs(count)} zero-byte elements 
preceded
+   * by a block byte-size; the decoder normalizes it to a positive count, which
+   * must still be bounded by the allocation cap on both reader paths (matching
+   * the negative-block-count coverage of the C and Python SDKs).
+   */
+  @Test
+  void fastAndClassicReaderRejectNegativeNullBlockCount() throws Exception {
+    
System.setProperty(SystemLimitException.MAX_COLLECTION_ALLOCATION_PROPERTY, 
"1000");
+    org.apache.avro.TestSystemLimitException.resetLimits();
+    try {
+      for (boolean fast : new boolean[] { true, false }) {
+        GenericDatumReader<Object> reader = 
arrayReader(Schema.create(Schema.Type.NULL), fast);
+        // -200000 items (zigzag negative), followed by a block byte-size of 0,
+        // then the end-of-array terminator. Normalized to 200000 > 1000.
+        byte[] data = encodeVarints(-200_000L, 0L, 0L);
+        BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(data, null);
+        assertThrows(SystemLimitException.class, () -> reader.read(null, 
decoder), "fastReader=" + fast);
+      }
+    } finally {
+      
System.clearProperty(SystemLimitException.MAX_COLLECTION_ALLOCATION_PROPERTY);
+      org.apache.avro.TestSystemLimitException.resetLimits();
+    }
+  }
+
+  /**
+   * {@code Long.MIN_VALUE} as a block count is the pathological overflow case:
+   * negating it overflows back to a negative value. Rather than letting it be
+   * truncated to {@code 0} (which would silently end the collection without
+   * consuming the end marker and desynchronize decoding of following fields), 
the
+   * decoder rejects it outright as malformed, matching the C SDK.
+   */
+  @Test
+  void fastAndClassicReaderRejectMinValueBlockCount() throws Exception {
+    for (boolean fast : new boolean[] { true, false }) {
+      GenericDatumReader<Object> reader = 
arrayReader(Schema.create(Schema.Type.NULL), fast);
+      // Long.MIN_VALUE items (zigzag), a block byte-size of 0, then the
+      // end-of-array terminator. Negating Long.MIN_VALUE overflows, so it 
must be
+      // rejected as malformed instead of decoded as an empty array.
+      byte[] data = encodeVarints(Long.MIN_VALUE, 0L, 0L);
+      BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(data, null);
+      assertThrows(AvroRuntimeException.class, () -> reader.read(null, 
decoder), "fastReader=" + fast);
+    }
+  }
+
+  /**
+   * A union branch index outside {@code [0, branch count)} is malformed and 
must
+   * be rejected on both reader paths rather than throwing a generic
+   * {@code IndexOutOfBoundsException}.
+   */
+  @Test
+  void fastAndClassicReaderRejectOutOfRangeUnionIndex() throws Exception {
+    Schema schema = Schema.createUnion(Schema.create(Schema.Type.NULL), 
Schema.create(Schema.Type.LONG));
+    for (boolean fast : new boolean[] { true, false }) {
+      for (long index : new long[] { 5L, -1L }) {
+        GenericDatumReader<Object> reader = readerFor(schema, fast);
+        byte[] data = encodeVarints(index);
+        BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(data, null);
+        assertThrows(org.apache.avro.AvroTypeException.class, () -> 
reader.read(null, decoder),
+            "fastReader=" + fast + " index=" + index);
+      }
+    }
+  }
+
+  /**
+   * An enum symbol index outside {@code [0, symbol count)} is malformed and 
must
+   * be rejected on both reader paths.
+   */
+  @Test
+  void fastAndClassicReaderRejectOutOfRangeEnumIndex() throws Exception {
+    Schema schema = Schema.createEnum("E", null, null, Arrays.asList("A", 
"B"));
+    for (boolean fast : new boolean[] { true, false }) {
+      for (int index : new int[] { 9, -1 }) {
+        GenericDatumReader<Object> reader = readerFor(schema, fast);
+        byte[] data = encodeVarints(index);
+        BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(data, null);
+        assertThrows(org.apache.avro.AvroTypeException.class, () -> 
reader.read(null, decoder),
+            "fastReader=" + fast + " index=" + index);
+      }
+    }
+  }
+
+  /**
+   * A legitimate array of nulls within the limit still decodes on both reader
+   * paths.
+   */
+  @Test
+  void fastAndClassicReaderDecodeSmallNullArray() throws Exception {
+    for (boolean fast : new boolean[] { true, false }) {
+      GenericDatumReader<Object> reader = 
arrayReader(Schema.create(Schema.Type.NULL), fast);
+      byte[] data = encodeVarints(1000L, 0L);
+      BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(data, null);
+      Collection<?> result = (Collection<?>) reader.read(null, decoder);
+      assertEquals(1000, result.size(), "fastReader=" + fast);
+    }
+  }
+
+  /**
+   * Skipping a huge zero-byte array (e.g. during schema projection) is 
bounded by
+   * the heap-aware allocation cap, so it cannot loop unboundedly even though 
it
+   * reads nothing.
+   */
+  @Test
+  void skipArrayOfNullRejectsHugeCount() throws Exception {
+    
System.setProperty(SystemLimitException.MAX_COLLECTION_ALLOCATION_PROPERTY, 
"1000");
+    org.apache.avro.TestSystemLimitException.resetLimits();
+    try {
+      Schema schema = Schema.createArray(Schema.create(Schema.Type.NULL));
+      byte[] data = encodeVarints(200_000L, 0L);
+      BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(data, null);
+      assertThrows(SystemLimitException.class, () -> 
GenericDatumReader.skip(schema, decoder));
+    } finally {
+      
System.clearProperty(SystemLimitException.MAX_COLLECTION_ALLOCATION_PROPERTY);
+      org.apache.avro.TestSystemLimitException.resetLimits();
+    }
+  }
+
+  /**
+   * A legitimate small zero-byte array is skipped without error.
+   */
+  @Test
+  void skipSmallNullArraySucceeds() throws Exception {
+    Schema schema = Schema.createArray(Schema.create(Schema.Type.NULL));
+    // 1000 nulls then the end marker, followed by a trailing long we can read 
to
+    // confirm the skip advanced correctly.
+    byte[] data = encodeVarints(1000L, 0L, 42L);
+    BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(data, null);
+    GenericDatumReader.skip(schema, decoder);
+    assertEquals(42L, decoder.readLong());
+  }
+
+  /**
+   * Skipping a huge map is bounded by the structural collection cap.
+   */
+  @Test
+  void skipMapRejectsHugeCount() throws Exception {
+    Schema schema = Schema.createMap(Schema.create(Schema.Type.NULL));
+    // A single block declaring more than Integer.MAX_VALUE - 8 entries.
+    byte[] data = encodeVarints((long) Integer.MAX_VALUE, 0L);
+    BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(data, null);
+    // Integer.MAX_VALUE exceeds MAX_ARRAY_VM_LIMIT, so this hits the VM
+    // structural-limit path (an UnsupportedOperationException).
+    assertThrows(UnsupportedOperationException.class, () -> 
GenericDatumReader.skip(schema, decoder));
+  }
+
+  /**
+   * A writer array field that the reader schema omits is skipped during
+   * resolution through the decoder's skipArray; a huge count must be bounded 
on
+   * both the fast and classic reader paths.
+   */
+  @Test
+  void resolvingSkipOfHugeNullArrayFieldIsBounded() throws Exception {
+    System.setProperty(SystemLimitException.MAX_COLLECTION_LENGTH_PROPERTY, 
"1000");
+    org.apache.avro.TestSystemLimitException.resetLimits();
+    try {
+      Schema writer = new 
Schema.Parser().parse("{\"type\":\"record\",\"name\":\"R\",\"fields\":["
+          + 
"{\"name\":\"arr\",\"type\":{\"type\":\"array\",\"items\":\"null\"}},"
+          + "{\"name\":\"a\",\"type\":\"long\"}]}");
+      Schema reader = new Schema.Parser()
+          
.parse("{\"type\":\"record\",\"name\":\"R\",\"fields\":[{\"name\":\"a\",\"type\":\"long\"}]}");
+      byte[] data = encodeVarints(2000L, 0L, 42L);
+      for (boolean fast : new boolean[] { true, false }) {
+        GenericData data2 = new GenericData();
+        data2.setFastReaderEnabled(fast);
+        GenericDatumReader<Object> r = new GenericDatumReader<>(writer, 
reader, data2);
+        BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(data, null);
+        assertThrows(SystemLimitException.class, () -> r.read(null, decoder), 
"fastReader=" + fast);
+      }
+    } finally {
+      
System.clearProperty(SystemLimitException.MAX_COLLECTION_LENGTH_PROPERTY);
+      org.apache.avro.TestSystemLimitException.resetLimits();
+    }
+  }
 }

Reply via email to