rs_matter/im/expand.rs
1/*
2 *
3 * Copyright (c) 2022-2026 Project CHIP Authors
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 * http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18//! Interaction-Model path expansion.
19//!
20//! Expands (potentially wildcard) Read, Write and Invoke request paths into
21//! concrete attribute/command details against the node metadata, applying
22//! accessor (ACL) and fabric/dataver filtering. Consumed by the IM engine in
23//! [`crate::im`].
24
25use core::cell::Cell;
26
27use crate::acl::Accessor;
28use crate::dm::{AttrDetails, ClusterId, CmdDetails, EndptId, Metadata, Node, Quality};
29use crate::error::Error;
30use crate::im::encoding::{
31 AttrData, AttrPath, AttrStatus, CmdData, CmdStatus, DataVersionFilter, GenericPath,
32 IMStatusCode, InvReq, ReportDataReq, WriteReq,
33};
34use crate::tlv::{TLVArray, TLVElement};
35
36/// Expand (potentially wildcard) read requests into concrete attribute details
37/// using the node metadata.
38///
39/// As part of the expansion, the method will check whether the attributes are
40/// accessible by the accessor and whether they should be served based on the
41/// fabric filtering and dataver filtering rules and filter out the inaccessible ones (wildcard reads)
42/// or report an error status for the non-wildcard ones.
43pub fn expand_read<'m, M, F>(
44 metadata: M,
45 req: &'m ReportDataReq,
46 accessor: &'m Accessor<'m>,
47 filter: F,
48) -> Result<impl Iterator<Item = Result<Result<AttrDetails, AttrStatus>, Error>> + 'm, Error>
49where
50 M: Metadata + 'm,
51 F: FnMut(EndptId, ClusterId, u32) -> bool + 'm,
52{
53 let dataver_filters = req.dataver_filters()?;
54 let fabric_filtered = req.fabric_filtered()?;
55
56 Ok(PathExpanderIterator::new(
57 metadata,
58 accessor,
59 false,
60 req.attr_requests()?.map(|reqs| {
61 reqs.into_iter().map(move |path_result| {
62 path_result.map(|path| AttrReadPath {
63 path,
64 dataver_filters: dataver_filters.clone(),
65 fabric_filtered,
66 })
67 })
68 }),
69 filter,
70 ))
71}
72
73/// Expand (potentially wildcard) write requests into concrete attribute details
74/// using the node metadata.
75///
76/// As part of the expansion, the method will check whether the attributes are
77/// accessible by the accessor and filter out the inaccessible ones (wildcard writes)
78/// or report an error status for the non-wildcard ones.
79#[allow(clippy::type_complexity)]
80pub fn expand_write<'m, M>(
81 metadata: M,
82 req: &'m WriteReq,
83 accessor: &'m Accessor<'m>,
84) -> Result<
85 impl Iterator<Item = Result<Result<(AttrDetails, TLVElement<'m>), AttrStatus>, Error>> + 'm,
86 Error,
87>
88where
89 M: Metadata + 'm,
90{
91 Ok(PathExpanderIterator::new(
92 metadata,
93 accessor,
94 req.timed_request()?,
95 Some(req.write_requests()?.into_iter()),
96 |_, _, _| true,
97 ))
98}
99
100/// Expand (potentially wildcard) invoke requests into concrete command details
101/// using the node metadata.
102///
103/// As part of the expansion, the method will check whether the commands are
104/// accessible by the accessor and filter out the inaccessible ones (wildcard invocations)
105/// or report an error status for the non-wildcard ones.
106#[allow(clippy::type_complexity)]
107pub fn expand_invoke<'m, M>(
108 metadata: M,
109 req: &'m InvReq,
110 accessor: &'m Accessor<'m>,
111) -> Result<
112 impl Iterator<Item = Result<Result<(CmdDetails, TLVElement<'m>), CmdStatus>, Error>> + 'm,
113 Error,
114>
115where
116 M: Metadata + 'm,
117{
118 Ok(PathExpanderIterator::new(
119 metadata,
120 accessor,
121 req.timed_request()?,
122 req.inv_requests()?.map(move |reqs| reqs.into_iter()),
123 |_, _, _| true,
124 ))
125}
126
127/// A helper type for `AttrPath` that enriches it with the request-scope information
128/// of whether the attributes served as part of that request should be fabric filtered
129/// as well as with information which attributes should only be served if their
130/// dataver had changed.
131#[derive(Debug)]
132#[cfg_attr(feature = "defmt", derive(defmt::Format))]
133struct AttrReadPath<'a> {
134 path: AttrPath,
135 dataver_filters: Option<TLVArray<'a, DataVersionFilter>>,
136 fabric_filtered: bool,
137}
138
139/// A helper type for `PathExpander` that captures what type of expansion is being done:
140/// Read requests, write requests, or invoke requests.
141#[derive(Debug)]
142#[cfg_attr(feature = "defmt", derive(defmt::Format))]
143enum Operation {
144 Read,
145 Write,
146 Invoke,
147}
148
149/// A helper trait type for `PathExpander` modeling a generic "item which can be expanded".
150///
151/// The item must contain a path (`GenericPath`) but might contain other data as well,
152/// which needs to be carried over to the expanded output.
153trait PathExpansionItem<'a> {
154 /// Path of expansion for what type of operation: read (or subscribe which is considered the same), write or invoke
155 const OPERATION: Operation;
156
157 /// The type of the expanded item
158 type Expanded<'n>;
159 /// The type of the error status if expansion of that particular item failed
160 type Status;
161
162 /// The path of the item to be expanded
163 fn path(&self) -> GenericPath;
164
165 /// Expand the item into the expanded output.
166 ///
167 /// When expanding, the provided endpoint/cluser/leaf IDs are used
168 /// as the original ones might be wildcarded.
169 fn expand(
170 &self,
171 accessor: &Accessor<'_>,
172 endpoint_id: EndptId,
173 cluster_id: ClusterId,
174 leaf_id: u32,
175 array: bool,
176 ) -> Result<Self::Expanded<'a>, Error>;
177
178 /// Convert the item into an error status if the expansion failed.
179 fn into_status(self, status: IMStatusCode) -> Self::Status;
180}
181
182/// `PathExpansionItem` implementation for `AttrReadPath` (attr read requests expansion).
183impl<'a> PathExpansionItem<'a> for AttrReadPath<'a> {
184 const OPERATION: Operation = Operation::Read;
185
186 type Expanded<'n> = AttrDetails;
187 type Status = AttrStatus;
188
189 fn path(&self) -> GenericPath {
190 self.path.to_gp()
191 }
192
193 fn expand(
194 &self,
195 accessor: &Accessor<'_>,
196 endpoint_id: EndptId,
197 cluster_id: ClusterId,
198 leaf_id: u32,
199 array: bool,
200 ) -> Result<Self::Expanded<'a>, Error> {
201 Ok(AttrDetails {
202 endpoint_id,
203 cluster_id,
204 attr_id: leaf_id as _,
205 wildcard: self.path.to_gp().is_wildcard(),
206 list_index: self.path.list_index.clone(),
207 list_chunked: false,
208 fab_idx: accessor.fab_idx,
209 fab_filter: self.fabric_filtered,
210 dataver: dataver(self.dataver_filters.as_ref(), endpoint_id, cluster_id)?,
211 array,
212 cluster_status: Cell::new(0),
213 })
214 }
215
216 fn into_status(self, status: IMStatusCode) -> Self::Status {
217 AttrStatus::new(self.path, status, None)
218 }
219}
220
221/// `PathExpansionItem` implementation for `AttrData` (attr write requests expansion).
222impl<'a> PathExpansionItem<'a> for AttrData<'a> {
223 const OPERATION: Operation = Operation::Write;
224
225 type Expanded<'n> = (AttrDetails, TLVElement<'n>);
226 type Status = AttrStatus;
227
228 fn path(&self) -> GenericPath {
229 self.path.to_gp()
230 }
231
232 fn expand(
233 &self,
234 accessor: &Accessor<'_>,
235 endpoint_id: EndptId,
236 cluster_id: ClusterId,
237 leaf_id: u32,
238 array: bool,
239 ) -> Result<Self::Expanded<'a>, Error> {
240 let expanded = (
241 AttrDetails {
242 endpoint_id,
243 cluster_id,
244 attr_id: leaf_id as _,
245 wildcard: self.path.to_gp().is_wildcard(),
246 list_index: self.path.list_index.clone(),
247 list_chunked: false,
248 fab_idx: accessor.fab_idx,
249 // As per the Matter Core spec, Attribute Write requests
250 // are assumed to be always fabric-filtered
251 fab_filter: true,
252 dataver: self.data_ver,
253 array,
254 cluster_status: Cell::new(0),
255 },
256 self.data.clone(),
257 );
258
259 Ok(expanded)
260 }
261
262 fn into_status(self, status: IMStatusCode) -> Self::Status {
263 AttrStatus::new(self.path, status, None)
264 }
265}
266
267/// `PathExpansionItem` implementation for `CmdData` (command requests expansion).
268impl<'a> PathExpansionItem<'a> for CmdData<'a> {
269 const OPERATION: Operation = Operation::Invoke;
270
271 type Expanded<'n> = (CmdDetails, TLVElement<'n>);
272 type Status = CmdStatus;
273
274 fn path(&self) -> GenericPath {
275 self.path.to_gp()
276 }
277
278 fn expand(
279 &self,
280 accessor: &Accessor<'_>,
281 endpoint_id: EndptId,
282 cluster_id: ClusterId,
283 leaf_id: u32,
284 _array: bool,
285 ) -> Result<Self::Expanded<'a>, Error> {
286 let expanded = (
287 CmdDetails::new(
288 endpoint_id,
289 cluster_id,
290 leaf_id,
291 accessor.fab_idx,
292 false,
293 self.command_ref,
294 ),
295 self.data.clone(),
296 );
297
298 Ok(expanded)
299 }
300
301 fn into_status(self, status: IMStatusCode) -> Self::Status {
302 CmdStatus::new(self.path, status, None, self.command_ref)
303 }
304}
305
306/// An iterator that expands a list of paths into concrete attribute/command details.
307///
308/// While the iterator can be (and used to be) implemented by using monadic combinators,
309/// this implementation is done in a more imperative way to avoid the overhead of monadic
310/// combinators in terms of memory size.
311struct PathExpander<'a, T, I, F> {
312 /// The accessor to check the access rights.
313 accessor: &'a Accessor<'a>,
314 /// Where the paths are part of a timed interaction
315 timed: bool,
316 /// The paths to expand.
317 items: Option<I>,
318 /// The current path item being expanded.
319 item: Option<T>,
320 /// The id of the endpoint currently anchoring the scan, or `None`
321 /// before the first endpoint has been entered (or right after the
322 /// item changes / the previous endpoint has been exhausted). This
323 /// is the *only* state needed to resume correctly across
324 /// metadata-lock releases:
325 ///
326 /// At the top of every `next_for_path` call we look the id up in
327 /// `Node::endpoints` via `binary_search_by_key`:
328 /// - `Ok(i)` — endpoint is still there (possibly at a different
329 /// index); use `i` as `endpoint_index` and trust the existing
330 /// `cluster_index` / `leaf_index` (Node-level invariant: an
331 /// endpoint's cluster shape is stable for its lifetime).
332 /// - `Err(i)` — endpoint is gone; resume at `endpoint_index = i`
333 /// (the insertion point — strictly past everything we've already
334 /// yielded, because endpoints are sorted ascending by id) with
335 /// `cluster_index` / `leaf_index` zeroed.
336 ///
337 /// `cluster_index` / `leaf_index` are not separately mirrored by
338 /// id: the per-endpoint shape invariant means the array slot we
339 /// were about to read next is still the right one, so positional
340 /// indices suffice once the endpoint is re-anchored.
341 ///
342 /// The endpoint array *index* is not held on `self` at all — it
343 /// is derived fresh at the top of every `next_for_path` call from
344 /// this id (via `binary_search_by_key`), since the array layout
345 /// can shift between calls. Within a single call it lives on the
346 /// stack as the outer-loop counter.
347 endpoint_id: Option<EndptId>,
348 /// The current cluster index within the anchored endpoint.
349 cluster_index: u16,
350 /// The current leaf index within the anchored cluster.
351 leaf_index: u16,
352 /// Filter the expanded item or not
353 filter: F,
354 /// Last concrete `(endpoint_id, cluster_id, leaf_id)` triple whose
355 /// access check succeeded during this expansion run. When the next
356 /// expanded leaf matches this triple, the access check is bypassed.
357 ///
358 /// This is the mechanism that resolves the "DeleteAll + Add×N on the
359 /// ACL cluster within one WriteRequest" tension (Matter Core spec):
360 /// chip-tool serializes a list-replace as `DeleteAll`
361 /// followed by N per-element `Add`s, all targeting the **same**
362 /// concrete attribute path. The first op authorizes against the
363 /// fabric's current ACL; subsequent same-path ops cache-hit and
364 /// bypass the re-check — so an admin's permission isn't accidentally
365 /// revoked midway through replacing their own ACL with a new entry.
366 /// Mirrors `mLastSuccessfullyWrittenPath` in CHIP's `WriteHandler`.
367 ///
368 /// Soundness: the required privilege for a given concrete path is
369 /// constant (cluster-metadata-defined), and the accessor / session
370 /// doesn't change within an expansion, so re-using a prior
371 /// authorization for the same path is safe. The cache is **not**
372 /// reset when advancing to a new item from `items` — that's
373 /// deliberate: `DeleteAll + Add×N` arrives as N+1 separate
374 /// `AttrData` items in the same WriteRequest, and they all need
375 /// to share one access decision for the operation to be atomic.
376 /// A different concrete `(endpoint, cluster, leaf)` triple simply
377 /// misses the cache and re-runs the check.
378 last_authorized: Option<(EndptId, ClusterId, u32)>,
379}
380
381impl<'a, T, I, F> PathExpander<'a, T, I, F>
382where
383 I: Iterator<Item = Result<T, Error>>,
384 T: PathExpansionItem<'a>,
385 F: FnMut(EndptId, ClusterId, u32) -> bool,
386{
387 /// Create a new path expander with the given accessor and paths.
388 pub const fn new(accessor: &'a Accessor<'a>, timed: bool, paths: Option<I>, filter: F) -> Self {
389 Self {
390 accessor,
391 timed,
392 items: paths,
393 item: None,
394 endpoint_id: None,
395 cluster_index: 0,
396 leaf_index: 0,
397 filter,
398 last_authorized: None,
399 }
400 }
401
402 #[allow(clippy::type_complexity)]
403 fn next(
404 &mut self,
405 node: &Node<'_>,
406 ) -> Option<Result<Result<T::Expanded<'a>, T::Status>, Error>> {
407 loop {
408 // Fetch an item to expand if not already there
409 if self.item.is_none() {
410 let item = self.items.as_mut().and_then(|items| items.next())?;
411
412 match item {
413 Err(err) => break Some(Err(err)),
414 Ok(item) => self.item = Some(item),
415 }
416
417 // Each new item starts expansion from scratch; the
418 // endpoint-id resume anchor is per-item.
419 self.endpoint_id = None;
420 self.cluster_index = 0;
421 self.leaf_index = 0;
422 }
423
424 // From here on, we do have a valid `self.item` to expand
425
426 // Step on the first/next expanded path of the item
427 match self.next_for_path(node) {
428 Ok(Some((endpoint_id, cluster_id, leaf_id, array))) => {
429 // Next expansion of the path
430
431 let expanded = unwrap!(self.item.as_ref()).expand(
432 self.accessor,
433 endpoint_id,
434 cluster_id,
435 leaf_id,
436 array,
437 );
438
439 if !unwrap!(self.item.as_ref()).path().is_wildcard() {
440 // Non-wildcard path, remove the current item
441 self.item = None;
442 }
443
444 break Some(expanded.map(Ok));
445 }
446 Ok(None) => {
447 // This path is exhausted, time to move to the next one
448 self.item = None;
449 }
450 Err(status) => {
451 // Report an error status and remove the current item
452 break Some(Ok(Err(unwrap!(self.item.take()).into_status(status))));
453 }
454 }
455 }
456 }
457
458 /// Move to the next (endpoint, cluster, leaf) triple that matches the path
459 /// of the current item.
460 ///
461 /// Returns an error status if no match is found, where the error status indicates
462 /// whether the endpoint, the cluster, or the leaf is not matching.
463 ///
464 /// This method should only be called when `self.item` is `Some` or else it will panic.
465 fn next_for_path(
466 &mut self,
467 node: &Node<'_>,
468 ) -> Result<Option<(EndptId, ClusterId, u32, bool)>, IMStatusCode> {
469 let path = unwrap!(self.item.as_ref().map(PathExpansionItem::path));
470
471 let command = matches!(T::OPERATION, Operation::Invoke);
472 let attr_read = matches!(T::OPERATION, Operation::Read);
473
474 // Do some basic checks on wildcards, as not all wildcards are supported for each type of operation
475 if !attr_read {
476 if path.cluster.is_none() {
477 return Err(IMStatusCode::UnsupportedCluster);
478 }
479
480 if path.leaf.is_none() {
481 return Err(IMStatusCode::UnsupportedAttribute);
482 }
483 }
484
485 // Re-anchor the scan against the *current* Node before
486 // resuming. If the Node hasn't changed since the previous
487 // call this is an O(log N) check; otherwise we recover by
488 // looking up the endpoint id we were last anchored at.
489 // See the `endpoint_id` field doc-comment for semantics.
490 let mut endpoint_index = self.resume_endpoint_index(node);
491
492 while endpoint_index < node.endpoints.len() {
493 let endpoint = &node.endpoints[endpoint_index];
494 // Remember the id of the endpoint we're entering so the
495 // next `next_for_path` call can re-anchor against it even
496 // if the underlying `Node` has been swapped in between.
497 self.endpoint_id = Some(endpoint.id);
498
499 if (path.endpoint.is_none() || path.endpoint == Some(endpoint.id))
500 && self.accessor.is_endpoint_accessible(endpoint.id)
501 {
502 while (self.cluster_index as usize) < endpoint.clusters.len() {
503 let cluster = &endpoint.clusters[self.cluster_index as usize];
504
505 if path.cluster.is_none() || path.cluster == Some(cluster.id) {
506 let cluster_leaves_len = if command {
507 cluster.commands().count()
508 } else {
509 cluster.attributes().count()
510 };
511
512 while (self.leaf_index as usize) < cluster_leaves_len {
513 let leaf_id = if command {
514 unwrap!(cluster
515 .commands()
516 .map(|cmd| cmd.id)
517 .nth(self.leaf_index as usize))
518 } else {
519 unwrap!(cluster
520 .attributes()
521 .map(|attr| attr.id)
522 .nth(self.leaf_index as usize))
523 };
524
525 if path.leaf.is_none() || path.leaf == Some(leaf_id as _) {
526 // Leaf found, filter and check its access rights
527
528 #[allow(clippy::if_same_then_else)]
529 let check = if (self.filter)(endpoint.id, cluster.id, leaf_id) {
530 if self.last_authorized
531 == Some((endpoint.id, cluster.id, leaf_id))
532 {
533 Ok(true)
534 } else if command {
535 cluster
536 .check_cmd_access(
537 self.accessor,
538 self.timed,
539 GenericPath::new(
540 Some(endpoint.id),
541 Some(cluster.id),
542 Some(leaf_id),
543 ),
544 endpoint.device_types,
545 unwrap!(cluster
546 .commands()
547 .map(|cmd| cmd.id)
548 .nth(self.leaf_index as usize)),
549 )
550 .map(|_| true)
551 } else {
552 // TODO: Need to also check that the code is not trying to access an element of an array
553 // when the attribute is not an array
554
555 cluster
556 .check_attr_access(
557 self.accessor,
558 self.timed,
559 GenericPath::new(
560 Some(endpoint.id),
561 Some(cluster.id),
562 Some(leaf_id),
563 ),
564 endpoint.device_types,
565 !attr_read,
566 unwrap!(cluster
567 .attributes()
568 .map(|attr| attr.id)
569 .nth(self.leaf_index as usize)),
570 )
571 .map(|_| true)
572 }
573 } else {
574 Ok(false)
575 };
576
577 match check {
578 Ok(true) => {
579 // Because on the next call we should start from the next leaf or if leaves
580 // are over, from the next cluster and so on. `endpoint_id` is
581 // already set to `endpoint.id` at the top of the outer loop,
582 // which is how the next call re-anchors to this endpoint even
583 // if the underlying Node has been mutated in between.
584 self.leaf_index += 1;
585
586 // Cache this concrete triple as the
587 // last-authorized one. The next
588 // expansion that lands on the same
589 // (endpoint, cluster, leaf) — typical
590 // for chip-tool's list-write
591 // `DeleteAll + Add×N` encoding — will
592 // bypass the access re-check above.
593 self.last_authorized =
594 Some((endpoint.id, cluster.id, leaf_id));
595
596 let array = !command
597 && cluster
598 .attribute(leaf_id)
599 .map(|attr| attr.quality.contains(Quality::ARRAY))
600 .unwrap_or(false);
601
602 return Ok(Some((endpoint.id, cluster.id, leaf_id, array)));
603 }
604 Ok(false) => {
605 // Filtered out. For a non-wildcard path the
606 // leaf exists but the filter explicitly rejected
607 // it - treat this as "no output" rather than
608 // reporting `UnsupportedAttribute`/`UnsupportedCommand`.
609 if !path.is_wildcard() {
610 self.leaf_index += 1;
611 return Ok(None);
612 }
613 // Else: just skip it and continue scanning
614 }
615 Err(status) => {
616 if !path.is_wildcard() {
617 // Only return if non-wildcard, else just skip the error and
618 // continue scanning
619 return Err(status);
620 }
621 }
622 }
623 }
624
625 self.leaf_index += 1;
626 }
627
628 if !path.is_wildcard() {
629 if command {
630 return Err(IMStatusCode::UnsupportedCommand);
631 } else {
632 return Err(IMStatusCode::UnsupportedAttribute);
633 }
634 }
635
636 self.leaf_index = 0;
637 }
638
639 self.cluster_index += 1;
640 }
641
642 if !path.is_wildcard() {
643 return Err(IMStatusCode::UnsupportedCluster);
644 }
645
646 self.cluster_index = 0;
647 }
648
649 endpoint_index += 1;
650 }
651
652 if !path.is_wildcard() {
653 Err(IMStatusCode::UnsupportedEndpoint)
654 } else {
655 Ok(None)
656 }
657 }
658
659 /// Compute the starting `endpoint_index` for the outer loop in
660 /// `next_for_path` by re-anchoring against the *current* `node`
661 /// using the `endpoint_id` we last entered. Called once at the
662 /// top of every `next_for_path` so the scan resumes correctly
663 /// even if the underlying `Node` has been swapped or mutated
664 /// since the last `next` call.
665 ///
666 /// Relies on the [`Node`]-level invariants:
667 /// - **`endpoints` sorted ascending by id, no duplicates** —
668 /// enables `binary_search_by_key` and means the insertion point
669 /// on a miss is strictly past everything we've already yielded.
670 /// - **Per-endpoint shape is stable for the endpoint's lifetime**
671 /// — means `cluster_index` and `leaf_index` are still valid
672 /// positional cursors whenever the endpoint is found again.
673 ///
674 /// Returns `0` when `endpoint_id` is `None` (we haven't entered
675 /// any endpoint yet for the current item; the cluster / leaf
676 /// cursors are already at `0/0`).
677 fn resume_endpoint_index(&mut self, node: &Node<'_>) -> usize {
678 let Some(ep_id) = self.endpoint_id else {
679 return 0;
680 };
681
682 debug_assert!(
683 node.endpoints.windows(2).all(|w| w[0].id < w[1].id),
684 "Node::endpoints must be sorted ascending by id and contain no duplicates",
685 );
686
687 match node.endpoints.binary_search_by_key(&ep_id, |e| e.id) {
688 // Same endpoint, possibly at a different slot. The
689 // per-endpoint shape invariant means `cluster_index` and
690 // `leaf_index` still point at the array slot we were
691 // going to read next, so leave them alone.
692 Ok(i) => i,
693 Err(i) => {
694 // Endpoint is gone. The insertion point `i` is the
695 // index of the first remaining endpoint whose id is
696 // strictly greater than ours — i.e. one we have not
697 // yielded yet (would have come after the lost
698 // endpoint in the previous-Node iteration order).
699 // Reset the cluster / leaf cursors and clear the
700 // anchor so the outer loop reads the new endpoint
701 // fresh.
702 self.cluster_index = 0;
703 self.leaf_index = 0;
704 self.endpoint_id = None;
705 i
706 }
707 }
708 }
709}
710
711struct PathExpanderIterator<'a, M, T, I, F> {
712 metadata: M,
713 expander: PathExpander<'a, T, I, F>,
714}
715
716impl<'a, M, T, I, F> PathExpanderIterator<'a, M, T, I, F>
717where
718 M: Metadata,
719 I: Iterator<Item = Result<T, Error>>,
720 T: PathExpansionItem<'a>,
721 F: FnMut(EndptId, ClusterId, u32) -> bool,
722{
723 /// Create a new path expander iterator with the given metadata, accessor and paths.
724 pub const fn new(
725 metadata: M,
726 accessor: &'a Accessor<'a>,
727 timed: bool,
728 paths: Option<I>,
729 filter: F,
730 ) -> Self {
731 Self {
732 metadata,
733 expander: PathExpander::new(accessor, timed, paths, filter),
734 }
735 }
736}
737
738impl<'a, M, T, I, F> Iterator for PathExpanderIterator<'a, M, T, I, F>
739where
740 M: Metadata,
741 I: Iterator<Item = Result<T, Error>>,
742 T: PathExpansionItem<'a>,
743 F: FnMut(EndptId, ClusterId, u32) -> bool,
744{
745 type Item = Result<Result<T::Expanded<'a>, T::Status>, Error>;
746
747 fn next(&mut self) -> Option<Self::Item> {
748 let metadata = &self.metadata;
749 let expander = &mut self.expander;
750
751 metadata.access(|node| expander.next(node))
752 }
753}
754
755/// Helper function to get the data version for a given endpoint and cluster
756/// from the provided collection of filters
757fn dataver(
758 dataver_filters: Option<&TLVArray<DataVersionFilter>>,
759 ep: EndptId,
760 cl: ClusterId,
761) -> Result<Option<u32>, Error> {
762 if let Some(dataver_filters) = dataver_filters {
763 for filter in dataver_filters {
764 let filter = filter?;
765
766 if filter.path.endpoint == ep && filter.path.cluster == cl {
767 return Ok(Some(filter.data_ver));
768 }
769 }
770 }
771
772 Ok(None)
773}
774
775#[cfg(test)]
776mod test {
777 use crate::acl::{Accessor, AccessorSubjects, AuthMode};
778 use crate::dm::{
779 Access, Attribute, Cluster, ClusterId, Command, DeviceType, Endpoint, EndptId, Node,
780 Quality,
781 };
782 use crate::error::{Error, ErrorCode};
783 use crate::im::encoding::{GenericPath, IMStatusCode};
784 use crate::test::test_matter;
785
786 use super::{Operation, PathExpanderIterator, PathExpansionItem};
787
788 // For tests
789 impl<'a> PathExpansionItem<'a> for GenericPath {
790 const OPERATION: Operation = Operation::Read;
791
792 type Expanded<'n> = GenericPath;
793 type Status = IMStatusCode;
794
795 fn path(&self) -> GenericPath {
796 self.clone()
797 }
798
799 fn expand(
800 &self,
801 _accessor: &Accessor<'_>,
802 endpoint_id: EndptId,
803 cluster_id: ClusterId,
804 leaf_id: u32,
805 _array: bool,
806 ) -> Result<Self::Expanded<'a>, Error> {
807 Ok(GenericPath::new(
808 Some(endpoint_id),
809 Some(cluster_id),
810 Some(leaf_id),
811 ))
812 }
813
814 fn into_status(self, status: IMStatusCode) -> Self::Status {
815 status
816 }
817 }
818
819 /// Compare an input of paths against their expanded expectations.
820 fn test(
821 node: &Node,
822 input: &[GenericPath],
823 expected: &[Result<Result<GenericPath, IMStatusCode>, ErrorCode>],
824 ) {
825 test_with_filter(node, input, |_, _, _| true, expected)
826 }
827
828 /// Compare an input of paths against their expanded expectations,
829 /// using the provided per-(endpoint, cluster, leaf) filter.
830 fn test_with_filter(
831 node: &Node,
832 input: &[GenericPath],
833 filter: impl FnMut(EndptId, ClusterId, u32) -> bool,
834 expected: &[Result<Result<GenericPath, IMStatusCode>, ErrorCode>],
835 ) {
836 let matter = test_matter();
837 let accessor = Accessor::new(
838 0,
839 false,
840 AccessorSubjects::new(0),
841 Some(AuthMode::Pase),
842 &matter,
843 );
844
845 let expander = PathExpanderIterator::new(
846 node,
847 &accessor,
848 false,
849 Some(input.iter().cloned().map(Ok)),
850 filter,
851 );
852
853 assert_eq!(
854 expander
855 .map(|r| r.map_err(|e| e.code()))
856 .collect::<alloc::vec::Vec<_>>()
857 .as_slice(),
858 expected
859 );
860 }
861
862 #[test]
863 fn test_none() {
864 static NODE: Node = Node::new(&[]);
865
866 // Invalid endpoint with wildcard paths should not return anything
867 test(&NODE, &[GenericPath::new(Some(0), None, None)], &[]);
868
869 // Invalid cluster with wildcard paths should not return anything
870 test(&NODE, &[GenericPath::new(None, Some(0), None)], &[]);
871
872 // Invalid leaf with wildcard paths should not return anything
873 test(&NODE, &[GenericPath::new(None, None, Some(0))], &[]);
874
875 // Invalid endpoint with non-wildcard paths should return an err status
876 test(
877 &NODE,
878 &[GenericPath::new(Some(0), Some(0), Some(0))],
879 &[Ok(Err(IMStatusCode::UnsupportedEndpoint))],
880 );
881 }
882
883 #[test]
884 fn test_one_all() {
885 static NODE: Node = Node::new(&[Endpoint::new(
886 0,
887 &[DeviceType { dtype: 0, drev: 0 }],
888 &[Cluster::new(
889 0,
890 1,
891 0,
892 &[Attribute::new(0, Access::all(), Quality::all())],
893 &[Command::new(0, None, Access::all())],
894 &[],
895 |_, _, _| true,
896 |_, _, _| true,
897 |_, _, _| true,
898 )],
899 )]);
900
901 // Happy path, wildcard
902 test(
903 &NODE,
904 &[GenericPath::new(None, None, None)],
905 &[Ok(Ok(GenericPath::new(Some(0), Some(0), Some(0))))],
906 );
907
908 // Happy path, non-wildcard
909 test(
910 &NODE,
911 &[GenericPath::new(Some(0), Some(0), Some(0))],
912 &[Ok(Ok(GenericPath::new(Some(0), Some(0), Some(0))))],
913 );
914
915 // Invalid cluster with non-wildcard paths should return an err status
916 test(
917 &NODE,
918 &[GenericPath::new(Some(0), Some(1), Some(0))],
919 &[Ok(Err(IMStatusCode::UnsupportedCluster))],
920 );
921
922 // Invalid leaf with non-wildcard paths should return an err status
923 test(
924 &NODE,
925 &[GenericPath::new(Some(0), Some(0), Some(1))],
926 &[Ok(Err(IMStatusCode::UnsupportedAttribute))],
927 );
928
929 // Multiple wildcard paths with an empty node should not return anything
930 test(
931 &Node::new(&[]),
932 &[
933 GenericPath::new(None, None, None),
934 GenericPath::new(None, None, None),
935 ],
936 &[],
937 );
938
939 // Multiple wildcard paths with non-empty node should return twice the output
940 test(
941 &NODE,
942 &[
943 GenericPath::new(None, None, None),
944 GenericPath::new(None, None, None),
945 ],
946 &[
947 Ok(Ok(GenericPath::new(Some(0), Some(0), Some(0)))),
948 Ok(Ok(GenericPath::new(Some(0), Some(0), Some(0)))),
949 ],
950 );
951
952 // One wildcard and one non-wildcard should also return twice the output
953 test(
954 &NODE,
955 &[
956 GenericPath::new(None, None, None),
957 GenericPath::new(Some(0), Some(0), Some(0)),
958 ],
959 &[
960 Ok(Ok(GenericPath::new(Some(0), Some(0), Some(0)))),
961 Ok(Ok(GenericPath::new(Some(0), Some(0), Some(0)))),
962 ],
963 );
964
965 // One correct non-wildcard and one incorrect wildcard should return once the output
966 test(
967 &NODE,
968 &[
969 GenericPath::new(Some(0), Some(0), Some(0)),
970 GenericPath::new(None, Some(1), None),
971 ],
972 &[Ok(Ok(GenericPath::new(Some(0), Some(0), Some(0))))],
973 );
974
975 // One incorrect non-wildcard and one correct wildcard should return once an error and once the output
976 test(
977 &NODE,
978 &[
979 GenericPath::new(Some(0), Some(1), Some(0)),
980 GenericPath::new(None, Some(0), Some(0)),
981 ],
982 &[
983 Ok(Err(IMStatusCode::UnsupportedCluster)),
984 Ok(Ok(GenericPath::new(Some(0), Some(0), Some(0)))),
985 ],
986 );
987 }
988
989 #[test]
990 fn test_multiple() {
991 static NODE: Node = Node::new(&[
992 Endpoint::new(
993 0,
994 &[DeviceType { dtype: 0, drev: 0 }],
995 &[
996 Cluster::new(
997 1,
998 1,
999 0,
1000 &[Attribute::new(1, Access::all(), Quality::all())],
1001 &[Command::new(1, None, Access::all())],
1002 &[],
1003 |_, _, _| true,
1004 |_, _, _| true,
1005 |_, _, _| true,
1006 ),
1007 Cluster::new(
1008 10,
1009 1,
1010 0,
1011 &[Attribute::new(1, Access::all(), Quality::all())],
1012 &[Command::new(1, None, Access::all())],
1013 &[],
1014 |_, _, _| true,
1015 |_, _, _| true,
1016 |_, _, _| true,
1017 ),
1018 ],
1019 ),
1020 Endpoint::new(
1021 5,
1022 &[DeviceType { dtype: 0, drev: 0 }],
1023 &[
1024 Cluster::new(
1025 1,
1026 1,
1027 0,
1028 &[Attribute::new(1, Access::all(), Quality::all())],
1029 &[Command::new(1, None, Access::all())],
1030 &[],
1031 |_, _, _| true,
1032 |_, _, _| true,
1033 |_, _, _| true,
1034 ),
1035 Cluster::new(
1036 20,
1037 1,
1038 0,
1039 &[
1040 Attribute::new(20, Access::all(), Quality::all()),
1041 Attribute::new(30, Access::all(), Quality::all()),
1042 ],
1043 &[
1044 Command::new(20, None, Access::all()),
1045 Command::new(30, None, Access::all()),
1046 ],
1047 &[],
1048 |_, _, _| true,
1049 |_, _, _| true,
1050 |_, _, _| true,
1051 ),
1052 ],
1053 ),
1054 ]);
1055
1056 // Test with a single, global wildcard
1057 test(
1058 &NODE,
1059 &[GenericPath::new(None, None, None)],
1060 &[
1061 Ok(Ok(GenericPath::new(Some(0), Some(1), Some(1)))),
1062 Ok(Ok(GenericPath::new(Some(0), Some(10), Some(1)))),
1063 Ok(Ok(GenericPath::new(Some(5), Some(1), Some(1)))),
1064 Ok(Ok(GenericPath::new(Some(5), Some(20), Some(20)))),
1065 Ok(Ok(GenericPath::new(Some(5), Some(20), Some(30)))),
1066 ],
1067 );
1068
1069 // Test with two concrete correct non-wildcards,
1070 // one incorrect non-wildcard and one incorrect wildcard
1071 test(
1072 &NODE,
1073 &[
1074 GenericPath::new(Some(0), Some(1), Some(1)),
1075 GenericPath::new(Some(5), Some(20), Some(20)),
1076 GenericPath::new(Some(0), Some(1), Some(11)),
1077 GenericPath::new(None, Some(2), None),
1078 ],
1079 &[
1080 Ok(Ok(GenericPath::new(Some(0), Some(1), Some(1)))),
1081 Ok(Ok(GenericPath::new(Some(5), Some(20), Some(20)))),
1082 Ok(Err(IMStatusCode::UnsupportedAttribute)),
1083 ],
1084 );
1085
1086 // Test with a global wildcard, two concrete correct non-wildcards,
1087 // one incorrect non-wildcard and one incorrect wildcard
1088 test(
1089 &NODE,
1090 &[
1091 GenericPath::new(None, None, None),
1092 GenericPath::new(Some(0), Some(1), Some(1)),
1093 GenericPath::new(Some(5), Some(20), Some(20)),
1094 GenericPath::new(Some(0), Some(1), Some(11)),
1095 GenericPath::new(None, Some(2), None),
1096 ],
1097 &[
1098 Ok(Ok(GenericPath::new(Some(0), Some(1), Some(1)))),
1099 Ok(Ok(GenericPath::new(Some(0), Some(10), Some(1)))),
1100 Ok(Ok(GenericPath::new(Some(5), Some(1), Some(1)))),
1101 Ok(Ok(GenericPath::new(Some(5), Some(20), Some(20)))),
1102 Ok(Ok(GenericPath::new(Some(5), Some(20), Some(30)))),
1103 Ok(Ok(GenericPath::new(Some(0), Some(1), Some(1)))),
1104 Ok(Ok(GenericPath::new(Some(5), Some(20), Some(20)))),
1105 Ok(Err(IMStatusCode::UnsupportedAttribute)),
1106 ],
1107 );
1108 }
1109
1110 #[test]
1111 fn test_filter() {
1112 static NODE: Node = Node::new(&[
1113 Endpoint::new(
1114 0,
1115 &[DeviceType { dtype: 0, drev: 0 }],
1116 &[Cluster::new(
1117 1,
1118 1,
1119 0,
1120 &[
1121 Attribute::new(1, Access::all(), Quality::all()),
1122 Attribute::new(2, Access::all(), Quality::all()),
1123 ],
1124 &[Command::new(1, None, Access::all())],
1125 &[],
1126 |_, _, _| true,
1127 |_, _, _| true,
1128 |_, _, _| true,
1129 )],
1130 ),
1131 Endpoint::new(
1132 5,
1133 &[DeviceType { dtype: 0, drev: 0 }],
1134 &[Cluster::new(
1135 1,
1136 1,
1137 0,
1138 &[Attribute::new(1, Access::all(), Quality::all())],
1139 &[Command::new(1, None, Access::all())],
1140 &[],
1141 |_, _, _| true,
1142 |_, _, _| true,
1143 |_, _, _| true,
1144 )],
1145 ),
1146 ]);
1147
1148 // Non-wildcard path, leaf exists but is filtered out: the expander
1149 // must yield nothing for this input (neither an expansion nor an
1150 // `UnsupportedAttribute` status).
1151 test_with_filter(
1152 &NODE,
1153 &[GenericPath::new(Some(0), Some(1), Some(1))],
1154 |_, _, _| false,
1155 &[],
1156 );
1157
1158 // Non-wildcard path, leaf does not exist: filter must not be consulted
1159 // and the expander must still produce `UnsupportedAttribute`.
1160 test_with_filter(
1161 &NODE,
1162 &[GenericPath::new(Some(0), Some(1), Some(99))],
1163 |_, _, _| true,
1164 &[Ok(Err(IMStatusCode::UnsupportedAttribute))],
1165 );
1166
1167 // Wildcard path, filter rejects everything: empty output, no errors.
1168 test_with_filter(
1169 &NODE,
1170 &[GenericPath::new(None, None, None)],
1171 |_, _, _| false,
1172 &[],
1173 );
1174
1175 // Wildcard path, filter rejects some leaves: the accepted ones are
1176 // yielded and the rejected ones are silently skipped.
1177 test_with_filter(
1178 &NODE,
1179 &[GenericPath::new(None, None, None)],
1180 |_, _, leaf| leaf == 1,
1181 &[
1182 Ok(Ok(GenericPath::new(Some(0), Some(1), Some(1)))),
1183 Ok(Ok(GenericPath::new(Some(5), Some(1), Some(1)))),
1184 ],
1185 );
1186
1187 // Mixed: a non-wildcard filtered-out path followed by a wildcard
1188 // should only yield items from the wildcard.
1189 test_with_filter(
1190 &NODE,
1191 &[
1192 GenericPath::new(Some(0), Some(1), Some(1)),
1193 GenericPath::new(None, None, None),
1194 ],
1195 |_ep, _cl, leaf| leaf != 1,
1196 &[Ok(Ok(GenericPath::new(Some(0), Some(1), Some(2))))],
1197 );
1198 }
1199
1200 // -----------------------------------------------------------------
1201 // Recovery from concurrent Node mutations between `next()` calls.
1202 //
1203 // The `Metadata` lock is now acquired afresh per `next()` call (see
1204 // `PathExpanderIterator`), so the application is free to swap in a
1205 // different `Node` between iterations. `PathExpander` is expected
1206 // to recover by ID — these tests pin down the behaviour:
1207 // - stable Node → unchanged output
1208 // - endpoint added/removed between iterations → graceful advance
1209 // - cluster added/removed → graceful advance
1210 // - attribute added/removed → graceful advance
1211 // - whole Node swapped → best-effort
1212 // -----------------------------------------------------------------
1213
1214 use core::cell::Cell;
1215
1216 use crate::dm::Metadata;
1217
1218 /// A `Metadata` impl whose backing `Node` can be hot-swapped between
1219 /// `access` calls — the test simulator for concurrent application
1220 /// mutations.
1221 struct SwappableMetadata {
1222 current: Cell<&'static Node<'static>>,
1223 }
1224
1225 impl SwappableMetadata {
1226 fn new(node: &'static Node<'static>) -> Self {
1227 Self {
1228 current: Cell::new(node),
1229 }
1230 }
1231
1232 fn swap(&self, new_node: &'static Node<'static>) {
1233 self.current.set(new_node);
1234 }
1235 }
1236
1237 impl Metadata for SwappableMetadata {
1238 fn access<F, R>(&self, f: F) -> R
1239 where
1240 F: FnOnce(&Node<'_>) -> R,
1241 {
1242 f(self.current.get())
1243 }
1244 }
1245
1246 /// Run the expander interactively, invoking `after_yield(i, &metadata)`
1247 /// after each `next()` result so the test can swap the Node in
1248 /// between iterations. Compares the cumulative output to `expected`.
1249 fn test_swap(
1250 initial: &'static Node<'static>,
1251 input: &[GenericPath],
1252 mut after_yield: impl FnMut(usize, &SwappableMetadata),
1253 expected: &[Result<Result<GenericPath, IMStatusCode>, ErrorCode>],
1254 ) {
1255 let matter = test_matter();
1256 let accessor = Accessor::new(
1257 0,
1258 false,
1259 AccessorSubjects::new(0),
1260 Some(AuthMode::Pase),
1261 &matter,
1262 );
1263
1264 let metadata = SwappableMetadata::new(initial);
1265
1266 let expander = PathExpanderIterator::new(
1267 &metadata,
1268 &accessor,
1269 false,
1270 Some(input.iter().cloned().map(Ok::<_, Error>)),
1271 |_, _, _| true,
1272 );
1273
1274 let mut actual: alloc::vec::Vec<_> = alloc::vec::Vec::new();
1275 for (i, result) in expander.enumerate() {
1276 actual.push(result.map_err(|e| e.code()));
1277 after_yield(i, &metadata);
1278 }
1279
1280 assert_eq!(actual.as_slice(), expected);
1281 }
1282
1283 // ---- Fixtures used across the recovery tests ------------------------
1284
1285 /// Sanity check: with no mutations the swap-aware test path matches
1286 /// the static-Node test path.
1287 #[test]
1288 fn recovery_stable_node() {
1289 static EP0_C1_A12: [Cluster; 1] = [make_cluster_const(1, &[1, 2])];
1290 static NODE: Node = Node::new(&[Endpoint::new(0, &[], &EP0_C1_A12)]);
1291
1292 test_swap(
1293 &NODE,
1294 &[GenericPath::new(None, None, None)],
1295 |_, _| {}, // no swap
1296 &[
1297 Ok(Ok(GenericPath::new(Some(0), Some(1), Some(1)))),
1298 Ok(Ok(GenericPath::new(Some(0), Some(1), Some(2)))),
1299 ],
1300 );
1301 }
1302
1303 /// New endpoint inserted *after* the yielded one. We must continue
1304 /// from where we left off and then also visit the newcomer.
1305 #[test]
1306 fn recovery_endpoint_inserted_after_yield() {
1307 static EP0_C1_A1: [Cluster; 1] = [make_cluster_const(1, &[1])];
1308 static EP7_C1_A1: [Cluster; 1] = [make_cluster_const(1, &[1])];
1309
1310 static ONE_EP: [Endpoint; 1] = [Endpoint::new(0, &[], &EP0_C1_A1)];
1311 static TWO_EPS: [Endpoint; 2] = [
1312 Endpoint::new(0, &[], &EP0_C1_A1),
1313 Endpoint::new(7, &[], &EP7_C1_A1),
1314 ];
1315
1316 static NODE_BEFORE: Node = Node::new(&ONE_EP);
1317 static NODE_AFTER: Node = Node::new(&TWO_EPS);
1318
1319 test_swap(
1320 &NODE_BEFORE,
1321 &[GenericPath::new(None, None, None)],
1322 |i, m| {
1323 // After the first yield, swap in the larger Node.
1324 if i == 0 {
1325 m.swap(&NODE_AFTER);
1326 }
1327 },
1328 &[
1329 Ok(Ok(GenericPath::new(Some(0), Some(1), Some(1)))),
1330 Ok(Ok(GenericPath::new(Some(7), Some(1), Some(1)))),
1331 ],
1332 );
1333 }
1334
1335 /// Endpoint *removed* between two yields (the one we just yielded
1336 /// against): we must still visit the remaining endpoints exactly
1337 /// once each.
1338 #[test]
1339 fn recovery_endpoint_removed_after_yield() {
1340 static EP0_C1_A1: [Cluster; 1] = [make_cluster_const(1, &[1])];
1341 static EP5_C1_A1: [Cluster; 1] = [make_cluster_const(1, &[1])];
1342 static EP7_C1_A1: [Cluster; 1] = [make_cluster_const(1, &[1])];
1343
1344 static THREE_EPS: [Endpoint; 3] = [
1345 Endpoint::new(0, &[], &EP0_C1_A1),
1346 Endpoint::new(5, &[], &EP5_C1_A1),
1347 Endpoint::new(7, &[], &EP7_C1_A1),
1348 ];
1349 // After yield #1 we remove endpoint 5 (which was at index 1).
1350 // Endpoint 7 shifts to index 1.
1351 static TWO_EPS: [Endpoint; 2] = [
1352 Endpoint::new(0, &[], &EP0_C1_A1),
1353 Endpoint::new(7, &[], &EP7_C1_A1),
1354 ];
1355
1356 static NODE_BEFORE: Node = Node::new(&THREE_EPS);
1357 static NODE_AFTER: Node = Node::new(&TWO_EPS);
1358
1359 test_swap(
1360 &NODE_BEFORE,
1361 &[GenericPath::new(None, None, None)],
1362 |i, m| {
1363 // After yielding ep=0, drop ep=5. `binary_search` for
1364 // ep_id=0 still finds it at slot 0; we exhaust it,
1365 // advance to slot 1 — now ep=7 in the new array — and
1366 // yield once from there. ep=5 is never re-visited.
1367 if i == 0 {
1368 m.swap(&NODE_AFTER);
1369 }
1370 },
1371 &[
1372 Ok(Ok(GenericPath::new(Some(0), Some(1), Some(1)))),
1373 Ok(Ok(GenericPath::new(Some(7), Some(1), Some(1)))),
1374 ],
1375 );
1376 }
1377
1378 /// Cluster removed from the endpoint we're currently iterating
1379 /// after we yielded against it. NB: this scenario *violates* the
1380 /// [`Node`] per-endpoint-shape-stability invariant — once an
1381 /// endpoint with a given id is exposed, its cluster slice must
1382 /// not change. The test stays in place as defence-in-depth:
1383 /// even under that contract violation the expander must not
1384 /// panic or duplicate yields, it just exits gracefully when the
1385 /// cluster array turns out shorter than the cursor.
1386 #[test]
1387 fn recovery_cluster_removed_after_yield() {
1388 static C1_A1: [Cluster; 1] = [make_cluster_const(1, &[1])];
1389 static C1_C10: [Cluster; 2] = [make_cluster_const(1, &[1]), make_cluster_const(10, &[1])];
1390
1391 static EP_BEFORE: [Endpoint; 1] = [Endpoint::new(0, &[], &C1_C10)];
1392 // After yield #1 we drop cluster 10. Cluster 1 is still there.
1393 static EP_AFTER: [Endpoint; 1] = [Endpoint::new(0, &[], &C1_A1)];
1394
1395 static NODE_BEFORE: Node = Node::new(&EP_BEFORE);
1396 static NODE_AFTER: Node = Node::new(&EP_AFTER);
1397
1398 test_swap(
1399 &NODE_BEFORE,
1400 &[GenericPath::new(None, None, None)],
1401 |i, m| {
1402 if i == 0 {
1403 // We just yielded (ep=0, cluster=1, attr=1).
1404 // Now remove cluster 10. The expander should
1405 // notice the cluster array shortened and exit
1406 // gracefully.
1407 m.swap(&NODE_AFTER);
1408 }
1409 },
1410 &[Ok(Ok(GenericPath::new(Some(0), Some(1), Some(1))))],
1411 );
1412 }
1413
1414 /// Attribute appended to the cluster after the slot we just
1415 /// yielded against. As with `recovery_cluster_removed_after_yield`,
1416 /// this scenario *violates* the [`Node`] per-endpoint-shape-stability
1417 /// invariant; the test remains as defence-in-depth, documenting that
1418 /// the expander deterministically picks up an extended attribute
1419 /// list rather than panicking or skipping.
1420 #[test]
1421 fn recovery_attribute_appended() {
1422 static C1_A1: [Cluster; 1] = [make_cluster_const(1, &[1])];
1423 static C1_A1_A2: [Cluster; 1] = [make_cluster_const(1, &[1, 2])];
1424
1425 static EP_BEFORE: [Endpoint; 1] = [Endpoint::new(0, &[], &C1_A1)];
1426 static EP_AFTER: [Endpoint; 1] = [Endpoint::new(0, &[], &C1_A1_A2)];
1427
1428 static NODE_BEFORE: Node = Node::new(&EP_BEFORE);
1429 static NODE_AFTER: Node = Node::new(&EP_AFTER);
1430
1431 test_swap(
1432 &NODE_BEFORE,
1433 &[GenericPath::new(None, None, None)],
1434 |i, m| {
1435 if i == 0 {
1436 // Append attribute id=2 to cluster 1 after we
1437 // yielded attribute id=1.
1438 m.swap(&NODE_AFTER);
1439 }
1440 },
1441 &[
1442 Ok(Ok(GenericPath::new(Some(0), Some(1), Some(1)))),
1443 // The newly-appended attribute is picked up.
1444 Ok(Ok(GenericPath::new(Some(0), Some(1), Some(2)))),
1445 ],
1446 );
1447 }
1448
1449 /// Whole Node replaced after the first yield — best-effort
1450 /// continuation. Documents the observable behaviour rather than
1451 /// asserting a specific "right" answer; the contract is that the
1452 /// expander does not panic and does not infinite-loop.
1453 #[test]
1454 fn recovery_whole_node_swapped() {
1455 static C1_A1: [Cluster; 1] = [make_cluster_const(1, &[1])];
1456 static EP0_ORIG: [Endpoint; 1] = [Endpoint::new(0, &[], &C1_A1)];
1457 static EP99_NEW: [Endpoint; 1] = [Endpoint::new(99, &[], &C1_A1)];
1458
1459 static NODE_BEFORE: Node = Node::new(&EP0_ORIG);
1460 static NODE_AFTER: Node = Node::new(&EP99_NEW);
1461
1462 test_swap(
1463 &NODE_BEFORE,
1464 &[GenericPath::new(None, None, None)],
1465 |i, m| {
1466 if i == 0 {
1467 m.swap(&NODE_AFTER);
1468 }
1469 },
1470 // After yielding (0, 1, 1) we swap to a Node whose only
1471 // endpoint is id=99. `binary_search` for ep_id=0 in [99]
1472 // returns `Err(0)` — the insertion point is index 0, so
1473 // we resume there onto the new endpoint with the cluster /
1474 // leaf cursors reset to 0.
1475 &[
1476 Ok(Ok(GenericPath::new(Some(0), Some(1), Some(1)))),
1477 Ok(Ok(GenericPath::new(Some(99), Some(1), Some(1)))),
1478 ],
1479 );
1480 }
1481
1482 /// Helper: `const fn` wrapper around `Cluster::new` for use inside
1483 /// `static` initializers. Equivalent to the `make_cluster` runtime
1484 /// helper but evaluable at compile time, which is what `static`
1485 /// arrays require.
1486 const fn make_cluster_const(id: ClusterId, attr_ids: &[u32]) -> Cluster<'static> {
1487 // Specialised for the attribute layouts we use in recovery
1488 // fixtures: [1], [1, 2], [1, 2, 3], [10], [20].
1489 static ATTRS_1: [Attribute; 1] = [Attribute::new(1, Access::all(), Quality::all())];
1490 static ATTRS_10: [Attribute; 1] = [Attribute::new(10, Access::all(), Quality::all())];
1491 static ATTRS_20: [Attribute; 1] = [Attribute::new(20, Access::all(), Quality::all())];
1492 static ATTRS_1_2: [Attribute; 2] = [
1493 Attribute::new(1, Access::all(), Quality::all()),
1494 Attribute::new(2, Access::all(), Quality::all()),
1495 ];
1496 static ATTRS_1_2_3: [Attribute; 3] = [
1497 Attribute::new(1, Access::all(), Quality::all()),
1498 Attribute::new(2, Access::all(), Quality::all()),
1499 Attribute::new(3, Access::all(), Quality::all()),
1500 ];
1501
1502 let slice: &[Attribute] = if attr_ids.len() == 1 {
1503 match attr_ids[0] {
1504 1 => &ATTRS_1,
1505 10 => &ATTRS_10,
1506 20 => &ATTRS_20,
1507 _ => panic!("unsupported single-attr fixture"),
1508 }
1509 } else if attr_ids.len() == 2 {
1510 match (attr_ids[0], attr_ids[1]) {
1511 (1, 2) => &ATTRS_1_2,
1512 _ => panic!("unsupported two-attr fixture"),
1513 }
1514 } else if attr_ids.len() == 3 {
1515 match (attr_ids[0], attr_ids[1], attr_ids[2]) {
1516 (1, 2, 3) => &ATTRS_1_2_3,
1517 _ => panic!("unsupported three-attr fixture"),
1518 }
1519 } else {
1520 panic!("unsupported attr_ids len");
1521 };
1522
1523 Cluster::new(
1524 id,
1525 1,
1526 0,
1527 slice,
1528 &[],
1529 &[],
1530 |_, _, _| true,
1531 |_, _, _| true,
1532 |_, _, _| true,
1533 )
1534 }
1535}