Skip to main content

matter_commissioning/state_machine/
commissioner.rs

1//! `Commissioner` — the state-machine cursor.
2
3#![forbid(unsafe_code)]
4
5use std::sync::Arc;
6
7use matter_cert::time::MatterTime;
8
9use crate::attestation::PaaTrustStore;
10use crate::noc::{FabricRecord, NocRng};
11use crate::setup::SetupPayload;
12use crate::state_machine::action::{Action, Expectation};
13use crate::state_machine::error::CommissioningError;
14use crate::state_machine::stage::Stage;
15
16#[cfg(feature = "tracing")]
17use tracing::instrument;
18
19/// Fallback failsafe extension (seconds) applied at
20/// `Stage::FailsafeBeforeNetworkEnable` when the device did not report a
21/// usable `ConnectMaxTimeSeconds`. Chosen generously (Thread attach +
22/// SRP registration is slower than Wi-Fi association); the C1 Wi-Fi path
23/// adopts it harmlessly. Matter Core Spec §11.9.5.4 defines the attribute
24/// but does not mandate a minimum, so a conservative default is safest.
25pub(crate) const DEFAULT_CONNECT_MAX_TIME_SECONDS: u16 = 90;
26
27/// Wi-Fi station credentials supplied to `AddOrUpdateWiFiNetwork`.
28///
29/// `ssid` must be 1–32 bytes (Matter Core Spec §11.9 constraints).
30/// `credentials` must be 0–64 bytes — empty means open network, ≤64
31/// bytes covers WPA2/WPA3 PSK lengths.
32///
33/// `Debug` is hand-written to redact `credentials` (renders only the
34/// length). `Clone` is derived. Validation runs in
35/// `Commissioner::new` (M6.5.2 Task 13).
36#[derive(Clone, PartialEq, Eq)]
37pub struct WiFiCredentials {
38    /// SSID bytes, 1–32 bytes.
39    pub ssid: Vec<u8>,
40    /// Pre-shared key / passphrase bytes, 0–64 bytes. Empty means
41    /// open network.
42    pub credentials: Vec<u8>,
43}
44
45impl core::fmt::Debug for WiFiCredentials {
46    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
47        f.debug_struct("WiFiCredentials")
48            .field("ssid", &format_args!("<{} bytes>", self.ssid.len()))
49            .field(
50                "credentials",
51                &format_args!("<redacted, {} bytes>", self.credentials.len()),
52            )
53            .finish()
54    }
55}
56
57/// Operational-network credentials for the commissionee, selecting which
58/// network-provisioning sub-cursor the state machine runs after `AddNOC`.
59///
60/// Mirrors chip's `AutoCommissioner`: network provisioning runs only for
61/// the concrete network type whose credentials are supplied.
62///
63/// - [`NetworkCredentials::WiFi`] provisions Wi-Fi via
64///   `AddOrUpdateWiFiNetwork` + `ConnectNetwork`.
65/// - [`NetworkCredentials::Thread`] provisions Thread from an operational
66///   dataset via `AddOrUpdateThreadNetwork` + `ConnectNetwork` (the
67///   Extended PAN ID is the `ConnectNetwork` `network_id`); the dataset is
68///   self-validated at [`ThreadDataset`](crate::ThreadDataset)
69///   construction.
70/// - [`NetworkCredentials::AlreadyOnNetwork`] skips network provisioning
71///   entirely — correct both for Ethernet-only devices and for devices
72///   already reachable on their operational network (the usual
73///   IP-commissioning case, e.g. a second-fabric commission).
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub enum NetworkCredentials {
76    /// Provision Wi-Fi using the supplied station credentials.
77    WiFi(WiFiCredentials),
78    /// Provision Thread using the supplied operational dataset.
79    Thread(crate::thread_dataset::ThreadDataset),
80    /// Device is already on an operational network; skip provisioning.
81    AlreadyOnNetwork,
82}
83
84/// Configuration passed to [`Commissioner::new`].
85///
86/// All fields are by-reference where possible so the state machine
87/// can share long-lived caller-owned resources (the fabric record, the
88/// trust store, the setup payload) without copying.
89///
90/// **Not `#[non_exhaustive]`** — callers build this as a struct literal
91/// with all public fields populated. Adding a field is a breaking change,
92/// accepted for a pre-1.0 unpublished crate. `#[non_exhaustive]` stays on
93/// [`Action`], [`Expectation`], [`Stage`], and [`CommissioningError`] —
94/// those are read by callers, not constructed by them.
95pub struct CommissionerConfig<'a> {
96    /// 16-byte attestation challenge derived from the active PASE
97    /// session. Matter Core Spec §3.6.4: bytes `[32..48]` of the
98    /// 48-byte PASE session key blob (exposed as
99    /// `PaseSessionKeys::attestation_key`).
100    pub pase_attestation_challenge: [u8; 16],
101    /// The commissioner's fabric record (RCAC keypair + signer + IPK).
102    /// Constructed via [`FabricRecord::new_root_only`] from M6.3.
103    pub fabric: &'a FabricRecord,
104    /// The setup payload parsed from QR or manual code (M6.1). Used
105    /// to cross-check VID/PID against the DAC's subject during
106    /// attestation verification.
107    pub setup_payload: &'a SetupPayload,
108    /// Trusted PAA roots for attestation chain validation (M6.2).
109    pub paa_trust_store: &'a PaaTrustStore,
110    /// Trusted CSA Certification Declaration signing roots (M6.4.3).
111    /// Tests can use `CdSigningRoots::with_example_device_roots()`; production
112    /// callers supply CSA-published roots via `CdSigningRoots::from_pem`.
113    pub cd_signing_roots: &'a crate::attestation::CdSigningRoots,
114    /// The commissioner's own operational node ID on this fabric.
115    /// Must be non-zero.
116    pub commissioner_node_id: u64,
117    /// The operational node ID being assigned to the device on this
118    /// fabric. Must be non-zero and distinct from
119    /// `commissioner_node_id`.
120    pub assigned_node_id: u64,
121    /// 16-byte Identity Protection Key (IPK) epoch key for `AddNOC`.
122    /// Matter Core Spec §4.15.2. Must not be all-zero (rejected by
123    /// the device-side `AddNOC` handler).
124    pub ipk_epoch_key: [u8; 16],
125    /// CASE admin subject for `AddNOC` (typically the commissioner's
126    /// own operational node ID).
127    pub case_admin_subject: u64,
128    /// Admin vendor ID for `AddNOC`.
129    pub admin_vendor_id: u16,
130    /// Wall-clock time at construction. Used for NOC + RCAC validity
131    /// windows and for chain verification's `not_before` / `not_after`
132    /// checks.
133    pub now: MatterTime,
134    /// RNG for nonces (`CSRNonce`, `AttestationNonce`) and NOC serials.
135    pub rng: Arc<dyn NocRng>,
136    /// Operational-network credentials for the commissionee.
137    ///
138    /// [`NetworkCredentials::AlreadyOnNetwork`] skips the network
139    /// sub-cursor entirely, mirroring chip's `AutoCommissioner`: network
140    /// provisioning runs ONLY when concrete credentials are supplied. It is
141    /// correct both for Ethernet-only devices and for devices already
142    /// reachable on their operational network (the usual case for IP
143    /// commissioning, e.g. a second-fabric commission). Supplying
144    /// [`NetworkCredentials::WiFi`] or [`NetworkCredentials::Thread`]
145    /// forces provisioning via `Stage::NetworkSetup`.
146    pub network: NetworkCredentials,
147}
148
149/// The commissioning state machine cursor.
150///
151/// One `Commissioner` per in-flight commissioning. `Send` but `!Sync`.
152/// See module docs in [`crate::state_machine`] for the driver-loop
153/// example.
154// `commissioner_node_id` mirrors Matter Core Spec terminology
155// (commissioner node ID vs. assigned node ID). Renaming to satisfy
156// the lint would obscure the spec mapping.
157#[allow(clippy::struct_field_names)]
158pub struct Commissioner {
159    stage: Stage,
160
161    // Configuration captured at construction time. Storage slots for
162    // M6.4.2+ (`pai_der`, `dac_der`, `attestation_response`, CSR /
163    // NOC artefacts, the CASE-awaiting flag, etc.) are added in
164    // later tasks as the corresponding stages land — keeping the
165    // struct minimal here avoids per-task churn on the field list.
166    #[allow(dead_code)] // Used by attestation/CSR verification in M6.4.2+.
167    pase_attestation_challenge: [u8; 16],
168    #[allow(dead_code)] // Used by NOC issuance + chain validation in M6.4.4.
169    fabric: FabricRecord,
170    #[allow(dead_code)] // Used by chain validation in M6.4.2.
171    paa_trust_store: PaaTrustStore,
172    cd_signing_roots: crate::attestation::CdSigningRoots,
173    #[allow(dead_code)] // Used by VID/PID cross-check in M6.4.2.
174    setup_payload: SetupPayload,
175    #[allow(dead_code)] // Used by NOC subject in M6.4.4.
176    commissioner_node_id: u64,
177    #[allow(dead_code)] // Used by NOC subject in M6.4.4.
178    assigned_node_id: u64,
179    #[allow(dead_code)] // Used by AddNOC payload in M6.4.4.
180    ipk_epoch_key: [u8; 16],
181    #[allow(dead_code)] // Used by AddNOC payload in M6.4.4.
182    case_admin_subject: u64,
183    #[allow(dead_code)] // Used by AddNOC payload in M6.4.4.
184    admin_vendor_id: u16,
185    #[allow(dead_code)] // Used by cert validity windows in M6.4.2 + M6.4.4.
186    now: MatterTime,
187    #[allow(dead_code)] // Used for nonce generation in M6.4.2 + M6.4.4.
188    rng: Arc<dyn NocRng>,
189
190    // Attestation slots — populated by SendPaiCertRequest /
191    // SendDacCertRequest / SendAttestationRequest, consumed by
192    // AttestationVerification (M6.4.2 T18-T21).
193    pai_der: Option<Vec<u8>>,
194    dac_der: Option<Vec<u8>>,
195    attestation_nonce: Option<[u8; 32]>,
196    attestation_response: Option<crate::attestation::AttestationResponse>,
197
198    // CSR + NOC slots — populated by SendOpCertSigningRequest /
199    // ValidateCsr / GenerateNocChain, consumed by SendTrustedRootCert
200    // and SendNoc (M6.4.4 T35-T40).
201    csr_nonce: Option<[u8; 32]>,
202    csr_response: Option<crate::noc::CsrResponse>,
203    verified_csr: Option<crate::noc::VerifiedCsr>,
204    issued_noc: Option<matter_cert::MatterCertificate>,
205    issued_noc_public_key: Option<[u8; 65]>,
206
207    /// Operational-network credentials captured from config at
208    /// construction; consumed by the network-provisioning sub-cursor
209    /// (`Stage::NetworkSetup`, `AddOrUpdateWiFiNetwork` for Wi-Fi or
210    /// `AddOrUpdateThreadNetwork` for Thread).
211    /// [`NetworkCredentials::AlreadyOnNetwork`] skips provisioning.
212    network: NetworkCredentials,
213
214    /// Maximum failsafe expiry the device accepts, in seconds.
215    /// Initialised to 60 (the M6.4 fallback) and updated from
216    /// `BasicCommissioningInfo::failsafe_expiry_length_seconds` once
217    /// the `Expectation::CommissioningInfo` response arrives. The first
218    /// `Stage::ArmFailsafe` consumes this.
219    failsafe_expiry_seconds: u16,
220
221    /// Device-declared `ConnectMaxTimeSeconds` (`NetworkCommissioning`
222    /// attribute `0x0003`), captured from the
223    /// `Expectation::NetworkCommissioningInfo` read. `0` means unread /
224    /// absent, in which case [`Self::network_enable_failsafe_seconds`]
225    /// falls back to [`DEFAULT_CONNECT_MAX_TIME_SECONDS`]. Sizes the
226    /// `Stage::FailsafeBeforeNetworkEnable` failsafe extension so Thread
227    /// attach (slower than Wi-Fi association) has room to complete before
228    /// the failsafe expires.
229    connect_max_time_seconds: u16,
230
231    /// Monotonically-increasing breadcrumb attached to every
232    /// breadcrumb-bearing cluster command. Matter Core Spec §11.10
233    /// uses breadcrumb so an interrupted commissioning can be resumed
234    /// from the last acknowledged step. Initialised to `1` in
235    /// `Commissioner::new`; incremented after every breadcrumb emit.
236    breadcrumb_counter: u64,
237
238    /// `true` after [`Stage::FindOperationalForComplete`] emits
239    /// `Action::EstablishCase`; cleared by
240    /// [`Commissioner::on_case_established`] (success) or by
241    /// `on_response(Expectation::CaseFailed, _)` (failure).
242    awaiting_case_session: bool,
243
244    /// The Expectation the state machine last emitted with `poll()`.
245    /// `None` while not waiting for a response (terminal stages, or
246    /// pre-poll).
247    awaiting: Option<Expectation>,
248
249    /// Cached pending Action so repeated `poll()` calls between
250    /// `on_response`s are idempotent. Cleared when the cursor advances.
251    pending_action: Option<Action>,
252
253    /// Rendered summary of why the state machine entered `Failed`,
254    /// stashed by `on_response`'s error path and read by the
255    /// `Stage::Failed` arm of `dispatch_stage` so `Action::Abort.reason`
256    /// surfaces the real failure (not a hard-coded placeholder).
257    last_failure: Option<String>,
258}
259
260impl Commissioner {
261    /// Construct a new commissioner from a validated config.
262    ///
263    /// # Errors
264    ///
265    /// Returns [`CommissioningError::InvalidConfig`] if any field fails
266    /// basic validation: zero `commissioner_node_id`, zero
267    /// `assigned_node_id`, `commissioner_node_id == assigned_node_id`,
268    /// or all-zero `ipk_epoch_key`.
269    pub fn new(cfg: CommissionerConfig<'_>) -> Result<Self, CommissioningError> {
270        if cfg.commissioner_node_id == 0 {
271            return Err(CommissioningError::InvalidConfig(
272                "commissioner_node_id must be non-zero",
273            ));
274        }
275        if cfg.assigned_node_id == 0 {
276            return Err(CommissioningError::InvalidConfig(
277                "assigned_node_id must be non-zero",
278            ));
279        }
280        if cfg.assigned_node_id == cfg.commissioner_node_id {
281            return Err(CommissioningError::InvalidConfig(
282                "assigned_node_id must differ from commissioner_node_id",
283            ));
284        }
285        if cfg.ipk_epoch_key == [0u8; 16] {
286            return Err(CommissioningError::InvalidConfig(
287                "ipk_epoch_key must not be all-zero",
288            ));
289        }
290        // Only Wi-Fi credentials carry length bounds here; `Thread`
291        // datasets are self-validated at `ThreadDataset` construction and
292        // `AlreadyOnNetwork` carries no data to check.
293        if let NetworkCredentials::WiFi(creds) = &cfg.network {
294            if creds.ssid.is_empty() {
295                return Err(CommissioningError::InvalidConfig(
296                    "network: Wi-Fi ssid must not be empty",
297                ));
298            }
299            if creds.ssid.len() > 32 {
300                return Err(CommissioningError::InvalidConfig(
301                    "network: Wi-Fi ssid must be ≤32 bytes",
302                ));
303            }
304            if creds.credentials.len() > 64 {
305                return Err(CommissioningError::InvalidConfig(
306                    "network: Wi-Fi credentials must be ≤64 bytes",
307                ));
308            }
309        }
310        Ok(Self {
311            stage: Stage::SecurePairing,
312            pase_attestation_challenge: cfg.pase_attestation_challenge,
313            fabric: cfg.fabric.clone(),
314            paa_trust_store: cfg.paa_trust_store.clone(),
315            cd_signing_roots: cfg.cd_signing_roots.clone(),
316            setup_payload: cfg.setup_payload.clone(),
317            commissioner_node_id: cfg.commissioner_node_id,
318            assigned_node_id: cfg.assigned_node_id,
319            ipk_epoch_key: cfg.ipk_epoch_key,
320            case_admin_subject: cfg.case_admin_subject,
321            admin_vendor_id: cfg.admin_vendor_id,
322            now: cfg.now,
323            rng: cfg.rng,
324            pai_der: None,
325            dac_der: None,
326            attestation_nonce: None,
327            attestation_response: None,
328            csr_nonce: None,
329            csr_response: None,
330            verified_csr: None,
331            issued_noc: None,
332            issued_noc_public_key: None,
333            network: cfg.network,
334            failsafe_expiry_seconds: 60,
335            connect_max_time_seconds: 0,
336            breadcrumb_counter: 1,
337            awaiting_case_session: false,
338            awaiting: None,
339            pending_action: None,
340            last_failure: None,
341        })
342    }
343
344    /// Current cursor position. Useful for logging + tests.
345    #[must_use]
346    pub fn stage(&self) -> Stage {
347        self.stage
348    }
349
350    /// The operational-network credentials captured at construction.
351    /// Read by tests and by the network-provisioning dispatch/routing
352    /// (Task 5 consumes this to select the Thread sub-cursor).
353    #[allow(dead_code)] // Consumed by tests now; by Thread routing in Task 5.
354    pub(crate) fn network(&self) -> &NetworkCredentials {
355        &self.network
356    }
357
358    /// **Test-only.** Jumps the cursor to `stage` and applies any opt-in
359    /// seeds in `seeds`. Consumes `self` and returns the repositioned
360    /// `Commissioner`.
361    ///
362    /// Use this in integration tests when a real M6.4 attestation +
363    /// NOC-issuance flow is not yet available (the M6.4.6 real-fixture
364    /// e2e driver is still operator-touch deferred — see
365    /// `TODO-1.0.md`). Never use in production code.
366    ///
367    /// Behind the `__test_shortcuts` feature flag.
368    #[cfg(feature = "__test_shortcuts")]
369    #[must_use]
370    pub fn position_at_stage_for_test(mut self, stage: Stage, seeds: TestStateSeeds) -> Self {
371        self.stage = stage;
372        if let Some(pk) = seeds.synthetic_noc_pubkey {
373            self.issued_noc_public_key = Some(pk);
374        }
375        self
376    }
377
378    /// Drive the state machine forward.
379    ///
380    /// Returns the next [`Action`] the caller must perform. Idempotent:
381    /// calling `poll` twice without an intervening `on_response` returns
382    /// the same `Action`.
383    ///
384    /// # Errors
385    ///
386    /// Returns the typed error that caused a transition into
387    /// [`Stage::Failed`] — when this happens, the cursor advances to
388    /// `Failed` and the next `poll()` call emits an
389    /// [`Action::Abort`] with a rendered summary of the failure.
390    #[cfg_attr(feature = "tracing", instrument(skip(self), fields(stage = ?self.stage)))]
391    pub fn poll(&mut self) -> Result<Action, CommissioningError> {
392        if let Some(act) = self.pending_action.clone() {
393            return Ok(act);
394        }
395        let action = self.dispatch_stage()?;
396        self.pending_action = Some(action.clone());
397        Ok(action)
398    }
399
400    /// Failsafe extension (seconds) for `Stage::FailsafeBeforeNetworkEnable`.
401    ///
402    /// Uses the device-reported `ConnectMaxTimeSeconds` when non-zero,
403    /// else [`DEFAULT_CONNECT_MAX_TIME_SECONDS`]. Sized to give the device
404    /// room to associate with the operational network (Thread attach is
405    /// slower than Wi-Fi association) before the failsafe expires.
406    fn network_enable_failsafe_seconds(&self) -> u16 {
407        if self.connect_max_time_seconds > 0 {
408            self.connect_max_time_seconds
409        } else {
410            DEFAULT_CONNECT_MAX_TIME_SECONDS
411        }
412    }
413
414    /// Record the device's `ConnectMaxTimeSeconds` (`NetworkCommissioning`
415    /// attribute `0x0003`), read alongside the `FeatureMap` at
416    /// `Stage::ReadNetworkCommissioningInfo`. Consumed by
417    /// [`Self::network_enable_failsafe_seconds`] to size the
418    /// `FailsafeBeforeNetworkEnable` extension. Called by the driver's
419    /// read-dispatch after the `FeatureMap` response is applied; a `0`
420    /// value (unread/absent) leaves the default in force.
421    pub(crate) fn set_connect_max_time_seconds(&mut self, seconds: u16) {
422        self.connect_max_time_seconds = seconds;
423    }
424
425    /// The device-reported `ConnectMaxTimeSeconds`, or `0` if the device
426    /// hasn't reported it yet (default before
427    /// `Stage::ReadNetworkCommissioningInfo` completes, or the device
428    /// reported `0`).
429    ///
430    /// Consumed by the driver ([`crate::driver::commission`]) to size the
431    /// BLE-path `ConnectNetwork` response deadline from the same value that
432    /// [`Self::network_enable_failsafe_seconds`] uses for the failsafe
433    /// extension (spec D7: both must be sized from `ConnectMaxTimeSeconds`).
434    #[must_use]
435    pub(crate) fn connect_max_time_seconds(&self) -> u16 {
436        self.connect_max_time_seconds
437    }
438
439    /// Helper: emit an `ArmFailsafe` action at the current stage.
440    ///
441    /// Used by both `Stage::ArmFailsafe` and
442    /// `Stage::FailsafeBeforeNetworkEnable`, which share identical action
443    /// logic. The failsafe expiry differs: the first arm uses the
444    /// device's `failsafe_expiry_length_seconds`; the pre-`ConnectNetwork`
445    /// extension uses [`Self::network_enable_failsafe_seconds`].
446    fn arm_failsafe_action(&mut self) -> Action {
447        use crate::clusters::general_commissioning as gc;
448        use crate::state_machine::action::SessionContext;
449        let expiry_seconds = if self.stage == Stage::FailsafeBeforeNetworkEnable {
450            self.network_enable_failsafe_seconds()
451        } else {
452            self.failsafe_expiry_seconds
453        };
454        let breadcrumb = self.next_breadcrumb();
455        let payload = gc::encode_arm_fail_safe(expiry_seconds, breadcrumb);
456        self.awaiting = Some(Expectation::ArmFailsafeResponse);
457        Action::Invoke {
458            session: SessionContext::Pase,
459            endpoint: 0,
460            cluster: gc::CLUSTER_ID,
461            command: gc::command_id::ARM_FAIL_SAFE,
462            payload,
463            expect: Expectation::ArmFailsafeResponse,
464        }
465    }
466
467    /// Compute the next [`Action`] for the current [`Stage`].
468    ///
469    /// Called by [`Self::poll`] only when there is no cached
470    /// `pending_action`. Walks `Stage::SecurePairing` forward to the
471    /// first wire stage by self-recursion; stages past
472    /// `Stage::ConfigRegulatory` short-circuit to `Stage::Failed` until
473    /// M6.4.2+ tasks land.
474    // Lint carve-out: the per-stage arms each carry their own
475    // payload-shape comments, so collapsing them into smaller helpers
476    // would obscure the cluster-command mapping the function
477    // documents. Each new stage adds a small fixed arm.
478    #[allow(clippy::too_many_lines)]
479    fn dispatch_stage(&mut self) -> Result<Action, CommissioningError> {
480        use crate::clusters::general_commissioning as gc;
481        use crate::state_machine::action::SessionContext;
482        match self.stage {
483            Stage::SecurePairing => {
484                // Entry → first wire stage. Advance and re-dispatch.
485                self.stage = Stage::ReadCommissioningInfo;
486                self.dispatch_stage()
487            }
488            Stage::ReadCommissioningInfo => {
489                self.awaiting = Some(Expectation::CommissioningInfo);
490                Ok(Action::ReadAttribute {
491                    session: SessionContext::Pase,
492                    endpoint: 0,
493                    cluster: gc::CLUSTER_ID,
494                    attributes: &[
495                        // GeneralCommissioning attribute ids per spec §11.10.6
496                        // (confirmed against a real device's report).
497                        0x0000, // Breadcrumb
498                        0x0001, // BasicCommissioningInfo (failsafe_expiry_length_seconds, …)
499                        0x0002, // RegulatoryConfig
500                        0x0004, // SupportsConcurrentConnection
501                    ],
502                    expect: Expectation::CommissioningInfo,
503                })
504            }
505            Stage::ArmFailsafe | Stage::FailsafeBeforeNetworkEnable => {
506                Ok(self.arm_failsafe_action())
507            }
508            Stage::ConfigRegulatory => {
509                let breadcrumb = self.next_breadcrumb();
510                // FIXME(temp, uncommitted): hardcoding IndoorOutdoor(2) exceeds
511                // stricter devices' LocationCapability → SetRegulatoryConfig
512                // returns ValueOutsideRange (errorCode 1). The chip-faithful fix
513                // is to read attr 0x03 (LocationCapability) in ReadCommissioningInfo
514                // and echo it here. Indoor(0) is the safe universal value (accepted
515                // by Indoor-only AND IndoorOutdoor devices) — minimal unblock.
516                let payload = gc::encode_set_regulatory_config(
517                    gc::RegulatoryLocation::Indoor,
518                    "XX",
519                    breadcrumb,
520                );
521                self.awaiting = Some(Expectation::SetRegulatoryConfigResponse);
522                Ok(Action::Invoke {
523                    session: SessionContext::Pase,
524                    endpoint: 0,
525                    cluster: gc::CLUSTER_ID,
526                    command: gc::command_id::SET_REGULATORY_CONFIG,
527                    payload,
528                    expect: Expectation::SetRegulatoryConfigResponse,
529                })
530            }
531            Stage::SendPaiCertRequest => {
532                use crate::noc::{encode_certificate_chain_request, CertChainType};
533                let payload = encode_certificate_chain_request(CertChainType::Pai);
534                self.awaiting = Some(Expectation::PaiCertChainResponse);
535                Ok(Action::Invoke {
536                    session: SessionContext::Pase,
537                    endpoint: 0,
538                    cluster: 0x003E,
539                    command: 0x02,
540                    payload,
541                    expect: Expectation::PaiCertChainResponse,
542                })
543            }
544            Stage::SendDacCertRequest => {
545                use crate::noc::{encode_certificate_chain_request, CertChainType};
546                let payload = encode_certificate_chain_request(CertChainType::Dac);
547                self.awaiting = Some(Expectation::DacCertChainResponse);
548                Ok(Action::Invoke {
549                    session: SessionContext::Pase,
550                    endpoint: 0,
551                    cluster: 0x003E,
552                    command: 0x02,
553                    payload,
554                    expect: Expectation::DacCertChainResponse,
555                })
556            }
557            Stage::SendAttestationRequest => {
558                use crate::noc::encode_attestation_request;
559                let mut nonce = [0u8; 32];
560                self.rng
561                    .fill(&mut nonce)
562                    .map_err(CommissioningError::from)?;
563                let payload = encode_attestation_request(&nonce);
564                self.attestation_nonce = Some(nonce);
565                self.awaiting = Some(Expectation::AttestationResponse);
566                Ok(Action::Invoke {
567                    session: SessionContext::Pase,
568                    endpoint: 0,
569                    cluster: 0x003E,
570                    command: 0x00,
571                    payload,
572                    expect: Expectation::AttestationResponse,
573                })
574            }
575            Stage::AttestationVerification => {
576                match self.run_attestation_verification() {
577                    Ok(()) => {
578                        self.advance(Stage::SendOpCertSigningRequest);
579                        self.dispatch_stage()
580                    }
581                    Err(err) => {
582                        // Poll-time failure: align with the contract documented
583                        // on `poll()` — cursor advances to `Failed`, the next
584                        // `poll()` emits `Action::Abort` with a rendered reason.
585                        self.last_failure = Some(err.to_string());
586                        self.stage = Stage::Failed;
587                        self.awaiting = None;
588                        self.pending_action = None;
589                        Err(err)
590                    }
591                }
592            }
593            Stage::SendOpCertSigningRequest => {
594                use crate::noc::encode_csr_request;
595                let mut nonce = [0u8; 32];
596                self.rng
597                    .fill(&mut nonce)
598                    .map_err(CommissioningError::from)?;
599                // Spec §11.18.5.5 `CSRRequest`. `is_for_update_noc` is
600                // hard-coded false: M6.4 only commissions new fabrics.
601                let payload = encode_csr_request(&nonce, false);
602                self.csr_nonce = Some(nonce);
603                self.awaiting = Some(Expectation::CsrResponse);
604                Ok(Action::Invoke {
605                    session: SessionContext::Pase,
606                    endpoint: 0,
607                    cluster: 0x003E,
608                    command: 0x04,
609                    payload,
610                    expect: Expectation::CsrResponse,
611                })
612            }
613            Stage::ValidateCsr => {
614                // Off-wire: M6.3's three-check verify_csr_response gate.
615                self.run_validate_csr()?;
616                self.advance(Stage::GenerateNocChain);
617                self.dispatch_stage()
618            }
619            Stage::GenerateNocChain => {
620                // Off-wire: build + sign the NOC under the fabric's RCAC.
621                self.run_generate_noc_chain()?;
622                self.advance(Stage::SendTrustedRootCert);
623                self.dispatch_stage()
624            }
625            Stage::SendTrustedRootCert => {
626                use crate::noc::encode_add_trusted_root;
627                // RCAC is already TLV-serialisable via matter-cert's
628                // `to_tlv`. Surfaces as NocError::CertBuild on the rare
629                // re-serialisation failure path (codec / extension shape
630                // regression). Sanity: `FabricRecord::new_root_only` round-
631                // tripped the cert through `verify_signed_by` at
632                // construction, so the bytes are well-formed by here.
633                let rcac_tlv =
634                    self.fabric.root_cert.to_tlv().map_err(|e| {
635                        CommissioningError::from(crate::noc::NocError::CertBuild(e))
636                    })?;
637                #[cfg(feature = "tracing")]
638                tracing::debug!(
639                    rcac_tlv = %crate::hexdump::hex(&rcac_tlv),
640                    "sending AddTrustedRootCertificate"
641                );
642                let payload = encode_add_trusted_root(&rcac_tlv);
643                self.awaiting = Some(Expectation::AddTrustedRootResponse);
644                Ok(Action::Invoke {
645                    session: SessionContext::Pase,
646                    endpoint: 0,
647                    cluster: 0x003E,
648                    command: 0x0B,
649                    payload,
650                    expect: Expectation::AddTrustedRootResponse,
651                })
652            }
653            Stage::SendNoc => {
654                use crate::noc::encode_add_noc;
655                let noc = self
656                    .issued_noc
657                    .as_ref()
658                    .ok_or(CommissioningError::OutOfOrderResponse(self.stage))?;
659                let noc_tlv = noc
660                    .to_tlv()
661                    .map_err(|e| CommissioningError::from(crate::noc::NocError::CertBuild(e)))?;
662                // If this fabric runs a 3-tier RCAC -> ICAC -> NOC chain, the
663                // device NOC was signed under the ICAC (see
664                // `run_generate_noc_chain` -> `issue_noc`, which signs under
665                // `fabric.icac_signer` whenever the fabric carries one). The
666                // device therefore needs the ICAC certificate to assemble and
667                // validate the chain, so we transmit it in AddNOC's optional
668                // ICACValue field (spec §11.18.5.9 field 1). Flat RCAC -> NOC
669                // fabrics carry no ICAC and the field is omitted, leaving the
670                // M6.3 wire bytes unchanged.
671                let icac_tlv = match self.fabric.icac_cert.as_ref() {
672                    Some(icac) => Some(icac.to_tlv().map_err(|e| {
673                        CommissioningError::from(crate::noc::NocError::CertBuild(e))
674                    })?),
675                    None => None,
676                };
677                let payload = encode_add_noc(
678                    &noc_tlv,
679                    icac_tlv.as_deref(),
680                    &self.ipk_epoch_key,
681                    self.case_admin_subject,
682                    self.admin_vendor_id,
683                );
684                self.awaiting = Some(Expectation::NocResponse);
685                Ok(Action::Invoke {
686                    session: SessionContext::Pase,
687                    endpoint: 0,
688                    cluster: 0x003E,
689                    command: 0x06,
690                    payload,
691                    expect: Expectation::NocResponse,
692                })
693            }
694            Stage::ReadNetworkCommissioningInfo => {
695                self.awaiting = Some(Expectation::NetworkCommissioningInfo);
696                Ok(Action::ReadAttribute {
697                    session: SessionContext::Pase,
698                    endpoint: 0,
699                    cluster: crate::clusters::network_commissioning::CLUSTER_ID,
700                    attributes: &[
701                        crate::clusters::network_commissioning::attribute_id::FEATURE_MAP,
702                        // ConnectMaxTimeSeconds (spec §11.9.5.4) — sizes the
703                        // FailsafeBeforeNetworkEnable extension (D7). Thread
704                        // attach is slower than Wi-Fi association.
705                        crate::clusters::network_commissioning::attribute_id::CONNECT_MAX_TIME_SECONDS,
706                    ],
707                    expect: Expectation::NetworkCommissioningInfo,
708                })
709            }
710            Stage::NetworkSetup => {
711                use crate::clusters::network_commissioning as nc;
712                // Select the provisioning command by the supplied
713                // credential type. The FeatureMap-cross-check at
714                // `Expectation::NetworkCommissioningInfo` guarantees the
715                // device actually supports this network type before we
716                // reach here, so `AlreadyOnNetwork` never lands in this
717                // arm — treat it as an out-of-order state, not a silent
718                // skip.
719                let breadcrumb = self.next_breadcrumb();
720                let (command, payload) = match &self.network {
721                    NetworkCredentials::WiFi(creds) => (
722                        nc::command_id::ADD_OR_UPDATE_WIFI_NETWORK,
723                        nc::encode_add_or_update_wifi_network(
724                            &creds.ssid,
725                            &creds.credentials,
726                            breadcrumb,
727                        ),
728                    ),
729                    NetworkCredentials::Thread(dataset) => (
730                        nc::command_id::ADD_OR_UPDATE_THREAD_NETWORK,
731                        nc::encode_add_or_update_thread_network(dataset.as_bytes(), breadcrumb),
732                    ),
733                    NetworkCredentials::AlreadyOnNetwork => {
734                        return Err(CommissioningError::OutOfOrderResponse(self.stage));
735                    }
736                };
737                self.awaiting = Some(Expectation::NetworkConfigResponse);
738                Ok(Action::Invoke {
739                    session: SessionContext::Pase,
740                    endpoint: 0,
741                    cluster: nc::CLUSTER_ID,
742                    command,
743                    payload,
744                    expect: Expectation::NetworkConfigResponse,
745                })
746            }
747            Stage::NetworkEnable => {
748                use crate::clusters::network_commissioning as nc;
749                // `ConnectNetwork` takes an opaque `network_id`: the SSID
750                // for Wi-Fi, the Extended PAN ID for Thread (spec §11.9.6.6).
751                let breadcrumb = self.next_breadcrumb();
752                let payload = match &self.network {
753                    NetworkCredentials::WiFi(creds) => {
754                        nc::encode_connect_network(&creds.ssid, breadcrumb)
755                    }
756                    NetworkCredentials::Thread(dataset) => {
757                        nc::encode_connect_network(&dataset.ext_pan_id(), breadcrumb)
758                    }
759                    NetworkCredentials::AlreadyOnNetwork => {
760                        return Err(CommissioningError::OutOfOrderResponse(self.stage));
761                    }
762                };
763                self.awaiting = Some(Expectation::ConnectNetworkResponse);
764                Ok(Action::Invoke {
765                    session: SessionContext::Pase,
766                    endpoint: 0,
767                    cluster: nc::CLUSTER_ID,
768                    command: nc::command_id::CONNECT_NETWORK,
769                    payload,
770                    expect: Expectation::ConnectNetworkResponse,
771                })
772            }
773            Stage::EvictPreviousCaseSessions => {
774                // New-fabric commissioning has no prior CASE session
775                // to evict. M8 multi-fabric work will emit
776                // Action::EvictCase here.
777                self.advance(Stage::FindOperationalForComplete);
778                self.dispatch_stage()
779            }
780            Stage::FindOperationalForComplete => {
781                self.awaiting_case_session = true;
782                Ok(Action::EstablishCase {
783                    fabric_id: self.fabric.fabric_id,
784                    peer_node_id: self.assigned_node_id,
785                })
786            }
787            Stage::SendComplete => {
788                let payload = gc::encode_commissioning_complete();
789                self.awaiting = Some(Expectation::CommissioningCompleteResponse);
790                Ok(Action::Invoke {
791                    session: SessionContext::Case,
792                    endpoint: 0,
793                    cluster: gc::CLUSTER_ID,
794                    command: gc::command_id::COMMISSIONING_COMPLETE,
795                    payload,
796                    expect: Expectation::CommissioningCompleteResponse,
797                })
798            }
799            Stage::Cleanup => {
800                let public_key = self
801                    .issued_noc_public_key
802                    .ok_or(CommissioningError::OutOfOrderResponse(self.stage))?;
803                Ok(Action::Done(crate::state_machine::CommissionedFabric {
804                    fabric: self.fabric.clone(),
805                    peer_node_id: self.assigned_node_id,
806                    peer_root_public_key: public_key,
807                    terminated_at: Stage::Cleanup,
808                }))
809            }
810            Stage::Failed => {
811                // Subsequent poll() after a failure surfaces the Abort.
812                // The state machine stays in Failed.
813                self.awaiting = None;
814                let reason = self
815                    .last_failure
816                    .clone()
817                    .unwrap_or_else(|| "commissioning aborted".to_string());
818                Ok(Action::Abort {
819                    send_disarm_failsafe: true,
820                    reason,
821                })
822            } // Every `Stage` variant has its own arm above. `Stage` is
823              // `#[non_exhaustive]` for cross-crate consumers, but within
824              // this crate the match is exhaustive — no `_ =>` arm needed.
825        }
826    }
827
828    /// Feed a response payload back into the state machine.
829    ///
830    /// `expect` MUST match the [`Expectation`] from the last `poll()`'s
831    /// emitted `Action`.
832    ///
833    /// # Errors
834    ///
835    /// - [`CommissioningError::OutOfOrderResponse`] if the state machine
836    ///   isn't currently waiting for a response.
837    /// - [`CommissioningError::UnexpectedResponseKind`] if `expect`
838    ///   doesn't match the last `Action`'s `Expectation`. The cursor
839    ///   does not advance.
840    /// - [`CommissioningError::MalformedResponse`] if `payload` fails
841    ///   to decode at the cluster-command level.
842    /// - [`CommissioningError::DeviceImStatus`] if the device returned a
843    ///   non-OK Interaction Model status.
844    ///
845    /// Any error other than `OutOfOrderResponse` and
846    /// `UnexpectedResponseKind` transitions the cursor to
847    /// [`Stage::Failed`]; the next `poll()` call emits
848    /// [`Action::Abort`] with a rendered summary.
849    #[cfg_attr(feature = "tracing", instrument(skip(self, payload), fields(stage = ?self.stage, expectation = ?expect)))]
850    pub fn on_response(
851        &mut self,
852        expect: Expectation,
853        payload: &[u8],
854    ) -> Result<(), CommissioningError> {
855        if expect == Expectation::CaseFailed {
856            // CaseFailed bypasses the awaiting check — the caller
857            // signals failure of the EstablishCase action explicitly,
858            // and EstablishCase tracks readiness via
859            // `awaiting_case_session`, not `awaiting`.
860            if !self.awaiting_case_session {
861                return Err(CommissioningError::OutOfOrderResponse(self.stage));
862            }
863            self.awaiting_case_session = false;
864            self.stage = Stage::Failed;
865            self.awaiting = None;
866            self.pending_action = None;
867            self.last_failure = Some(CommissioningError::CaseEstablishmentFailed.to_string());
868            return Err(CommissioningError::CaseEstablishmentFailed);
869        }
870        let Some(awaiting) = self.awaiting else {
871            return Err(CommissioningError::OutOfOrderResponse(self.stage));
872        };
873        if awaiting != expect {
874            return Err(CommissioningError::UnexpectedResponseKind {
875                expected: awaiting,
876                got: expect,
877            });
878        }
879        match self.handle_response(expect, payload) {
880            Ok(()) => Ok(()),
881            Err(err) => {
882                self.last_failure = Some(err.to_string());
883                self.stage = Stage::Failed;
884                self.awaiting = None;
885                self.pending_action = None;
886                Err(err)
887            }
888        }
889    }
890
891    /// Signal that CASE establishment (mDNS find-operational + the
892    /// SIGMA-I handshake — both M6.6 mechanics, owned by the driver)
893    /// has succeeded. The state machine advances from
894    /// [`Stage::FindOperationalForComplete`] to [`Stage::SendComplete`].
895    ///
896    /// # Errors
897    ///
898    /// Returns [`CommissioningError::OutOfOrderResponse`] if the state
899    /// machine isn't currently awaiting CASE establishment (i.e., the
900    /// cursor is not at `FindOperationalForComplete` or the
901    /// `EstablishCase` action hasn't been emitted yet).
902    #[cfg_attr(feature = "tracing", instrument(skip(self)))]
903    pub fn on_case_established(&mut self) -> Result<(), CommissioningError> {
904        if !self.awaiting_case_session {
905            return Err(CommissioningError::OutOfOrderResponse(self.stage));
906        }
907        self.awaiting_case_session = false;
908        self.advance(Stage::SendComplete);
909        Ok(())
910    }
911
912    #[allow(clippy::too_many_lines)]
913    fn handle_response(
914        &mut self,
915        expect: Expectation,
916        payload: &[u8],
917    ) -> Result<(), CommissioningError> {
918        use crate::clusters::general_commissioning as gc;
919        match expect {
920            Expectation::CommissioningInfo => {
921                Self::assert_tlv_well_formed(self.stage, payload)?;
922                // Best-effort: scan the response for a BasicCommissioningInfo
923                // struct and update failsafe_expiry_seconds. Malformed or
924                // missing → keep the M6.4 fallback (60s) silently.
925                if let Some(info) = gc::decode_basic_commissioning_info(payload) {
926                    if info.failsafe_expiry_length_seconds > 0 {
927                        self.failsafe_expiry_seconds = info.failsafe_expiry_length_seconds;
928                    }
929                    // Cap against the device's hard cumulative failsafe limit
930                    // (Matter §11.10.5.1): an `ArmFailSafe` whose expiry exceeds
931                    // `MaxCumulativeFailsafeSeconds` is guaranteed to be rejected
932                    // (BoundsExceeded), so never round-trip such a value.
933                    if info.max_cumulative_failsafe_seconds > 0 {
934                        self.failsafe_expiry_seconds = self
935                            .failsafe_expiry_seconds
936                            .min(info.max_cumulative_failsafe_seconds);
937                    }
938                }
939                self.advance(Stage::ArmFailsafe);
940                Ok(())
941            }
942            Expectation::ArmFailsafeResponse => {
943                let resp = gc::decode_arm_fail_safe_response(payload)?;
944                if resp.error_code != 0 {
945                    return Err(CommissioningError::DeviceImStatus {
946                        stage: self.stage,
947                        im_status: u16::from(resp.error_code),
948                    });
949                }
950                let next = match self.stage {
951                    Stage::ArmFailsafe => Stage::ConfigRegulatory,
952                    Stage::FailsafeBeforeNetworkEnable => Stage::NetworkEnable,
953                    other => {
954                        return Err(CommissioningError::OutOfOrderResponse(other));
955                    }
956                };
957                self.advance(next);
958                Ok(())
959            }
960            Expectation::SetRegulatoryConfigResponse => {
961                let resp = gc::decode_set_regulatory_config_response(payload)?;
962                if resp.error_code != 0 {
963                    return Err(CommissioningError::DeviceImStatus {
964                        stage: Stage::ConfigRegulatory,
965                        im_status: u16::from(resp.error_code),
966                    });
967                }
968                self.advance(Stage::SendPaiCertRequest);
969                Ok(())
970            }
971            Expectation::PaiCertChainResponse => {
972                let resp = crate::noc::decode_certificate_chain_response(payload)?;
973                self.pai_der = Some(resp.certificate);
974                self.advance(Stage::SendDacCertRequest);
975                Ok(())
976            }
977            Expectation::DacCertChainResponse => {
978                let resp = crate::noc::decode_certificate_chain_response(payload)?;
979                self.dac_der = Some(resp.certificate);
980                self.advance(Stage::SendAttestationRequest);
981                Ok(())
982            }
983            Expectation::AttestationResponse => {
984                let resp = crate::noc::decode_attestation_response(payload)?;
985                self.attestation_response = Some(resp);
986                self.advance(Stage::AttestationVerification);
987                Ok(())
988            }
989            Expectation::CsrResponse => {
990                let resp = crate::noc::decode_csr_response(payload)?;
991                self.csr_response = Some(resp);
992                self.advance(Stage::ValidateCsr);
993                Ok(())
994            }
995            Expectation::AddTrustedRootResponse => {
996                // `AddTrustedRootCertificate` has no typed response —
997                // success is a status-only ack at the Interaction Model
998                // layer. The caller surfaces the IM status as a 1-byte
999                // payload: `0x00` = success, anything else = error.
1000                if payload.first() != Some(&0u8) {
1001                    return Err(CommissioningError::DeviceImStatus {
1002                        stage: Stage::SendTrustedRootCert,
1003                        im_status: u16::from(payload.first().copied().unwrap_or(0xFF)),
1004                    });
1005                }
1006                self.advance(Stage::SendNoc);
1007                Ok(())
1008            }
1009            Expectation::NocResponse => {
1010                let resp = crate::noc::decode_noc_response(payload)?;
1011                if resp.status != 0 {
1012                    return Err(CommissioningError::DeviceImStatus {
1013                        stage: Stage::SendNoc,
1014                        im_status: u16::from(resp.status),
1015                    });
1016                }
1017                self.advance(Stage::ReadNetworkCommissioningInfo);
1018                Ok(())
1019            }
1020            Expectation::NetworkCommissioningInfo => {
1021                use crate::clusters::network_commissioning as nc;
1022                use crate::state_machine::NetworkKind;
1023                // EMPTY payload = the driver's absent-cluster sentinel: the
1024                // device answered the read with unsupported-path statuses
1025                // instead of data (it exposes no NetworkCommissioning cluster
1026                // — observed on a real device: eufy E31 lock commissioned
1027                // over IP). With no credentials to provision that is fine —
1028                // the device is already reachable on its operational network
1029                // — so skip provisioning exactly like the AlreadyOnNetwork
1030                // feature-map path below. With Wi-Fi/Thread credentials the
1031                // device CANNOT be provisioned: surface the typed error, not
1032                // a framing error.
1033                if payload.is_empty() {
1034                    match &self.network {
1035                        NetworkCredentials::AlreadyOnNetwork => {
1036                            self.advance(Stage::EvictPreviousCaseSessions);
1037                            return Ok(());
1038                        }
1039                        NetworkCredentials::WiFi(_) => {
1040                            return Err(CommissioningError::NetworkFeatureUnsupported {
1041                                needed: NetworkKind::WiFi,
1042                            });
1043                        }
1044                        NetworkCredentials::Thread(_) => {
1045                            return Err(CommissioningError::NetworkFeatureUnsupported {
1046                                needed: NetworkKind::Thread,
1047                            });
1048                        }
1049                    }
1050                }
1051                let features = nc::decode_feature_map(payload)?;
1052                // A FeatureMap with no recognised interface bit is
1053                // malformed regardless of the supplied credentials — a
1054                // NetworkCommissioning cluster always exposes at least one
1055                // of Wi-Fi / Thread / Ethernet.
1056                if features.is_empty() {
1057                    return Err(CommissioningError::MalformedResponse(
1058                        Stage::ReadNetworkCommissioningInfo,
1059                    ));
1060                }
1061                // Route by the *supplied* credential type, cross-checked
1062                // against the device FeatureMap. This resolves the
1063                // dual-stack ordering ambiguity (a Wi-Fi+Thread device is
1064                // provisioned per the caller's chosen credential type, not
1065                // by feature-bit order) and rejects a mismatch (credential
1066                // type absent from the FeatureMap) instead of silently
1067                // skipping provisioning.
1068                match &self.network {
1069                    NetworkCredentials::WiFi(_) => {
1070                        if !features.contains(nc::NetworkCommissioningFeature::WIFI) {
1071                            return Err(CommissioningError::NetworkFeatureUnsupported {
1072                                needed: NetworkKind::WiFi,
1073                            });
1074                        }
1075                        self.advance(Stage::NetworkSetup);
1076                    }
1077                    NetworkCredentials::Thread(_) => {
1078                        if !features.contains(nc::NetworkCommissioningFeature::THREAD) {
1079                            return Err(CommissioningError::NetworkFeatureUnsupported {
1080                                needed: NetworkKind::Thread,
1081                            });
1082                        }
1083                        self.advance(Stage::NetworkSetup);
1084                    }
1085                    NetworkCredentials::AlreadyOnNetwork => {
1086                        // No credentials to provision: the device is
1087                        // already reachable on its operational network (IP
1088                        // commissioning reached it there — e.g. a
1089                        // second-fabric commission of an already-provisioned
1090                        // device, or an Ethernet-only device). Mirror
1091                        // chip's AutoCommissioner: skip the network
1092                        // sub-cursor entirely (observed necessary on a real
1093                        // device: Tapo P110M, M6.6.5 validation).
1094                        self.advance(Stage::EvictPreviousCaseSessions);
1095                    }
1096                }
1097                Ok(())
1098            }
1099            Expectation::NetworkConfigResponse => {
1100                use crate::clusters::network_commissioning as nc;
1101                let resp = nc::decode_network_config_response(Stage::NetworkSetup, payload)?;
1102                if resp.networking_status != 0 {
1103                    return Err(CommissioningError::NetworkRejected {
1104                        stage: Stage::NetworkSetup,
1105                        networking_status: resp.networking_status,
1106                        debug_text: resp.debug_text,
1107                        remediation_hint: nc::remediation_for(resp.networking_status),
1108                    });
1109                }
1110                self.advance(Stage::FailsafeBeforeNetworkEnable);
1111                Ok(())
1112            }
1113            Expectation::ConnectNetworkResponse => {
1114                use crate::clusters::network_commissioning as nc;
1115                let resp = nc::decode_connect_network_response(Stage::NetworkEnable, payload)?;
1116                if resp.networking_status != 0 {
1117                    return Err(CommissioningError::NetworkRejected {
1118                        stage: Stage::NetworkEnable,
1119                        networking_status: resp.networking_status,
1120                        debug_text: resp.debug_text,
1121                        remediation_hint: nc::remediation_for(resp.networking_status),
1122                    });
1123                }
1124                self.advance(Stage::EvictPreviousCaseSessions);
1125                Ok(())
1126            }
1127            Expectation::CommissioningCompleteResponse => {
1128                let (error_code, _debug) =
1129                    gc::decode_commissioning_error_response(Stage::SendComplete, payload)?;
1130                if error_code != 0 {
1131                    return Err(CommissioningError::DeviceImStatus {
1132                        stage: Stage::SendComplete,
1133                        im_status: u16::from(error_code),
1134                    });
1135                }
1136                self.advance(Stage::Cleanup);
1137                Ok(())
1138            }
1139            // `Expectation::CaseFailed` is handled by `on_response`'s
1140            // pre-awaiting fast path and never reaches handle_response.
1141            _ => Err(CommissioningError::OutOfOrderResponse(self.stage)),
1142        }
1143    }
1144
1145    fn next_breadcrumb(&mut self) -> u64 {
1146        let b = self.breadcrumb_counter;
1147        self.breadcrumb_counter = b.saturating_add(1);
1148        b
1149    }
1150
1151    fn advance(&mut self, next: Stage) {
1152        self.stage = next;
1153        self.awaiting = None;
1154        self.pending_action = None;
1155    }
1156
1157    /// Off-wire attestation verification chain (M6.4.2 T21).
1158    ///
1159    /// Consumes the PAI/DAC DER + `AttestationResponse` + nonce captured
1160    /// by [`Stage::SendPaiCertRequest`] / [`Stage::SendDacCertRequest`]
1161    /// / [`Stage::SendAttestationRequest`] and runs M6.2's verifier
1162    /// chain end-to-end:
1163    ///
1164    /// 1. Parse PAI/DAC DER.
1165    /// 2. `verify_chain` — webpki path validation + Matter VID/PID
1166    ///    overlay (M6.2.2).
1167    /// 3. `verify_attestation_response` — ECDSA signature over
1168    ///    `attestation_elements || attestation_challenge` (M6.2.3).
1169    /// 4. `extract_attestation_elements_fields` — pull the
1170    ///    `attestation_nonce` echo + CD bytes out of the TLV blob.
1171    /// 5. Confirm the device echoed the nonce we sent.
1172    /// 6. `verify_certification_declaration` — verify the CSA-signed CD
1173    ///    embedded in `attestation_elements` against
1174    ///    [`crate::attestation::CdSigningRoots`] and confirm the
1175    ///    declared VID/PID match what the DAC subject claimed.
1176    fn run_attestation_verification(&mut self) -> Result<(), CommissioningError> {
1177        use crate::attestation::profile::{verify_attestation_cert_format, CertRole};
1178        use crate::attestation::{
1179            extract_attestation_elements_fields, verify_attestation_response, verify_chain,
1180            AttestationError, Dac, Pai,
1181        };
1182
1183        let pai_der = self
1184            .pai_der
1185            .as_ref()
1186            .ok_or(CommissioningError::OutOfOrderResponse(self.stage))?;
1187        let dac_der = self
1188            .dac_der
1189            .as_ref()
1190            .ok_or(CommissioningError::OutOfOrderResponse(self.stage))?;
1191        let response = self
1192            .attestation_response
1193            .as_ref()
1194            .ok_or(CommissioningError::OutOfOrderResponse(self.stage))?;
1195        let expected_nonce = self
1196            .attestation_nonce
1197            .ok_or(CommissioningError::OutOfOrderResponse(self.stage))?;
1198
1199        // 1. Parse chain certs.
1200        let pai = Pai::from_der(pai_der)?;
1201        let dac = Dac::from_der(dac_der)?;
1202        #[cfg(feature = "tracing")]
1203        tracing::debug!(
1204            dac_der = %crate::hexdump::hex(dac_der),
1205            pai_der = %crate::hexdump::hex(pai_der),
1206            "verifying attestation chain"
1207        );
1208
1209        // 1b. Certificate format profile (Matter §6.2.2) — mirrors chip's
1210        //     `VerifyAttestationCertificateFormat`. `rustls-webpki` ignores
1211        //     the KeyUsage extension and never requires SKID/AKID, so a
1212        //     counterfeit DAC carrying `keyUsage = keyCertSign` (a signing
1213        //     key posing as a device leaf) would pass path validation. We
1214        //     enforce the DAC/PAI X.509 profile ourselves, before trusting
1215        //     the chain. PAI first, then DAC — chip's order.
1216        verify_attestation_cert_format(pai.der(), CertRole::Pai)?;
1217        verify_attestation_cert_format(dac.der(), CertRole::Dac)?;
1218
1219        // 2. Chain validation (M6.2.2 — webpki path validation + VID/PID overlay).
1220        //    The returned `ChainVerification` carries the VID/PID that
1221        //    both webpki and the Matter overlay agreed on; we re-use
1222        //    those for the CD check below so a single source of truth
1223        //    drives both the chain validation and the CD VID/PID
1224        //    equality check.
1225        let chain = verify_chain(&dac, &pai, &self.paa_trust_store, self.now)?;
1226
1227        // 3. AttestationResponse signature (M6.2.3).
1228        verify_attestation_response(response, &self.pase_attestation_challenge, dac.public_key())?;
1229
1230        // 4. Extract attestation_elements fields: CD bytes (M6.4.3 will verify),
1231        //    nonce echo, timestamp.
1232        let fields = extract_attestation_elements_fields(&response.attestation_elements)?;
1233        if fields.attestation_nonce != expected_nonce {
1234            return Err(CommissioningError::Attestation(
1235                AttestationError::ResponseElementsMalformed,
1236            ));
1237        }
1238
1239        // 5. CD verification — verify the device's declared VID/PID
1240        //    against the CSA-signed Certification Declaration extracted
1241        //    from `attestation_elements`.
1242        #[cfg(feature = "tracing")]
1243        tracing::debug!(
1244            cd_cms = %crate::hexdump::hex(&fields.certification_declaration),
1245            "verifying certification declaration"
1246        );
1247        crate::attestation::verify_certification_declaration_with_paa(
1248            &fields.certification_declaration,
1249            chain.vendor_id,
1250            chain.product_id,
1251            &self.cd_signing_roots,
1252            chain.paa_skid.as_deref(),
1253        )?;
1254        Ok(())
1255    }
1256
1257    /// Off-wire CSR verification (M6.4.4 `Stage::ValidateCsr`).
1258    ///
1259    /// Consumes the `CsrResponse` captured by `Stage::SendOpCertSigningRequest`
1260    /// plus the DAC DER captured earlier by `Stage::SendDacCertRequest`,
1261    /// and runs M6.3's `verify_csr_response` three-check atomic gate:
1262    ///
1263    /// 1. PKCS#10 self-signature on the embedded CSR.
1264    /// 2. The device's `CSRNonce` echo equals the commissioner-issued nonce.
1265    /// 3. The DAC's attestation signature over
1266    ///    `nocsr_elements || attestation_challenge`.
1267    fn run_validate_csr(&mut self) -> Result<(), CommissioningError> {
1268        use crate::attestation::Dac;
1269        use crate::noc::verify_csr_response;
1270
1271        let resp = self
1272            .csr_response
1273            .as_ref()
1274            .ok_or(CommissioningError::OutOfOrderResponse(self.stage))?;
1275        let dac_der = self
1276            .dac_der
1277            .as_ref()
1278            .ok_or(CommissioningError::OutOfOrderResponse(self.stage))?;
1279        let csr_nonce = self
1280            .csr_nonce
1281            .ok_or(CommissioningError::OutOfOrderResponse(self.stage))?;
1282
1283        let dac = Dac::from_der(dac_der)?;
1284        let verified = verify_csr_response(
1285            &resp.nocsr_elements,
1286            &resp.attestation_signature,
1287            &csr_nonce,
1288            &self.pase_attestation_challenge,
1289            dac.public_key(),
1290        )?;
1291        self.verified_csr = Some(verified);
1292        Ok(())
1293    }
1294
1295    /// Off-wire NOC issuance (M6.4.4 `Stage::GenerateNocChain`).
1296    ///
1297    /// Consumes the [`crate::noc::VerifiedCsr`] populated by
1298    /// [`Self::run_validate_csr`] and mints a NOC signed by the fabric's
1299    /// RCAC via M6.3's `issue_noc`.
1300    ///
1301    /// Validity window: M6.4 uses `(self.now, MatterTime::NO_EXPIRY)` —
1302    /// the same convention `issue_noc`'s own unit test uses. M8 may
1303    /// tighten this to a bounded operational-cert lifetime per Matter
1304    /// Core Spec §6.4 once persistence + rotation policy lands.
1305    ///
1306    /// CATs (CASE Authenticated Tags) are empty in M6.4; tag-based
1307    /// access control comes later.
1308    fn run_generate_noc_chain(&mut self) -> Result<(), CommissioningError> {
1309        use crate::noc::issue_noc;
1310
1311        let verified = self
1312            .verified_csr
1313            .as_ref()
1314            .ok_or(CommissioningError::OutOfOrderResponse(self.stage))?;
1315        let noc = issue_noc(
1316            &self.fabric,
1317            verified,
1318            self.assigned_node_id,
1319            &[],
1320            (self.now, MatterTime::NO_EXPIRY),
1321            self.rng.as_ref(),
1322        )?;
1323        // Cache the NOC public key (the same bytes the verified CSR
1324        // committed to) for later use — currently only consumed by
1325        // M6.4.5's PASE -> CASE handoff in `CommissionedFabric`. Stored
1326        // here so the SendNoc stage doesn't have to re-derive it.
1327        self.issued_noc_public_key = Some(*verified.public_key.as_bytes());
1328        self.issued_noc = Some(noc);
1329        Ok(())
1330    }
1331
1332    fn assert_tlv_well_formed(stage: Stage, payload: &[u8]) -> Result<(), CommissioningError> {
1333        use matter_codec::{ContainerKind, Element, Tag, TlvReader};
1334        let mut reader = TlvReader::new(payload);
1335        match reader
1336            .next()
1337            .map_err(|_| CommissioningError::MalformedResponse(stage))?
1338        {
1339            Some(Element::ContainerStart {
1340                tag: Tag::Anonymous,
1341                kind: ContainerKind::Structure,
1342            }) => {}
1343            _ => return Err(CommissioningError::MalformedResponse(stage)),
1344        }
1345        // Walk to ContainerEnd; ignore contents for M6.4.1.
1346        loop {
1347            match reader
1348                .next()
1349                .map_err(|_| CommissioningError::MalformedResponse(stage))?
1350            {
1351                None => return Err(CommissioningError::MalformedResponse(stage)),
1352                Some(Element::ContainerEnd) => return Ok(()),
1353                Some(_) => {}
1354            }
1355        }
1356    }
1357}
1358
1359/// Test-only state seeds for [`Commissioner::position_at_stage_for_test`].
1360///
1361/// Each field is `None` by default — the caller opts in to each seed
1362/// explicitly. **Never use in production.**
1363#[cfg(feature = "__test_shortcuts")]
1364#[derive(Default, Debug, Clone, Copy)]
1365pub struct TestStateSeeds {
1366    /// Override `issued_noc_public_key` (normally populated when the
1367    /// state machine actually issues a NOC). Set to a synthetic SEC1-
1368    /// uncompressed P-256 byte pattern (e.g. `[0xCC; 65]`) when
1369    /// fast-forwarding past the NOC-issuance stages.
1370    pub synthetic_noc_pubkey: Option<[u8; 65]>,
1371}
1372
1373#[cfg(test)]
1374mod tests {
1375    // Test-code carve-out: see CLAUDE.md.
1376    #![allow(clippy::unwrap_used, clippy::expect_used)]
1377
1378    use super::*;
1379    use crate::attestation::CdSigningRoots;
1380    use crate::noc::{FabricRecord, NocRng, SystemNocRng};
1381    use crate::setup::{
1382        CommissioningFlow, DiscoveryCapabilities, Discriminator, Passcode, SetupPayload,
1383    };
1384    use crate::state_machine::{Action, Expectation};
1385    use crate::PaaTrustStore;
1386    use matter_cert::time::MatterTime;
1387    use matter_crypto::{RingSigner, Signer};
1388    use std::sync::Arc;
1389
1390    fn make_setup_payload() -> SetupPayload {
1391        SetupPayload {
1392            version: 0,
1393            vendor_id: Some(0xFFF1),
1394            product_id: Some(0x8000),
1395            commissioning_flow: CommissioningFlow::Standard,
1396            discovery_capabilities: DiscoveryCapabilities::ON_NETWORK,
1397            discriminator: Discriminator::new(0x0F00).expect("valid discriminator"),
1398            passcode: Passcode::new(20_202_021).expect("valid passcode"),
1399        }
1400    }
1401
1402    fn make_fabric_record() -> FabricRecord {
1403        let (signer, _pkcs8) = RingSigner::generate().unwrap();
1404        let signer: Arc<dyn Signer> = Arc::new(signer);
1405        FabricRecord::new_root_only(
1406            /* fabric_id */ 0x0000_0000_0000_0001,
1407            signer,
1408            /* not_before */ MatterTime::from_unix_secs(1_704_067_200),
1409            /* not_after */ MatterTime::from_unix_secs(1_735_689_600),
1410            /* rcac_id */ 42,
1411            &SystemNocRng,
1412        )
1413        .unwrap()
1414    }
1415
1416    /// A fabric that issues a 3-tier RCAC -> ICAC -> NOC chain: the RCAC from
1417    /// [`make_fabric_record`] with a freshly-minted ICAC attached (so
1418    /// `issue_noc` signs the device NOC under the ICAC and `SendNoc` must
1419    /// transmit the ICAC alongside it).
1420    fn make_fabric_record_with_icac() -> FabricRecord {
1421        let mut fabric = make_fabric_record();
1422        let (icac_signer, _pkcs8) = RingSigner::generate().unwrap();
1423        let icac_public_key =
1424            matter_cert::PublicKey::from_slice(icac_signer.public_key().as_bytes()).unwrap();
1425        let icac = crate::noc::issue_icac(
1426            &fabric,
1427            /* icac_id */ 7,
1428            &icac_public_key,
1429            (
1430                MatterTime::from_unix_secs(1_704_067_200),
1431                MatterTime::NO_EXPIRY,
1432            ),
1433            &SystemNocRng,
1434        )
1435        .unwrap();
1436        fabric.icac_signer = Some(Arc::new(icac_signer));
1437        fabric.icac_cert = Some(icac);
1438        fabric
1439    }
1440
1441    /// True iff the encoded `AddNOC` payload carries an `ICACValue` (field 1,
1442    /// spec §11.18.5.9). Scans the top-level struct members for a
1443    /// context-1 element; every `AddNOC` field is a scalar/byte-string, so
1444    /// no descent past the outer container is needed.
1445    fn add_noc_payload_has_icac(payload: &[u8]) -> bool {
1446        use matter_codec::{Element, Tag, TlvReader};
1447        let mut r = TlvReader::new(payload);
1448        match r.next().unwrap() {
1449            Some(Element::ContainerStart { .. }) => {}
1450            other => panic!("expected an AddNOC struct, got {other:?}"),
1451        }
1452        while let Some(el) = r.next().unwrap() {
1453            match el {
1454                Element::ContainerEnd => break,
1455                Element::Scalar {
1456                    tag: Tag::Context(1),
1457                    ..
1458                } => return true,
1459                _ => {}
1460            }
1461        }
1462        false
1463    }
1464
1465    #[test]
1466    fn send_noc_includes_icac_for_three_tier_fabric() {
1467        let fabric = make_fabric_record_with_icac();
1468        let setup = make_setup_payload();
1469        let paa = PaaTrustStore::with_example_device_roots();
1470        let cd = CdSigningRoots::with_example_device_roots();
1471        let rng: Arc<dyn NocRng> = Arc::new(SystemNocRng);
1472        let mut sm =
1473            Commissioner::new(base_config(&fabric, &setup, &paa, &cd, rng)).expect("valid config");
1474        // `SendNoc` reads `issued_noc` + `fabric.icac_cert`; the NOC bytes are
1475        // irrelevant to whether the ICAC is attached, so stub the NOC and jump
1476        // straight to the stage under test.
1477        sm.issued_noc = Some(fabric.root_cert.clone());
1478        sm.stage = Stage::SendNoc;
1479        match sm.dispatch_stage().expect("SendNoc dispatch") {
1480            Action::Invoke {
1481                cluster,
1482                command,
1483                payload,
1484                ..
1485            } => {
1486                assert_eq!(cluster, 0x003E);
1487                assert_eq!(command, 0x06);
1488                assert!(
1489                    add_noc_payload_has_icac(&payload),
1490                    "AddNOC must carry the ICAC (ctx1) when the fabric runs a 3-tier chain"
1491                );
1492            }
1493            other => panic!("expected Invoke, got {other:?}"),
1494        }
1495    }
1496
1497    #[test]
1498    fn send_noc_omits_icac_for_flat_fabric() {
1499        let fabric = make_fabric_record();
1500        let setup = make_setup_payload();
1501        let paa = PaaTrustStore::with_example_device_roots();
1502        let cd = CdSigningRoots::with_example_device_roots();
1503        let rng: Arc<dyn NocRng> = Arc::new(SystemNocRng);
1504        let mut sm =
1505            Commissioner::new(base_config(&fabric, &setup, &paa, &cd, rng)).expect("valid config");
1506        sm.issued_noc = Some(fabric.root_cert.clone());
1507        sm.stage = Stage::SendNoc;
1508        match sm.dispatch_stage().expect("SendNoc dispatch") {
1509            Action::Invoke { payload, .. } => {
1510                assert!(
1511                    !add_noc_payload_has_icac(&payload),
1512                    "flat RCAC->NOC AddNOC must omit the ICAC field"
1513                );
1514            }
1515            other => panic!("expected Invoke, got {other:?}"),
1516        }
1517    }
1518
1519    fn base_config<'a>(
1520        fabric: &'a FabricRecord,
1521        setup: &'a SetupPayload,
1522        paa: &'a PaaTrustStore,
1523        cd: &'a crate::attestation::CdSigningRoots,
1524        rng: Arc<dyn NocRng>,
1525    ) -> CommissionerConfig<'a> {
1526        CommissionerConfig {
1527            pase_attestation_challenge: [0u8; 16],
1528            fabric,
1529            setup_payload: setup,
1530            paa_trust_store: paa,
1531            cd_signing_roots: cd,
1532            commissioner_node_id: 0x1,
1533            assigned_node_id: 0x2,
1534            ipk_epoch_key: [0x42_u8; 16],
1535            case_admin_subject: 0x1,
1536            admin_vendor_id: 0xFFF1,
1537            now: MatterTime::from_unix_secs(1_704_067_200),
1538            rng,
1539            network: NetworkCredentials::AlreadyOnNetwork,
1540        }
1541    }
1542
1543    #[test]
1544    fn new_rejects_zero_commissioner_node_id() {
1545        let fabric = make_fabric_record();
1546        let setup = make_setup_payload();
1547        let paa = PaaTrustStore::with_example_device_roots();
1548        let cd = CdSigningRoots::with_example_device_roots();
1549        let rng: Arc<dyn NocRng> = Arc::new(SystemNocRng);
1550        let mut cfg = base_config(&fabric, &setup, &paa, &cd, rng);
1551        cfg.commissioner_node_id = 0;
1552        // Cannot use `expect_err`: `Commissioner` does not impl Debug
1553        // because `FabricRecord` (a stored field) is not Debug.
1554        let Err(err) = Commissioner::new(cfg) else {
1555            panic!("zero commissioner_node_id should fail");
1556        };
1557        assert!(
1558            matches!(err, CommissioningError::InvalidConfig(_)),
1559            "got {err:?}"
1560        );
1561    }
1562
1563    #[test]
1564    fn new_rejects_zero_assigned_node_id() {
1565        let fabric = make_fabric_record();
1566        let setup = make_setup_payload();
1567        let paa = PaaTrustStore::with_example_device_roots();
1568        let cd = CdSigningRoots::with_example_device_roots();
1569        let rng: Arc<dyn NocRng> = Arc::new(SystemNocRng);
1570        let mut cfg = base_config(&fabric, &setup, &paa, &cd, rng);
1571        cfg.assigned_node_id = 0;
1572        let Err(err) = Commissioner::new(cfg) else {
1573            panic!("zero assigned_node_id should fail");
1574        };
1575        assert!(
1576            matches!(err, CommissioningError::InvalidConfig(_)),
1577            "got {err:?}"
1578        );
1579    }
1580
1581    #[test]
1582    fn new_rejects_equal_commissioner_and_assigned_ids() {
1583        let fabric = make_fabric_record();
1584        let setup = make_setup_payload();
1585        let paa = PaaTrustStore::with_example_device_roots();
1586        let cd = CdSigningRoots::with_example_device_roots();
1587        let rng: Arc<dyn NocRng> = Arc::new(SystemNocRng);
1588        let mut cfg = base_config(&fabric, &setup, &paa, &cd, rng);
1589        cfg.commissioner_node_id = 0x42;
1590        cfg.assigned_node_id = 0x42;
1591        let Err(err) = Commissioner::new(cfg) else {
1592            panic!("equal IDs should fail");
1593        };
1594        assert!(
1595            matches!(err, CommissioningError::InvalidConfig(_)),
1596            "got {err:?}"
1597        );
1598    }
1599
1600    #[test]
1601    fn new_rejects_zero_ipk_epoch_key() {
1602        let fabric = make_fabric_record();
1603        let setup = make_setup_payload();
1604        let paa = PaaTrustStore::with_example_device_roots();
1605        let cd = CdSigningRoots::with_example_device_roots();
1606        let rng: Arc<dyn NocRng> = Arc::new(SystemNocRng);
1607        let mut cfg = base_config(&fabric, &setup, &paa, &cd, rng);
1608        cfg.ipk_epoch_key = [0u8; 16];
1609        let Err(err) = Commissioner::new(cfg) else {
1610            panic!("zero IPK should fail");
1611        };
1612        assert!(
1613            matches!(err, CommissioningError::InvalidConfig(_)),
1614            "got {err:?}"
1615        );
1616    }
1617
1618    #[test]
1619    fn new_returns_secure_pairing_stage() {
1620        let fabric = make_fabric_record();
1621        let setup = make_setup_payload();
1622        let paa = PaaTrustStore::with_example_device_roots();
1623        let cd = CdSigningRoots::with_example_device_roots();
1624        let rng: Arc<dyn NocRng> = Arc::new(SystemNocRng);
1625        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
1626        let sm = Commissioner::new(cfg).expect("valid config should construct");
1627        assert_eq!(sm.stage(), Stage::SecurePairing);
1628    }
1629
1630    #[test]
1631    fn poll_from_secure_pairing_emits_read_commissioning_info() {
1632        let fabric = make_fabric_record();
1633        let setup = make_setup_payload();
1634        let paa = PaaTrustStore::with_example_device_roots();
1635        let cd = CdSigningRoots::with_example_device_roots();
1636        let rng: Arc<dyn NocRng> = Arc::new(SystemNocRng);
1637        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
1638        let mut sm = Commissioner::new(cfg).expect("valid config");
1639        let act = sm.poll().expect("poll succeeds");
1640        match act {
1641            Action::ReadAttribute {
1642                session,
1643                endpoint,
1644                cluster,
1645                attributes,
1646                expect,
1647            } => {
1648                assert_eq!(session, crate::state_machine::SessionContext::Pase);
1649                assert_eq!(endpoint, 0);
1650                assert_eq!(cluster, 0x0030);
1651                assert_eq!(expect, Expectation::CommissioningInfo);
1652                assert!(!attributes.is_empty());
1653            }
1654            other => panic!("expected ReadAttribute, got {other:?}"),
1655        }
1656        assert_eq!(sm.stage(), Stage::ReadCommissioningInfo);
1657    }
1658
1659    #[test]
1660    fn poll_is_idempotent_between_responses() {
1661        let fabric = make_fabric_record();
1662        let setup = make_setup_payload();
1663        let paa = PaaTrustStore::with_example_device_roots();
1664        let cd = CdSigningRoots::with_example_device_roots();
1665        let rng: Arc<dyn NocRng> = Arc::new(SystemNocRng);
1666        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
1667        let mut sm = Commissioner::new(cfg).expect("valid config");
1668        let act1 = sm.poll().expect("first poll");
1669        let act2 = sm.poll().expect("second poll");
1670        match (act1, act2) {
1671            (
1672                Action::ReadAttribute {
1673                    cluster: c1,
1674                    expect: e1,
1675                    ..
1676                },
1677                Action::ReadAttribute {
1678                    cluster: c2,
1679                    expect: e2,
1680                    ..
1681                },
1682            ) => {
1683                assert_eq!(c1, c2);
1684                assert_eq!(e1, e2);
1685            }
1686            other => panic!("idempotent poll returned different variants: {other:?}"),
1687        }
1688    }
1689
1690    #[test]
1691    fn full_happy_path_through_config_regulatory_lands_on_send_pai_cert_request() {
1692        let fabric = make_fabric_record();
1693        let setup = make_setup_payload();
1694        let paa = PaaTrustStore::with_example_device_roots();
1695        let cd = CdSigningRoots::with_example_device_roots();
1696        let rng: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
1697        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
1698        let mut sm = Commissioner::new(cfg).expect("valid config");
1699
1700        // SecurePairing → ReadCommissioningInfo
1701        let _ = sm.poll().expect("poll #1");
1702        let canned_info = encode_read_commissioning_info_response();
1703        sm.on_response(Expectation::CommissioningInfo, &canned_info)
1704            .expect("commissioning info accepted");
1705        assert_eq!(sm.stage(), Stage::ArmFailsafe);
1706
1707        // ArmFailsafe
1708        let _ = sm.poll().expect("poll #2");
1709        sm.on_response(
1710            Expectation::ArmFailsafeResponse,
1711            &[0x15, 0x24, 0x00, 0x00, 0x18],
1712        )
1713        .expect("arm failsafe ok");
1714        assert_eq!(sm.stage(), Stage::ConfigRegulatory);
1715
1716        // ConfigRegulatory
1717        let _ = sm.poll().expect("poll #3");
1718        sm.on_response(
1719            Expectation::SetRegulatoryConfigResponse,
1720            &[0x15, 0x24, 0x00, 0x00, 0x18],
1721        )
1722        .expect("config regulatory ok");
1723        assert_eq!(sm.stage(), Stage::SendPaiCertRequest);
1724
1725        // M6.4.2: SendPaiCertRequest now actually emits an Invoke.
1726        match sm.poll().expect("poll #4") {
1727            Action::Invoke {
1728                cluster,
1729                command,
1730                expect,
1731                ..
1732            } => {
1733                assert_eq!(cluster, 0x003E);
1734                assert_eq!(command, 0x02);
1735                assert_eq!(expect, Expectation::PaiCertChainResponse);
1736            }
1737            other => panic!("expected Invoke, got {other:?}"),
1738        }
1739    }
1740
1741    #[test]
1742    fn arm_failsafe_busy_response_aborts_with_device_im_status() {
1743        let fabric = make_fabric_record();
1744        let setup = make_setup_payload();
1745        let paa = PaaTrustStore::with_example_device_roots();
1746        let cd = CdSigningRoots::with_example_device_roots();
1747        let rng: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
1748        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
1749        let mut sm = Commissioner::new(cfg).expect("valid config");
1750        let _ = sm.poll().expect("poll info");
1751        sm.on_response(
1752            Expectation::CommissioningInfo,
1753            &encode_read_commissioning_info_response(),
1754        )
1755        .expect("commissioning info ok");
1756        let _ = sm.poll().expect("poll arm failsafe");
1757        // Device returns BusyWithOtherAdmin: error_code = 4 (spec §11.10.5.1).
1758        let err = sm
1759            .on_response(
1760                Expectation::ArmFailsafeResponse,
1761                &[0x15, 0x24, 0x00, 0x04, 0x18],
1762            )
1763            .expect_err("busy should fail");
1764        assert!(matches!(
1765            err,
1766            CommissioningError::DeviceImStatus {
1767                stage: Stage::ArmFailsafe,
1768                im_status: 4,
1769            }
1770        ));
1771        assert_eq!(sm.stage(), Stage::Failed);
1772        match sm.poll().expect("abort emission") {
1773            Action::Abort {
1774                send_disarm_failsafe,
1775                reason,
1776            } => {
1777                assert!(send_disarm_failsafe);
1778                assert!(reason.contains("ArmFailsafe"), "reason was {reason}");
1779                assert!(reason.contains("0x4"), "reason was {reason}");
1780            }
1781            other => panic!("expected Abort, got {other:?}"),
1782        }
1783    }
1784
1785    #[test]
1786    fn out_of_order_response_returns_error_without_advancing() {
1787        let fabric = make_fabric_record();
1788        let setup = make_setup_payload();
1789        let paa = PaaTrustStore::with_example_device_roots();
1790        let cd = CdSigningRoots::with_example_device_roots();
1791        let rng: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
1792        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
1793        let mut sm = Commissioner::new(cfg).expect("valid config");
1794        // No poll called — state machine isn't waiting on anything.
1795        let err = sm
1796            .on_response(Expectation::ArmFailsafeResponse, &[])
1797            .expect_err("should reject out-of-order");
1798        assert!(matches!(err, CommissioningError::OutOfOrderResponse(_)));
1799        assert_eq!(sm.stage(), Stage::SecurePairing);
1800    }
1801
1802    #[test]
1803    fn wrong_expectation_returns_unexpected_response_kind() {
1804        let fabric = make_fabric_record();
1805        let setup = make_setup_payload();
1806        let paa = PaaTrustStore::with_example_device_roots();
1807        let cd = CdSigningRoots::with_example_device_roots();
1808        let rng: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
1809        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
1810        let mut sm = Commissioner::new(cfg).expect("valid config");
1811        let _ = sm.poll().expect("poll");
1812        let err = sm
1813            .on_response(Expectation::ArmFailsafeResponse, &[])
1814            .expect_err("wrong kind should fail");
1815        assert!(matches!(
1816            err,
1817            CommissioningError::UnexpectedResponseKind {
1818                expected: Expectation::CommissioningInfo,
1819                got: Expectation::ArmFailsafeResponse,
1820            }
1821        ));
1822        // Wrong-kind does NOT advance the cursor.
1823        assert_eq!(sm.stage(), Stage::ReadCommissioningInfo);
1824    }
1825
1826    fn encode_read_commissioning_info_response() -> Vec<u8> {
1827        // Minimal well-formed anonymous struct. M6.4.1 doesn't parse
1828        // individual attributes yet.
1829        vec![0x15, 0x18]
1830    }
1831
1832    // --- M6.4.2 T18-T21: attestation flow tests ---
1833
1834    fn drive_to_send_pai_cert_request(sm: &mut Commissioner) {
1835        let _ = sm.poll().expect("poll info");
1836        sm.on_response(Expectation::CommissioningInfo, &[0x15, 0x18])
1837            .expect("info ok");
1838        let _ = sm.poll().expect("poll arm failsafe");
1839        sm.on_response(
1840            Expectation::ArmFailsafeResponse,
1841            &[0x15, 0x24, 0x00, 0x00, 0x18],
1842        )
1843        .expect("arm ok");
1844        let _ = sm.poll().expect("poll config regulatory");
1845        sm.on_response(
1846            Expectation::SetRegulatoryConfigResponse,
1847            &[0x15, 0x24, 0x00, 0x00, 0x18],
1848        )
1849        .expect("regulatory ok");
1850    }
1851
1852    fn synthetic_cert_chain_response(cert: &[u8]) -> Vec<u8> {
1853        use matter_codec::{Tag, TlvWriter};
1854        let mut buf = Vec::new();
1855        let mut w = TlvWriter::new(&mut buf);
1856        w.start_structure(Tag::Anonymous).expect("infallible");
1857        w.put_bytes(Tag::Context(0), cert).expect("infallible");
1858        w.end_container().expect("infallible");
1859        buf
1860    }
1861
1862    fn nonce_from_attestation_invoke(act: &Action) -> [u8; 32] {
1863        match act {
1864            Action::Invoke { payload, .. } => {
1865                use matter_codec::{Element, Tag, TlvReader, Value};
1866                let mut r = TlvReader::new(payload);
1867                let _ = r.next().expect("reader").expect("anon-struct-start");
1868                loop {
1869                    match r.next().expect("reader") {
1870                        Some(Element::Scalar {
1871                            tag: Tag::Context(0),
1872                            value: Value::Bytes(b),
1873                        }) => {
1874                            return b.as_slice().try_into().expect("32 bytes");
1875                        }
1876                        Some(_) => {}
1877                        None => panic!("no nonce found"),
1878                    }
1879                }
1880            }
1881            other => panic!("expected Invoke, got {other:?}"),
1882        }
1883    }
1884
1885    #[test]
1886    fn poll_at_send_pai_emits_certificate_chain_request_pai() {
1887        let fabric = make_fabric_record();
1888        let setup = make_setup_payload();
1889        let paa = PaaTrustStore::with_example_device_roots();
1890        let cd = CdSigningRoots::with_example_device_roots();
1891        let rng: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
1892        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
1893        let mut sm = Commissioner::new(cfg).expect("valid config");
1894        drive_to_send_pai_cert_request(&mut sm);
1895        assert_eq!(sm.stage(), Stage::SendPaiCertRequest);
1896        match sm.poll().expect("poll PAI") {
1897            Action::Invoke {
1898                cluster,
1899                command,
1900                expect,
1901                payload,
1902                ..
1903            } => {
1904                assert_eq!(cluster, 0x003E);
1905                assert_eq!(command, 0x02);
1906                assert_eq!(expect, Expectation::PaiCertChainResponse);
1907                // CertificateChainTypeEnum (spec §11.18.5.2): 2 = PAI.
1908                assert_eq!(payload, vec![0x15, 0x24, 0x00, 0x02, 0x18]);
1909            }
1910            other => panic!("expected Invoke, got {other:?}"),
1911        }
1912    }
1913
1914    #[test]
1915    fn poll_at_send_dac_emits_certificate_chain_request_dac() {
1916        let fabric = make_fabric_record();
1917        let setup = make_setup_payload();
1918        let paa = PaaTrustStore::with_example_device_roots();
1919        let cd = CdSigningRoots::with_example_device_roots();
1920        let rng: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
1921        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
1922        let mut sm = Commissioner::new(cfg).expect("valid config");
1923        drive_to_send_pai_cert_request(&mut sm);
1924        let _ = sm.poll().expect("poll PAI");
1925        let pai_response = synthetic_cert_chain_response(&[0xAA, 0xBB, 0xCC]);
1926        sm.on_response(Expectation::PaiCertChainResponse, &pai_response)
1927            .expect("PAI accepted");
1928        assert_eq!(sm.stage(), Stage::SendDacCertRequest);
1929        match sm.poll().expect("poll DAC") {
1930            Action::Invoke {
1931                cluster,
1932                command,
1933                expect,
1934                payload,
1935                ..
1936            } => {
1937                assert_eq!(cluster, 0x003E);
1938                assert_eq!(command, 0x02);
1939                assert_eq!(expect, Expectation::DacCertChainResponse);
1940                // CertificateChainTypeEnum (spec §11.18.5.2): 1 = DAC.
1941                assert_eq!(payload, vec![0x15, 0x24, 0x00, 0x01, 0x18]);
1942            }
1943            other => panic!("expected Invoke, got {other:?}"),
1944        }
1945    }
1946
1947    #[test]
1948    fn send_attestation_request_uses_fresh_random_nonce_each_time() {
1949        let fabric = make_fabric_record();
1950        let setup = make_setup_payload();
1951        let paa = PaaTrustStore::with_example_device_roots();
1952        let cd = CdSigningRoots::with_example_device_roots();
1953
1954        let rng_a: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
1955        let cfg_a = base_config(&fabric, &setup, &paa, &cd, rng_a);
1956        let mut sm_a = Commissioner::new(cfg_a).expect("valid config");
1957        drive_to_send_pai_cert_request(&mut sm_a);
1958        let _ = sm_a.poll().expect("poll PAI a");
1959        let pai_response = synthetic_cert_chain_response(&[0xAA]);
1960        sm_a.on_response(Expectation::PaiCertChainResponse, &pai_response)
1961            .expect("ok");
1962        let _ = sm_a.poll().expect("poll DAC a");
1963        let dac_response = synthetic_cert_chain_response(&[0xBB]);
1964        sm_a.on_response(Expectation::DacCertChainResponse, &dac_response)
1965            .expect("ok");
1966        let nonce_a = nonce_from_attestation_invoke(&sm_a.poll().expect("poll att a"));
1967
1968        let rng_b: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
1969        let cfg_b = base_config(&fabric, &setup, &paa, &cd, rng_b);
1970        let mut sm_b = Commissioner::new(cfg_b).expect("valid config");
1971        drive_to_send_pai_cert_request(&mut sm_b);
1972        let _ = sm_b.poll().expect("poll PAI b");
1973        sm_b.on_response(Expectation::PaiCertChainResponse, &pai_response)
1974            .expect("ok");
1975        let _ = sm_b.poll().expect("poll DAC b");
1976        sm_b.on_response(Expectation::DacCertChainResponse, &dac_response)
1977            .expect("ok");
1978        let nonce_b = nonce_from_attestation_invoke(&sm_b.poll().expect("poll att b"));
1979
1980        assert_ne!(
1981            nonce_a, nonce_b,
1982            "two independent runs should use different random nonces"
1983        );
1984    }
1985
1986    // --- M6.4.4 T35-T40: CSR + NOC issuance flow tests ---
1987
1988    /// Extract the 32-byte `CSRNonce` from a `CSRRequest` Invoke payload.
1989    /// Mirrors `nonce_from_attestation_invoke` — same TLV shape, both
1990    /// pull the bytes at context tag 0 inside the anonymous outer struct.
1991    fn nonce_from_csr_invoke(act: &Action) -> [u8; 32] {
1992        match act {
1993            Action::Invoke { payload, .. } => {
1994                use matter_codec::{Element, Tag, TlvReader, Value};
1995                let mut r = TlvReader::new(payload);
1996                let _ = r.next().expect("reader").expect("anon-struct-start");
1997                loop {
1998                    match r.next().expect("reader") {
1999                        Some(Element::Scalar {
2000                            tag: Tag::Context(0),
2001                            value: Value::Bytes(b),
2002                        }) => {
2003                            return b.as_slice().try_into().expect("32 bytes");
2004                        }
2005                        Some(_) => {}
2006                        None => panic!("no nonce found"),
2007                    }
2008                }
2009            }
2010            other => panic!("expected Invoke, got {other:?}"),
2011        }
2012    }
2013
2014    /// Glass-box test: jumps the cursor straight to
2015    /// `Stage::SendOpCertSigningRequest` (bypassing PAI/DAC/Att, which
2016    /// would otherwise demand real fixtures M6.4.2's verifier accepts)
2017    /// and checks the emitted `CSRRequest` Invoke's nonce randomness
2018    /// across two independent commissioner instances. The full
2019    /// integration drive ships in T41 with real matter.js fixtures.
2020    #[test]
2021    fn send_op_cert_signing_request_emits_csr_with_random_nonce() {
2022        let fabric = make_fabric_record();
2023        let setup = make_setup_payload();
2024        let paa = PaaTrustStore::with_example_device_roots();
2025        let cd = CdSigningRoots::with_example_device_roots();
2026
2027        let rng_a: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
2028        let cfg_a = base_config(&fabric, &setup, &paa, &cd, rng_a);
2029        let mut sm_a = Commissioner::new(cfg_a).expect("valid config");
2030        // Jump the cursor + plant the prerequisite DAC slot. Glass-box
2031        // crate-private access is fine inside the in-module `tests`
2032        // submodule.
2033        sm_a.stage = Stage::SendOpCertSigningRequest;
2034        sm_a.dac_der = Some(vec![0xAA, 0xBB]);
2035        let act_a = sm_a.poll().expect("poll csr a");
2036        let nonce_a = nonce_from_csr_invoke(&act_a);
2037
2038        let rng_b: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
2039        let cfg_b = base_config(&fabric, &setup, &paa, &cd, rng_b);
2040        let mut sm_b = Commissioner::new(cfg_b).expect("valid config");
2041        sm_b.stage = Stage::SendOpCertSigningRequest;
2042        sm_b.dac_der = Some(vec![0xAA, 0xBB]);
2043        let act_b = sm_b.poll().expect("poll csr b");
2044        let nonce_b = nonce_from_csr_invoke(&act_b);
2045
2046        assert_ne!(
2047            nonce_a, nonce_b,
2048            "two independent runs should use different CSR nonces"
2049        );
2050
2051        match act_a {
2052            Action::Invoke {
2053                cluster,
2054                command,
2055                expect,
2056                ..
2057            } => {
2058                assert_eq!(cluster, 0x003E);
2059                assert_eq!(command, 0x04);
2060                assert_eq!(expect, Expectation::CsrResponse);
2061            }
2062            other => panic!("expected Invoke, got {other:?}"),
2063        }
2064    }
2065
2066    /// Glass-box test: with the CSR + NOC artefacts pre-populated and
2067    /// the cursor placed at `Stage::SendNoc`, `poll()` must emit an
2068    /// `AddNOC` Invoke targeting cluster `0x003E` / command `0x06`.
2069    /// Then drive the synthetic `NOCResponse { status: 0 }` through
2070    /// `on_response` and assert the cursor lands on
2071    /// `Stage::ReadNetworkCommissioningInfo`.
2072    #[test]
2073    fn drive_through_send_noc_with_synthetic_noc_response() {
2074        use matter_cert::{
2075            BasicConstraints, DistinguishedName, DnAttribute, Extensions, MatterCertificate,
2076            PublicKey,
2077        };
2078
2079        let fabric = make_fabric_record();
2080        let setup = make_setup_payload();
2081        let paa = PaaTrustStore::with_example_device_roots();
2082        let cd = CdSigningRoots::with_example_device_roots();
2083        let rng: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
2084        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
2085        let mut sm = Commissioner::new(cfg).expect("valid config");
2086
2087        // Skip past attestation/CSR machinery — we want to test the
2088        // SendNoc dispatch arm + the NOC response handler in isolation.
2089        // Plant a structurally-valid synthetic NOC the AddNOC encoder
2090        // can re-serialise. (The device side wouldn't accept it, but
2091        // we're not talking to a device — we feed a canned response.)
2092        let mut key_bytes = [0u8; 65];
2093        key_bytes[0] = 0x04;
2094        let synthetic_noc = MatterCertificate::builder()
2095            .serial(vec![1, 2, 3])
2096            .issuer(fabric.root_cert.subject().clone())
2097            .subject(DistinguishedName::new(vec![
2098                DnAttribute::FabricId(fabric.fabric_id),
2099                DnAttribute::NodeId(0x2),
2100            ]))
2101            .validity(
2102                MatterTime::from_unix_secs(1_704_067_200),
2103                MatterTime::NO_EXPIRY,
2104            )
2105            .public_key(PublicKey::new(key_bytes).expect("valid sec1 prefix"))
2106            .extensions(
2107                Extensions::builder()
2108                    .basic_constraints(Some(BasicConstraints::new(false, None)))
2109                    .build(),
2110            )
2111            .build_unsigned()
2112            .expect("builder")
2113            .assemble([0u8; 64]);
2114
2115        sm.stage = Stage::SendNoc;
2116        sm.issued_noc = Some(synthetic_noc);
2117        sm.issued_noc_public_key = Some(key_bytes);
2118
2119        match sm.poll().expect("poll SendNoc") {
2120            Action::Invoke {
2121                cluster,
2122                command,
2123                expect,
2124                ..
2125            } => {
2126                assert_eq!(cluster, 0x003E);
2127                assert_eq!(command, 0x06);
2128                assert_eq!(expect, Expectation::NocResponse);
2129            }
2130            other => panic!("expected Invoke, got {other:?}"),
2131        }
2132
2133        // Synthetic NOCResponse: anonymous struct with status=0 + fabric_index=1.
2134        let mut noc_response = Vec::new();
2135        {
2136            use matter_codec::{Tag, TlvWriter};
2137            let mut w = TlvWriter::new(&mut noc_response);
2138            w.start_structure(Tag::Anonymous).expect("infallible");
2139            w.put_uint(Tag::Context(0), 0).expect("infallible"); // status = OK
2140            w.put_uint(Tag::Context(1), 1).expect("infallible"); // fabric_index = 1
2141            w.end_container().expect("infallible");
2142        }
2143        sm.on_response(Expectation::NocResponse, &noc_response)
2144            .expect("NocResponse accepted");
2145        assert_eq!(sm.stage(), Stage::ReadNetworkCommissioningInfo);
2146    }
2147
2148    /// Glass-box test: a non-zero NOC status surfaces as
2149    /// `CommissioningError::DeviceImStatus { stage: SendNoc, ... }`
2150    /// and transitions the cursor to `Failed`.
2151    #[test]
2152    fn send_noc_failure_status_aborts_with_device_im_status() {
2153        use matter_cert::{
2154            BasicConstraints, DistinguishedName, DnAttribute, Extensions, MatterCertificate,
2155            PublicKey,
2156        };
2157
2158        let fabric = make_fabric_record();
2159        let setup = make_setup_payload();
2160        let paa = PaaTrustStore::with_example_device_roots();
2161        let cd = CdSigningRoots::with_example_device_roots();
2162        let rng: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
2163        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
2164        let mut sm = Commissioner::new(cfg).expect("valid config");
2165
2166        let mut key_bytes = [0u8; 65];
2167        key_bytes[0] = 0x04;
2168        let synthetic_noc = MatterCertificate::builder()
2169            .serial(vec![9])
2170            .issuer(fabric.root_cert.subject().clone())
2171            .subject(DistinguishedName::new(vec![
2172                DnAttribute::FabricId(fabric.fabric_id),
2173                DnAttribute::NodeId(0x2),
2174            ]))
2175            .validity(
2176                MatterTime::from_unix_secs(1_704_067_200),
2177                MatterTime::NO_EXPIRY,
2178            )
2179            .public_key(PublicKey::new(key_bytes).expect("valid sec1 prefix"))
2180            .extensions(
2181                Extensions::builder()
2182                    .basic_constraints(Some(BasicConstraints::new(false, None)))
2183                    .build(),
2184            )
2185            .build_unsigned()
2186            .expect("builder")
2187            .assemble([0u8; 64]);
2188
2189        sm.stage = Stage::SendNoc;
2190        sm.issued_noc = Some(synthetic_noc);
2191        sm.issued_noc_public_key = Some(key_bytes);
2192
2193        let _ = sm.poll().expect("poll SendNoc");
2194
2195        // status = 9 (InvalidNOC, spec §11.18.6.1).
2196        let mut bad_response = Vec::new();
2197        {
2198            use matter_codec::{Tag, TlvWriter};
2199            let mut w = TlvWriter::new(&mut bad_response);
2200            w.start_structure(Tag::Anonymous).expect("infallible");
2201            w.put_uint(Tag::Context(0), 9).expect("infallible");
2202            w.end_container().expect("infallible");
2203        }
2204        let err = sm
2205            .on_response(Expectation::NocResponse, &bad_response)
2206            .expect_err("non-zero NOC status should fail");
2207        assert!(matches!(
2208            err,
2209            CommissioningError::DeviceImStatus {
2210                stage: Stage::SendNoc,
2211                im_status: 9,
2212            }
2213        ));
2214        assert_eq!(sm.stage(), Stage::Failed);
2215    }
2216
2217    /// Glass-box test: `SendTrustedRootCert` emits an `AddTrustedRootCertificate`
2218    /// Invoke whose payload TLV starts with anonymous-struct + context-0
2219    /// octet-string carrying the RCAC TLV bytes. A subsequent `[0x00]`
2220    /// status-ack advances the cursor to `Stage::SendNoc`.
2221    #[test]
2222    fn send_trusted_root_cert_emits_invoke_and_status_ack_advances() {
2223        let fabric = make_fabric_record();
2224        let setup = make_setup_payload();
2225        let paa = PaaTrustStore::with_example_device_roots();
2226        let cd = CdSigningRoots::with_example_device_roots();
2227        let rng: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
2228        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
2229        let mut sm = Commissioner::new(cfg).expect("valid config");
2230
2231        sm.stage = Stage::SendTrustedRootCert;
2232
2233        match sm.poll().expect("poll SendTrustedRootCert") {
2234            Action::Invoke {
2235                cluster,
2236                command,
2237                expect,
2238                payload,
2239                ..
2240            } => {
2241                assert_eq!(cluster, 0x003E);
2242                assert_eq!(command, 0x0B);
2243                assert_eq!(expect, Expectation::AddTrustedRootResponse);
2244                // Sanity: payload is at least the anonymous-struct
2245                // wrapper + a non-trivial octet-string of RCAC TLV.
2246                assert!(payload.len() > 16, "RCAC TLV too short: {}", payload.len());
2247                assert_eq!(payload[0], 0x15); // anonymous struct start
2248            }
2249            other => panic!("expected Invoke, got {other:?}"),
2250        }
2251
2252        // Status-ack of 0x00 (success) advances to SendNoc.
2253        sm.on_response(Expectation::AddTrustedRootResponse, &[0x00])
2254            .expect("status-ack accepted");
2255        assert_eq!(sm.stage(), Stage::SendNoc);
2256    }
2257
2258    // --- M6.4.5 T44-T47: PASE -> CASE handoff + CommissioningComplete tests ---
2259
2260    #[test]
2261    fn find_operational_for_complete_emits_establish_case() {
2262        let fabric = make_fabric_record();
2263        let setup = make_setup_payload();
2264        let paa = PaaTrustStore::with_example_device_roots();
2265        let cd = crate::attestation::CdSigningRoots::with_example_device_roots();
2266        let rng: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
2267        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
2268        let mut sm = Commissioner::new(cfg).expect("valid config");
2269        sm.stage = Stage::FindOperationalForComplete;
2270        match sm.poll().expect("poll establish case") {
2271            Action::EstablishCase {
2272                fabric_id,
2273                peer_node_id,
2274            } => {
2275                assert_eq!(fabric_id, fabric.fabric_id);
2276                assert_eq!(peer_node_id, 0x2); // matches base_config's assigned_node_id
2277            }
2278            other => panic!("expected EstablishCase, got {other:?}"),
2279        }
2280        assert!(sm.awaiting_case_session);
2281    }
2282
2283    #[test]
2284    fn on_case_established_advances_to_send_complete() {
2285        let fabric = make_fabric_record();
2286        let setup = make_setup_payload();
2287        let paa = PaaTrustStore::with_example_device_roots();
2288        let cd = crate::attestation::CdSigningRoots::with_example_device_roots();
2289        let rng: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
2290        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
2291        let mut sm = Commissioner::new(cfg).expect("valid config");
2292        sm.stage = Stage::FindOperationalForComplete;
2293        let _ = sm.poll().expect("emit EstablishCase");
2294        sm.on_case_established().expect("case established");
2295        assert_eq!(sm.stage(), Stage::SendComplete);
2296        assert!(!sm.awaiting_case_session);
2297    }
2298
2299    #[test]
2300    fn on_case_established_without_pending_emits_out_of_order() {
2301        let fabric = make_fabric_record();
2302        let setup = make_setup_payload();
2303        let paa = PaaTrustStore::with_example_device_roots();
2304        let cd = crate::attestation::CdSigningRoots::with_example_device_roots();
2305        let rng: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
2306        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
2307        let mut sm = Commissioner::new(cfg).expect("valid config");
2308        let err = sm.on_case_established().expect_err("no pending establish");
2309        assert!(matches!(err, CommissioningError::OutOfOrderResponse(_)));
2310    }
2311
2312    #[test]
2313    fn send_complete_emits_invoke_over_case_session() {
2314        let fabric = make_fabric_record();
2315        let setup = make_setup_payload();
2316        let paa = PaaTrustStore::with_example_device_roots();
2317        let cd = crate::attestation::CdSigningRoots::with_example_device_roots();
2318        let rng: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
2319        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
2320        let mut sm = Commissioner::new(cfg).expect("valid config");
2321        sm.stage = Stage::SendComplete;
2322        match sm.poll().expect("poll send complete") {
2323            Action::Invoke {
2324                session,
2325                cluster,
2326                command,
2327                expect,
2328                payload,
2329                ..
2330            } => {
2331                assert_eq!(session, crate::state_machine::SessionContext::Case);
2332                assert_eq!(cluster, 0x0030);
2333                assert_eq!(command, 0x04); // CommissioningComplete
2334                assert_eq!(expect, Expectation::CommissioningCompleteResponse);
2335                // CommissioningComplete carries no payload fields — empty struct.
2336                assert_eq!(payload, vec![0x15, 0x18]);
2337            }
2338            other => panic!("expected Invoke, got {other:?}"),
2339        }
2340    }
2341
2342    #[test]
2343    fn send_complete_success_advances_to_cleanup() {
2344        let fabric = make_fabric_record();
2345        let setup = make_setup_payload();
2346        let paa = PaaTrustStore::with_example_device_roots();
2347        let cd = crate::attestation::CdSigningRoots::with_example_device_roots();
2348        let rng: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
2349        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
2350        let mut sm = Commissioner::new(cfg).expect("valid config");
2351        sm.stage = Stage::SendComplete;
2352        let _ = sm.poll().expect("emit invoke");
2353        sm.on_response(
2354            Expectation::CommissioningCompleteResponse,
2355            &[0x15, 0x24, 0x00, 0x00, 0x18], // error_code = 0
2356        )
2357        .expect("complete ok");
2358        assert_eq!(sm.stage(), Stage::Cleanup);
2359    }
2360
2361    #[test]
2362    fn cleanup_emits_done_with_noc_public_key() {
2363        let fabric = make_fabric_record();
2364        let setup = make_setup_payload();
2365        let paa = PaaTrustStore::with_example_device_roots();
2366        let cd = crate::attestation::CdSigningRoots::with_example_device_roots();
2367        let rng: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
2368        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
2369        let mut sm = Commissioner::new(cfg).expect("valid config");
2370        sm.stage = Stage::Cleanup;
2371        sm.issued_noc_public_key = Some([0xCA; 65]);
2372        match sm.poll().expect("poll cleanup") {
2373            Action::Done(cf) => {
2374                assert_eq!(cf.peer_node_id, 0x2);
2375                assert_eq!(cf.peer_root_public_key, [0xCA; 65]);
2376                assert_eq!(cf.terminated_at, Stage::Cleanup);
2377                assert_eq!(cf.fabric.fabric_id, fabric.fabric_id);
2378            }
2379            other => panic!("expected Done, got {other:?}"),
2380        }
2381    }
2382
2383    // --- M6.4.5 T49: CaseFailed negative coverage ---
2384
2385    #[test]
2386    fn case_failed_response_aborts_with_case_establishment_failed() {
2387        let fabric = make_fabric_record();
2388        let setup = make_setup_payload();
2389        let paa = PaaTrustStore::with_example_device_roots();
2390        let cd = crate::attestation::CdSigningRoots::with_example_device_roots();
2391        let rng: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
2392        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
2393        let mut sm = Commissioner::new(cfg).expect("valid config");
2394
2395        // Glass-box: jump to FindOperationalForComplete (skipping the
2396        // attestation + CSR + NOC stages that need real fixtures).
2397        sm.stage = Stage::FindOperationalForComplete;
2398        let _ = sm.poll().expect("emit EstablishCase");
2399        assert!(sm.awaiting_case_session);
2400
2401        // Caller signals CASE establishment failure.
2402        let err = sm
2403            .on_response(Expectation::CaseFailed, &[])
2404            .expect_err("CaseFailed should error");
2405        assert!(matches!(err, CommissioningError::CaseEstablishmentFailed));
2406        assert_eq!(sm.stage(), Stage::Failed);
2407        assert!(!sm.awaiting_case_session);
2408
2409        // Subsequent poll emits Action::Abort with send_disarm_failsafe=true.
2410        match sm.poll().expect("emit abort") {
2411            Action::Abort {
2412                send_disarm_failsafe,
2413                reason,
2414            } => {
2415                assert!(send_disarm_failsafe);
2416                assert!(
2417                    reason.contains("CASE"),
2418                    "abort reason should mention CASE: {reason}"
2419                );
2420            }
2421            other => panic!("expected Abort, got {other:?}"),
2422        }
2423    }
2424
2425    #[test]
2426    fn case_failed_when_not_awaiting_returns_out_of_order() {
2427        let fabric = make_fabric_record();
2428        let setup = make_setup_payload();
2429        let paa = PaaTrustStore::with_example_device_roots();
2430        let cd = crate::attestation::CdSigningRoots::with_example_device_roots();
2431        let rng: Arc<dyn crate::noc::NocRng> = Arc::new(SystemNocRng);
2432        let cfg = base_config(&fabric, &setup, &paa, &cd, rng);
2433        let mut sm = Commissioner::new(cfg).expect("valid config");
2434        let err = sm
2435            .on_response(Expectation::CaseFailed, &[])
2436            .expect_err("CaseFailed without pending should error");
2437        assert!(matches!(err, CommissioningError::OutOfOrderResponse(_)));
2438    }
2439
2440    /// Returns a fully-populated, valid [`CommissionerConfig`] for use in
2441    /// unit tests that only need to mutate one field. All held references
2442    /// are leaked so the config is `'static`; acceptable for test code.
2443    fn sample_valid_config() -> CommissionerConfig<'static> {
2444        use std::sync::OnceLock;
2445        static FABRIC: OnceLock<FabricRecord> = OnceLock::new();
2446        static SETUP: OnceLock<SetupPayload> = OnceLock::new();
2447        static PAA: OnceLock<PaaTrustStore> = OnceLock::new();
2448        static CD: OnceLock<crate::attestation::CdSigningRoots> = OnceLock::new();
2449
2450        let fabric = FABRIC.get_or_init(make_fabric_record);
2451        let setup = SETUP.get_or_init(make_setup_payload);
2452        let paa = PAA.get_or_init(PaaTrustStore::with_example_device_roots);
2453        let cd = CD.get_or_init(crate::attestation::CdSigningRoots::with_example_device_roots);
2454        let rng: Arc<dyn NocRng> = Arc::new(SystemNocRng);
2455
2456        CommissionerConfig {
2457            pase_attestation_challenge: [0u8; 16],
2458            fabric,
2459            setup_payload: setup,
2460            paa_trust_store: paa,
2461            cd_signing_roots: cd,
2462            commissioner_node_id: 0x1,
2463            assigned_node_id: 0x2,
2464            ipk_epoch_key: [0x42_u8; 16],
2465            case_admin_subject: 0x1,
2466            admin_vendor_id: 0xFFF1,
2467            now: MatterTime::from_unix_secs(1_704_067_200),
2468            rng,
2469            network: NetworkCredentials::AlreadyOnNetwork,
2470        }
2471    }
2472
2473    #[test]
2474    fn empty_ssid_is_rejected() {
2475        let mut config = sample_valid_config();
2476        config.network = NetworkCredentials::WiFi(WiFiCredentials {
2477            ssid: vec![],
2478            credentials: vec![],
2479        });
2480        let Err(err) = Commissioner::new(config) else {
2481            panic!("empty ssid should fail");
2482        };
2483        assert!(
2484            matches!(err, CommissioningError::InvalidConfig(m) if m.contains("ssid")),
2485            "got {err:?}",
2486        );
2487    }
2488
2489    #[test]
2490    fn oversize_ssid_is_rejected() {
2491        let mut config = sample_valid_config();
2492        config.network = NetworkCredentials::WiFi(WiFiCredentials {
2493            ssid: vec![b'a'; 33],
2494            credentials: vec![],
2495        });
2496        let Err(err) = Commissioner::new(config) else {
2497            panic!("33-byte ssid should fail");
2498        };
2499        assert!(
2500            matches!(err, CommissioningError::InvalidConfig(m) if m.contains("≤32")),
2501            "got {err:?}",
2502        );
2503    }
2504
2505    #[test]
2506    fn oversize_credentials_is_rejected() {
2507        let mut config = sample_valid_config();
2508        config.network = NetworkCredentials::WiFi(WiFiCredentials {
2509            ssid: b"matter".to_vec(),
2510            credentials: vec![0u8; 65],
2511        });
2512        let Err(err) = Commissioner::new(config) else {
2513            panic!("65-byte credentials should fail");
2514        };
2515        assert!(
2516            matches!(err, CommissioningError::InvalidConfig(m) if m.contains("≤64")),
2517            "got {err:?}",
2518        );
2519    }
2520
2521    #[test]
2522    fn wifi_credentials_none_is_accepted() {
2523        let mut config = sample_valid_config();
2524        config.network = NetworkCredentials::AlreadyOnNetwork;
2525        Commissioner::new(config).expect("AlreadyOnNetwork should pass validation");
2526    }
2527
2528    /// A device that omits the `NetworkCommissioning` cluster (observed: eufy
2529    /// E31 lock over IP — `UNSUPPORTED_CLUSTER` for both read paths) reaches
2530    /// the state machine as an EMPTY `NetworkCommissioningInfo` payload (the
2531    /// driver's absent-cluster sentinel). With `AlreadyOnNetwork` there is
2532    /// nothing to provision, so the absent cluster is acceptable: skip
2533    /// straight to `EvictPreviousCaseSessions`, exactly like the
2534    /// feature-map-present path.
2535    #[test]
2536    fn network_commissioning_cluster_absent_skips_provisioning_when_already_on_network() {
2537        let mut config = sample_valid_config();
2538        config.network = NetworkCredentials::AlreadyOnNetwork;
2539        let mut sm = Commissioner::new(config).expect("valid config");
2540        sm.stage = Stage::ReadNetworkCommissioningInfo;
2541        match sm.poll().expect("poll emits the read") {
2542            Action::ReadAttribute { expect, .. } => {
2543                assert_eq!(expect, Expectation::NetworkCommissioningInfo);
2544            }
2545            other => panic!("expected ReadAttribute, got {other:?}"),
2546        }
2547
2548        sm.on_response(Expectation::NetworkCommissioningInfo, &[])
2549            .expect("absent cluster is acceptable when already on network");
2550        assert_eq!(sm.stage(), Stage::EvictPreviousCaseSessions);
2551    }
2552
2553    /// The empty sentinel with Wi-Fi credentials supplied is a REAL error —
2554    /// the device cannot be provisioned onto a network it exposes no
2555    /// `NetworkCommissioning` cluster for. Surface the typed
2556    /// `NetworkFeatureUnsupported`, not a framing error.
2557    #[test]
2558    fn network_commissioning_cluster_absent_rejects_wifi_provisioning() {
2559        use crate::state_machine::NetworkKind;
2560
2561        let mut config = sample_valid_config();
2562        config.network = NetworkCredentials::WiFi(WiFiCredentials {
2563            ssid: b"matter".to_vec(),
2564            credentials: b"hunter22".to_vec(),
2565        });
2566        let mut sm = Commissioner::new(config).expect("valid config");
2567        sm.stage = Stage::ReadNetworkCommissioningInfo;
2568        let _ = sm.poll().expect("poll emits the read");
2569
2570        let Err(err) = sm.on_response(Expectation::NetworkCommissioningInfo, &[]) else {
2571            panic!("absent cluster with Wi-Fi credentials must fail");
2572        };
2573        assert!(
2574            matches!(
2575                err,
2576                CommissioningError::NetworkFeatureUnsupported {
2577                    needed: NetworkKind::WiFi,
2578                }
2579            ),
2580            "got {err:?}",
2581        );
2582    }
2583
2584    #[test]
2585    fn network_credentials_thread_variant_accepted() {
2586        // Minimal well-formed dataset: a single Extended PAN ID TLV
2587        // (type 0x02, length 8). ThreadDataset::new self-validates.
2588        let ds =
2589            crate::thread_dataset::ThreadDataset::new(vec![0x02, 0x08, 0, 0, 0, 0, 0, 0, 0, 0])
2590                .expect("minimal ext-pan-id dataset is valid");
2591        let mut config = sample_valid_config();
2592        config.network = NetworkCredentials::Thread(ds);
2593        let c = Commissioner::new(config).expect("Thread network should pass validation");
2594        assert!(matches!(c.network(), NetworkCredentials::Thread(_)));
2595    }
2596
2597    #[test]
2598    fn wifi_credentials_debug_redacts_passphrase() {
2599        let creds = WiFiCredentials {
2600            ssid: b"matter".to_vec(),
2601            credentials: b"hunter22".to_vec(),
2602        };
2603        let rendered = format!("{creds:?}");
2604        assert!(
2605            !rendered.contains("hunter22"),
2606            "Debug must not contain credentials bytes: {rendered}",
2607        );
2608        assert!(rendered.contains("redacted"), "got {rendered}");
2609        assert!(
2610            rendered.contains('8'),
2611            "credentials length should appear: {rendered}"
2612        );
2613        assert!(
2614            rendered.contains('6'),
2615            "ssid length should appear: {rendered}"
2616        );
2617    }
2618
2619    // --- M6.5.2 T14: failsafe expiry derivation from BasicCommissioningInfo ---
2620
2621    #[test]
2622    fn failsafe_expiry_derives_from_basic_commissioning_info() {
2623        let mut sm = Commissioner::new(sample_valid_config()).expect("valid config");
2624        // Advance to ReadCommissioningInfo and feed a BasicCommissioningInfo with 120s.
2625        let _initial = sm.poll().expect("initial poll");
2626        let response = vec![
2627            0x15, 0x25, 0x00, 0x78, 0x00, // u16 = 120
2628            0x18,
2629        ];
2630        sm.on_response(Expectation::CommissioningInfo, &response)
2631            .expect("commissioning info accepted");
2632        // Now ArmFailsafe should emit with expiry=120.
2633        let action = sm.poll().expect("arm-failsafe poll");
2634        match action {
2635            Action::Invoke {
2636                payload,
2637                cluster,
2638                command,
2639                ..
2640            } => {
2641                assert_eq!(cluster, 0x0030);
2642                assert_eq!(command, 0x00);
2643                // ArmFailSafe payload byte for expiry: TLV-encoded u8/u16 at context tag 0.
2644                // For value 120 the smallest-width encoding is u8 = 0x24 0x00 0x78.
2645                assert!(
2646                    payload.windows(3).any(|w| w == [0x24, 0x00, 0x78]),
2647                    "ArmFailSafe payload should carry expiry=120: {payload:02x?}",
2648                );
2649            }
2650            other => panic!("expected Invoke, got {other:?}"),
2651        }
2652    }
2653
2654    #[test]
2655    fn failsafe_expiry_capped_at_max_cumulative_failsafe_seconds() {
2656        let mut sm = Commissioner::new(sample_valid_config()).expect("valid config");
2657        let _initial = sm.poll().expect("initial poll");
2658        // BasicCommissioningInfo: failsafe_expiry_length=120 (tag 0),
2659        // max_cumulative=90 (tag 1). Our requested expiry must cap at 90 so the
2660        // device never rejects the ArmFailSafe with BoundsExceeded.
2661        let response = vec![
2662            0x15, //
2663            0x25, 0x00, 0x78, 0x00, // tag0 u16 = 120
2664            0x25, 0x01, 0x5A, 0x00, // tag1 u16 = 90 (max cumulative)
2665            0x18,
2666        ];
2667        sm.on_response(Expectation::CommissioningInfo, &response)
2668            .expect("commissioning info accepted");
2669        let action = sm.poll().expect("arm-failsafe poll");
2670        match action {
2671            Action::Invoke { payload, .. } => {
2672                assert!(
2673                    payload.windows(3).any(|w| w == [0x24, 0x00, 0x5A]),
2674                    "ArmFailSafe expiry must be capped to 90: {payload:02x?}",
2675                );
2676                assert!(
2677                    !payload.windows(3).any(|w| w == [0x24, 0x00, 0x78]),
2678                    "the uncapped 120 must NOT be sent: {payload:02x?}",
2679                );
2680            }
2681            other => panic!("expected Invoke, got {other:?}"),
2682        }
2683    }
2684
2685    #[test]
2686    fn failsafe_expiry_falls_back_to_60_on_empty_basic_commissioning_info() {
2687        let mut sm = Commissioner::new(sample_valid_config()).expect("valid config");
2688        let _initial = sm.poll().expect("initial poll");
2689        // Feed a well-formed empty struct — decode_basic_commissioning_info
2690        // returns None when the failsafe field is missing, so the M6.4
2691        // fallback of 60s applies.
2692        sm.on_response(Expectation::CommissioningInfo, &[0x15, 0x18])
2693            .expect("empty struct accepted");
2694        let action = sm.poll().expect("arm-failsafe poll");
2695        if let Action::Invoke { payload, .. } = action {
2696            // 60 = 0x3C, anonymous struct with context-tag-0 u8.
2697            assert!(
2698                payload.windows(3).any(|w| w == [0x24, 0x00, 0x3C]),
2699                "ArmFailSafe payload should carry expiry=60 fallback: {payload:02x?}",
2700            );
2701        }
2702    }
2703
2704    // --- M6.5.2 T15: breadcrumb monotonicity ---
2705
2706    #[test]
2707    fn breadcrumb_increases_across_commands() {
2708        fn extract_uint_at_tag(payload: &[u8], tag_num: u8) -> Option<u64> {
2709            use matter_codec::{Element, Tag, TlvReader, Value};
2710            let mut reader = TlvReader::new(payload);
2711            while let Ok(Some(elem)) = reader.next() {
2712                if let Element::Scalar {
2713                    tag: Tag::Context(t),
2714                    value: Value::Uint(v),
2715                } = elem
2716                {
2717                    if t == tag_num {
2718                        return Some(v);
2719                    }
2720                }
2721            }
2722            None
2723        }
2724
2725        let mut sm = Commissioner::new(sample_valid_config()).expect("valid config");
2726        let mut breadcrumbs: Vec<u64> = Vec::new();
2727
2728        // Poll #1: ReadCommissioningInfo (no breadcrumb).
2729        let _ = sm.poll().expect("read commissioning info");
2730        sm.on_response(Expectation::CommissioningInfo, &[0x15, 0x18])
2731            .expect("info accepted");
2732
2733        // Poll #2: ArmFailsafe — first breadcrumb-bearing command.
2734        let action = sm.poll().expect("arm-failsafe");
2735        if let Action::Invoke { payload, .. } = action {
2736            if let Some(b) = extract_uint_at_tag(&payload, 1) {
2737                breadcrumbs.push(b);
2738            }
2739        }
2740        sm.on_response(
2741            Expectation::ArmFailsafeResponse,
2742            &[0x15, 0x24, 0x00, 0x00, 0x18],
2743        )
2744        .expect("arm-failsafe ok");
2745
2746        // Poll #3: SetRegulatoryConfig — second breadcrumb-bearing command.
2747        let action = sm.poll().expect("set-regulatory");
2748        if let Action::Invoke { payload, .. } = action {
2749            if let Some(b) = extract_uint_at_tag(&payload, 2) {
2750                breadcrumbs.push(b);
2751            }
2752        }
2753
2754        assert_eq!(
2755            breadcrumbs.len(),
2756            2,
2757            "should have extracted exactly two breadcrumbs, got {breadcrumbs:?}",
2758        );
2759        assert!(
2760            breadcrumbs[0] < breadcrumbs[1],
2761            "breadcrumbs should be strictly increasing: {breadcrumbs:?}",
2762        );
2763    }
2764}