Skip to main content

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}