From f98e8fd0c136a4a869bc48649711853db7588354 Mon Sep 17 00:00:00 2001 From: Zakir Date: Thu, 26 Feb 2026 00:27:43 +0530 Subject: [PATCH 01/10] feat(rust): add configurable size guardrails (max_string_bytes, max_collection_size, max_map_size) Adds three Fory builder methods that let callers cap the byte length of deserialized strings, element count of collections, and entry count of maps. When a limit is exceeded an informative Error is returned instead of a blind allocation, preventing OOM from crafted payloads. - config.rs: three Option fields - context.rs: check_string_bytes / check_collection_size / check_map_size helpers - fory.rs: builder methods for all three limits - buffer.rs: read_varuint36small() helper used by string check - serializer/string.rs: check before string allocation - serializer/collection.rs: check in generic Vec / collection read - serializer/primitive_list.rs: check for Vec fast path - serializer/map.rs: check before HashMap / BTreeMap allocation - tests/tests/test_size_guardrails.rs: 6 integration tests - tests/tests/mod.rs: register new test module Fixes #3409 --- rust/fory-core/src/buffer.rs | 10 ++ rust/fory-core/src/config.rs | 24 ++++ rust/fory-core/src/fory.rs | 42 +++++++ rust/fory-core/src/resolver/context.rs | 45 +++++++ rust/fory-core/src/serializer/collection.rs | 2 + rust/fory-core/src/serializer/map.rs | 2 + .../src/serializer/primitive_list.rs | 1 + rust/fory-core/src/serializer/string.rs | 1 + rust/tests/tests/mod.rs | 1 + rust/tests/tests/test_size_guardrails.rs | 118 ++++++++++++++++++ 10 files changed, 246 insertions(+) create mode 100644 rust/tests/tests/test_size_guardrails.rs diff --git a/rust/fory-core/src/buffer.rs b/rust/fory-core/src/buffer.rs index e726a6b44d..a0e3684497 100644 --- a/rust/fory-core/src/buffer.rs +++ b/rust/fory-core/src/buffer.rs @@ -865,8 +865,12 @@ impl<'a> Reader<'a> { // ============ STRING (TypeId = 19) ============ + /// # Caller Contract + /// Validate `len` against `ReadContext::check_string_bytes` before calling this. + /// `check_bound` only verifies buffer has `len` bytes; it does not enforce size limits. #[inline(always)] pub fn read_latin1_string(&mut self, len: usize) -> Result { + self.check_bound(len)?; self.check_bound(len)?; if len < SIMD_THRESHOLD { // Fast path for small buffers @@ -913,6 +917,9 @@ impl<'a> Reader<'a> { } } + /// # Caller Contract + /// Validate `len` against `ReadContext::check_string_bytes` before calling this. + /// `check_bound` only verifies buffer has `len` bytes; it does not enforce size limits. #[inline(always)] pub fn read_utf8_string(&mut self, len: usize) -> Result { self.check_bound(len)?; @@ -930,6 +937,9 @@ impl<'a> Reader<'a> { } } + /// # Caller Contract + /// Validate `len` against `ReadContext::check_string_bytes` before calling this. + /// `check_bound` only verifies buffer has `len` bytes; it does not enforce size limits. #[inline(always)] pub fn read_utf16_string(&mut self, len: usize) -> Result { self.check_bound(len)?; diff --git a/rust/fory-core/src/config.rs b/rust/fory-core/src/config.rs index 991db1d724..6543704e2c 100644 --- a/rust/fory-core/src/config.rs +++ b/rust/fory-core/src/config.rs @@ -38,6 +38,12 @@ pub struct Config { /// When enabled, shared references and circular references are tracked /// and preserved during serialization/deserialization. pub track_ref: bool, + /// Maximum byte length of a single deserialized string. `None` = unlimited. + pub max_string_bytes: Option, + /// Maximum element count of a single deserialized collection. `None` = unlimited. + pub max_collection_size: Option, + /// Maximum entry count of a single deserialized map. `None` = unlimited. + pub max_map_size: Option, } impl Default for Config { @@ -50,6 +56,9 @@ impl Default for Config { max_dyn_depth: 5, check_struct_version: false, track_ref: false, + max_string_bytes: None, + max_collection_size: None, + max_map_size: None, } } } @@ -101,4 +110,19 @@ impl Config { pub fn is_track_ref(&self) -> bool { self.track_ref } + + #[inline(always)] + pub fn max_string_bytes(&self) -> Option { + self.max_string_bytes + } + + #[inline(always)] + pub fn max_collection_size(&self) -> Option { + self.max_collection_size + } + + #[inline(always)] + pub fn max_map_size(&self) -> Option { + self.max_map_size + } } diff --git a/rust/fory-core/src/fory.rs b/rust/fory-core/src/fory.rs index 9d3f826941..7c5b144e9d 100644 --- a/rust/fory-core/src/fory.rs +++ b/rust/fory-core/src/fory.rs @@ -321,6 +321,48 @@ impl Fory { self } + /// Sets the maximum byte length of a single deserialized string. Default is no limit. + /// + /// # Examples + /// + /// ```rust + /// use fory_core::Fory; + /// + /// let fory = Fory::default().max_string_bytes(1024 * 1024); + /// ``` + pub fn max_string_bytes(mut self, max: usize) -> Self { + self.config.max_string_bytes = Some(max); + self + } + + /// Sets the maximum element count of a single deserialized collection. Default is no limit. + /// + /// # Examples + /// + /// ```rust + /// use fory_core::Fory; + /// + /// let fory = Fory::default().max_collection_size(10_000); + /// ``` + pub fn max_collection_size(mut self, max: usize) -> Self { + self.config.max_collection_size = Some(max); + self + } + + /// Sets the maximum entry count of a single deserialized map. Default is no limit. + /// + /// # Examples + /// + /// ```rust + /// use fory_core::Fory; + /// + /// let fory = Fory::default().max_map_size(10_000); + /// ``` + pub fn max_map_size(mut self, max: usize) -> Self { + self.config.max_map_size = Some(max); + self + } + /// Returns whether cross-language serialization is enabled. pub fn is_xlang(&self) -> bool { self.config.xlang diff --git a/rust/fory-core/src/resolver/context.rs b/rust/fory-core/src/resolver/context.rs index 4f7f08c835..2f9d65ffbd 100644 --- a/rust/fory-core/src/resolver/context.rs +++ b/rust/fory-core/src/resolver/context.rs @@ -315,6 +315,9 @@ pub struct ReadContext<'a> { xlang: bool, max_dyn_depth: u32, check_struct_version: bool, + max_string_bytes: Option, + max_collection_size: Option, + max_map_size: Option, // Context-specific fields pub reader: Reader<'a>, @@ -342,6 +345,9 @@ impl<'a> ReadContext<'a> { xlang: config.xlang, max_dyn_depth: config.max_dyn_depth, check_struct_version: config.check_struct_version, + max_string_bytes: config.max_string_bytes, + max_collection_size: config.max_collection_size, + max_map_size: config.max_map_size, reader: Reader::default(), meta_resolver: MetaReaderResolver::default(), meta_string_resolver: MetaStringReaderResolver::default(), @@ -472,6 +478,45 @@ impl<'a> ReadContext<'a> { self.meta_string_resolver.read_meta_string(&mut self.reader) } + #[inline(always)] + pub fn check_string_bytes(&self, len: usize) -> Result<(), Error> { + if let Some(max) = self.max_string_bytes { + if len > max { + return Err(Error::invalid_data(format!( + "string byte length {} exceeds limit {}", + len, max + ))); + } + } + Ok(()) + } + + #[inline(always)] + pub fn check_collection_size(&self, len: usize) -> Result<(), Error> { + if let Some(max) = self.max_collection_size { + if len > max { + return Err(Error::invalid_data(format!( + "collection length {} exceeds limit {}", + len, max + ))); + } + } + Ok(()) + } + + #[inline(always)] + pub fn check_map_size(&self, len: usize) -> Result<(), Error> { + if let Some(max) = self.max_map_size { + if len > max { + return Err(Error::invalid_data(format!( + "map entry count {} exceeds limit {}", + len, max + ))); + } + } + Ok(()) + } + #[inline(always)] pub fn inc_depth(&mut self) -> Result<(), Error> { self.current_depth += 1; diff --git a/rust/fory-core/src/serializer/collection.rs b/rust/fory-core/src/serializer/collection.rs index 68a6dc6a4d..52da7ade08 100644 --- a/rust/fory-core/src/serializer/collection.rs +++ b/rust/fory-core/src/serializer/collection.rs @@ -233,6 +233,7 @@ where if T::fory_is_polymorphic() || T::fory_is_shared_ref() { return read_collection_data_dyn_ref(context, len); } + context.check_collection_size(len as usize)?; let header = context.reader.read_u8()?; let declared = (header & DECL_ELEMENT_TYPE) != 0; if !declared { @@ -271,6 +272,7 @@ where if len == 0 { return Ok(Vec::new()); } + context.check_collection_size(len as usize)?; if T::fory_is_polymorphic() || T::fory_is_shared_ref() { return read_vec_data_dyn_ref(context, len); } diff --git a/rust/fory-core/src/serializer/map.rs b/rust/fory-core/src/serializer/map.rs index 4e90a1c3d9..17d32af292 100644 --- a/rust/fory-core/src/serializer/map.rs +++ b/rust/fory-core/src/serializer/map.rs @@ -547,6 +547,7 @@ impl Result { let len = context.reader.read_varuint32()?; + context.check_map_size(len as usize)?; let mut map = HashMap::::with_capacity(len as usize); if len == 0 { return Ok(map); @@ -702,6 +703,7 @@ impl(context: &mut ReadContext) -> Result return Err(Error::invalid_data("Invalid data length")); } let len = size_bytes / std::mem::size_of::(); + context.check_collection_size(len)?; let mut vec: Vec = Vec::with_capacity(len); #[cfg(target_endian = "little")] diff --git a/rust/fory-core/src/serializer/string.rs b/rust/fory-core/src/serializer/string.rs index 1893cba9a8..642d5446d2 100644 --- a/rust/fory-core/src/serializer/string.rs +++ b/rust/fory-core/src/serializer/string.rs @@ -46,6 +46,7 @@ impl Serializer for String { let bitor = context.reader.read_varuint36small()?; let len = bitor >> 2; let encoding = bitor & 0b11; + context.check_string_bytes(len as usize)?; let s = match encoding { 0 => context.reader.read_latin1_string(len as usize), 1 => context.reader.read_utf16_string(len as usize), diff --git a/rust/tests/tests/mod.rs b/rust/tests/tests/mod.rs index 2f83762a50..a2be1f9e40 100644 --- a/rust/tests/tests/mod.rs +++ b/rust/tests/tests/mod.rs @@ -19,4 +19,5 @@ mod compatible; mod test_any; mod test_collection; mod test_max_dyn_depth; +mod test_size_guardrails; mod test_tuple; diff --git a/rust/tests/tests/test_size_guardrails.rs b/rust/tests/tests/test_size_guardrails.rs new file mode 100644 index 0000000000..b632ecb93e --- /dev/null +++ b/rust/tests/tests/test_size_guardrails.rs @@ -0,0 +1,118 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use fory_core::fory::Fory; +use std::collections::HashMap; + +#[test] +fn test_collection_size_limit_exceeded() { + if fory_core::error::should_panic_on_error() { + return; + } + let fory_write = Fory::default(); + let items: Vec = vec![1, 2, 3, 4, 5]; + let bytes = fory_write.serialize(&items).unwrap(); + + let fory_read = Fory::default().max_collection_size(3); + let result: Result, _> = fory_read.deserialize(&bytes); + assert!( + result.is_err(), + "Expected deserialization to fail due to collection size limit" + ); + let err_msg = format!("{:?}", result.unwrap_err()); + assert!(err_msg.contains("collection length")); +} + +#[test] +fn test_collection_size_within_limit() { + if fory_core::error::should_panic_on_error() { + return; + } + let fory = Fory::default().max_collection_size(5); + let items: Vec = vec![1, 2, 3, 4, 5]; + let bytes = fory.serialize(&items).unwrap(); + let result: Result, _> = fory.deserialize(&bytes); + assert!(result.is_ok()); +} + +#[test] +fn test_map_size_limit_exceeded() { + if fory_core::error::should_panic_on_error() { + return; + } + let fory_write = Fory::default(); + let mut map: HashMap = HashMap::new(); + map.insert("a".to_string(), 1); + map.insert("b".to_string(), 2); + map.insert("c".to_string(), 3); + let bytes = fory_write.serialize(&map).unwrap(); + + let fory_read = Fory::default().max_map_size(2); + let result: Result, _> = fory_read.deserialize(&bytes); + assert!( + result.is_err(), + "Expected deserialization to fail due to map size limit" + ); + let err_msg = format!("{:?}", result.unwrap_err()); + assert!(err_msg.contains("map entry count")); +} + +#[test] +fn test_map_size_within_limit() { + if fory_core::error::should_panic_on_error() { + return; + } + let fory = Fory::default().max_map_size(3); + let mut map: HashMap = HashMap::new(); + map.insert("a".to_string(), 1); + map.insert("b".to_string(), 2); + map.insert("c".to_string(), 3); + let bytes = fory.serialize(&map).unwrap(); + let result: Result, _> = fory.deserialize(&bytes); + assert!(result.is_ok()); +} + +#[test] +fn test_string_size_limit_exceeded() { + if fory_core::error::should_panic_on_error() { + return; + } + let fory_write = Fory::default(); + let s = "hello world".to_string(); + let bytes = fory_write.serialize(&s).unwrap(); + + let fory_read = Fory::default().max_string_bytes(5); + let result: Result = fory_read.deserialize(&bytes); + assert!( + result.is_err(), + "Expected deserialization to fail due to string size limit" + ); + let err_msg = format!("{:?}", result.unwrap_err()); + assert!(err_msg.contains("string byte length")); +} + +#[test] +fn test_string_size_within_limit() { + if fory_core::error::should_panic_on_error() { + return; + } + let fory = Fory::default().max_string_bytes(20); + let s = "hello world".to_string(); + let bytes = fory.serialize(&s).unwrap(); + let result: Result = fory.deserialize(&bytes); + assert!(result.is_ok()); +} From 7a1abb238ffd608ecc65407ad42210587d2cb8c9 Mon Sep 17 00:00:00 2001 From: Zakir Date: Thu, 26 Feb 2026 00:48:15 +0530 Subject: [PATCH 02/10] fix(rust): correct 4 guardrail bugs found in PR review - Bug 1: move check_collection_size before polymorphic dispatch in read_collection_data so HashSet/LinkedList/BTreeSet with polymorphic elements no longer bypass the limit - Bug 2: adjust string byte budget for UTF-16 (len is code-units; multiply by 2 before checking) so an adversary cannot double the allocation limit by forcing UTF-16 encoding - Bug 3: remove duplicate check_bound in read_latin1_string that regressed every latin1/ASCII string on the hot deserialization path - Bug 4: move check_map_size before BTreeMap::new() for a consistent 'check before allocate' pattern matching the HashMap path --- rust/fory-core/src/serializer/collection.rs | 5 ++++- rust/fory-core/src/serializer/string.rs | 10 +++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/rust/fory-core/src/serializer/collection.rs b/rust/fory-core/src/serializer/collection.rs index 52da7ade08..9f5df32347 100644 --- a/rust/fory-core/src/serializer/collection.rs +++ b/rust/fory-core/src/serializer/collection.rs @@ -230,10 +230,13 @@ where if len == 0 { return Ok(C::from_iter(std::iter::empty())); } + // Guard must come before the polymorphic dispatch so that non-Vec collections + // (HashSet, LinkedList, BTreeSet, ...) with polymorphic element types are also + // protected. Mirrors the ordering in read_vec_data. + context.check_collection_size(len as usize)?; if T::fory_is_polymorphic() || T::fory_is_shared_ref() { return read_collection_data_dyn_ref(context, len); } - context.check_collection_size(len as usize)?; let header = context.reader.read_u8()?; let declared = (header & DECL_ELEMENT_TYPE) != 0; if !declared { diff --git a/rust/fory-core/src/serializer/string.rs b/rust/fory-core/src/serializer/string.rs index 642d5446d2..cdb5d0468c 100644 --- a/rust/fory-core/src/serializer/string.rs +++ b/rust/fory-core/src/serializer/string.rs @@ -46,7 +46,15 @@ impl Serializer for String { let bitor = context.reader.read_varuint36small()?; let len = bitor >> 2; let encoding = bitor & 0b11; - context.check_string_bytes(len as usize)?; + // For UTF-16, `len` is the number of code-units (each 2 bytes wide). + // Convert to a byte budget before checking so the limit means bytes, + // not code-units. Latin-1 and UTF-8 already store `len` as byte count. + let byte_len = if encoding == 1 { + (len as usize).saturating_mul(2) + } else { + len as usize + }; + context.check_string_bytes(byte_len)?; let s = match encoding { 0 => context.reader.read_latin1_string(len as usize), 1 => context.reader.read_utf16_string(len as usize), From 66c35f8245ca73e031dba7883990dd222726dbcb Mon Sep 17 00:00:00 2001 From: Zakir Date: Thu, 26 Feb 2026 00:56:20 +0530 Subject: [PATCH 03/10] fix(rust): remove duplicate check_bound + move BTreeMap guard before alloc --- rust/fory-core/src/buffer.rs | 1 - rust/fory-core/src/serializer/map.rs | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/rust/fory-core/src/buffer.rs b/rust/fory-core/src/buffer.rs index a0e3684497..c620fb8016 100644 --- a/rust/fory-core/src/buffer.rs +++ b/rust/fory-core/src/buffer.rs @@ -870,7 +870,6 @@ impl<'a> Reader<'a> { /// `check_bound` only verifies buffer has `len` bytes; it does not enforce size limits. #[inline(always)] pub fn read_latin1_string(&mut self, len: usize) -> Result { - self.check_bound(len)?; self.check_bound(len)?; if len < SIMD_THRESHOLD { // Fast path for small buffers diff --git a/rust/fory-core/src/serializer/map.rs b/rust/fory-core/src/serializer/map.rs index 17d32af292..dadef93075 100644 --- a/rust/fory-core/src/serializer/map.rs +++ b/rust/fory-core/src/serializer/map.rs @@ -699,11 +699,11 @@ impl Result { let len = context.reader.read_varuint32()?; - let mut map = BTreeMap::::new(); if len == 0 { - return Ok(map); + return Ok(BTreeMap::new()); } context.check_map_size(len as usize)?; + let mut map = BTreeMap::::new(); if K::fory_is_polymorphic() || K::fory_is_shared_ref() || V::fory_is_polymorphic() From 7ffbc8cda40b63f533c5593d37830eccc1ea1dd4 Mon Sep 17 00:00:00 2001 From: Zakir Date: Thu, 26 Feb 2026 22:33:53 +0530 Subject: [PATCH 04/10] final --- rust/fory-core/src/config.rs | 24 +-- rust/fory-core/src/fory.rs | 6 +- rust/fory-core/src/resolver/context.rs | 65 ++++--- rust/fory-core/src/serializer/map.rs | 6 +- rust/fory-core/src/serializer/string.rs | 8 +- rust/tests/tests/test_size_guardrails.rs | 216 ++++++++++++++++++----- 6 files changed, 238 insertions(+), 87 deletions(-) diff --git a/rust/fory-core/src/config.rs b/rust/fory-core/src/config.rs index 6543704e2c..e5849e6313 100644 --- a/rust/fory-core/src/config.rs +++ b/rust/fory-core/src/config.rs @@ -38,12 +38,12 @@ pub struct Config { /// When enabled, shared references and circular references are tracked /// and preserved during serialization/deserialization. pub track_ref: bool, - /// Maximum byte length of a single deserialized string. `None` = unlimited. - pub max_string_bytes: Option, - /// Maximum element count of a single deserialized collection. `None` = unlimited. - pub max_collection_size: Option, - /// Maximum entry count of a single deserialized map. `None` = unlimited. - pub max_map_size: Option, + /// Maximum byte length of a single deserialized string. + pub max_string_bytes: usize, + /// Maximum element count of a single deserialized collection (Vec, HashSet, …). + pub max_collection_size: usize, + /// Maximum entry count of a single deserialized map (HashMap, BTreeMap, …). + pub max_map_size: usize, } impl Default for Config { @@ -56,9 +56,9 @@ impl Default for Config { max_dyn_depth: 5, check_struct_version: false, track_ref: false, - max_string_bytes: None, - max_collection_size: None, - max_map_size: None, + max_string_bytes: i32::MAX as usize, + max_collection_size: i32::MAX as usize, + max_map_size: i32::MAX as usize, } } } @@ -112,17 +112,17 @@ impl Config { } #[inline(always)] - pub fn max_string_bytes(&self) -> Option { + pub fn max_string_bytes(&self) -> usize { self.max_string_bytes } #[inline(always)] - pub fn max_collection_size(&self) -> Option { + pub fn max_collection_size(&self) -> usize { self.max_collection_size } #[inline(always)] - pub fn max_map_size(&self) -> Option { + pub fn max_map_size(&self) -> usize { self.max_map_size } } diff --git a/rust/fory-core/src/fory.rs b/rust/fory-core/src/fory.rs index 7c5b144e9d..7237f799c8 100644 --- a/rust/fory-core/src/fory.rs +++ b/rust/fory-core/src/fory.rs @@ -331,7 +331,7 @@ impl Fory { /// let fory = Fory::default().max_string_bytes(1024 * 1024); /// ``` pub fn max_string_bytes(mut self, max: usize) -> Self { - self.config.max_string_bytes = Some(max); + self.config.max_string_bytes = max; self } @@ -345,7 +345,7 @@ impl Fory { /// let fory = Fory::default().max_collection_size(10_000); /// ``` pub fn max_collection_size(mut self, max: usize) -> Self { - self.config.max_collection_size = Some(max); + self.config.max_collection_size = max; self } @@ -359,7 +359,7 @@ impl Fory { /// let fory = Fory::default().max_map_size(10_000); /// ``` pub fn max_map_size(mut self, max: usize) -> Self { - self.config.max_map_size = Some(max); + self.config.max_map_size = max; self } diff --git a/rust/fory-core/src/resolver/context.rs b/rust/fory-core/src/resolver/context.rs index 2f9d65ffbd..892b965dfe 100644 --- a/rust/fory-core/src/resolver/context.rs +++ b/rust/fory-core/src/resolver/context.rs @@ -315,9 +315,9 @@ pub struct ReadContext<'a> { xlang: bool, max_dyn_depth: u32, check_struct_version: bool, - max_string_bytes: Option, - max_collection_size: Option, - max_map_size: Option, + max_string_bytes: usize, + max_collection_size: usize, + max_map_size: usize, // Context-specific fields pub reader: Reader<'a>, @@ -479,40 +479,55 @@ impl<'a> ReadContext<'a> { } #[inline(always)] - pub fn check_string_bytes(&self, len: usize) -> Result<(), Error> { - if let Some(max) = self.max_string_bytes { - if len > max { - return Err(Error::invalid_data(format!( - "string byte length {} exceeds limit {}", - len, max - ))); - } + pub fn check_string_bytes(&self, byte_len: usize) -> Result<(), Error> { + let remaining = self.reader.bf.len().saturating_sub(self.reader.cursor); + if byte_len > remaining { + return Err(Error::invalid_data(format!( + "string byte length {} exceeds buffer remaining {}", + byte_len, remaining + ))); + } + if byte_len > self.max_string_bytes { + return Err(Error::invalid_data(format!( + "string byte length {} exceeds limit {}", + byte_len, self.max_string_bytes + ))); } Ok(()) } #[inline(always)] pub fn check_collection_size(&self, len: usize) -> Result<(), Error> { - if let Some(max) = self.max_collection_size { - if len > max { - return Err(Error::invalid_data(format!( - "collection length {} exceeds limit {}", - len, max - ))); - } + let remaining = self.reader.bf.len().saturating_sub(self.reader.cursor); + if len > remaining { + return Err(Error::invalid_data(format!( + "collection length {} exceeds buffer remaining {}", + len, remaining + ))); + } + if len > self.max_collection_size { + return Err(Error::invalid_data(format!( + "collection length {} exceeds limit {}", + len, self.max_collection_size + ))); } Ok(()) } #[inline(always)] pub fn check_map_size(&self, len: usize) -> Result<(), Error> { - if let Some(max) = self.max_map_size { - if len > max { - return Err(Error::invalid_data(format!( - "map entry count {} exceeds limit {}", - len, max - ))); - } + let remaining = self.reader.bf.len().saturating_sub(self.reader.cursor); + if len > remaining / 2 { + return Err(Error::invalid_data(format!( + "map entry count {} exceeds buffer remaining capacity {}", + len, remaining + ))); + } + if len > self.max_map_size { + return Err(Error::invalid_data(format!( + "map entry count {} exceeds limit {}", + len, self.max_map_size + ))); } Ok(()) } diff --git a/rust/fory-core/src/serializer/map.rs b/rust/fory-core/src/serializer/map.rs index dadef93075..e6a0578e0b 100644 --- a/rust/fory-core/src/serializer/map.rs +++ b/rust/fory-core/src/serializer/map.rs @@ -547,11 +547,11 @@ impl Result { let len = context.reader.read_varuint32()?; - context.check_map_size(len as usize)?; - let mut map = HashMap::::with_capacity(len as usize); if len == 0 { - return Ok(map); + return Ok(HashMap::new()); } + context.check_map_size(len as usize)?; + let mut map = HashMap::::with_capacity(len as usize); if K::fory_is_polymorphic() || K::fory_is_shared_ref() || V::fory_is_polymorphic() diff --git a/rust/fory-core/src/serializer/string.rs b/rust/fory-core/src/serializer/string.rs index cdb5d0468c..f3ada8331e 100644 --- a/rust/fory-core/src/serializer/string.rs +++ b/rust/fory-core/src/serializer/string.rs @@ -49,10 +49,10 @@ impl Serializer for String { // For UTF-16, `len` is the number of code-units (each 2 bytes wide). // Convert to a byte budget before checking so the limit means bytes, // not code-units. Latin-1 and UTF-8 already store `len` as byte count. - let byte_len = if encoding == 1 { - (len as usize).saturating_mul(2) - } else { - len as usize + let len_usize = usize::try_from(len).unwrap_or(usize::MAX); + let byte_len = match encoding { + 1 => len_usize.saturating_mul(2), + _ => len_usize, }; context.check_string_bytes(byte_len)?; let s = match encoding { diff --git a/rust/tests/tests/test_size_guardrails.rs b/rust/tests/tests/test_size_guardrails.rs index b632ecb93e..0ba27ffaaf 100644 --- a/rust/tests/tests/test_size_guardrails.rs +++ b/rust/tests/tests/test_size_guardrails.rs @@ -16,32 +16,38 @@ // under the License. use fory_core::fory::Fory; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap, HashSet}; + +// ── Collection (Vec) ───────────────────────────────────────────────────── #[test] fn test_collection_size_limit_exceeded() { - if fory_core::error::should_panic_on_error() { - return; - } let fory_write = Fory::default(); let items: Vec = vec![1, 2, 3, 4, 5]; let bytes = fory_write.serialize(&items).unwrap(); let fory_read = Fory::default().max_collection_size(3); - let result: Result, _> = fory_read.deserialize(&bytes); - assert!( - result.is_err(), - "Expected deserialization to fail due to collection size limit" - ); - let err_msg = format!("{:?}", result.unwrap_err()); - assert!(err_msg.contains("collection length")); + + // FORY_PANIC_ON_ERROR is a compile-time constant (see error.rs). + // When set, Error constructors panic instead of returning Err, so we + // catch_unwind in that build variant rather than asserting is_err(). + if fory_core::error::PANIC_ON_ERROR { + let _ = std::panic::catch_unwind(|| { + let _: Result, _> = fory_read.deserialize(&bytes); + }); + } else { + let result: Result, _> = fory_read.deserialize(&bytes); + assert!( + result.is_err(), + "Expected deserialization to fail due to collection size limit" + ); + let err_msg = format!("{:?}", result.unwrap_err()); + assert!(err_msg.contains("collection length")); + } } #[test] fn test_collection_size_within_limit() { - if fory_core::error::should_panic_on_error() { - return; - } let fory = Fory::default().max_collection_size(5); let items: Vec = vec![1, 2, 3, 4, 5]; let bytes = fory.serialize(&items).unwrap(); @@ -49,11 +55,10 @@ fn test_collection_size_within_limit() { assert!(result.is_ok()); } +// ── Map (HashMap) ───────────────────────────────────────────────────────────── + #[test] fn test_map_size_limit_exceeded() { - if fory_core::error::should_panic_on_error() { - return; - } let fory_write = Fory::default(); let mut map: HashMap = HashMap::new(); map.insert("a".to_string(), 1); @@ -62,20 +67,24 @@ fn test_map_size_limit_exceeded() { let bytes = fory_write.serialize(&map).unwrap(); let fory_read = Fory::default().max_map_size(2); - let result: Result, _> = fory_read.deserialize(&bytes); - assert!( - result.is_err(), - "Expected deserialization to fail due to map size limit" - ); - let err_msg = format!("{:?}", result.unwrap_err()); - assert!(err_msg.contains("map entry count")); + + if fory_core::error::PANIC_ON_ERROR { + let _ = std::panic::catch_unwind(|| { + let _: Result, _> = fory_read.deserialize(&bytes); + }); + } else { + let result: Result, _> = fory_read.deserialize(&bytes); + assert!( + result.is_err(), + "Expected deserialization to fail due to map size limit" + ); + let err_msg = format!("{:?}", result.unwrap_err()); + assert!(err_msg.contains("map entry count")); + } } #[test] fn test_map_size_within_limit() { - if fory_core::error::should_panic_on_error() { - return; - } let fory = Fory::default().max_map_size(3); let mut map: HashMap = HashMap::new(); map.insert("a".to_string(), 1); @@ -86,33 +95,160 @@ fn test_map_size_within_limit() { assert!(result.is_ok()); } +// ── Map (BTreeMap) ──────────────────────────────────────────────────────────── + #[test] -fn test_string_size_limit_exceeded() { - if fory_core::error::should_panic_on_error() { - return; +fn test_btreemap_size_limit_exceeded() { + // Regression: HashMap guard was previously placed before the len==0 early + // return and before with_capacity; this test also covers BTreeMap ordering. + let fory_write = Fory::default(); + let mut map: BTreeMap = BTreeMap::new(); + map.insert("x".to_string(), 1); + map.insert("y".to_string(), 2); + map.insert("z".to_string(), 3); + let bytes = fory_write.serialize(&map).unwrap(); + + let fory_read = Fory::default().max_map_size(2); + + if fory_core::error::PANIC_ON_ERROR { + let _ = std::panic::catch_unwind(|| { + let _: Result, _> = fory_read.deserialize(&bytes); + }); + } else { + let result: Result, _> = fory_read.deserialize(&bytes); + assert!( + result.is_err(), + "Expected deserialization to fail due to BTreeMap size limit" + ); + let err_msg = format!("{:?}", result.unwrap_err()); + assert!(err_msg.contains("map entry count")); + } +} + +#[test] +fn test_btreemap_size_within_limit() { + let fory = Fory::default().max_map_size(3); + let mut map: BTreeMap = BTreeMap::new(); + map.insert("x".to_string(), 1); + map.insert("y".to_string(), 2); + map.insert("z".to_string(), 3); + let bytes = fory.serialize(&map).unwrap(); + let result: Result, _> = fory.deserialize(&bytes); + assert!(result.is_ok()); +} + +// ── Collection (HashSet) ────────────────────────────────────────────────────── + +#[test] +fn test_hashset_size_limit_exceeded() { + let fory_write = Fory::default(); + let set: HashSet = vec![1, 2, 3, 4, 5].into_iter().collect(); + let bytes = fory_write.serialize(&set).unwrap(); + + let fory_read = Fory::default().max_collection_size(3); + + if fory_core::error::PANIC_ON_ERROR { + let _ = std::panic::catch_unwind(|| { + let _: Result, _> = fory_read.deserialize(&bytes); + }); + } else { + let result: Result, _> = fory_read.deserialize(&bytes); + assert!( + result.is_err(), + "Expected deserialization to fail due to HashSet size limit" + ); + let err_msg = format!("{:?}", result.unwrap_err()); + assert!(err_msg.contains("collection length")); } +} + +// ── String ──────────────────────────────────────────────────────────────────── + +#[test] +fn test_string_size_limit_exceeded() { let fory_write = Fory::default(); let s = "hello world".to_string(); let bytes = fory_write.serialize(&s).unwrap(); let fory_read = Fory::default().max_string_bytes(5); - let result: Result = fory_read.deserialize(&bytes); - assert!( - result.is_err(), - "Expected deserialization to fail due to string size limit" - ); - let err_msg = format!("{:?}", result.unwrap_err()); - assert!(err_msg.contains("string byte length")); + + if fory_core::error::PANIC_ON_ERROR { + let _ = std::panic::catch_unwind(|| { + let _: Result = fory_read.deserialize(&bytes); + }); + } else { + let result: Result = fory_read.deserialize(&bytes); + assert!( + result.is_err(), + "Expected deserialization to fail due to string size limit" + ); + let err_msg = format!("{:?}", result.unwrap_err()); + assert!(err_msg.contains("string byte length")); + } } #[test] fn test_string_size_within_limit() { - if fory_core::error::should_panic_on_error() { - return; - } let fory = Fory::default().max_string_bytes(20); let s = "hello world".to_string(); let bytes = fory.serialize(&s).unwrap(); let result: Result = fory.deserialize(&bytes); assert!(result.is_ok()); } + +// ── Primitive list (Vec) ────────────────────────────────────────────────── + +#[test] +fn test_primitive_vec_size_limit_exceeded() { + // Vec uses the primitive_list.rs bulk-copy path which is separate from + // the generic collection path — verifies the guard fires there too. + let fory_write = Fory::default(); + let data: Vec = vec![0u8; 100]; + let bytes = fory_write.serialize(&data).unwrap(); + + let fory_read = Fory::default().max_collection_size(50); + + if fory_core::error::PANIC_ON_ERROR { + let _ = std::panic::catch_unwind(|| { + let _: Result, _> = fory_read.deserialize(&bytes); + }); + } else { + let result: Result, _> = fory_read.deserialize(&bytes); + assert!( + result.is_err(), + "Expected deserialization to fail due to primitive list size limit" + ); + let err_msg = format!("{:?}", result.unwrap_err()); + assert!(err_msg.contains("collection length")); + } +} + +// ── Buffer truncation (buffer-remaining cross-check) ───────────────────────── + +#[test] +fn test_buffer_truncation_rejected() { + // Validates the buffer-remaining cross-check in check_string_bytes + // independently of any configured limit: a structurally truncated buffer + // must be rejected even with default (i32::MAX) limits. + let fory_write = Fory::default(); + let s = "hello world".to_string(); + let bytes = fory_write.serialize(&s).unwrap(); + + // Drop the last 4 bytes so the payload is structurally incomplete. + let truncated = &bytes[..bytes.len().saturating_sub(4)]; + let fory_read = Fory::default(); + + // Truncation causes a read past the buffer end — this always returns Err + // (or panics in PANIC_ON_ERROR builds), never silently succeeds. + if fory_core::error::PANIC_ON_ERROR { + let _ = std::panic::catch_unwind(|| { + let _: Result = fory_read.deserialize(truncated); + }); + } else { + let result: Result = fory_read.deserialize(truncated); + assert!( + result.is_err(), + "Truncated buffer must be rejected during deserialization" + ); + } +} From 8dba330e13909d3a4a91edbf9012ee2e60fbce89 Mon Sep 17 00:00:00 2001 From: Zakir Date: Thu, 26 Feb 2026 23:00:28 +0530 Subject: [PATCH 05/10] fix ci --- rust/fory-core/src/resolver/context.rs | 29 ++++---------------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/rust/fory-core/src/resolver/context.rs b/rust/fory-core/src/resolver/context.rs index 892b965dfe..1271b5c907 100644 --- a/rust/fory-core/src/resolver/context.rs +++ b/rust/fory-core/src/resolver/context.rs @@ -150,7 +150,7 @@ impl<'a> WriteContext<'a> { } } - #[inline(always)] + #[inline(always)] fn get_leak_buffer() -> &'static mut Vec { Box::leak(Box::new(vec![])) } @@ -480,16 +480,9 @@ impl<'a> ReadContext<'a> { #[inline(always)] pub fn check_string_bytes(&self, byte_len: usize) -> Result<(), Error> { - let remaining = self.reader.bf.len().saturating_sub(self.reader.cursor); - if byte_len > remaining { - return Err(Error::invalid_data(format!( - "string byte length {} exceeds buffer remaining {}", - byte_len, remaining - ))); - } if byte_len > self.max_string_bytes { return Err(Error::invalid_data(format!( - "string byte length {} exceeds limit {}", + "string byte length {} exceeds configured limit {}", byte_len, self.max_string_bytes ))); } @@ -498,16 +491,9 @@ impl<'a> ReadContext<'a> { #[inline(always)] pub fn check_collection_size(&self, len: usize) -> Result<(), Error> { - let remaining = self.reader.bf.len().saturating_sub(self.reader.cursor); - if len > remaining { - return Err(Error::invalid_data(format!( - "collection length {} exceeds buffer remaining {}", - len, remaining - ))); - } if len > self.max_collection_size { return Err(Error::invalid_data(format!( - "collection length {} exceeds limit {}", + "collection length {} exceeds configured limit {}", len, self.max_collection_size ))); } @@ -516,16 +502,9 @@ impl<'a> ReadContext<'a> { #[inline(always)] pub fn check_map_size(&self, len: usize) -> Result<(), Error> { - let remaining = self.reader.bf.len().saturating_sub(self.reader.cursor); - if len > remaining / 2 { - return Err(Error::invalid_data(format!( - "map entry count {} exceeds buffer remaining capacity {}", - len, remaining - ))); - } if len > self.max_map_size { return Err(Error::invalid_data(format!( - "map entry count {} exceeds limit {}", + "map entry count {} exceeds configured limit {}", len, self.max_map_size ))); } From b59ac23f9c449d9dd6162944f7fb2f90b3532f82 Mon Sep 17 00:00:00 2001 From: Zakir Date: Thu, 26 Feb 2026 23:13:57 +0530 Subject: [PATCH 06/10] fix ci --- rust/fory-core/src/resolver/context.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/fory-core/src/resolver/context.rs b/rust/fory-core/src/resolver/context.rs index 1271b5c907..35c516c586 100644 --- a/rust/fory-core/src/resolver/context.rs +++ b/rust/fory-core/src/resolver/context.rs @@ -150,7 +150,7 @@ impl<'a> WriteContext<'a> { } } - #[inline(always)] + #[inline(always)] fn get_leak_buffer() -> &'static mut Vec { Box::leak(Box::new(vec![])) } From 65172ea5c053a909040b9c40f89838fec7dcbad9 Mon Sep 17 00:00:00 2001 From: Zakir Date: Thu, 26 Feb 2026 23:32:42 +0530 Subject: [PATCH 07/10] Add buffer-remaining check --- rust/fory-core/src/resolver/context.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/rust/fory-core/src/resolver/context.rs b/rust/fory-core/src/resolver/context.rs index 35c516c586..17834f0fc7 100644 --- a/rust/fory-core/src/resolver/context.rs +++ b/rust/fory-core/src/resolver/context.rs @@ -478,8 +478,14 @@ impl<'a> ReadContext<'a> { self.meta_string_resolver.read_meta_string(&mut self.reader) } - #[inline(always)] pub fn check_string_bytes(&self, byte_len: usize) -> Result<(), Error> { + let remaining = self.reader.bf.len().saturating_sub(self.reader.cursor); + if byte_len > remaining { + return Err(Error::invalid_data(format!( + "string byte length {} exceeds buffer remaining {}", + byte_len, remaining + ))); + } if byte_len > self.max_string_bytes { return Err(Error::invalid_data(format!( "string byte length {} exceeds configured limit {}", From b48f931992e73a86b6271d95f73eed79f4eb7c9e Mon Sep 17 00:00:00 2001 From: Zakir Date: Fri, 27 Feb 2026 00:17:09 +0530 Subject: [PATCH 08/10] remove buff check --- rust/fory-core/src/resolver/context.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/rust/fory-core/src/resolver/context.rs b/rust/fory-core/src/resolver/context.rs index 17834f0fc7..5f06c4e92f 100644 --- a/rust/fory-core/src/resolver/context.rs +++ b/rust/fory-core/src/resolver/context.rs @@ -479,13 +479,6 @@ impl<'a> ReadContext<'a> { } pub fn check_string_bytes(&self, byte_len: usize) -> Result<(), Error> { - let remaining = self.reader.bf.len().saturating_sub(self.reader.cursor); - if byte_len > remaining { - return Err(Error::invalid_data(format!( - "string byte length {} exceeds buffer remaining {}", - byte_len, remaining - ))); - } if byte_len > self.max_string_bytes { return Err(Error::invalid_data(format!( "string byte length {} exceeds configured limit {}", From c547e7e6f0d0ae5094f3c516fa79c8ce458792fd Mon Sep 17 00:00:00 2001 From: Zakir Date: Fri, 27 Feb 2026 00:40:32 +0530 Subject: [PATCH 09/10] addong buff rem checks --- rust/fory-core/src/serializer/collection.rs | 14 +++++++++++--- rust/fory-core/src/serializer/map.rs | 11 +++++++++++ rust/fory-core/src/serializer/string.rs | 11 +++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/rust/fory-core/src/serializer/collection.rs b/rust/fory-core/src/serializer/collection.rs index 9f5df32347..0c09f408dd 100644 --- a/rust/fory-core/src/serializer/collection.rs +++ b/rust/fory-core/src/serializer/collection.rs @@ -230,9 +230,17 @@ where if len == 0 { return Ok(C::from_iter(std::iter::empty())); } - // Guard must come before the polymorphic dispatch so that non-Vec collections - // (HashSet, LinkedList, BTreeSet, ...) with polymorphic element types are also - // protected. Mirrors the ordering in read_vec_data. + let remaining = context + .reader + .bf + .len() + .saturating_sub(context.reader.cursor); + if len as usize > remaining { + return Err(Error::invalid_data(format!( + "collection length {} exceeds buffer remaining {}", + len, remaining + ))); + } context.check_collection_size(len as usize)?; if T::fory_is_polymorphic() || T::fory_is_shared_ref() { return read_collection_data_dyn_ref(context, len); diff --git a/rust/fory-core/src/serializer/map.rs b/rust/fory-core/src/serializer/map.rs index e6a0578e0b..f13b8512b2 100644 --- a/rust/fory-core/src/serializer/map.rs +++ b/rust/fory-core/src/serializer/map.rs @@ -550,6 +550,17 @@ impl remaining { + return Err(Error::invalid_data(format!( + "map entry count {} exceeds buffer remaining {}", + len, remaining + ))); + } context.check_map_size(len as usize)?; let mut map = HashMap::::with_capacity(len as usize); if K::fory_is_polymorphic() diff --git a/rust/fory-core/src/serializer/string.rs b/rust/fory-core/src/serializer/string.rs index f3ada8331e..26a73c3938 100644 --- a/rust/fory-core/src/serializer/string.rs +++ b/rust/fory-core/src/serializer/string.rs @@ -54,6 +54,17 @@ impl Serializer for String { 1 => len_usize.saturating_mul(2), _ => len_usize, }; + let remaining = context + .reader + .bf + .len() + .saturating_sub(context.reader.cursor); + if byte_len > remaining { + return Err(Error::invalid_data(format!( + "string byte length {} exceeds buffer remaining {}", + byte_len, remaining + ))); + } context.check_string_bytes(byte_len)?; let s = match encoding { 0 => context.reader.read_latin1_string(len as usize), From 6c2a02c08587988e3635bd599468773967767521 Mon Sep 17 00:00:00 2001 From: Zakir Date: Fri, 27 Feb 2026 00:45:05 +0530 Subject: [PATCH 10/10] fix ci --- rust/fory-core/src/serializer/map.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/rust/fory-core/src/serializer/map.rs b/rust/fory-core/src/serializer/map.rs index f13b8512b2..7ac0748b34 100644 --- a/rust/fory-core/src/serializer/map.rs +++ b/rust/fory-core/src/serializer/map.rs @@ -713,6 +713,17 @@ impl remaining { + return Err(Error::invalid_data(format!( + "map entry count {} exceeds buffer remaining {}", + len, remaining + ))); + } context.check_map_size(len as usize)?; let mut map = BTreeMap::::new(); if K::fory_is_polymorphic()