Skip to main content

matter_commissioning/state_machine/
error.rs

1//! `CommissioningError` — error variants surfaced by the state machine.
2
3#![forbid(unsafe_code)]
4
5use crate::attestation::AttestationError;
6use crate::noc::NocError;
7use crate::state_machine::action::Expectation;
8use crate::state_machine::stage::Stage;
9
10/// Errors emitted by the commissioning state machine.
11///
12/// All variants are `#[non_exhaustive]` — future sub-phases or future
13/// milestones (M6.5 network commissioning, etc.) can add variants
14/// without breaking `SemVer`.
15///
16/// `CommissioningError` is intentionally **not** `Clone`. The summary
17/// emitted in [`super::Action::Abort`] is a pre-rendered `String`, so
18/// callers never need to clone the full error.
19#[derive(Debug, thiserror::Error)]
20#[non_exhaustive]
21pub enum CommissioningError {
22    /// `CommissionerConfig` failed validation in `Commissioner::new`.
23    /// Carries a `&'static str` describing which field is bad — no
24    /// alloc on the error path.
25    #[error("invalid commissioner config: {0}")]
26    InvalidConfig(&'static str),
27
28    /// Caller invoked `on_response` with an `Expectation` that does
29    /// not match the last `poll()`'s emitted `Expectation`.
30    #[error("unexpected response kind: expected {expected:?}, got {got:?}")]
31    UnexpectedResponseKind {
32        /// The Expectation the state machine emitted with the last
33        /// Action.
34        expected: Expectation,
35        /// The Expectation the caller passed in.
36        got: Expectation,
37    },
38
39    /// Caller invoked `on_response` or `on_case_established` in a
40    /// stage where the state machine is not waiting for input (e.g.
41    /// `Stage::Cleanup` or `Stage::SecurePairing`).
42    #[error("response delivered out of order at stage {0:?}")]
43    OutOfOrderResponse(Stage),
44
45    /// Device returned a non-OK Interaction Model status for a cluster
46    /// command at `stage`. The 16-bit `im_status` is the canonical
47    /// Matter status code from the response envelope.
48    #[error("device rejected stage {stage:?}: IM status {im_status:#x}")]
49    DeviceImStatus {
50        /// Where the rejection happened.
51        stage: Stage,
52        /// IM status code (Matter Core Spec §8.10).
53        im_status: u16,
54    },
55
56    /// Response TLV failed to decode at the cluster command level.
57    #[error("malformed response at stage {0:?}")]
58    MalformedResponse(Stage),
59
60    /// Attestation verification failed (chain / signature / CD).
61    #[error("attestation verification failed: {0}")]
62    Attestation(#[from] AttestationError),
63
64    /// CSR verification or NOC issuance failed.
65    #[error("NOC issuance failed: {0}")]
66    Noc(#[from] NocError),
67
68    /// CASE establishment failed (caller called
69    /// `on_response(Expectation::CaseFailed, &[])`).
70    #[error("CASE session establishment failed")]
71    CaseEstablishmentFailed,
72
73    /// The device's `NetworkCommissioning::FeatureMap` does not declare
74    /// the network type the caller supplied credentials for (e.g.
75    /// `NetworkCredentials::Thread` was supplied but the device's
76    /// `FeatureMap` lacks the Thread bit). Both Wi-Fi and Thread are
77    /// supported network types as of M9-C2 — this variant signals a
78    /// device/credential *mismatch*, not an unsupported network type.
79    ///
80    /// **Wording pinned:** `WeaveHome` substring-matches
81    /// `does not support Thread network type` to route Wi-Fi-only devices off
82    /// its automatic Thread path. Do not reword without coordinating — the
83    /// typed replacement is `matter_controller::Error::network_feature_unsupported()`.
84    #[error("device does not support {needed:?} network type (credential/device mismatch)")]
85    NetworkFeatureUnsupported {
86        /// Which network type the supplied credentials required.
87        needed: NetworkKind,
88    },
89
90    /// Device rejected `AddOrUpdateWiFiNetwork` or `ConnectNetwork`
91    /// with a non-OK `NetworkCommissioningStatusEnum` value
92    /// (spec §11.9.5.1).
93    #[error(
94        "network commissioning rejected at stage {stage:?}: \
95             networking_status {networking_status:#x}, \
96             debug_text={}, hint={remediation_hint:?}",
97        display_debug_text(debug_text.as_ref())
98    )]
99    NetworkRejected {
100        /// Which stage the device rejected.
101        stage: Stage,
102        /// Raw `NetworkCommissioningStatusEnum` value from the
103        /// response.
104        networking_status: u8,
105        /// Optional human-readable debug text echoed by the device,
106        /// capped at the spec's 512-octet bound at decode.
107        /// **Device-controlled free text** — it may name networks (e.g.
108        /// an SSID); log deliberately.
109        debug_text: Option<String>,
110        /// Mapped remediation category for downstream UI rendering.
111        remediation_hint: RemediationHint,
112    },
113}
114
115/// Render `debug_text` for `Display`, capped at 64 chars with an ellipsis.
116/// The device controls this string and it can echo an SSID; the full (still
117/// 512-byte-capped) value stays on the field for deliberate consumers.
118///
119/// Rendered with `Debug` (`{:?}`), not `Display` — deliberately. The device
120/// fully controls these bytes (decoded as UTF-8, so `\n`, `\r`, and ANSI
121/// escapes like `\u{1b}` are all legal content), and this string lands
122/// directly in whatever a consumer logs. `Debug` escapes control characters
123/// into their `\n`/`\r`/`\u{1b}` textual form; passing the raw string
124/// through `Display` would let a malicious or buggy device inject newlines
125/// or terminal escape sequences straight into consumer logs.
126fn display_debug_text(text: Option<&String>) -> String {
127    match text {
128        None => "None".to_owned(),
129        Some(s) => {
130            let capped: String = s.chars().take(64).collect();
131            if capped.len() < s.len() {
132                format!("Some({capped:?}…)")
133            } else {
134                format!("Some({s:?})")
135            }
136        }
137    }
138}
139
140/// Which Matter network-commissioning type a device declared in its
141/// `NetworkCommissioning::FeatureMap`.
142///
143/// `#[non_exhaustive]` — future Matter-spec network interfaces
144/// (e.g. Thread Border Router relay) can be added without a breaking
145/// change.
146#[non_exhaustive]
147#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
148pub enum NetworkKind {
149    /// Wi-Fi network interface (`FeatureMap` bit 0).
150    WiFi,
151    /// Thread network interface (`FeatureMap` bit 1).
152    Thread,
153    /// Ethernet network interface (`FeatureMap` bit 2).
154    Ethernet,
155}
156
157/// Hint describing what a downstream UI could suggest to remediate a
158/// `CommissioningError::NetworkRejected` (lands in M6.5.2).
159///
160/// Maps from a Matter `NetworkCommissioningStatusEnum` value (spec
161/// §11.9.5.1) into a category callers can render meaningfully without
162/// parsing the raw status code. The mapping table lives in
163/// `crate::clusters::network_commissioning::remediation_for`.
164///
165/// # Stability
166///
167/// `#[non_exhaustive]` from inception. New variants may be added in any
168/// release. Existing variants will never be renamed or reordered.
169/// Changes to the `status_code` → variant mapping are documented in the
170/// CHANGELOG as semi-public behavioural changes.
171#[non_exhaustive]
172#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
173pub enum RemediationHint {
174    /// Password/passphrase likely wrong. From `AuthFailure` (7).
175    CheckPassphrase,
176    /// SSID not found. From `NetworkNotFound` (5), `NetworkIDNotFound` (3).
177    CheckSsid,
178    /// Country code / regulatory location mismatch. From
179    /// `RegulatoryError` (6).
180    CheckRegulatoryRegion,
181    /// Wi-Fi security cipher unsupported (e.g. WEP-only device). From
182    /// `UnsupportedSecurity` (8).
183    UpgradeSecurityMode,
184    /// Device reached its `MaxNetworks` limit. From `BoundsExceeded` (2).
185    DeviceNetworkSlotsFull,
186    /// IP-stack-layer failure on the device side. From `IPV6Failed` (10),
187    /// `IPBindFailed` (11).
188    DeviceIpStackFailure,
189    /// No specific guidance available. From `OtherConnectionFailure` (9),
190    /// `UnknownError` (12), or any status code not yet mapped.
191    None,
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::state_machine::action::Expectation;
198    use crate::state_machine::stage::Stage;
199
200    #[test]
201    fn invalid_config_carries_message() {
202        let e = CommissioningError::InvalidConfig("missing IPK epoch key");
203        let msg = e.to_string();
204        assert!(msg.contains("missing IPK"), "{msg}");
205    }
206
207    #[test]
208    fn unexpected_response_kind_shows_both_sides() {
209        let e = CommissioningError::UnexpectedResponseKind {
210            expected: Expectation::ArmFailsafeResponse,
211            got: Expectation::AttestationResponse,
212        };
213        let msg = e.to_string();
214        assert!(msg.contains("ArmFailsafeResponse"), "{msg}");
215        assert!(msg.contains("AttestationResponse"), "{msg}");
216    }
217
218    #[test]
219    fn out_of_order_response_names_the_stage() {
220        let e = CommissioningError::OutOfOrderResponse(Stage::ArmFailsafe);
221        let msg = e.to_string();
222        assert!(msg.contains("ArmFailsafe"), "{msg}");
223    }
224
225    #[test]
226    fn device_im_status_includes_stage_and_status_code() {
227        let e = CommissioningError::DeviceImStatus {
228            stage: Stage::ArmFailsafe,
229            im_status: 0x0098,
230        };
231        let msg = e.to_string();
232        assert!(msg.contains("ArmFailsafe"), "{msg}");
233        assert!(msg.contains("0x98"), "{msg}");
234    }
235
236    #[test]
237    fn remediation_hint_is_copy_eq_hash() {
238        fn assert_copy<T: Copy + Eq + std::hash::Hash>() {}
239        assert_copy::<RemediationHint>();
240        assert_eq!(RemediationHint::None, RemediationHint::None);
241        assert_ne!(RemediationHint::None, RemediationHint::CheckPassphrase);
242    }
243
244    #[test]
245    fn network_rejected_display_caps_debug_text() {
246        let e = CommissioningError::NetworkRejected {
247            stage: Stage::NetworkSetup,
248            networking_status: 5,
249            debug_text: Some("s".repeat(300)),
250            remediation_hint: RemediationHint::CheckSsid,
251        };
252        let msg = e.to_string();
253        // 64 chars + ellipsis, not the whole 300. Rendered via `{:?}`, so the
254        // capped run of plain ASCII 's' is unescaped but still quoted.
255        assert!(msg.contains(&format!("{:?}", "s".repeat(64))));
256        assert!(!msg.contains(&"s".repeat(65)));
257        assert!(msg.contains('…'));
258
259        // Short text renders in full, no ellipsis, still Debug-quoted.
260        let short = CommissioningError::NetworkRejected {
261            stage: Stage::NetworkSetup,
262            networking_status: 5,
263            debug_text: Some("bad ssid".into()),
264            remediation_hint: RemediationHint::CheckSsid,
265        };
266        assert!(short.to_string().contains("\"bad ssid\""));
267        assert!(!short.to_string().contains('…'));
268    }
269
270    #[test]
271    fn network_rejected_display_escapes_control_chars() {
272        // The critical property this Display impl exists for: a
273        // device-controlled debug_text containing a newline or an ANSI
274        // escape must never appear raw in the rendered message — only in
275        // its escaped `Debug` form (`\n`, `\u{1b}`). Otherwise a malicious
276        // device can inject fake log lines or terminal control sequences
277        // into whatever logs this error's Display output.
278        let e = CommissioningError::NetworkRejected {
279            stage: Stage::NetworkSetup,
280            networking_status: 5,
281            debug_text: Some("evil\nFAKE LOG LINE\u{1b}[31mred".to_owned()),
282            remediation_hint: RemediationHint::CheckSsid,
283        };
284        let msg = e.to_string();
285        assert!(!msg.contains('\n'), "raw newline leaked into: {msg}");
286        assert!(!msg.contains('\u{1b}'), "raw ESC leaked into: {msg}");
287        assert!(msg.contains("\\n"), "newline must render escaped: {msg}");
288        assert!(msg.contains("\\u{1b}"), "ESC must render escaped: {msg}");
289    }
290
291    #[test]
292    fn network_feature_unsupported_wording_is_pinned() {
293        // WeaveHome routes Wi-Fi-only devices off its Thread path by
294        // substring-matching this exact wording (their state_machine/error.rs
295        // consumer). A reword compiles cleanly downstream and silently breaks
296        // the fall-through — this pin makes a reword a visible test failure.
297        // Coordinate any change with WeaveHome and their typed replacement,
298        // matter_controller::Error::network_feature_unsupported().
299        let e = CommissioningError::NetworkFeatureUnsupported {
300            needed: NetworkKind::Thread,
301        };
302        assert!(
303            e.to_string()
304                .contains("does not support Thread network type"),
305            "pinned substring changed: {e}"
306        );
307    }
308}