This is an automated email from the ASF dual-hosted git repository.
RyanSkraba pushed a commit to branch branch-1.12
in repository https://gitbox.apache.org/repos/asf/avro.git
The following commit(s) were added to refs/heads/branch-1.12 by this push:
new c702676903 AVRO-4296: [python] Bound allocation when decoding
length-prefixed values and collections (#3861)
c702676903 is described below
commit c702676903c38680171f41fa95d4e5c56d01efff
Author: Ismaël Mejía <[email protected]>
AuthorDate: Thu Aug 6 18:24:13 2026 +0200
AVRO-4296: [python] Bound allocation when decoding length-prefixed values
and collections (#3861)
When decoding an array or map, DatumReader.read_array/read_map used the
block
count read from the (potentially malformed or truncated) input directly as a
loop counter, and the length-prefixed byte/string readers allocated from an
unchecked length. A small payload could therefore declare a very large
count or
length and drive an unbounded allocation before any element bytes were
present.
This bounds those allocations, mirroring the Java SDK's two-limit approach:
- Length-prefixed values and collection blocks are validated against the
number
of bytes actually remaining in the input (when the reader is seekable)
before
allocating, so a truncated payload fails fast instead of over-allocating.
- Element types whose minimum encoded size is zero (null, a zero-length
fixed,
or a record whose fields are all zero-byte) cannot be bounded by the bytes
remaining, so their cumulative count is capped by a separate configurable
limit (AVRO_MAX_COLLECTION_ITEMS). A structural cap of Integer.MAX_VALUE
- 8
applies to all collections as defense in depth.
It also hardens related decode paths surfaced by this work:
overlong/overflowing
varints are rejected, negative skips and out-of-range union/enum indices are
rejected, sized-block skips validate their byte size, and bytes_remaining()
restores the reader position and degrades gracefully on non-seekable
readers.
Reading malformed or truncated input now fails fast with a clear
AvroCollectionSizeException or InvalidAvroBinaryEncoding; valid data reads
unchanged.
---
lang/py/avro/errors.py | 9 +
lang/py/avro/io.py | 328 ++++++++++++++++++++++++++++-
lang/py/avro/test/test_io.py | 485 +++++++++++++++++++++++++++++++++++++++++++
3 files changed, 814 insertions(+), 8 deletions(-)
diff --git a/lang/py/avro/errors.py b/lang/py/avro/errors.py
index b961a04ae4..3939a364fa 100644
--- a/lang/py/avro/errors.py
+++ b/lang/py/avro/errors.py
@@ -36,6 +36,15 @@ class InvalidAvroBinaryEncoding(AvroException):
"""For invalid numbers of bytes read."""
+class AvroCollectionSizeException(AvroException):
+ """Raised when a decoded array or map would exceed a collection size limit.
+
+ This covers both the zero-byte-element cap (the cumulative number of
+ zero-byte elements such as ``null``) and the structural cap on the total
+ number of elements in any collection.
+ """
+
+
class SchemaParseException(AvroException):
"""Raised when a schema failed to parse."""
diff --git a/lang/py/avro/io.py b/lang/py/avro/io.py
index d0d9a738eb..a67230f073 100644
--- a/lang/py/avro/io.py
+++ b/lang/py/avro/io.py
@@ -87,9 +87,10 @@ in that datum, if there are any.
import collections
import datetime
import decimal
+import os
import struct
import warnings
-from typing import IO, Generator, Iterable, List, Mapping, Optional, Sequence,
Union
+from typing import IO, Dict, Generator, Iterable, List, Mapping, Optional,
Sequence, Set, Tuple, Union
import avro.constants
import avro.errors
@@ -198,6 +199,13 @@ class BinaryDecoder:
_reader: IO[bytes]
+ # Reads with a declared length above this many bytes are validated against
+ # the number of bytes actually remaining (when the reader is seekable)
+ # before allocating, to guard against an out-of-memory attack from a
+ # malicious or truncated input. Smaller reads skip the check to avoid
+ # per-value overhead, since they cannot cause a meaningful over-allocation.
+ _MAX_UNCHECKED_READ = 1024 * 1024
+
def __init__(self, reader: IO[bytes]) -> None:
"""
reader is a Python object on which we can call read, seek, and tell.
@@ -214,11 +222,46 @@ class BinaryDecoder:
"""
if n < 0:
raise avro.errors.InvalidAvroBinaryEncoding(f"Requested {n} bytes
to read, expected positive integer.")
+ if n > self._MAX_UNCHECKED_READ:
+ remaining = self.bytes_remaining()
+ if remaining is not None and n > remaining:
+ raise avro.errors.InvalidAvroBinaryEncoding(f"Requested {n}
bytes to read, but only {remaining} remain.")
read_bytes = self.reader.read(n)
if len(read_bytes) != n:
raise avro.errors.InvalidAvroBinaryEncoding(f"Read
{len(read_bytes)} bytes, expected {n} bytes")
return read_bytes
+ def bytes_remaining(self) -> Optional[int]:
+ """
+ Return the number of bytes still available to read, or ``None`` when
+ that count is not known (a non-seekable reader, or one whose position
+ cannot be obtained). Used to reject a declared length or collection
+ block count that exceeds the data actually available before allocating
+ for it.
+ """
+ reader = self.reader
+ try:
+ pos = reader.tell()
+ except (OSError, ValueError, AttributeError, TypeError):
+ # Not seekable, or the position could not be determined.
+ return None
+ try:
+ reader.seek(0, os.SEEK_END)
+ end = reader.tell()
+ # Clamp to 0: a stream positioned past its end (seekable file-like
+ # objects allow seeking beyond EOF) would otherwise report a
negative
+ # "remaining", violating the non-negative contract.
+ return max(0, end - pos)
+ except (OSError, ValueError, AttributeError, TypeError):
+ return None
+ finally:
+ # Always restore the original position, even if seeking to the end
+ # or reading it failed, so the reader is never left at EOF.
+ try:
+ reader.seek(pos)
+ except (OSError, ValueError, AttributeError, TypeError):
+ pass
+
def read_null(self) -> None:
"""
null is written as zero bytes
@@ -246,7 +289,17 @@ class BinaryDecoder:
n = b & 0x7F
shift = 7
while (b & 0x80) != 0:
+ # A 64-bit value needs at most 10 bytes (shifts 0..63); reject an
+ # overlong varint rather than accepting a malformed, arbitrarily
+ # large value.
+ if shift >= 70:
+ raise avro.errors.InvalidAvroBinaryEncoding("Varint is too
long")
b = ord(self.read(1))
+ # The 10th byte (shift == 63) contributes only bit 63; any higher
+ # payload bit would push the value outside the 64-bit range, so a
+ # valid zig-zag long must have them clear.
+ if shift == 63 and (b & 0x7E) != 0:
+ raise avro.errors.InvalidAvroBinaryEncoding("Varint is too
long")
n |= (b & 0x7F) << shift
shift += 7
datum = (n >> 1) ^ -(n & 1)
@@ -383,8 +436,21 @@ class BinaryDecoder:
def skip_long(self) -> None:
b = ord(self.read(1))
+ shift = 7
while (b & 0x80) != 0:
+ # A 64-bit varint is at most 10 bytes; reject an overlong chain so
a
+ # skipped long can't force scanning unbounded input (read_long caps
+ # the same way).
+ if shift >= 70:
+ raise avro.errors.InvalidAvroBinaryEncoding("Varint is too
long")
b = ord(self.read(1))
+ # The 10th byte (shift == 63) contributes only bit 63; any higher
+ # payload bit would push the value outside the 64-bit range. Reject
+ # it here too so skip_long enforces the same validity as read_long
+ # (e.g. a malformed negative-block byte-size in
read_array/read_map).
+ if shift == 63 and (b & 0x7E) != 0:
+ raise avro.errors.InvalidAvroBinaryEncoding("Varint is too
long")
+ shift += 7
def skip_float(self) -> None:
self.skip(4)
@@ -393,12 +459,26 @@ class BinaryDecoder:
self.skip(8)
def skip_bytes(self) -> None:
- self.skip(self.read_long())
+ # The length prefix is attacker-controlled: an oversized value would
seek
+ # past EOF (a negative one is rejected by ``skip`` below, which is the
+ # single place that guards backward seeks). Validate an oversized
length
+ # against the bytes remaining the same way ``read`` does before
skipping.
+ n = self.read_long()
+ if n > self._MAX_UNCHECKED_READ:
+ remaining = self.bytes_remaining()
+ if remaining is not None and n > remaining:
+ raise avro.errors.InvalidAvroBinaryEncoding(f"Requested {n}
bytes to skip, but only {remaining} remain.")
+ self.skip(n)
def skip_utf8(self) -> None:
self.skip_bytes()
def skip(self, n: int) -> None:
+ # Guard against a negative skip (a backward seek), which would corrupt
+ # the decoder position. Callers pass schema-derived sizes (fixed/float/
+ # double) or already-validated lengths, so this is a defensive
backstop.
+ if n < 0:
+ raise avro.errors.InvalidAvroBinaryEncoding(f"Cannot skip a
negative number of bytes: {n}")
self.reader.seek(self.reader.tell() + n)
@@ -599,6 +679,118 @@ class BinaryEncoder:
#
# DatumReader/Writer
#
+
+# Environment variable overriding the collection element limits. When set to a
+# non-negative integer it caps both the number of zero-byte-encoded collection
+# elements (e.g. an array of nulls) and the structural cap on the total number
of
+# elements in any collection, allocated from a single decode.
+MAX_COLLECTION_ITEMS_ENV = "AVRO_MAX_COLLECTION_ITEMS"
+
+# Default maximum number of zero-byte-encoded collection elements to allocate.
+# Elements whose schema encodes to zero bytes (``null``, a zero-length
``fixed``,
+# or a record with only zero-byte fields) consume no input, so the
bytes-remaining
+# check cannot bound their count; without a cap a tiny payload can declare a
huge
+# block count and exhaust memory. A legitimate collection of zero-byte
elements is
+# small, so this default is generous while still rejecting pathological input.
It
+# can be raised (or lowered) with the ``AVRO_MAX_COLLECTION_ITEMS`` environment
+# variable.
+DEFAULT_MAX_COLLECTION_ITEMS = 10_000_000
+
+# Default structural cap on the number of elements in any array or map (a
+# collection larger than this is treated as malformed regardless of element
+# type). Matches the historical ``Integer.MAX_VALUE - 8`` limit. Elements with
a
+# positive on-wire size are also bounded by the bytes remaining; this cap is an
+# additional overflow/defense-in-depth guard. ``AVRO_MAX_COLLECTION_ITEMS``,
when
+# set, overrides both this and ``DEFAULT_MAX_COLLECTION_ITEMS``.
+DEFAULT_MAX_COLLECTION_STRUCTURAL = (1 << 31) - 1 - 8 # Integer.MAX_VALUE - 8
+
+# Block counts at or below this are not checked against the bytes remaining. A
+# collection element with a positive on-wire size must be backed by real bytes,
+# so a small block cannot over-allocate meaningfully; skipping the check
avoids a
+# per-block ``bytes_remaining()`` seek (tell + seek-to-end + seek-back), which
is
+# not free on a real file object. Mirrors
``BinaryDecoder._MAX_UNCHECKED_READ``.
+# The structural cap below is always enforced (no seek), and zero-byte elements
+# keep their own cumulative cap.
+_MAX_UNCHECKED_COLLECTION = 1024
+
+
+def _collection_limits() -> Tuple[int, int]:
+ """Return ``(zero_byte_limit, structural_limit)``.
+
+ ``AVRO_MAX_COLLECTION_ITEMS``, when set to a non-negative integer, pins
*both*
+ limits to that single value. The coupling runs in both directions: raising
it
+ to lift the zero-byte-element limit also lowers the structural cap from
+ ``DEFAULT_MAX_COLLECTION_STRUCTURAL`` (~2.1 billion) to that same value,
and
+ lowering it tightens both. Set it above
``DEFAULT_MAX_COLLECTION_STRUCTURAL``
+ if you need to raise the zero-byte limit without reducing the structural
cap.
+ When unset, zero-byte elements use the tighter
``DEFAULT_MAX_COLLECTION_ITEMS``
+ and all collections use ``DEFAULT_MAX_COLLECTION_STRUCTURAL``.
+ """
+ value = os.environ.get(MAX_COLLECTION_ITEMS_ENV)
+ if value is None:
+ return DEFAULT_MAX_COLLECTION_ITEMS, DEFAULT_MAX_COLLECTION_STRUCTURAL
+ try:
+ parsed = int(value)
+ except ValueError:
+ warnings.warn(avro.errors.AvroWarning(f"Ignoring invalid
{MAX_COLLECTION_ITEMS_ENV} value: {value!r}"))
+ return DEFAULT_MAX_COLLECTION_ITEMS, DEFAULT_MAX_COLLECTION_STRUCTURAL
+ if parsed < 0:
+ warnings.warn(avro.errors.AvroWarning(f"Ignoring negative
{MAX_COLLECTION_ITEMS_ENV} value: {value!r}"))
+ return DEFAULT_MAX_COLLECTION_ITEMS, DEFAULT_MAX_COLLECTION_STRUCTURAL
+ return parsed, parsed
+
+
+def _max_collection_items() -> int:
+ """Return the configured zero-byte-element collection limit."""
+ return _collection_limits()[0]
+
+
+def _min_bytes_per_element(schema: avro.schema.Schema, visited:
Optional[Set[int]] = None) -> int:
+ """
+ Return the minimum number of bytes a single value of ``schema`` can occupy
+ on the wire. Used to reject an array/map block count that could not
possibly
+ be backed by the bytes remaining. A type that can encode to zero bytes
+ (``null``) returns 0, which disables the collection check for it (avoiding
a
+ false positive on, e.g., an array of nulls).
+ """
+ if visited is None:
+ visited = set()
+ schema_type = schema.type
+ if schema_type == "null":
+ return 0
+ if schema_type == "float":
+ return 4
+ if schema_type == "double":
+ return 8
+ if schema_type == "fixed":
+ return getattr(schema, "size", 1)
+ if schema_type in ("record", "error"):
+ # Guard against self-referencing records (recursion would not
terminate).
+ # A recursive reference is not a zero-byte value: any finite recursive
+ # value must terminate through a union (>= 1 byte branch index) or an
+ # empty array/map (>= 1 byte block count), so 1 is a safe conservative
+ # lower bound. Returning 0 here would wrongly treat recursive records
as
+ # zero-byte elements and weaken the bytes-remaining precheck.
+ if id(schema) in visited:
+ return 1
+ visited.add(id(schema))
+ total = 0
+ for field in getattr(schema, "fields", []):
+ total += _min_bytes_per_element(field.type, visited)
+ visited.discard(id(schema))
+ return total
+ if schema_type == "union":
+ # A union encodes a >= 1 byte branch index plus the selected branch's
+ # payload, so its minimum is 1 + the smallest branch minimum (which is
+ # 1 when any branch is null / zero-byte).
+ branches = getattr(schema, "schemas", [])
+ if not branches:
+ return 1
+ return 1 + min(_min_bytes_per_element(branch, visited) for branch in
branches)
+ # boolean, int, long, bytes, string, enum, array, map: all >= 1 byte.
+ return 1
+
+
class DatumReader:
"""Deserialize Avro-encoded data into a Python data structure."""
@@ -765,7 +957,14 @@ class DatumReader:
"""
# read data
index_of_symbol = decoder.read_int()
+ if index_of_symbol < 0:
+ # A negative index is malformed data and never resolves to a
default.
+ raise avro.errors.SchemaResolutionException(
+ f"Can't access enum index {index_of_symbol} for enum with
{len(writers_schema.symbols)} symbols", writers_schema, readers_schema
+ )
if index_of_symbol >= len(writers_schema.symbols):
+ # A symbol beyond the writer's schema may be a future symbol; fall
+ # back to the schema's default when one is defined.
default = writers_schema.default
if default is not None:
return default
@@ -783,6 +982,54 @@ class DatumReader:
def skip_enum(self, writers_schema: avro.schema.EnumSchema, decoder:
BinaryDecoder) -> None:
return decoder.skip_int()
+ @staticmethod
+ def _ensure_collection_available(
+ decoder: BinaryDecoder,
+ existing: int,
+ count: int,
+ min_bytes_per_element: int,
+ zero_byte_limit: int,
+ structural_limit: int,
+ ) -> None:
+ """
+ Reject a collection (array or map) block that could not be backed by
the
+ input, before iterating.
+
+ For elements with a positive minimum on-wire size, the declared count
is
+ checked against the bytes actually remaining and against a structural
+ limit (an overflow/defense-in-depth cap). For zero-byte elements (e.g.
an
+ array of nulls), which consume no input and so cannot be bounded by the
+ bytes remaining, the cumulative count is checked against the (tighter)
+ zero-byte limit.
+ """
+ if count <= 0:
+ return
+ if min_bytes_per_element > 0:
+ # Only pay for the bytes_remaining() seek on a large block: a small
+ # block of positive-size elements must be backed by real bytes on
the
+ # wire and so cannot over-allocate meaningfully (see
+ # _MAX_UNCHECKED_COLLECTION). The structural cap below is always
+ # enforced without a seek.
+ if count > _MAX_UNCHECKED_COLLECTION:
+ remaining = decoder.bytes_remaining()
+ # Compare via integer division rather than multiplying, so an
+ # attacker-controlled (unbounded) count does not create a huge
+ # intermediate product.
+ if remaining is not None and count > remaining //
min_bytes_per_element:
+ raise avro.errors.InvalidAvroBinaryEncoding(
+ f"Collection claims {count} elements with at least
{min_bytes_per_element} bytes each, but only {remaining} bytes are available."
+ )
+ if existing + count > structural_limit:
+ raise avro.errors.AvroCollectionSizeException(
+ f"Cannot read a collection of more than {structural_limit}
elements "
+ f"(declared {existing + count}); raise the
{MAX_COLLECTION_ITEMS_ENV} limit if this is legitimate."
+ )
+ elif existing + count > zero_byte_limit:
+ raise avro.errors.AvroCollectionSizeException(
+ f"Cannot read a collection of more than {zero_byte_limit}
zero-byte elements "
+ f"(declared {existing + count}); raise the
{MAX_COLLECTION_ITEMS_ENV} limit if this is legitimate."
+ )
+
def read_array(self, writers_schema: avro.schema.ArraySchema,
readers_schema: avro.schema.ArraySchema, decoder: BinaryDecoder) ->
List[object]:
"""
Arrays are encoded as a series of blocks.
@@ -798,23 +1045,69 @@ class DatumReader:
The actual count in this case
is the absolute value of the count written.
"""
- read_items = []
+ read_items: List[object] = []
+ min_bytes = _min_bytes_per_element(writers_schema.items)
+ zero_byte_limit, structural_limit = _collection_limits()
block_count = decoder.read_long()
while block_count != 0:
if block_count < 0:
block_count = -block_count
decoder.skip_long()
+ self._ensure_collection_available(decoder, len(read_items),
block_count, min_bytes, zero_byte_limit, structural_limit)
for i in range(block_count):
read_items.append(self.read_data(writers_schema.items,
readers_schema.items, decoder))
block_count = decoder.read_long()
return read_items
+ @staticmethod
+ def _skip_block_bytes(decoder: BinaryDecoder, block_size: int,
block_count: int, min_bytes: int) -> None:
+ """Skip a sized-block's byte count, rejecting malformed sizes.
+
+ The block_size is attacker-controlled: a negative value would seek
+ backwards and an oversized value past EOF, either corrupting the
decoder
+ position. Also require that block_size can plausibly hold block_count
+ elements at their minimum on-wire size, so a too-small size cannot
+ misalign the decoder. A zero-byte element type (``null``, a zero-length
+ ``fixed``, or a record of only zero-byte fields) encodes to exactly 0
+ bytes, so its block payload must be empty; a positive size would skip
+ into the following fields. Reject before skipping.
+ """
+ if block_size < 0:
+ raise avro.errors.InvalidAvroBinaryEncoding(f"Invalid negative
block size: {block_size}")
+ remaining = decoder.bytes_remaining()
+ if remaining is not None and block_size > remaining:
+ raise avro.errors.InvalidAvroBinaryEncoding(f"Block size
{block_size} exceeds the {remaining} bytes remaining")
+ if min_bytes > 0:
+ if block_count > block_size // min_bytes:
+ raise avro.errors.InvalidAvroBinaryEncoding(
+ f"Block size {block_size} is too small for {block_count}
elements of >= {min_bytes} bytes"
+ )
+ elif block_size != 0:
+ raise avro.errors.InvalidAvroBinaryEncoding(f"Block size
{block_size} must be 0 for a zero-byte element type")
+ try:
+ decoder.skip(block_size)
+ except (OSError, ValueError, OverflowError, AttributeError, TypeError)
as e:
+ # The underlying reader can reject the seek (oversized offset,
invalid
+ # seek, or other IO error), or lack seek()/tell() entirely;
surface it
+ # as an Avro decoding error rather than leaking a non-Avro
exception.
+ raise avro.errors.InvalidAvroBinaryEncoding(f"Cannot skip block of
{block_size} bytes: {e}") from e
+
def skip_array(self, writers_schema: avro.schema.ArraySchema, decoder:
BinaryDecoder) -> None:
+ min_bytes = _min_bytes_per_element(writers_schema.items)
+ zero_byte_limit, structural_limit = _collection_limits()
+ items_skipped = 0
block_count = decoder.read_long()
while block_count != 0:
+ block_size = None
if block_count < 0:
+ block_count = -block_count
block_size = decoder.read_long()
- decoder.skip(block_size)
+ # Bound the (normalized) count on both the sized and unsized paths
so
+ # a negative block count cannot bypass the collection limits.
+ self._ensure_collection_available(decoder, items_skipped,
block_count, min_bytes, zero_byte_limit, structural_limit)
+ items_skipped += block_count
+ if block_size is not None:
+ self._skip_block_bytes(decoder, block_size, block_count,
min_bytes)
else:
for i in range(block_count):
self.skip_data(writers_schema.items, decoder)
@@ -835,12 +1128,21 @@ class DatumReader:
The actual count in this case
is the absolute value of the count written.
"""
- read_items = {}
+ read_items: Dict[str, object] = {}
+ # Map keys are strings (>= 1 byte length prefix) plus the value.
+ min_bytes = 1 + _min_bytes_per_element(writers_schema.values)
+ zero_byte_limit, structural_limit = _collection_limits()
+ # Track decoded pairs separately: len(read_items) counts unique keys,
so
+ # duplicate keys (later entries overwrite earlier ones) would
undercount
+ # and let a multi-block map exceed the cumulative caps.
+ items_read = 0
block_count = decoder.read_long()
while block_count != 0:
if block_count < 0:
block_count = -block_count
decoder.skip_long()
+ self._ensure_collection_available(decoder, items_read,
block_count, min_bytes, zero_byte_limit, structural_limit)
+ items_read += block_count
for i in range(block_count):
key = decoder.read_utf8()
read_items[key] = self.read_data(writers_schema.values,
readers_schema.values, decoder)
@@ -848,11 +1150,21 @@ class DatumReader:
return read_items
def skip_map(self, writers_schema: avro.schema.MapSchema, decoder:
BinaryDecoder) -> None:
+ min_bytes = 1 + _min_bytes_per_element(writers_schema.values)
+ zero_byte_limit, structural_limit = _collection_limits()
+ items_skipped = 0
block_count = decoder.read_long()
while block_count != 0:
+ block_size = None
if block_count < 0:
+ block_count = -block_count
block_size = decoder.read_long()
- decoder.skip(block_size)
+ # Bound the (normalized) count on both the sized and unsized paths
so
+ # a negative block count cannot bypass the collection limits.
+ self._ensure_collection_available(decoder, items_skipped,
block_count, min_bytes, zero_byte_limit, structural_limit)
+ items_skipped += block_count
+ if block_size is not None:
+ self._skip_block_bytes(decoder, block_size, block_count,
min_bytes)
else:
for i in range(block_count):
decoder.skip_utf8()
@@ -867,7 +1179,7 @@ class DatumReader:
"""
# schema resolution
index_of_schema = int(decoder.read_long())
- if index_of_schema >= len(writers_schema.schemas):
+ if index_of_schema < 0 or index_of_schema >=
len(writers_schema.schemas):
raise avro.errors.SchemaResolutionException(
f"Can't access branch index {index_of_schema} for union with
{len(writers_schema.schemas)} branches", writers_schema, readers_schema
)
@@ -878,7 +1190,7 @@ class DatumReader:
def skip_union(self, writers_schema: avro.schema.UnionSchema, decoder:
BinaryDecoder) -> None:
index_of_schema = int(decoder.read_long())
- if index_of_schema >= len(writers_schema.schemas):
+ if index_of_schema < 0 or index_of_schema >=
len(writers_schema.schemas):
raise avro.errors.SchemaResolutionException(
f"Can't access branch index {index_of_schema} for union with
{len(writers_schema.schemas)} branches", writers_schema
)
diff --git a/lang/py/avro/test/test_io.py b/lang/py/avro/test/test_io.py
index 41d7f827cf..9ee39b1937 100644
--- a/lang/py/avro/test/test_io.py
+++ b/lang/py/avro/test/test_io.py
@@ -23,7 +23,9 @@ import decimal
import io
import itertools
import json
+import os
import unittest
+import unittest.mock
import uuid
import warnings
from typing import BinaryIO, Collection, Dict, List, Optional, Tuple, Union,
cast
@@ -550,6 +552,209 @@ class TestIncompatibleSchemaReading(unittest.TestCase):
)
+class TestBinaryDecoderAvailableBytes(unittest.TestCase):
+ """A bytes/string value declares a length prefix; a malicious or truncated
+ input can declare far more bytes than actually exist. On a seekable reader
+ that is rejected before allocating for it."""
+
+ @staticmethod
+ def _encode_length_prefix(length: int) -> bytes:
+ buf = io.BytesIO()
+ avro.io.BinaryEncoder(buf).write_long(length)
+ return buf.getvalue()
+
+ def test_read_rejects_length_beyond_stream(self) -> None:
+ # Declares 100 MiB but provides no data.
+ prefix = self._encode_length_prefix(100 * 1024 * 1024)
+ with io.BytesIO(prefix) as bio:
+ decoder = avro.io.BinaryDecoder(bio)
+ self.assertRaises(avro.errors.InvalidAvroBinaryEncoding,
decoder.read_bytes)
+
+ def test_read_within_stream_still_reads(self) -> None:
+ # A well-formed large value whose data is actually present still reads.
+ payload = b"x" * (2 * 1024 * 1024)
+ buf = io.BytesIO()
+ avro.io.BinaryEncoder(buf).write_bytes(payload)
+ with io.BytesIO(buf.getvalue()) as bio:
+ decoder = avro.io.BinaryDecoder(bio)
+ self.assertEqual(decoder.read_bytes(), payload)
+
+ def test_bytes_remaining_restores_position(self) -> None:
+ # bytes_remaining() must leave the reader position unchanged.
+ with io.BytesIO(b"abcdefghij") as bio:
+ bio.seek(3)
+ decoder = avro.io.BinaryDecoder(bio)
+ self.assertEqual(decoder.bytes_remaining(), 7)
+ self.assertEqual(bio.tell(), 3)
+
+ def test_bytes_remaining_restores_position_on_error(self) -> None:
+ # If reading the end offset fails after seeking, the original position
+ # must still be restored (via the finally block).
+ class FailingEndStream(io.BytesIO):
+ def seek(self, offset: int, whence: int = os.SEEK_SET) -> int:
+ # Fail only when seeking to the end.
+ if whence == os.SEEK_END:
+ raise OSError("cannot seek to end")
+ return super().seek(offset, whence)
+
+ stream = FailingEndStream(b"abcdefghij")
+ stream.seek(4)
+ decoder = avro.io.BinaryDecoder(stream)
+ self.assertIsNone(decoder.bytes_remaining())
+ self.assertEqual(stream.tell(), 4)
+
+ def test_read_non_seekable_falls_back(self) -> None:
+ # A non-seekable reader skips the pre-check, but the post-read length
+ # check still rejects a truncated oversized value.
+ prefix = self._encode_length_prefix(100 * 1024 * 1024)
+
+ class NonSeekable:
+ def __init__(self, data: bytes) -> None:
+ self._bio = io.BytesIO(data)
+
+ def read(self, n: int = -1) -> bytes:
+ return self._bio.read(n)
+
+ def seekable(self) -> bool:
+ return False
+
+ decoder = avro.io.BinaryDecoder(NonSeekable(prefix)) # type:
ignore[arg-type]
+ self.assertRaises(avro.errors.InvalidAvroBinaryEncoding,
decoder.read_bytes)
+
+ def test_skip_bytes_rejects_negative_length(self) -> None:
+ # A negative length prefix on a skipped bytes/string field would seek
+ # backwards; it must be rejected rather than corrupting the position.
+ prefix = self._encode_length_prefix(-5)
+ with io.BytesIO(prefix + b"payload") as bio:
+ decoder = avro.io.BinaryDecoder(bio)
+ self.assertRaises(avro.errors.InvalidAvroBinaryEncoding,
decoder.skip_bytes)
+
+ def test_skip_bytes_rejects_length_beyond_stream(self) -> None:
+ # A skipped bytes/string field declaring far more bytes than remain is
+ # rejected on a seekable reader before seeking past EOF.
+ prefix = self._encode_length_prefix(100 * 1024 * 1024)
+ with io.BytesIO(prefix) as bio:
+ decoder = avro.io.BinaryDecoder(bio)
+ self.assertRaises(avro.errors.InvalidAvroBinaryEncoding,
decoder.skip_bytes)
+
+ def test_skip_bytes_within_stream_still_skips(self) -> None:
+ # A well-formed skipped value advances the position past its data.
+ buf = io.BytesIO()
+ avro.io.BinaryEncoder(buf).write_bytes(b"hello")
+ trailer = b"AFTER"
+ with io.BytesIO(buf.getvalue() + trailer) as bio:
+ decoder = avro.io.BinaryDecoder(bio)
+ decoder.skip_bytes()
+ self.assertEqual(bio.read(), trailer)
+
+ def test_skip_rejects_negative(self) -> None:
+ # The low-level skip() backstop rejects a negative byte count.
+ with io.BytesIO(b"abcdef") as bio:
+ decoder = avro.io.BinaryDecoder(bio)
+ self.assertRaises(avro.errors.InvalidAvroBinaryEncoding,
decoder.skip, -1)
+
+
+class TestDatumReaderCollectionAvailableBytes(unittest.TestCase):
+ """An array/map block declares an element count; a malicious or truncated
+ input can declare far more elements than the remaining bytes could hold.
+ The count is validated against the bytes remaining before iterating, using
+ the minimum on-wire size of the element schema (so 0-byte elements such as
+ ``null`` are not falsely rejected)."""
+
+ @staticmethod
+ def _decode(schema_json: str, encoded: bytes) -> object:
+ schema = avro.schema.parse(schema_json)
+ reader = avro.io.DatumReader(schema)
+ with io.BytesIO(encoded) as bio:
+ return reader.read(avro.io.BinaryDecoder(bio))
+
+ def test_array_rejects_count_beyond_stream(self) -> None:
+ # A long array block count (1,000,000 long elements) with no element
+ # data following it.
+ buf = io.BytesIO()
+ avro.io.BinaryEncoder(buf).write_long(1_000_000)
+ self.assertRaises(
+ avro.errors.InvalidAvroBinaryEncoding,
+ self._decode,
+ '{"type": "array", "items": "long"}',
+ buf.getvalue(),
+ )
+
+ def test_map_rejects_count_beyond_stream(self) -> None:
+ buf = io.BytesIO()
+ avro.io.BinaryEncoder(buf).write_long(1_000_000)
+ self.assertRaises(
+ avro.errors.InvalidAvroBinaryEncoding,
+ self._decode,
+ '{"type": "map", "values": "long"}',
+ buf.getvalue(),
+ )
+
+ def test_array_of_null_not_falsely_rejected(self) -> None:
+ # null elements occupy zero bytes, so a large count is legitimate and
+ # must not be rejected.
+ count = 100_000
+ buf = io.BytesIO()
+ enc = avro.io.BinaryEncoder(buf)
+ enc.write_long(count) # one block of `count` nulls
+ enc.write_long(0) # end-of-array marker
+ result = self._decode('{"type": "array", "items": "null"}',
buf.getvalue())
+ self.assertEqual(result, [None] * count)
+
+ def test_small_truncated_array_still_rejected(self) -> None:
+ # A block count at or below _MAX_UNCHECKED_COLLECTION skips the
+ # bytes_remaining() pre-check (a perf optimization), but the elements
+ # still have to be read: a truncated small collection must not slip
+ # through. Here 10 longs are declared with no element bytes following.
+ self.assertLessEqual(10, avro.io._MAX_UNCHECKED_COLLECTION)
+ buf = io.BytesIO()
+ avro.io.BinaryEncoder(buf).write_long(10)
+ self.assertRaises(
+ avro.errors.InvalidAvroBinaryEncoding,
+ self._decode,
+ '{"type": "array", "items": "long"}',
+ buf.getvalue(),
+ )
+
+ def test_array_within_stream_still_reads(self) -> None:
+ schema_json = '{"type": "array", "items": "long"}'
+ schema = avro.schema.parse(schema_json)
+ buf = io.BytesIO()
+ writer = avro.io.DatumWriter(schema)
+ writer.write([1, 2, 3], avro.io.BinaryEncoder(buf))
+ self.assertEqual(self._decode(schema_json, buf.getvalue()), [1, 2, 3])
+
+ def test_recursive_record_min_bytes_is_non_zero(self) -> None:
+ # A recursive record reference is not a zero-byte value: returning 0
for
+ # it would wrongly treat recursive records as zero-byte elements and
+ # weaken the bytes-remaining precheck. The minimum must stay >= 1.
+ schema = avro.schema.parse(
+ json.dumps(
+ {
+ "type": "record",
+ "name": "Node",
+ "fields": [{"name": "next", "type": ["null", "Node"]}],
+ }
+ )
+ )
+ self.assertGreaterEqual(avro.io._min_bytes_per_element(schema), 1)
+ # A union that includes the recursive record must not be underestimated
+ # below the >= 1 byte branch index either.
+ union = avro.schema.parse(
+ json.dumps(
+ [
+ "null",
+ {
+ "type": "record",
+ "name": "Node",
+ "fields": [{"name": "next", "type": ["null", "Node"]}],
+ },
+ ]
+ )
+ )
+ self.assertGreaterEqual(avro.io._min_bytes_per_element(union), 1)
+
+
class TestMisc(unittest.TestCase):
def test_decimal_bytes_small_scale(self) -> None:
"""Avro should raise an AvroTypeException when attempting to write a
decimal with a larger exponent than the schema's scale."""
@@ -598,6 +803,33 @@ class TestMisc(unittest.TestCase):
datum_reader = avro.io.DatumReader(writers_schema, readers_schema)
self.assertRaises(avro.errors.SchemaResolutionException,
datum_reader.read, decoder)
+ def test_union_index_out_of_range(self) -> None:
+ # A union branch index that is negative or >= the number of branches is
+ # malformed and must be rejected before indexing (a negative index
would
+ # otherwise wrap in Python and silently select the wrong branch).
+ schema = avro.schema.parse(json.dumps(["null", "long"]))
+ datum_reader = avro.io.DatumReader(schema)
+ for encoded in (b"\x0a", b"\x01"): # zig-zag long 5, and -1
+ decoder = avro.io.BinaryDecoder(io.BytesIO(encoded))
+ self.assertRaises(avro.errors.SchemaResolutionException,
datum_reader.read, decoder)
+
+ def test_enum_index_out_of_range(self) -> None:
+ # An enum symbol index that is negative or >= the number of symbols is
+ # malformed and must be rejected before indexing.
+ schema = avro.schema.parse(json.dumps({"type": "enum", "name": "E",
"symbols": ["A", "B"]}))
+ datum_reader = avro.io.DatumReader(schema)
+ for encoded in (b"\x12", b"\x01"): # zig-zag int 9, and -1
+ decoder = avro.io.BinaryDecoder(io.BytesIO(encoded))
+ self.assertRaises(avro.errors.SchemaResolutionException,
datum_reader.read, decoder)
+
+ def test_read_long_rejects_overlong_varint(self) -> None:
+ # A 64-bit value uses at most 10 bytes; an 11th continuation byte is
+ # malformed and must be rejected rather than accepted as an arbitrarily
+ # large integer.
+ encoded = b"\x80" * 10 + b"\x01"
+ decoder = avro.io.BinaryDecoder(io.BytesIO(encoded))
+ self.assertRaises(avro.errors.InvalidAvroBinaryEncoding,
decoder.read_long)
+
def test_no_default_value(self) -> None:
writers_schema = LONG_RECORD_SCHEMA
datum_to_write = LONG_RECORD_DATUM
@@ -825,6 +1057,256 @@ class TestMisc(unittest.TestCase):
reader.read(decoder)
+class TestDatumReaderCollectionSizeLimit(unittest.TestCase):
+ """Elements whose schema encodes to zero bytes (``null``, a zero-length
+ ``fixed``, or a record with only zero-byte fields) consume no input, so the
+ bytes-remaining check cannot bound them. A huge declared block count of
such
+ elements is capped so a tiny payload cannot exhaust memory. The limit is
+ configurable via the ``AVRO_MAX_COLLECTION_ITEMS`` environment variable."""
+
+ @staticmethod
+ def _decode(schema_json: str, encoded: bytes) -> object:
+ schema = avro.schema.parse(schema_json)
+ reader = avro.io.DatumReader(schema)
+ with io.BytesIO(encoded) as bio:
+ return reader.read(avro.io.BinaryDecoder(bio))
+
+ @staticmethod
+ def _skip(schema_json: str, encoded: bytes) -> None:
+ schema = avro.schema.parse(schema_json)
+ reader = avro.io.DatumReader(schema)
+ with io.BytesIO(encoded) as bio:
+ reader.skip_data(schema, avro.io.BinaryDecoder(bio))
+
+ @staticmethod
+ def _array_block(count: int, *, negative: bool = False) -> bytes:
+ """One array/map block of ``count`` zero-byte elements + end marker."""
+ buf = io.BytesIO()
+ enc = avro.io.BinaryEncoder(buf)
+ if negative:
+ enc.write_long(-count) # negative count is followed by a block
byte-size
+ enc.write_long(0)
+ else:
+ enc.write_long(count)
+ enc.write_long(0) # end-of-collection marker
+ return buf.getvalue()
+
+ def test_array_of_null_exceeds_default_limit(self) -> None:
+ # The reported exploit: a ~6 byte payload declaring 200,000,000 nulls.
+ self.assertRaises(
+ avro.errors.AvroCollectionSizeException,
+ self._decode,
+ '{"type": "array", "items": "null"}',
+ self._array_block(200_000_000),
+ )
+
+ def test_array_of_null_int64_min_block_count(self) -> None:
+ # INT64_MIN as a block count is the pathological negation case. Python
+ # integers do not overflow, so negating it yields 2**63, which the cap
+ # rejects. INT64_MIN zig-zag encodes as the 10-byte varint below,
+ # followed by a block byte-size (0) that the negative-block path reads.
+ payload = b"\xff\xff\xff\xff\xff\xff\xff\xff\xff\x01\x00"
+ self.assertRaises(
+ avro.errors.AvroCollectionSizeException,
+ self._decode,
+ '{"type": "array", "items": "null"}',
+ payload,
+ )
+
+ def test_array_of_null_within_configured_limit_still_reads(self) -> None:
+ with unittest.mock.patch.dict(os.environ,
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+ result = self._decode('{"type": "array", "items": "null"}',
self._array_block(1000))
+ self.assertEqual(result, [None] * 1000)
+
+ def test_array_of_null_exceeds_configured_limit(self) -> None:
+ with unittest.mock.patch.dict(os.environ,
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+ self.assertRaises(
+ avro.errors.AvroCollectionSizeException,
+ self._decode,
+ '{"type": "array", "items": "null"}',
+ self._array_block(1001),
+ )
+
+ def test_array_of_null_cumulative_across_blocks(self) -> None:
+ # Two blocks of 600 nulls each (1200 > 1000) must be rejected on the
second.
+ buf = io.BytesIO()
+ enc = avro.io.BinaryEncoder(buf)
+ enc.write_long(600)
+ enc.write_long(600)
+ enc.write_long(0)
+ with unittest.mock.patch.dict(os.environ,
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+ self.assertRaises(
+ avro.errors.AvroCollectionSizeException,
+ self._decode,
+ '{"type": "array", "items": "null"}',
+ buf.getvalue(),
+ )
+
+ def test_map_duplicate_keys_counted_cumulatively(self) -> None:
+ # Two blocks of 600 pairs that repeat the SAME key: len(read_items)
would
+ # be 1, so a separate decoded-pair counter is needed to reject 1200 >
1000.
+ buf = io.BytesIO()
+ enc = avro.io.BinaryEncoder(buf)
+ for _ in range(2):
+ enc.write_long(600)
+ for _ in range(600):
+ enc.write_utf8("k") # same key; value is null (zero bytes)
+ enc.write_long(0)
+ with unittest.mock.patch.dict(os.environ,
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+ self.assertRaises(
+ avro.errors.AvroCollectionSizeException,
+ self._decode,
+ '{"type": "map", "values": "null"}',
+ buf.getvalue(),
+ )
+
+ def test_array_of_null_negative_block_count(self) -> None:
+ # A negative count encodes abs(count) elements preceded by a block
size;
+ # after normalization it must still be bounded.
+ with unittest.mock.patch.dict(os.environ,
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+ self.assertRaises(
+ avro.errors.AvroCollectionSizeException,
+ self._decode,
+ '{"type": "array", "items": "null"}',
+ self._array_block(200_000, negative=True),
+ )
+
+ def test_array_of_zero_length_fixed_exceeds_limit(self) -> None:
+ schema_json = '{"type": "array", "items": {"type": "fixed", "name":
"empty", "size": 0}}'
+ with unittest.mock.patch.dict(os.environ,
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+ self.assertRaises(avro.errors.AvroCollectionSizeException,
self._decode, schema_json, self._array_block(2000))
+
+ def test_array_of_all_null_record_exceeds_limit(self) -> None:
+ schema_json = '{"type": "array", "items": {"type": "record", "name":
"R", "fields": [{"name": "n", "type": "null"}]}}'
+ with unittest.mock.patch.dict(os.environ,
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+ self.assertRaises(avro.errors.AvroCollectionSizeException,
self._decode, schema_json, self._array_block(2000))
+
+ def test_map_of_null_rejected_by_available_bytes(self) -> None:
+ # Map entries always carry a >= 1 byte key, so a huge map<null> is
bounded
+ # by the bytes-remaining check rather than the zero-byte cap.
+ self.assertRaises(
+ avro.errors.InvalidAvroBinaryEncoding,
+ self._decode,
+ '{"type": "map", "values": "null"}',
+ self._array_block(200_000_000),
+ )
+
+ def test_skip_array_of_null_respects_limit(self) -> None:
+ with unittest.mock.patch.dict(os.environ,
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+ self.assertRaises(
+ avro.errors.AvroCollectionSizeException,
+ self._skip,
+ '{"type": "array", "items": "null"}',
+ self._array_block(2000),
+ )
+
+ def test_skip_array_of_null_negative_block_respects_limit(self) -> None:
+ # A negative (byte-sized) block count must also be bounded when
skipping,
+ # so it cannot be used to bypass the collection cap during resolution.
+ with unittest.mock.patch.dict(os.environ,
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+ self.assertRaises(
+ avro.errors.AvroCollectionSizeException,
+ self._skip,
+ '{"type": "array", "items": "null"}',
+ self._array_block(2000, negative=True),
+ )
+
+ def test_skip_block_bytes_wraps_seek_error(self) -> None:
+ # A sized (negative-count) skip block seeks by the declared byte size.
If
+ # the underlying reader rejects the seek, surface it as an Avro
decoding
+ # error rather than leaking a raw OSError/ValueError/OverflowError, or
an
+ # AttributeError/TypeError from a reader lacking seek()/tell().
+ for exc in (OSError, ValueError, OverflowError, AttributeError,
TypeError):
+
+ class FailingSkipDecoder:
+ def bytes_remaining(self) -> None:
+ return None
+
+ def skip(self, n: int, _exc: type = exc) -> None:
+ raise _exc("cannot skip")
+
+ self.assertRaises(
+ avro.errors.InvalidAvroBinaryEncoding,
+ avro.io.DatumReader._skip_block_bytes,
+ cast(avro.io.BinaryDecoder, FailingSkipDecoder()),
+ 10, # block_size
+ 5, # block_count
+ 1, # min_bytes > 0 so the skip is actually attempted (and
fails)
+ )
+
+ def
test_skip_block_bytes_rejects_positive_size_for_zero_byte_element(self) -> None:
+ # A zero-byte element type (min_bytes == 0) encodes to exactly 0
bytes, so
+ # its sized block must be empty; a positive block size would skip into
the
+ # following fields and corrupt decoder alignment.
+ class NoSkipDecoder:
+ def bytes_remaining(self) -> None:
+ return None
+
+ def skip(self, n: int) -> None:
+ if n != 0: # pragma: no cover - must not be reached
+ raise AssertionError("skip should not be called with a
positive size for a zero-byte block")
+
+ self.assertRaises(
+ avro.errors.InvalidAvroBinaryEncoding,
+ avro.io.DatumReader._skip_block_bytes,
+ cast(avro.io.BinaryDecoder, NoSkipDecoder()),
+ 10, # block_size (must be 0 for a zero-byte element type)
+ 5, # block_count
+ 0, # min_bytes (zero-byte element type)
+ )
+ # A zero-size block for a zero-byte element type is valid and skips
nothing.
+ avro.io.DatumReader._skip_block_bytes(cast(avro.io.BinaryDecoder,
NoSkipDecoder()), 0, 5, 0)
+
+ def test_invalid_env_override_falls_back_to_default(self) -> None:
+ with unittest.mock.patch.dict(os.environ,
{"AVRO_MAX_COLLECTION_ITEMS": "not-a-number"}):
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ self.assertEqual(avro.io._max_collection_items(),
avro.io.DEFAULT_MAX_COLLECTION_ITEMS)
+
+ def test_negative_env_override_falls_back_to_default(self) -> None:
+ with unittest.mock.patch.dict(os.environ,
{"AVRO_MAX_COLLECTION_ITEMS": "-5"}):
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ self.assertEqual(avro.io._max_collection_items(),
avro.io.DEFAULT_MAX_COLLECTION_ITEMS)
+
+ def test_env_override_is_honored(self) -> None:
+ with unittest.mock.patch.dict(os.environ,
{"AVRO_MAX_COLLECTION_ITEMS": "7"}):
+ self.assertEqual(avro.io._max_collection_items(), 7)
+ result = self._decode('{"type": "array", "items": "null"}',
self._array_block(7))
+ self.assertEqual(result, [None] * 7)
+
+ def test_collection_limits_env_caps_both(self) -> None:
+ # When set, AVRO_MAX_COLLECTION_ITEMS caps both the zero-byte and the
+ # structural limit; unset, they differ (tighter zero-byte default).
+ with unittest.mock.patch.dict(os.environ,
{"AVRO_MAX_COLLECTION_ITEMS": "1234"}):
+ self.assertEqual(avro.io._collection_limits(), (1234, 1234))
+ # Assert the unset behavior inside a patch that snapshots os.environ,
so
+ # the test never mutates the real process environment.
+ with unittest.mock.patch.dict(os.environ):
+ os.environ.pop("AVRO_MAX_COLLECTION_ITEMS", None)
+ self.assertEqual(
+ avro.io._collection_limits(),
+ (avro.io.DEFAULT_MAX_COLLECTION_ITEMS,
avro.io.DEFAULT_MAX_COLLECTION_STRUCTURAL),
+ )
+
+ def
test_non_zero_collection_bounded_by_structural_cap_when_unseekable(self) ->
None:
+ # On a non-seekable reader the bytes-remaining check cannot run, so a
huge
+ # non-zero-byte collection is bounded by the structural cap instead.
+ class NoTell:
+ def __init__(self, data: bytes) -> None:
+ self._b = io.BytesIO(data)
+
+ def read(self, n: int = -1) -> bytes:
+ return self._b.read(n)
+
+ schema = avro.schema.parse('{"type": "array", "items": "long"}')
+ reader = avro.io.DatumReader(schema)
+ with unittest.mock.patch.dict(os.environ,
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+ decoder = avro.io.BinaryDecoder(cast(BinaryIO,
NoTell(self._array_block(2000))))
+ self.assertIsNone(decoder.bytes_remaining())
+ self.assertRaises(avro.errors.AvroCollectionSizeException,
reader.read, decoder)
+
+
def load_tests(loader: unittest.TestLoader, default_tests: None, pattern:
None) -> unittest.TestSuite:
"""Generate test cases across many test schema."""
suite = unittest.TestSuite()
@@ -839,6 +1321,9 @@ def load_tests(loader: unittest.TestLoader, default_tests:
None, pattern: None)
)
suite.addTests(DefaultValueTestCase(field_type, default) for field_type,
default in DEFAULT_VALUE_EXAMPLES)
suite.addTests(loader.loadTestsFromTestCase(TestIncompatibleSchemaReading))
+
suite.addTests(loader.loadTestsFromTestCase(TestBinaryDecoderAvailableBytes))
+
suite.addTests(loader.loadTestsFromTestCase(TestDatumReaderCollectionAvailableBytes))
+
suite.addTests(loader.loadTestsFromTestCase(TestDatumReaderCollectionSizeLimit))
return suite