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