This is an automated email from the ASF dual-hosted git repository.
Kriskras99 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/avro-rs.git
The following commit(s) were added to refs/heads/main by this push:
new a88ad17 fix: bound array/map allocation by decoded element count
(#581)
a88ad17 is described below
commit a88ad17b0ebad5555b8942d4d2a086e5d4d848bb
Author: Ismaël Mejía <[email protected]>
AuthorDate: Tue Jul 14 12:28:03 2026 +0200
fix: bound array/map allocation by decoded element count (#581)
* fix: bound array/map allocation by decoded element count
An array or map block is encoded as an element count followed by that many
items. When decoding, `items.reserve(len)` allocates `len *
size_of::<Value>()`
bytes (Value is 56 bytes), but the count was only validated with `safe_len`,
which bounds it as if it were a byte count. So a tiny payload declaring a
huge
block count could drive a multi-gigabyte allocation far exceeding the
configured
`max_allocation_bytes` budget (up to ~28 GiB at the safe_len ceiling), and a
count of zero-byte on-wire elements (e.g. `null`) could grow the collection
unboundedly since such elements consume no input.
Add `util::safe_collection_len`, which bounds the cumulative element count
against the allocation budget accounting for the in-memory size of each
element,
and apply it in the array and map decode loops before reserving. This caps
both
the up-front reservation and the total number of decoded elements, covering
zero-byte and non-zero-byte element types alike. The limit stays
configurable
through `max_allocation_bytes`.
Assisted-by: GitHub Copilot:claude-opus-4.8
* test: cover i64::MIN array block count rejection
decode_seq_len already rejects a block count whose absolute value is
i64::MIN
via checked_neg (which returns None, yielding Details::IntegerOverflow), so
no
code change is needed. Add a regression test decoding the 10-byte i64::MIN
varint as a negative block count and asserting it is rejected, for parity
with
the negation-overflow coverage in the other Avro SDKs.
Assisted-by: GitHub Copilot:claude-opus-4.8
* refactor: address review on collection-length bound
- safe_collection_len: use checked_mul instead of saturating_mul so a
usize::MAX allocation budget cannot mask a multiplication overflow (which
would let reserve() hit a capacity-overflow panic); return
Details::IntegerOverflow on overflow.
- tests: build block-count varints via the crate's zig_i64 encoder rather
than
a duplicated zigzag/varint implementation.
Assisted-by: GitHub Copilot:claude-opus-4.8
* refactor: reserve_exact for arrays; clarify budget is a lower-bound
estimate
Addresses review:
- Array decoding uses reserve_exact so the reservation matches the checked
element count, rather than reserve's amortized (larger) capacity which
could
request more than max_allocation_bytes across multiple blocks.
- Clarify in the safe_collection_len doc and the map decode comment that the
per-element size is a lower-bound estimate: HashMap::reserve (and Vec
growth)
can over-allocate for metadata/spare capacity, so the budget is enforced
approximately for maps (there is no HashMap::reserve_exact).
Assisted-by: GitHub Copilot:claude-opus-4.8
* docs: note item_size is clamped to at least 1 in safe_collection_len
Document that safe_collection_len uses item_size.max(1), so a zero-sized
element
type is treated as one byte and the reported `desired` allocation is
total_items * max(item_size, 1). This matches the implementation and
clarifies
the error's `desired` interpretation.
Assisted-by: GitHub Copilot:claude-opus-4.8
* chore: simplify docs and code
---------
Co-authored-by: Kriskras99 <[email protected]>
---
avro/src/decode.rs | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++--
avro/src/util.rs | 23 ++++++++++++
2 files changed, 124 insertions(+), 2 deletions(-)
diff --git a/avro/src/decode.rs b/avro/src/decode.rs
index c4eabf3..d78da71 100644
--- a/avro/src/decode.rs
+++ b/avro/src/decode.rs
@@ -24,7 +24,7 @@ use crate::{
error::Details,
schema::{DecimalSchema, EnumSchema, FixedSchema, Name, RecordSchema,
ResolvedSchema, Schema},
types::Value,
- util::{safe_len, zag_i32, zag_i64},
+ util::{safe_collection_len, safe_len, zag_i32, zag_i64},
};
use std::{
borrow::Borrow,
@@ -241,7 +241,15 @@ pub(crate) fn decode_internal<R: Read, S: Borrow<Schema>>(
break;
}
- items.reserve(len);
+ // Check that the Vec won't grow past the max allocation size
+ let total = items
+ .len()
+ .checked_add(len)
+ .ok_or(Details::IntegerOverflow)?;
+ safe_collection_len::<Value>(total)?;
+ // Use reserve_exact as reserve can allocate more than needed
defeating the purpose
+ // of the previous check
+ items.reserve_exact(len);
for _ in 0..len {
items.push(decode_internal(
&inner.items,
@@ -263,6 +271,15 @@ pub(crate) fn decode_internal<R: Read, S: Borrow<Schema>>(
break;
}
+ // Check that the HashMap won't grow past the max allocation
size. This is less
+ // precise than the Vec check above as HashMap allocates in
buckets and doesn't have
+ // a reserve_exact
+ let total = items
+ .len()
+ .checked_add(len)
+ .ok_or(Details::IntegerOverflow)?;
+ safe_collection_len::<(String, Value)>(total)?;
+
items.reserve(len);
for _ in 0..len {
match decode_internal(&Schema::String, names,
enclosing_namespace, reader)? {
@@ -388,6 +405,88 @@ mod tests {
Ok(())
}
+ // Create an array/map block with an object count of `n` and no items
+ fn create_block(n: i64) -> Vec<u8> {
+ // Reuse the crate's encoder so the tests cannot diverge from it.
+ let mut out = Vec::new();
+ crate::util::zig_i64(n, &mut out).unwrap();
+ out.push(0x00);
+ out
+ }
+
+ #[test]
+ fn test_decode_array_of_null_huge_count_is_rejected() -> TestResult {
+ // 200,000,000 nulls => ~11 GiB reserve, far above the default budget.
+ let payload = create_block(200_000_000);
+ let result = decode(
+ &Schema::array(Schema::Null).build(),
+ &mut payload.as_slice(),
+ );
+ assert!(
+ result.is_err(),
+ "a huge array<null> block count must be rejected, got {result:?}"
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_decode_array_of_long_huge_count_is_rejected() -> TestResult {
+ // Non-zero-byte elements are also affected: the reserve happens before
+ // any element is read.
+ let payload = create_block(200_000_000);
+ let result = decode(
+ &Schema::array(Schema::Long).build(),
+ &mut payload.as_slice(),
+ );
+ assert!(
+ result.is_err(),
+ "a huge array<long> block count must be rejected, got {result:?}"
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_decode_map_huge_count_is_rejected() -> TestResult {
+ let payload = create_block(200_000_000);
+ let result = decode(&Schema::map(Schema::Long).build(), &mut
payload.as_slice());
+ assert!(
+ result.is_err(),
+ "a huge map block count must be rejected, got {result:?}"
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_decode_small_array_of_null_still_decodes() -> TestResult {
+ // A modest array of nulls within the budget must still decode.
+ let payload = create_block(3);
+ let result = decode(
+ &Schema::array(Schema::Null).build(),
+ &mut payload.as_slice(),
+ )?;
+ assert_eq!(Array(vec!(Value::Null, Value::Null, Value::Null)), result);
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_decode_array_int64_min_block_count_is_rejected() -> TestResult {
+ // i64::MIN as a negative block count cannot be negated (checked_neg
+ // returns None); decoding must fail rather than wrap. i64::MIN zig-zag
+ // encodes as the 10-byte varint below, followed by a block byte-size.
+ let payload = create_block(i64::MIN);
+ let result = decode(&Schema::array(Schema::Int).build(), &mut
payload.as_slice());
+ assert!(
+ result.is_err(),
+ "an i64::MIN array block count must be rejected, got {result:?}"
+ );
+
+ Ok(())
+ }
+
#[test]
fn test_decode_map_without_size() -> TestResult {
let mut input: &[u8] = &[0x02, 0x08, 0x74, 0x65, 0x73, 0x74, 0x02,
0x00];
diff --git a/avro/src/util.rs b/avro/src/util.rs
index a0bcf29..4e4b106 100644
--- a/avro/src/util.rs
+++ b/avro/src/util.rs
@@ -186,6 +186,29 @@ pub(crate) fn safe_len(len: usize) -> AvroResult<usize> {
}
}
+/// Bound the cumulative number of elements a collection (array or map) may
hold.
+///
+/// This is equivalent to `safe_len(total_items * size_of::<T>)`
+pub(crate) fn safe_collection_len<T>(total_items: usize) -> AvroResult<()> {
+ let max_bytes = max_allocation_bytes(DEFAULT_MAX_ALLOCATION_BYTES);
+ // Use checked_mul (not saturating_mul): saturating to usize::MAX could
pass
+ // the check below when max_bytes is configured to usize::MAX, letting the
+ // subsequent reserve() hit a capacity-overflow panic instead of erroring.
+ let desired = total_items
+ .checked_mul(size_of::<T>())
+ .ok_or(Details::IntegerOverflow)?;
+
+ if desired <= max_bytes {
+ Ok(())
+ } else {
+ Err(Details::MemoryAllocation {
+ desired: Some(desired),
+ maximum: max_bytes,
+ }
+ .into())
+ }
+}
+
/// Set whether the serializer and deserializer should indicate to types that
the format is human-readable.
///
/// This function only changes the setting once. On subsequent calls the value
will stay the same