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

tisonkun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datasketches-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 934df69  fix(frequencies): reject out-of-range map-size fields on 
deserialize (#224)
934df69 is described below

commit 934df697dc0a975af8cb6986984b657aac058774
Author: Jaideep Pyne <[email protected]>
AuthorDate: Tue Aug 25 21:48:59 2026 +0530

    fix(frequencies): reject out-of-range map-size fields on deserialize (#224)
    
    Co-authored-by: tison <[email protected]>
---
 CHANGELOG.md                                  |   1 +
 datasketches/src/frequencies/sketch.rs        |  67 +++++++++++--
 datasketches/tests/frequencies_test/update.rs |   6 ++
 datasketches/tests/serde_tests/frequencies.rs | 133 ++++++++++++++++++++++++++
 4 files changed, 200 insertions(+), 7 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 91629ca..91ae5f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@ All significant changes to this project will be documented in 
this file.
 
 ### Bug fixes
 
+* Frequent-items map sizes are now limited consistently to the cross-language 
maximum of `2^30`: `FrequentItemsSketch::new` rejects larger configurations, 
and `deserialize` returns `InvalidData` for out-of-range or inconsistent header 
fields instead of panicking on corrupt input. Empty images restore the minimum 
backing map instead of allocating from `lg_cur_map_size`, and non-empty images 
validate `active_items` against the declared map capacity and remaining payload 
before preallocatin [...]
 * T-Digest compression now handles `k = u16::MAX` without overflowing the 
scale normalization input.
 * T-Digest deserialization now validates declared payload lengths before 
allocating. Updating a deserialized digest whose unmerged buffer already 
exceeds the compression threshold now compresses it instead of allowing the 
buffer to grow without bound.
 
diff --git a/datasketches/src/frequencies/sketch.rs 
b/datasketches/src/frequencies/sketch.rs
index 41b1ac7..bea0ca7 100644
--- a/datasketches/src/frequencies/sketch.rs
+++ b/datasketches/src/frequencies/sketch.rs
@@ -39,11 +39,38 @@ type SerializeItem<T> = fn(&mut SketchBytes, &T);
 type DeserializeItems<T> = fn(SketchSlice<'_>, usize) -> Result<Vec<T>, Error>;
 
 const LG_MIN_MAP_SIZE: u8 = 3;
+// Java represents map sizes as positive `int` powers of two, while the C++
+// implementation uses 32-bit table indices. Keep Rust configurations within
+// the same cross-language range.
+const LG_MAX_MAP_SIZE: u8 = 30;
+const MAX_MAP_SIZE: usize = 1usize << LG_MAX_MAP_SIZE;
 const SAMPLE_SIZE: usize = 1024;
 const EPSILON_FACTOR: f64 = 3.5;
 const LOAD_FACTOR_NUMERATOR: usize = 3;
 const LOAD_FACTOR_DENOMINATOR: usize = 4;
 
+fn map_capacity_for_lg(lg_map_size: u8) -> usize {
+    debug_assert!(lg_map_size <= LG_MAX_MAP_SIZE);
+    (1usize << lg_map_size) * LOAD_FACTOR_NUMERATOR / LOAD_FACTOR_DENOMINATOR
+}
+
+fn validate_lg_map_sizes(lg_max: u8, lg_cur: u8) -> Result<(), Error> {
+    if lg_cur < LG_MIN_MAP_SIZE {
+        return Err(Error::deserial(format!(
+            "lg_cur_map_size must be at least {LG_MIN_MAP_SIZE}, got {lg_cur}"
+        )));
+    }
+    if lg_max > LG_MAX_MAP_SIZE {
+        return Err(Error::deserial(format!(
+            "lg_max_map_size must be at most {LG_MAX_MAP_SIZE}, got {lg_max}"
+        )));
+    }
+    if lg_cur > lg_max {
+        return Err(Error::deserial("lg_cur_map_size exceeds lg_max_map_size"));
+    }
+    Ok(())
+}
+
 /// Error guarantees for frequent item queries.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum ErrorType {
@@ -110,7 +137,8 @@ impl<T: Eq + Hash> FrequentItemsSketch<T> {
     ///
     /// # Panics
     ///
-    /// Panics if `max_map_size` is not a power of two.
+    /// Panics if `max_map_size` is not a power of two or exceeds `2^30`, the
+    /// maximum supported by the cross-language format implementations.
     ///
     /// # Examples
     ///
@@ -127,6 +155,10 @@ impl<T: Eq + Hash> FrequentItemsSketch<T> {
             max_map_size.is_power_of_two(),
             "max_map_size must be power of 2"
         );
+        assert!(
+            max_map_size <= MAX_MAP_SIZE,
+            "max_map_size must not exceed {MAX_MAP_SIZE}"
+        );
         let lg_max_map_size = max_map_size.trailing_zeros() as u8;
         Self::with_lg_map_sizes(lg_max_map_size, LG_MIN_MAP_SIZE)
     }
@@ -230,7 +262,7 @@ impl<T: Eq + Hash> FrequentItemsSketch<T> {
     ///
     /// This is `0.75 * max_map_size`.
     pub fn maximum_map_capacity(&self) -> usize {
-        (1usize << self.lg_max_map_size) * LOAD_FACTOR_NUMERATOR / 
LOAD_FACTOR_DENOMINATOR
+        map_capacity_for_lg(self.lg_max_map_size)
     }
 
     /// Returns the current map capacity.
@@ -473,13 +505,17 @@ impl<T: Eq + Hash> FrequentItemsSketch<T> {
     fn with_lg_map_sizes(lg_max_map_size: u8, lg_cur_map_size: u8) -> Self {
         let lg_max = lg_max_map_size.max(LG_MIN_MAP_SIZE);
         let lg_cur = lg_cur_map_size.max(LG_MIN_MAP_SIZE);
+        assert!(
+            lg_max <= LG_MAX_MAP_SIZE,
+            "lg_max_map_size must not exceed {LG_MAX_MAP_SIZE}"
+        );
         assert!(
             lg_cur <= lg_max,
             "lg_cur_map_size must not exceed lg_max_map_size"
         );
         let map = ReversePurgeItemHashMap::new(1usize << lg_cur);
         let cur_map_cap = map.capacity();
-        let max_map_cap = (1usize << lg_max) * LOAD_FACTOR_NUMERATOR / 
LOAD_FACTOR_DENOMINATOR;
+        let max_map_cap = map_capacity_for_lg(lg_max);
         let sample_size = SAMPLE_SIZE.min(max_map_cap);
         Self {
             lg_max_map_size: lg_max,
@@ -567,14 +603,14 @@ impl<T: Eq + Hash> FrequentItemsSketch<T> {
 
         Family::FREQUENCY.validate_id(family)?;
         ensure_serial_version_is(SERIAL_VERSION, serial_version)?;
-        if lg_cur > lg_max {
-            return Err(Error::deserial("lg_cur_map_size exceeds 
lg_max_map_size"));
-        }
+        validate_lg_map_sizes(lg_max, lg_cur)?;
 
         let is_empty = (flags & EMPTY_FLAG_MASK) != 0;
         if is_empty {
             ensure_preamble_longs_in(&[PREAMBLE_LONGS_EMPTY], pre_longs)?;
-            return Ok(Self::with_lg_map_sizes(lg_max, lg_cur));
+            // Java also restores empty images at the minimum size. `lg_cur` 
does
+            // not carry item state here and must not control an eager 
allocation.
+            return Ok(Self::with_lg_map_sizes(lg_max, LG_MIN_MAP_SIZE));
         }
 
         ensure_preamble_longs_in(&[PREAMBLE_LONGS_NONEMPTY], pre_longs)?;
@@ -582,6 +618,13 @@ impl<T: Eq + Hash> FrequentItemsSketch<T> {
             .read_u32_le()
             .map_err(insufficient_data("active_items"))?;
         let active_items = active_items as usize;
+        let cur_map_cap = map_capacity_for_lg(lg_cur);
+        if active_items > cur_map_cap {
+            return Err(Error::deserial(format!(
+                "active_items ({active_items}) exceeds the capacity implied by 
\
+                 lg_cur_map_size ({lg_cur}): at most {cur_map_cap}"
+            )));
+        }
         cursor
             .read_u32_le()
             .map_err(insufficient_data("<unused>"))?;
@@ -590,6 +633,16 @@ impl<T: Eq + Hash> FrequentItemsSketch<T> {
             .map_err(insufficient_data("stream_weight"))?;
         let offset_val = 
cursor.read_u64_le().map_err(insufficient_data("offset"))?;
 
+        // Each active item has an eight-byte weight before its encoded key. 
Check
+        // that lower bound before trusting the count for `Vec` preallocation.
+        let weight_bytes = active_items.checked_mul(size_of::<u64>());
+        if !weight_bytes.is_some_and(|needed| needed <= 
cursor.remaining().len()) {
+            return Err(Error::insufficient_data(format!(
+                "active_items ({active_items}) exceeds the remaining {} bytes",
+                cursor.remaining().len()
+            )));
+        }
+
         let mut values = Vec::with_capacity(active_items);
         for i in 0..active_items {
             values.push(cursor.read_u64_le().map_err(|_| {
diff --git a/datasketches/tests/frequencies_test/update.rs 
b/datasketches/tests/frequencies_test/update.rs
index 13d115c..7419dba 100644
--- a/datasketches/tests/frequencies_test/update.rs
+++ b/datasketches/tests/frequencies_test/update.rs
@@ -561,6 +561,12 @@ fn test_items_invalid_map_size_panics() {
     FrequentItemsSketch::<String>::new(6);
 }
 
+#[test]
+#[should_panic(expected = "max_map_size must not exceed")]
+fn test_map_size_above_cross_language_limit_panics() {
+    FrequentItemsSketch::<i64>::new(1usize << 31);
+}
+
 #[test]
 fn test_estimated_size() {
     let mut sketch: FrequentItemsSketch<i64> = FrequentItemsSketch::new(64);
diff --git a/datasketches/tests/serde_tests/frequencies.rs 
b/datasketches/tests/serde_tests/frequencies.rs
index 0e4fbdc..75f69b6 100644
--- a/datasketches/tests/serde_tests/frequencies.rs
+++ b/datasketches/tests/serde_tests/frequencies.rs
@@ -24,7 +24,9 @@ use datasketches::error::ErrorKind;
 use datasketches::frequencies::FrequentItemValue;
 use datasketches::frequencies::FrequentItemsSketch;
 use googletest::assert_that;
+use googletest::prelude::anything;
 use googletest::prelude::contains_substring;
+use googletest::prelude::err;
 use googletest::prelude::gt;
 
 use crate::serialization_test_data;
@@ -395,3 +397,134 @@ fn test_go_frequent_strings_utf8() {
     assert_eq!(sketch.estimate(&"шщъыь".to_string()), 6);
     assert_eq!(sketch.estimate(&"эюя".to_string()), 7);
 }
+
+// Header field constants for the DataSketches frequent-items format.
+const FREQ_SERIAL_VERSION: u8 = 1;
+const FREQ_FAMILY_ID: u8 = 10;
+const FREQ_PREAMBLE_LONGS_EMPTY: u8 = 1;
+const FREQ_PREAMBLE_LONGS_NONEMPTY: u8 = 4;
+const FREQ_EMPTY_FLAG_MASK: u8 = 5;
+
+fn empty_header(lg_max: u8, lg_cur: u8) -> Vec<u8> {
+    let mut bytes = SketchBytes::with_capacity(8);
+    bytes.write_u8(FREQ_PREAMBLE_LONGS_EMPTY);
+    bytes.write_u8(FREQ_SERIAL_VERSION);
+    bytes.write_u8(FREQ_FAMILY_ID);
+    bytes.write_u8(lg_max);
+    bytes.write_u8(lg_cur);
+    bytes.write_u8(FREQ_EMPTY_FLAG_MASK);
+    bytes.write_u16_le(0);
+    bytes.into_bytes()
+}
+
+// Regression: a corrupt header must never panic (or trigger an oversized
+// allocation) inside `with_lg_map_sizes`; deserialize must reject it cleanly.
+// Before the fix, `lg_max_map_size = 222` drove `1usize << lg_max` past the
+// width of `usize`, panicking with "attempt to shift left with overflow" in
+// debug builds.
+#[test]
+fn test_deserialize_rejects_out_of_range_lg_max_map_size() {
+    let bytes = empty_header(222, 5);
+    let result = FrequentItemsSketch::<i64>::deserialize(&bytes);
+    assert_that!(result, err(anything()));
+    assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidData);
+}
+
+#[test]
+fn test_deserialize_rejects_out_of_range_lg_max_map_size_nonempty() {
+    let mut bytes = SketchBytes::with_capacity(16);
+    bytes.write_u8(FREQ_PREAMBLE_LONGS_NONEMPTY);
+    bytes.write_u8(FREQ_SERIAL_VERSION);
+    bytes.write_u8(FREQ_FAMILY_ID);
+    bytes.write_u8(200); // lg_max_map_size, out of range
+    bytes.write_u8(3); // lg_cur_map_size
+    bytes.write_u8(0); // flags (not empty)
+    bytes.write_u16_le(0);
+    bytes.write_u32_le(0); // active_items
+    bytes.write_u32_le(0);
+    bytes.write_u64_le(0); // stream_weight
+    bytes.write_u64_le(0); // offset
+    let result = FrequentItemsSketch::<i64>::deserialize(&bytes.into_bytes());
+    assert_that!(result, err(anything()));
+}
+
+// `lg_cur_map_size` below the documented minimum is also corruption; the C++
+// reference `check_size` rejects it, so the Rust port must too.
+#[test]
+fn test_deserialize_rejects_undersized_lg_cur_map_size() {
+    let bytes = empty_header(10, 1);
+    let result = FrequentItemsSketch::<i64>::deserialize(&bytes);
+    assert_that!(result, err(anything()));
+}
+
+// The largest supported configuration must remain cheap while the sketch is
+// empty and round-trip through the public constructor and serializer.
+#[test]
+fn test_maximum_map_size_empty_round_trip() {
+    let sketch = FrequentItemsSketch::<i64>::new(1usize << 30);
+    let bytes = sketch.serialize();
+    let restored = FrequentItemsSketch::<i64>::deserialize(&bytes).unwrap();
+    assert!(restored.is_empty());
+    assert_eq!(restored.lg_max_map_size(), 30);
+    assert_eq!(restored.lg_cur_map_size(), 3);
+}
+
+// Builds a minimal non-empty (four-preamble-long) header. The caller supplies
+// `lg_max`, `lg_cur`, and `active_items`; no item payload is appended, so this
+// is only useful for exercising the header-consistency guards that run before
+// the payload is read.
+fn nonempty_header(lg_max: u8, lg_cur: u8, active_items: u32) -> Vec<u8> {
+    let mut bytes = SketchBytes::with_capacity(32);
+    bytes.write_u8(FREQ_PREAMBLE_LONGS_NONEMPTY);
+    bytes.write_u8(FREQ_SERIAL_VERSION);
+    bytes.write_u8(FREQ_FAMILY_ID);
+    bytes.write_u8(lg_max);
+    bytes.write_u8(lg_cur);
+    bytes.write_u8(0); // flags (not empty)
+    bytes.write_u16_le(0);
+    bytes.write_u32_le(active_items);
+    bytes.write_u32_le(0); // unused
+    bytes.write_u64_le(0); // stream_weight
+    bytes.write_u64_le(0); // offset
+    bytes.into_bytes()
+}
+
+// Regression for tisonkun's report on #224: the accepted boundary
+// `(lg_max, lg_cur) = (30, 30)` on an *empty* header still drove
+// `ReversePurgeItemHashMap::new(1 << 30)` (~1e9 slots, multi-GB) before any
+// payload was read. An empty sketch holds nothing, so it must now build its 
map
+// at the minimum size and deserialize cheaply instead of attempting the
+// allocation. If the fix regressed, this test would OOM/hang rather than fail.
+#[test]
+fn test_deserialize_empty_header_does_not_over_allocate() {
+    let bytes = empty_header(30, 30);
+    let restored = FrequentItemsSketch::<i64>::deserialize(&bytes).unwrap();
+    assert!(restored.is_empty());
+    assert_eq!(restored.num_active_items(), 0);
+    assert_eq!(restored.lg_max_map_size(), 30);
+    assert_eq!(restored.lg_cur_map_size(), 3);
+}
+
+// A non-empty header whose `lg_cur_map_size` is too small to hold the claimed
+// `active_items` under the load factor is corrupt: a map of `1 << 3` slots has
+// capacity 6, so it can never hold 100 active items. Reject it instead of
+// trusting the header.
+#[test]
+fn test_deserialize_rejects_lg_cur_inconsistent_with_num_active() {
+    let bytes = nonempty_header(10, 3, 100);
+    let result = FrequentItemsSketch::<i64>::deserialize(&bytes);
+    assert_that!(result, err(anything()));
+    assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidData);
+}
+
+// A non-empty header claiming a huge `active_items` that the remaining bytes
+// cannot possibly contain must be rejected before any capacity is reserved 
from
+// that count, so a corrupt header cannot drive a multi-GB `Vec` allocation.
+#[test]
+fn test_deserialize_rejects_num_active_exceeding_payload() {
+    // 700_000_000 <= capacity implied by lg_cur = 30, so it clears the 
capacity
+    // guard, but there are no item bytes for it, so the length guard rejects 
it.
+    let bytes = nonempty_header(30, 30, 700_000_000);
+    let result = FrequentItemsSketch::<i64>::deserialize(&bytes);
+    assert_that!(result, err(anything()));
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to