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                    // IM-3: apply the same DataVersion guard `Replace` uses — a
191                    // stale-version append (older than what we already hold for
192                    // this list) must not land on a newer list. A chunked list's
193                    // appends share one DataVersion, so same/unknown versions
194                    // proceed; only a strictly older version is dropped.
195                    if let (Some(Some(old)), Some(new)) =
196                        (self.versions.get(&key), item.data_version)
197                    {
198                        if new < *old {
199                            continue;
200                        }
201                    }
202                    if !self.values.contains_key(&key) {
203                        self.order.push(item.path);
204                        self.values.insert(key, Value::Array(Vec::new()));
205                    }
206                    self.versions.insert(key, item.data_version);
207                    self.bytes = self.bytes.saturating_add(item_bytes);
208                    match self.values.get_mut(&key) {
209                        Some(Value::Array(list)) => list.push(item.value),
210                        // Malformed: an append targeting a non-list value (e.g.
211                        // a prior scalar `Replace` for the same path). A
212                        // conformant device never does this; coerce to a fresh
213                        // single-element list rather than silently dropping the
214                        // element.
215                        Some(slot) => *slot = Value::Array(vec![item.value]),
216                        None => {}
217                    }
218                }
219            }
220        }
221        Ok(())
222    }
223
224    /// Consume the accumulator, yielding `(path, value)` in first-seen order.
225    ///
226    /// Each [`Value`] is **moved** out of the consumed accumulator rather than
227    /// cloned: `self.order` records every accumulated path exactly once (a path
228    /// is pushed only on the first insert for its key — see [`push`](Self::push)),
229    /// so a single [`HashMap::remove`] per path drains the map without aliasing.
230    /// This avoids a full deep copy of every attribute subtree on the
231    /// chunked-read / subscription completion path.
232    #[must_use]
233    pub fn finish(mut self) -> Vec<(AttributePath, Value)> {
234        let mut out = Vec::with_capacity(self.order.len());
235        for path in std::mem::take(&mut self.order) {
236            let key = (path.endpoint, path.cluster, path.attribute);
237            if let Some(v) = self.values.remove(&key) {
238                out.push((path, v));
239            }
240        }
241        out
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    #![allow(clippy::unwrap_used, clippy::expect_used)]
248    use super::*;
249    use crate::read::AttributeReportItem;
250
251    fn report(items: Vec<AttributeReportItem>) -> ReportData {
252        ReportData {
253            items,
254            subscription_id: None,
255            more_chunked_messages: false,
256            suppress_response: false,
257            events: Vec::new(),
258            statuses: Vec::new(),
259        }
260    }
261
262    fn ap(endpoint: u16, cluster: u32, attribute: u32) -> AttributePath {
263        AttributePath {
264            endpoint,
265            cluster,
266            attribute,
267        }
268    }
269
270    fn replace(p: AttributePath, v: Value) -> AttributeReportItem {
271        AttributeReportItem {
272            path: p,
273            op: ReportOp::Replace,
274            value: v,
275            data_version: None,
276        }
277    }
278
279    fn append(p: AttributePath, v: Value) -> AttributeReportItem {
280        AttributeReportItem {
281            path: p,
282            op: ReportOp::Append,
283            value: v,
284            data_version: None,
285        }
286    }
287
288    fn append_v(p: AttributePath, v: Value, version: u32) -> AttributeReportItem {
289        AttributeReportItem {
290            path: p,
291            op: ReportOp::Append,
292            value: v,
293            data_version: Some(version),
294        }
295    }
296
297    #[test]
298    fn stale_version_append_is_rejected() {
299        // IM-3: a strictly-older DataVersion append must not land on a newer
300        // list. Two appends at version 5 build the list; a version-3 append is
301        // stale and must be dropped (not appended).
302        let mut acc = ReportAccumulator::new();
303        let p = ap(0, 0x1d, 0x0003);
304        acc.push(report(vec![append_v(p, Value::Uint(1), 5)]))
305            .unwrap();
306        acc.push(report(vec![append_v(p, Value::Uint(2), 5)]))
307            .unwrap();
308        acc.push(report(vec![append_v(p, Value::Uint(99), 3)]))
309            .unwrap();
310        let out = acc.finish();
311        assert_eq!(out.len(), 1);
312        assert_eq!(
313            out[0].1,
314            Value::Array(vec![Value::Uint(1), Value::Uint(2)]),
315            "the stale-version append must not land on the newer list"
316        );
317    }
318
319    #[test]
320    fn message_level_merge_preserves_order() {
321        let mut acc = ReportAccumulator::new();
322        acc.push(report(vec![replace(
323            ap(0, 0x28, 0x0002),
324            Value::Uint(5010),
325        )]))
326        .unwrap();
327        acc.push(report(vec![replace(
328            ap(1, 0x06, 0x0000),
329            Value::Bool(true),
330        )]))
331        .unwrap();
332        let out = acc.finish();
333        assert_eq!(out.len(), 2);
334        assert_eq!(out[0].0, ap(0, 0x28, 0x0002));
335        assert_eq!(out[0].1, Value::Uint(5010));
336        assert_eq!(out[1].0, ap(1, 0x06, 0x0000));
337        assert_eq!(out[1].1, Value::Bool(true));
338    }
339
340    #[test]
341    fn list_append_after_empty_replace() {
342        let mut acc = ReportAccumulator::new();
343        let p = ap(0, 0x1d, 0x0003);
344        acc.push(report(vec![replace(p, Value::Array(Vec::new()))]))
345            .unwrap();
346        acc.push(report(vec![append(p, Value::Uint(1))])).unwrap();
347        acc.push(report(vec![append(p, Value::Uint(2))])).unwrap();
348        let out = acc.finish();
349        assert_eq!(out.len(), 1);
350        assert_eq!(out[0].1, Value::Array(vec![Value::Uint(1), Value::Uint(2)]));
351    }
352
353    #[test]
354    fn append_without_base_starts_empty() {
355        let mut acc = ReportAccumulator::new();
356        let p = ap(0, 0x1d, 0x0003);
357        acc.push(report(vec![append(p, Value::Uint(9))])).unwrap();
358        assert_eq!(acc.finish()[0].1, Value::Array(vec![Value::Uint(9)]));
359    }
360
361    #[test]
362    fn append_onto_non_array_coerces_instead_of_dropping() {
363        // Malformed input: a scalar Replace then an Append on the same path.
364        // The element must not vanish — the slot coerces to a fresh list.
365        let mut acc = ReportAccumulator::new();
366        let p = ap(0, 0x1d, 0x0003);
367        acc.push(report(vec![replace(p, Value::Uint(1))])).unwrap();
368        acc.push(report(vec![append(p, Value::Uint(2))])).unwrap();
369        assert_eq!(acc.finish()[0].1, Value::Array(vec![Value::Uint(2)]));
370    }
371
372    #[test]
373    fn newest_data_version_wins() {
374        let mut acc = ReportAccumulator::new();
375        let p = ap(0, 0x28, 0x0000);
376        acc.push(report(vec![AttributeReportItem {
377            path: p,
378            op: ReportOp::Replace,
379            value: Value::Uint(1),
380            data_version: Some(5),
381        }]))
382        .unwrap();
383        acc.push(report(vec![AttributeReportItem {
384            path: p,
385            op: ReportOp::Replace,
386            value: Value::Uint(2),
387            data_version: Some(3),
388        }]))
389        .unwrap();
390        assert_eq!(
391            acc.finish()[0].1,
392            Value::Uint(1),
393            "older DataVersion must not overwrite"
394        );
395    }
396
397    /// `finish()` now MOVES values out of the consumed accumulator rather than
398    /// cloning them. Drive it with heap-bearing values (strings, byte strings,
399    /// nested lists) and assert the resulting `(path, value)` set is exactly
400    /// what was inserted — proving the move preserves content and order and
401    /// drops nothing.
402    #[test]
403    fn finish_moves_values_preserving_content_and_order() {
404        let mut acc = ReportAccumulator::new();
405        let p0 = ap(0, 0x28, 0x0001);
406        let p1 = ap(1, 0x06, 0x0000);
407        let p2 = ap(2, 0x1d, 0x0003);
408        let v0 = Value::Utf8(String::from("VendorName"));
409        let v1 = Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef]);
410        let v2 = Value::Array(vec![Value::Uint(1), Value::Utf8(String::from("x"))]);
411        acc.push(report(vec![
412            replace(p0, v0.clone()),
413            replace(p1, v1.clone()),
414            replace(p2, v2.clone()),
415        ]))
416        .unwrap();
417
418        let out = acc.finish();
419        assert_eq!(
420            out,
421            vec![(p0, v0), (p1, v1), (p2, v2)],
422            "moved-out set must match inserted (path, value) pairs in first-seen order"
423        );
424    }
425
426    use proptest::prelude::*;
427
428    #[test]
429    fn element_ceiling_is_enforced() {
430        // A tiny element cap; feeding past it must error rather than grow.
431        let mut acc = ReportAccumulator::with_limits(3, usize::MAX);
432        // 3 distinct attributes fit.
433        for i in 0..3u32 {
434            acc.push(report(vec![replace(
435                ap(0, 0x06, i),
436                Value::Uint(u64::from(i)),
437            )]))
438            .expect("within element cap");
439        }
440        // The 4th distinct attribute crosses the ceiling.
441        let err = acc
442            .push(report(vec![replace(ap(0, 0x06, 99), Value::Uint(1))]))
443            .expect_err("4th distinct element must exceed the cap");
444        assert!(
445            matches!(
446                err,
447                ImError::AccumulatorOverflow {
448                    max_elements: 3,
449                    ..
450                }
451            ),
452            "expected AccumulatorOverflow, got {err:?}"
453        );
454    }
455
456    #[test]
457    fn byte_ceiling_is_enforced() {
458        // Generous element cap, tiny byte cap. A large byte string trips it.
459        let mut acc = ReportAccumulator::with_limits(usize::MAX, 16);
460        let err = acc
461            .push(report(vec![replace(
462                ap(0, 0x28, 0x0001),
463                Value::Bytes(vec![0u8; 1024]),
464            )]))
465            .expect_err("1 KiB value must exceed a 16-byte cap");
466        assert!(
467            matches!(err, ImError::AccumulatorOverflow { max_bytes: 16, .. }),
468            "expected AccumulatorOverflow, got {err:?}"
469        );
470    }
471
472    #[test]
473    fn normal_sized_report_set_is_ok() {
474        // The default cap must comfortably admit a realistic large dump
475        // (project history: a 170-attribute device read). Simulate 200
476        // attributes carrying small values; all must accumulate without error.
477        let mut acc = ReportAccumulator::new();
478        for i in 0..200u32 {
479            acc.push(report(vec![replace(
480                ap(0, 0x28, i),
481                Value::Utf8(String::from("a-realistic-attribute-value")),
482            )]))
483            .expect("200 small attributes are well within the default ceiling");
484        }
485        assert_eq!(acc.finish().len(), 200);
486    }
487
488    proptest! {
489        // Splitting a set of whole-attribute Replace items across N chunks
490        // yields the same final set as one chunk (message-level chunking is
491        // transparent to reassembly), with first-seen order preserved.
492        #[test]
493        fn message_chunking_is_order_preserving(
494            attrs in proptest::collection::vec((0u16..4, 0u32..8, 0u32..8, 0u64..1000), 1..20),
495        ) {
496            // Dedup by key keeping first occurrence (matches accumulator semantics).
497            let mut seen = std::collections::HashSet::new();
498            let unique: Vec<_> = attrs.into_iter()
499                .filter(|(e, c, a, _)| seen.insert((*e, *c, *a)))
500                .collect();
501
502            // All items in one chunk.
503            let mut whole = ReportAccumulator::new();
504            whole.push(report(
505                unique.iter().map(|&(e, c, a, v)| replace(ap(e, c, a), Value::Uint(v))).collect(),
506            )).unwrap();
507            let whole_out = whole.finish();
508
509            // Same items, one per chunk.
510            let mut split = ReportAccumulator::new();
511            for &(e, c, a, v) in &unique {
512                split.push(report(vec![replace(ap(e, c, a), Value::Uint(v))])).unwrap();
513            }
514            let split_out = split.finish();
515
516            prop_assert_eq!(&whole_out, &split_out);
517            // Order matches first-seen.
518            for (i, &(e, c, a, _)) in unique.iter().enumerate() {
519                prop_assert_eq!(split_out[i].0, ap(e, c, a));
520            }
521        }
522
523        // Appends accumulate into a list of exactly the pushed elements, in order.
524        #[test]
525        fn appends_build_list_in_order(elems in proptest::collection::vec(0u64..1000, 0..30)) {
526            let p = ap(0, 0x1d, 0x0003);
527            let mut acc = ReportAccumulator::new();
528            acc.push(report(vec![replace(p, Value::Array(Vec::new()))])).unwrap();
529            for &v in &elems {
530                acc.push(report(vec![append(p, Value::Uint(v))])).unwrap();
531            }
532            let out = acc.finish();
533            let want: Vec<Value> = elems.iter().map(|&v| Value::Uint(v)).collect();
534            prop_assert_eq!(&out[0].1, &Value::Array(want));
535        }
536    }
537}