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}