Skip to main content

matter_interaction/
accumulator.rs

1//! Reassembles chunked `ReportData` messages — the controller-side analogue
2//! of chip's `ClusterStateCache`. Merges [`AttributeReportItem`](crate::AttributeReportItem)s
3//! across one or more chunks keyed by `(endpoint, cluster, attribute)`.
4
5#![forbid(unsafe_code)]
6
7use std::collections::HashMap;
8
9use matter_codec::Value;
10
11use crate::error::ImError;
12use crate::path::AttributePath;
13use crate::read::{ReportData, ReportOp};
14
15/// Default ceiling on the number of distinct accumulated attribute elements.
16///
17/// Sized far above any realistic single-device read (project history records a
18/// 170-attribute dump; a busy multi-endpoint device is still only thousands of
19/// attributes), so legitimate large reads never trip it, while a peer cannot
20/// stream an unbounded count of distinct paths.
21pub const DEFAULT_MAX_ELEMENTS: usize = 100_000;
22
23/// Default ceiling on the estimated total in-memory byte size of accumulated
24/// values.
25///
26/// The controller's pre-parse chunk gate caps raw chunked-read input at
27/// 256 KiB (`MAX_READ_BYTES`). The parsed-`Value` tree this accumulator holds
28/// can be somewhat larger than its wire encoding (per-value enum/heap
29/// overhead), so this in-crate ceiling is set to 4 MiB — a generous multiple
30/// of the wire cap that still bounds memory as defense-in-depth when the
31/// accumulator is driven directly (e.g. without the controller's gate).
32pub const DEFAULT_MAX_BYTES: usize = 4 * 1024 * 1024;
33
34/// Rough estimate of a [`Value`]'s in-memory byte footprint, used only to
35/// bound accumulator growth (not a precise allocation count). Heap-bearing
36/// variants are walked recursively; scalars count as a small fixed size.
37fn estimate_value_bytes(v: &Value) -> usize {
38    const SCALAR: usize = 8;
39    match v {
40        Value::Utf8(s) => s.len(),
41        Value::Bytes(b) => b.len(),
42        Value::Array(items) => items.iter().map(estimate_value_bytes).sum::<usize>() + SCALAR,
43        Value::Structure(members) | Value::List(members) => {
44            members
45                .iter()
46                .map(|(_, mv)| estimate_value_bytes(mv))
47                .sum::<usize>()
48                + SCALAR
49        }
50        // Scalars (`Bool`/`Uint`/`Int`/`Float`/`Double`/`Null`) and — since
51        // `Value` is `#[non_exhaustive]` — any future scalar-ish variant charge
52        // a small fixed size so the ceiling still bounds them.
53        _ => SCALAR,
54    }
55}
56
57/// Accumulates attribute reports across chunked `ReportData` messages and
58/// produces the final concrete `(path, value)` set.
59///
60/// - `Replace` items set the attribute's value; the newest `DataVersion`
61///   wins when the same attribute is replaced more than once.
62/// - `Append` items (`ListIndex` = null) push one element onto the
63///   attribute's list, starting from an empty list if none was seen.
64///
65/// First-seen attribute order is preserved by [`finish`](Self::finish).
66///
67/// This accumulator enforces an in-crate **total-size ceiling** as
68/// defense-in-depth: [`push`](Self::push) returns
69/// [`ImError::AccumulatorOverflow`] once the number of distinct accumulated
70/// elements or the estimated total byte size would exceed the configured caps
71/// ([`DEFAULT_MAX_ELEMENTS`] / [`DEFAULT_MAX_BYTES`], or the values given to
72/// [`with_limits`](Self::with_limits)). This bounds memory even when the
73/// accumulator is driven directly from an untrusted peer streaming an
74/// unbounded chunked read/report set; a caller may still layer its own
75/// chunk-count / wire-byte cap on top (the read-transaction layer does).
76///
77/// # Examples
78///
79/// ```
80/// use matter_interaction::{parse_report_data, ReportAccumulator};
81///
82/// # fn demo(chunk_bytes: &[Vec<u8>]) -> Result<(), matter_interaction::ImError> {
83/// let mut acc = ReportAccumulator::new();
84/// for chunk in chunk_bytes {
85///     acc.push(parse_report_data(chunk)?)?; // errors if the ceiling is exceeded
86/// }
87/// let attributes = acc.finish(); // every attribute across all chunks
88/// # let _ = attributes;
89/// # Ok(())
90/// # }
91/// ```
92pub struct ReportAccumulator {
93    order: Vec<AttributePath>,
94    values: HashMap<(u16, u32, u32), Value>,
95    versions: HashMap<(u16, u32, u32), Option<u32>>,
96    /// Estimated total byte size of every currently-stored value.
97    bytes: usize,
98    max_elements: usize,
99    max_bytes: usize,
100}
101
102impl Default for ReportAccumulator {
103    fn default() -> Self {
104        Self::with_limits(DEFAULT_MAX_ELEMENTS, DEFAULT_MAX_BYTES)
105    }
106}
107
108impl ReportAccumulator {
109    /// Create an empty accumulator with the default total-size ceiling
110    /// ([`DEFAULT_MAX_ELEMENTS`] / [`DEFAULT_MAX_BYTES`]).
111    #[must_use]
112    pub fn new() -> Self {
113        Self::default()
114    }
115
116    /// Create an empty accumulator with explicit caps on the number of
117    /// distinct accumulated elements and the estimated total byte size.
118    ///
119    /// Use this to tighten the ceiling for a constrained transport, or to
120    /// loosen it for an unusually large device. Prefer [`new`](Self::new)
121    /// unless you have a concrete reason to override the defaults.
122    #[must_use]
123    pub fn with_limits(max_elements: usize, max_bytes: usize) -> Self {
124        Self {
125            order: Vec::new(),
126            values: HashMap::new(),
127            versions: HashMap::new(),
128            bytes: 0,
129            max_elements,
130            max_bytes,
131        }
132    }
133
134    /// Build the [`ImError::AccumulatorOverflow`] describing the current state
135    /// against the configured caps.
136    fn overflow(&self) -> ImError {
137        ImError::AccumulatorOverflow {
138            elements: self.order.len(),
139            bytes: self.bytes,
140            max_elements: self.max_elements,
141            max_bytes: self.max_bytes,
142        }
143    }
144
145    /// Merge one parsed `ReportData` chunk's items into the accumulated state.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`ImError::AccumulatorOverflow`] if merging would push the
150    /// number of distinct accumulated elements above the configured element
151    /// cap, or the estimated total accumulated byte size above the configured
152    /// byte cap. On overflow the offending item is not merged and the
153    /// accumulator is left holding only the items accepted before the cap was
154    /// reached; the caller should treat the report set as truncated and
155    /// discard the transaction.
156    pub fn push(&mut self, report: ReportData) -> Result<(), ImError> {
157        for item in report.items {
158            let key = (item.path.endpoint, item.path.cluster, item.path.attribute);
159            let item_bytes = estimate_value_bytes(&item.value);
160            // A genuinely new key would add one element; reject before inserting
161            // so the element count never exceeds the cap.
162            if !self.values.contains_key(&key) && self.order.len() >= self.max_elements {
163                return Err(self.overflow());
164            }
165            // Adding this value's bytes must not exceed the byte cap.
166            if self.bytes.saturating_add(item_bytes) > self.max_bytes {
167                return Err(self.overflow());
168            }
169            match item.op {
170                ReportOp::Replace => {
171                    let newer = match (self.versions.get(&key), item.data_version) {
172                        (Some(Some(old)), Some(new)) => new >= *old,
173                        _ => true, // unknown versions ⇒ last write wins
174                    };
175                    if newer {
176                        if !self.values.contains_key(&key) {
177                            self.order.push(item.path);
178                        } else if let Some(prev) = self.values.get(&key) {
179                            // Replacing an existing value: drop its byte charge
180                            // before adding the new one so the running total
181                            // tracks what is actually held.
182                            self.bytes = self.bytes.saturating_sub(estimate_value_bytes(prev));
183                        }
184                        self.bytes = self.bytes.saturating_add(item_bytes);
185                        self.values.insert(key, item.value);
186                        self.versions.insert(key, item.data_version);
187                    }
188                }
189                ReportOp::Append => {
190                    if !self.values.contains_key(&key) {
191                        self.order.push(item.path);
192                        self.values.insert(key, Value::Array(Vec::new()));
193                        self.versions.insert(key, item.data_version);
194                    }
195                    self.bytes = self.bytes.saturating_add(item_bytes);
196                    match self.values.get_mut(&key) {
197                        Some(Value::Array(list)) => list.push(item.value),
198                        // Malformed: an append targeting a non-list value (e.g.
199                        // a prior scalar `Replace` for the same path). A
200                        // conformant device never does this; coerce to a fresh
201                        // single-element list rather than silently dropping the
202                        // element.
203                        Some(slot) => *slot = Value::Array(vec![item.value]),
204                        None => {}
205                    }
206                }
207            }
208        }
209        Ok(())
210    }
211
212    /// Consume the accumulator, yielding `(path, value)` in first-seen order.
213    ///
214    /// Each [`Value`] is **moved** out of the consumed accumulator rather than
215    /// cloned: `self.order` records every accumulated path exactly once (a path
216    /// is pushed only on the first insert for its key — see [`push`](Self::push)),
217    /// so a single [`HashMap::remove`] per path drains the map without aliasing.
218    /// This avoids a full deep copy of every attribute subtree on the
219    /// chunked-read / subscription completion path.
220    #[must_use]
221    pub fn finish(mut self) -> Vec<(AttributePath, Value)> {
222        let mut out = Vec::with_capacity(self.order.len());
223        for path in std::mem::take(&mut self.order) {
224            let key = (path.endpoint, path.cluster, path.attribute);
225            if let Some(v) = self.values.remove(&key) {
226                out.push((path, v));
227            }
228        }
229        out
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    #![allow(clippy::unwrap_used, clippy::expect_used)]
236    use super::*;
237    use crate::read::AttributeReportItem;
238
239    fn report(items: Vec<AttributeReportItem>) -> ReportData {
240        ReportData {
241            items,
242            subscription_id: None,
243            more_chunked_messages: false,
244            suppress_response: false,
245            events: Vec::new(),
246        }
247    }
248
249    fn ap(endpoint: u16, cluster: u32, attribute: u32) -> AttributePath {
250        AttributePath {
251            endpoint,
252            cluster,
253            attribute,
254        }
255    }
256
257    fn replace(p: AttributePath, v: Value) -> AttributeReportItem {
258        AttributeReportItem {
259            path: p,
260            op: ReportOp::Replace,
261            value: v,
262            data_version: None,
263        }
264    }
265
266    fn append(p: AttributePath, v: Value) -> AttributeReportItem {
267        AttributeReportItem {
268            path: p,
269            op: ReportOp::Append,
270            value: v,
271            data_version: None,
272        }
273    }
274
275    #[test]
276    fn message_level_merge_preserves_order() {
277        let mut acc = ReportAccumulator::new();
278        acc.push(report(vec![replace(
279            ap(0, 0x28, 0x0002),
280            Value::Uint(5010),
281        )]))
282        .unwrap();
283        acc.push(report(vec![replace(
284            ap(1, 0x06, 0x0000),
285            Value::Bool(true),
286        )]))
287        .unwrap();
288        let out = acc.finish();
289        assert_eq!(out.len(), 2);
290        assert_eq!(out[0].0, ap(0, 0x28, 0x0002));
291        assert_eq!(out[0].1, Value::Uint(5010));
292        assert_eq!(out[1].0, ap(1, 0x06, 0x0000));
293        assert_eq!(out[1].1, Value::Bool(true));
294    }
295
296    #[test]
297    fn list_append_after_empty_replace() {
298        let mut acc = ReportAccumulator::new();
299        let p = ap(0, 0x1d, 0x0003);
300        acc.push(report(vec![replace(p, Value::Array(Vec::new()))]))
301            .unwrap();
302        acc.push(report(vec![append(p, Value::Uint(1))])).unwrap();
303        acc.push(report(vec![append(p, Value::Uint(2))])).unwrap();
304        let out = acc.finish();
305        assert_eq!(out.len(), 1);
306        assert_eq!(out[0].1, Value::Array(vec![Value::Uint(1), Value::Uint(2)]));
307    }
308
309    #[test]
310    fn append_without_base_starts_empty() {
311        let mut acc = ReportAccumulator::new();
312        let p = ap(0, 0x1d, 0x0003);
313        acc.push(report(vec![append(p, Value::Uint(9))])).unwrap();
314        assert_eq!(acc.finish()[0].1, Value::Array(vec![Value::Uint(9)]));
315    }
316
317    #[test]
318    fn append_onto_non_array_coerces_instead_of_dropping() {
319        // Malformed input: a scalar Replace then an Append on the same path.
320        // The element must not vanish — the slot coerces to a fresh list.
321        let mut acc = ReportAccumulator::new();
322        let p = ap(0, 0x1d, 0x0003);
323        acc.push(report(vec![replace(p, Value::Uint(1))])).unwrap();
324        acc.push(report(vec![append(p, Value::Uint(2))])).unwrap();
325        assert_eq!(acc.finish()[0].1, Value::Array(vec![Value::Uint(2)]));
326    }
327
328    #[test]
329    fn newest_data_version_wins() {
330        let mut acc = ReportAccumulator::new();
331        let p = ap(0, 0x28, 0x0000);
332        acc.push(report(vec![AttributeReportItem {
333            path: p,
334            op: ReportOp::Replace,
335            value: Value::Uint(1),
336            data_version: Some(5),
337        }]))
338        .unwrap();
339        acc.push(report(vec![AttributeReportItem {
340            path: p,
341            op: ReportOp::Replace,
342            value: Value::Uint(2),
343            data_version: Some(3),
344        }]))
345        .unwrap();
346        assert_eq!(
347            acc.finish()[0].1,
348            Value::Uint(1),
349            "older DataVersion must not overwrite"
350        );
351    }
352
353    /// `finish()` now MOVES values out of the consumed accumulator rather than
354    /// cloning them. Drive it with heap-bearing values (strings, byte strings,
355    /// nested lists) and assert the resulting `(path, value)` set is exactly
356    /// what was inserted — proving the move preserves content and order and
357    /// drops nothing.
358    #[test]
359    fn finish_moves_values_preserving_content_and_order() {
360        let mut acc = ReportAccumulator::new();
361        let p0 = ap(0, 0x28, 0x0001);
362        let p1 = ap(1, 0x06, 0x0000);
363        let p2 = ap(2, 0x1d, 0x0003);
364        let v0 = Value::Utf8(String::from("VendorName"));
365        let v1 = Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef]);
366        let v2 = Value::Array(vec![Value::Uint(1), Value::Utf8(String::from("x"))]);
367        acc.push(report(vec![
368            replace(p0, v0.clone()),
369            replace(p1, v1.clone()),
370            replace(p2, v2.clone()),
371        ]))
372        .unwrap();
373
374        let out = acc.finish();
375        assert_eq!(
376            out,
377            vec![(p0, v0), (p1, v1), (p2, v2)],
378            "moved-out set must match inserted (path, value) pairs in first-seen order"
379        );
380    }
381
382    use proptest::prelude::*;
383
384    #[test]
385    fn element_ceiling_is_enforced() {
386        // A tiny element cap; feeding past it must error rather than grow.
387        let mut acc = ReportAccumulator::with_limits(3, usize::MAX);
388        // 3 distinct attributes fit.
389        for i in 0..3u32 {
390            acc.push(report(vec![replace(
391                ap(0, 0x06, i),
392                Value::Uint(u64::from(i)),
393            )]))
394            .expect("within element cap");
395        }
396        // The 4th distinct attribute crosses the ceiling.
397        let err = acc
398            .push(report(vec![replace(ap(0, 0x06, 99), Value::Uint(1))]))
399            .expect_err("4th distinct element must exceed the cap");
400        assert!(
401            matches!(
402                err,
403                ImError::AccumulatorOverflow {
404                    max_elements: 3,
405                    ..
406                }
407            ),
408            "expected AccumulatorOverflow, got {err:?}"
409        );
410    }
411
412    #[test]
413    fn byte_ceiling_is_enforced() {
414        // Generous element cap, tiny byte cap. A large byte string trips it.
415        let mut acc = ReportAccumulator::with_limits(usize::MAX, 16);
416        let err = acc
417            .push(report(vec![replace(
418                ap(0, 0x28, 0x0001),
419                Value::Bytes(vec![0u8; 1024]),
420            )]))
421            .expect_err("1 KiB value must exceed a 16-byte cap");
422        assert!(
423            matches!(err, ImError::AccumulatorOverflow { max_bytes: 16, .. }),
424            "expected AccumulatorOverflow, got {err:?}"
425        );
426    }
427
428    #[test]
429    fn normal_sized_report_set_is_ok() {
430        // The default cap must comfortably admit a realistic large dump
431        // (project history: a 170-attribute device read). Simulate 200
432        // attributes carrying small values; all must accumulate without error.
433        let mut acc = ReportAccumulator::new();
434        for i in 0..200u32 {
435            acc.push(report(vec![replace(
436                ap(0, 0x28, i),
437                Value::Utf8(String::from("a-realistic-attribute-value")),
438            )]))
439            .expect("200 small attributes are well within the default ceiling");
440        }
441        assert_eq!(acc.finish().len(), 200);
442    }
443
444    proptest! {
445        // Splitting a set of whole-attribute Replace items across N chunks
446        // yields the same final set as one chunk (message-level chunking is
447        // transparent to reassembly), with first-seen order preserved.
448        #[test]
449        fn message_chunking_is_order_preserving(
450            attrs in proptest::collection::vec((0u16..4, 0u32..8, 0u32..8, 0u64..1000), 1..20),
451        ) {
452            // Dedup by key keeping first occurrence (matches accumulator semantics).
453            let mut seen = std::collections::HashSet::new();
454            let unique: Vec<_> = attrs.into_iter()
455                .filter(|(e, c, a, _)| seen.insert((*e, *c, *a)))
456                .collect();
457
458            // All items in one chunk.
459            let mut whole = ReportAccumulator::new();
460            whole.push(report(
461                unique.iter().map(|&(e, c, a, v)| replace(ap(e, c, a), Value::Uint(v))).collect(),
462            )).unwrap();
463            let whole_out = whole.finish();
464
465            // Same items, one per chunk.
466            let mut split = ReportAccumulator::new();
467            for &(e, c, a, v) in &unique {
468                split.push(report(vec![replace(ap(e, c, a), Value::Uint(v))])).unwrap();
469            }
470            let split_out = split.finish();
471
472            prop_assert_eq!(&whole_out, &split_out);
473            // Order matches first-seen.
474            for (i, &(e, c, a, _)) in unique.iter().enumerate() {
475                prop_assert_eq!(split_out[i].0, ap(e, c, a));
476            }
477        }
478
479        // Appends accumulate into a list of exactly the pushed elements, in order.
480        #[test]
481        fn appends_build_list_in_order(elems in proptest::collection::vec(0u64..1000, 0..30)) {
482            let p = ap(0, 0x1d, 0x0003);
483            let mut acc = ReportAccumulator::new();
484            acc.push(report(vec![replace(p, Value::Array(Vec::new()))])).unwrap();
485            for &v in &elems {
486                acc.push(report(vec![append(p, Value::Uint(v))])).unwrap();
487            }
488            let out = acc.finish();
489            let want: Vec<Value> = elems.iter().map(|&v| Value::Uint(v)).collect();
490            prop_assert_eq!(&out[0].1, &Value::Array(want));
491        }
492    }
493}