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 285ae24  fix(tdigest): validate deserialized state invariants (#262)
285ae24 is described below

commit 285ae24dfa778cf614fe002ce0caa700e203a635
Author: tison <[email protected]>
AuthorDate: Wed Sep 2 12:11:18 2026 +0800

    fix(tdigest): validate deserialized state invariants (#262)
---
 CHANGELOG.md                                   |   4 +
 datasketches/src/tdigest/sketch.rs             | 101 ++++++++++++++++++++-----
 tests-integration/tests/serde_tests/tdigest.rs |  54 +++++++++++++
 3 files changed, 139 insertions(+), 20 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 339c6a6..546d83d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,10 @@ All significant changes to this project will be documented in 
this file.
 
 * Add KLL sketches behind the `kll` feature, including rank, quantile, PMF, 
and CDF queries, custom item ordering, merging, and C++/Java-compatible 
serialization.
 
+### Bug fixes
+
+* T-Digest deserialization now rejects unknown or conflicting flags, reversed 
extrema, out-of-range values, unsorted centroids, and non-empty images without 
stored values.
+
 ## v0.5.0
 
 ### Breaking changes
diff --git a/datasketches/src/tdigest/sketch.rs 
b/datasketches/src/tdigest/sketch.rs
index 942c735..bedd640 100644
--- a/datasketches/src/tdigest/sketch.rs
+++ b/datasketches/src/tdigest/sketch.rs
@@ -595,8 +595,20 @@ impl TDigestMut {
             return Err(Error::deserial(format!("k must be at least 10, got 
{k}")));
         }
         let flags = cursor.read_u8().map_err(insufficient_data("flags"))?;
+        let known_flags = FLAGS_IS_EMPTY | FLAGS_IS_SINGLE_VALUE | 
FLAGS_REVERSE_MERGE;
+        if flags & !known_flags != 0 {
+            return Err(Error::deserial(format!(
+                "malformed data: unknown TDigest flags 0x{:02x}",
+                flags & !known_flags
+            )));
+        }
         let is_empty = (flags & FLAGS_IS_EMPTY) != 0;
         let is_single_value = (flags & FLAGS_IS_SINGLE_VALUE) != 0;
+        if is_empty && is_single_value {
+            return Err(Error::deserial(
+                "malformed data: empty and single-value flags are mutually 
exclusive",
+            ));
+        }
         let expected_preamble_longs = if is_empty || is_single_value {
             PREAMBLE_LONGS_EMPTY_OR_SINGLE
         } else {
@@ -655,10 +667,7 @@ impl TDigestMut {
                 cursor.read_f64_le().map_err(insufficient_data("max"))?,
             )
         };
-        check_non_nan(min, "min")?;
-        check_non_nan(max, "max")?;
-        check_finite(min, "min")?;
-        check_finite(max, "max")?;
+        check_extrema(min, max, "TDigest")?;
         let (centroid_bytes, buffered_value_bytes) = if is_f32 {
             (size_of::<f32>() + size_of::<u32>(), size_of::<f32>())
         } else {
@@ -686,8 +695,15 @@ impl TDigestMut {
         let stored_centroids = 
num_centroids.checked_add(num_buffered).ok_or_else(|| {
             Error::deserial("num_centroids and num_buffered exceed the 
supported size")
         })?;
+        if stored_centroids == 0 {
+            return Err(Error::deserial(
+                "malformed data: non-empty TDigest must contain a centroid or 
buffered value",
+            ));
+        }
         let mut centroids = Vec::with_capacity(stored_centroids);
         let mut compressed_weight = 0u64;
+        let mut previous_mean = min;
+        let mut centroid_means_valid = true;
         for bytes in centroid_payload.chunks_exact(centroid_bytes) {
             let (mean, weight) = if is_f32 {
                 (
@@ -700,26 +716,36 @@ impl TDigestMut {
                     u64::from_le_bytes(bytes[8..].try_into().unwrap()),
                 )
             };
-            check_non_nan(mean, "centroid mean")?;
-            check_finite(mean, "centroid")?;
+            centroid_means_valid &= mean.is_finite() & (mean >= previous_mean) 
& (mean <= max);
+            previous_mean = mean;
             let weight = check_nonzero(weight, "centroid weight")?;
             compressed_weight = checked_weight_sum(compressed_weight, 
weight.get())?;
             centroids.push(Centroid { mean, weight });
         }
+        if !centroid_means_valid {
+            return Err(Error::deserial(
+                "malformed data: centroid means must be finite, within 
extrema, and nondecreasing",
+            ));
+        }
         checked_weight_sum(compressed_weight, num_buffered as u64)?;
+        let mut buffered_values_valid = true;
         for bytes in buffered_payload.chunks_exact(buffered_value_bytes) {
             let value = if is_f32 {
                 f32::from_le_bytes(bytes.try_into().unwrap()) as f64
             } else {
                 f64::from_le_bytes(bytes.try_into().unwrap())
             };
-            check_non_nan(value, "buffered_value mean")?;
-            check_finite(value, "buffered_value mean")?;
+            buffered_values_valid &= value.is_finite() & (value >= min) & 
(value <= max);
             centroids.push(Centroid {
                 mean: value,
                 weight: DEFAULT_WEIGHT,
             });
         }
+        if !buffered_values_valid {
+            return Err(Error::deserial(
+                "malformed data: buffered values must be finite and within 
extrema",
+            ));
+        }
         Ok(TDigestMut::make(
             k,
             reverse_merge,
@@ -748,10 +774,7 @@ impl TDigestMut {
                 // compatibility with asBytes()
                 let min = cursor.read_f64_be().map_err(make_error("min"))?;
                 let max = cursor.read_f64_be().map_err(make_error("max"))?;
-                check_non_nan(min, "min in compat double format")?;
-                check_non_nan(max, "max in compat double format")?;
-                check_finite(min, "min in compat double format")?;
-                check_finite(max, "max in compat double format")?;
+                check_extrema(min, max, "compat double TDigest")?;
                 let k = cursor.read_f64_be().map_err(make_error("k"))? as u16;
                 if k < 10 {
                     return Err(Error::deserial(format!(
@@ -760,18 +783,31 @@ impl TDigestMut {
                 }
                 let num_centroids =
                     cursor.read_u32_be().map_err(make_error("num_centroids"))? 
as usize;
+                if num_centroids == 0 {
+                    return Err(Error::deserial(
+                        "malformed data: compat double TDigest must contain a 
centroid",
+                    ));
+                }
                 let mut total_weight = 0u64;
                 let mut centroids = Vec::with_capacity(num_centroids);
+                let mut previous_mean = min;
+                let mut centroid_means_valid = true;
                 for _ in 0..num_centroids {
                     let weight = 
cursor.read_f64_be().map_err(make_error("weight"))?;
                     let mean = 
cursor.read_f64_be().map_err(make_error("mean"))?;
                     let weight =
                         check_compat_weight(weight, "centroid weight in compat 
double format")?;
-                    check_non_nan(mean, "centroid mean in compat double 
format")?;
-                    check_finite(mean, "centroid mean in compat double 
format")?;
+                    centroid_means_valid &=
+                        mean.is_finite() & (mean >= previous_mean) & (mean <= 
max);
+                    previous_mean = mean;
                     total_weight = checked_weight_sum(total_weight, 
weight.get())?;
                     centroids.push(Centroid { mean, weight });
                 }
+                if !centroid_means_valid {
+                    return Err(Error::deserial(
+                        "malformed data: centroid means in compat double 
format must be finite, within extrema, and nondecreasing",
+                    ));
+                }
                 Ok(TDigestMut::make(
                     k,
                     false,
@@ -789,10 +825,7 @@ impl TDigestMut {
                 // reference implementation uses doubles for min and max
                 let min = cursor.read_f64_be().map_err(make_error("min"))?;
                 let max = cursor.read_f64_be().map_err(make_error("max"))?;
-                check_non_nan(min, "min in compat float format")?;
-                check_non_nan(max, "max in compat float format")?;
-                check_finite(min, "min in compat float format")?;
-                check_finite(max, "max in compat float format")?;
+                check_extrema(min, max, "compat float TDigest")?;
                 let k = cursor.read_f32_be().map_err(make_error("k"))? as u16;
                 if k < 10 {
                     return Err(Error::deserial(format!(
@@ -804,18 +837,31 @@ impl TDigestMut {
                 cursor.read_u32_be().map_err(make_error("<unused>"))?;
                 let num_centroids =
                     cursor.read_u16_be().map_err(make_error("num_centroids"))? 
as usize;
+                if num_centroids == 0 {
+                    return Err(Error::deserial(
+                        "malformed data: compat float TDigest must contain a 
centroid",
+                    ));
+                }
                 let mut total_weight = 0u64;
                 let mut centroids = Vec::with_capacity(num_centroids);
+                let mut previous_mean = min;
+                let mut centroid_means_valid = true;
                 for _ in 0..num_centroids {
                     let weight = 
cursor.read_f32_be().map_err(make_error("weight"))? as f64;
                     let mean = 
cursor.read_f32_be().map_err(make_error("mean"))? as f64;
                     let weight =
                         check_compat_weight(weight, "centroid weight in compat 
float format")?;
-                    check_non_nan(mean, "centroid mean in compat float 
format")?;
-                    check_finite(mean, "centroid mean in compat float 
format")?;
+                    centroid_means_valid &=
+                        mean.is_finite() & (mean >= previous_mean) & (mean <= 
max);
+                    previous_mean = mean;
                     total_weight = checked_weight_sum(total_weight, 
weight.get())?;
                     centroids.push(Centroid { mean, weight });
                 }
+                if !centroid_means_valid {
+                    return Err(Error::deserial(
+                        "malformed data: centroid means in compat float format 
must be finite, within extrema, and nondecreasing",
+                    ));
+                }
                 Ok(TDigestMut::make(
                     k,
                     false,
@@ -1597,6 +1643,21 @@ fn check_finite(value: f64, tag: &'static str) -> 
Result<(), Error> {
     Ok(())
 }
 
+#[inline]
+fn check_extrema(min: f64, max: f64, format: &'static str) -> Result<(), 
Error> {
+    if !min.is_finite() || !max.is_finite() {
+        return Err(Error::deserial(format!(
+            "malformed data: {format} extrema must be finite"
+        )));
+    }
+    if min > max {
+        return Err(Error::deserial(format!(
+            "malformed data: {format} min {min} exceeds max {max}"
+        )));
+    }
+    Ok(())
+}
+
 fn check_nonzero(value: u64, tag: &'static str) -> Result<NonZeroU64, Error> {
     NonZeroU64::new(value)
         .ok_or_else(|| Error::deserial(format!("malformed data: {tag} cannot 
be zero")))
diff --git a/tests-integration/tests/serde_tests/tdigest.rs 
b/tests-integration/tests/serde_tests/tdigest.rs
index c5bd0fe..1b871ef 100644
--- a/tests-integration/tests/serde_tests/tdigest.rs
+++ b/tests-integration/tests/serde_tests/tdigest.rs
@@ -350,6 +350,60 @@ fn 
test_updates_normalize_overfull_deserialized_mixed_buffer() {
     assert_eq!(roundtrip.max_value(), Some(1_000.0));
 }
 
+fn serialized_two_value_digest() -> Vec<u8> {
+    let mut tdigest = TDigestMut::new(100).unwrap();
+    tdigest.update(0.0);
+    tdigest.update(1.0);
+    tdigest.serialize()
+}
+
+fn assert_invalid_tdigest(bytes: &[u8]) {
+    let error = TDigestMut::deserialize(bytes).unwrap_err();
+    assert_eq!(error.kind(), datasketches::error::ErrorKind::InvalidData);
+}
+
+#[test]
+fn test_deserialize_rejects_unknown_or_conflicting_flags() {
+    let mut unknown = serialized_two_value_digest();
+    unknown[5] |= 0x80;
+    assert_invalid_tdigest(&unknown);
+
+    let mut empty = TDigestMut::new(100).unwrap();
+    let mut conflicting = empty.serialize();
+    conflicting[5] |= 1 << 1;
+    assert_invalid_tdigest(&conflicting);
+}
+
+#[test]
+fn test_deserialize_rejects_invalid_extrema_and_centroid_ranges() {
+    let mut reversed_extrema = serialized_two_value_digest();
+    reversed_extrema[16..24].copy_from_slice(&2_f64.to_le_bytes());
+    assert_invalid_tdigest(&reversed_extrema);
+
+    let mut centroid_outside_extrema = serialized_two_value_digest();
+    centroid_outside_extrema[32..40].copy_from_slice(&(-1_f64).to_le_bytes());
+    assert_invalid_tdigest(&centroid_outside_extrema);
+
+    let mut buffered_outside_extrema = serialized_two_value_digest();
+    buffered_outside_extrema[8..12].copy_from_slice(&1_u32.to_le_bytes());
+    buffered_outside_extrema[12..16].copy_from_slice(&1_u32.to_le_bytes());
+    buffered_outside_extrema[48..56].copy_from_slice(&2_f64.to_le_bytes());
+    assert_invalid_tdigest(&buffered_outside_extrema);
+}
+
+#[test]
+fn test_deserialize_rejects_unsorted_or_missing_centroids() {
+    let mut unsorted = serialized_two_value_digest();
+    unsorted[32..40].copy_from_slice(&1_f64.to_le_bytes());
+    unsorted[48..56].copy_from_slice(&0_f64.to_le_bytes());
+    assert_invalid_tdigest(&unsorted);
+
+    let mut missing = serialized_two_value_digest();
+    missing[8..12].copy_from_slice(&0_u32.to_le_bytes());
+    missing[12..16].copy_from_slice(&0_u32.to_le_bytes());
+    assert_invalid_tdigest(&missing);
+}
+
 #[test]
 fn test_deserialize_rejects_truncated_large_payload_before_allocation() {
     let mut tdigest = TDigestMut::new(10).unwrap();


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

Reply via email to