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}