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}