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}