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