matter_commissioning/thread_dataset.rs
1//! Thread operational dataset validation and Extended PAN ID extraction.
2//!
3//! Implemented in M9-C2 (Thread commissioning). A Thread operational
4//! dataset is **not** Matter TLV — it is Thread's own flat TLV format:
5//! each element is `type(1 byte)`, `length(1 byte)`, `value(length
6//! bytes)`, walked from offset 0 with no outer container. See the
7//! Thread specification's Operational Dataset TLV encoding.
8
9#![forbid(unsafe_code)]
10
11use thiserror::Error;
12
13/// Maximum Thread operational dataset length in bytes, per the Thread
14/// specification's Operational Dataset TLV encoding.
15const MAX_DATASET_LEN: usize = 254;
16
17/// Thread TLV type for the Extended PAN ID element.
18const EXT_PAN_ID_TYPE: u8 = 0x02;
19
20/// Length in bytes of the Extended PAN ID TLV value.
21const EXT_PAN_ID_LEN: usize = 8;
22
23/// Errors produced while validating a Thread operational dataset.
24#[derive(Debug, Error)]
25#[non_exhaustive]
26pub enum ThreadDatasetError {
27 /// The dataset was empty.
28 #[error("Thread operational dataset is empty")]
29 Empty,
30
31 /// The dataset exceeded the Thread spec's maximum length (254 bytes).
32 /// Carries the actual (rejected) length.
33 #[error("Thread operational dataset is too large: {0} bytes (max 254)")]
34 TooLarge(usize),
35
36 /// The bytes are not well-formed Thread TLVs — a TLV's declared
37 /// `length` overran the remaining buffer.
38 #[error("Thread operational dataset is malformed (truncated TLV)")]
39 Malformed,
40
41 /// No Extended PAN ID TLV (type 0x02, length 8) was present.
42 #[error("Thread operational dataset has no Extended PAN ID TLV")]
43 NoExtPanId,
44}
45
46/// A Thread operational dataset (Thread TLV bytes) used to provision a
47/// device onto a Thread network. The caller obtains it from a border
48/// router (e.g. `ot-ctl dataset active -x`, hex-decoded).
49///
50/// This is **not** Matter TLV. Thread's operational dataset uses its own
51/// flat TLV encoding: each element is `type(1 byte)`, `length(1 byte)`,
52/// `value(length bytes)`, walked from offset 0 with no outer container.
53///
54/// `Debug` is hand-written to redact `bytes` (renders only the length):
55/// the dataset contains the Thread Network Key (TLV type `0x04`) and `PSKc`
56/// — secrets that must never land in logs via a stray `{:?}`. Mirrors
57/// `WiFiCredentials`' redacted `Debug` in `state_machine::commissioner`.
58#[derive(Clone, PartialEq, Eq)]
59pub struct ThreadDataset {
60 bytes: Vec<u8>,
61 ext_pan_id: [u8; 8],
62}
63
64impl core::fmt::Debug for ThreadDataset {
65 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
66 f.debug_struct("ThreadDataset")
67 .field("len", &self.bytes.len())
68 .field("ext_pan_id", &format_args!("{:02x?}", self.ext_pan_id))
69 .finish()
70 }
71}
72
73impl ThreadDataset {
74 /// Wrap and validate an operational dataset.
75 ///
76 /// Validates that the dataset is non-empty, within the Thread spec's
77 /// 254-byte maximum, consists of well-formed TLVs (every TLV's
78 /// declared length fits within the remaining buffer), and contains
79 /// an Extended PAN ID TLV (type `0x02`, length 8).
80 ///
81 /// # Errors
82 ///
83 /// - [`ThreadDatasetError::Empty`] if `bytes` is empty.
84 /// - [`ThreadDatasetError::TooLarge`] if `bytes` exceeds 254 bytes.
85 /// - [`ThreadDatasetError::Malformed`] if the bytes are not
86 /// well-formed Thread TLVs (a TLV's length overruns the buffer).
87 /// - [`ThreadDatasetError::NoExtPanId`] if no Extended PAN ID TLV
88 /// (type 2, len 8) is present.
89 pub fn new(bytes: Vec<u8>) -> Result<Self, ThreadDatasetError> {
90 if bytes.is_empty() {
91 return Err(ThreadDatasetError::Empty);
92 }
93 if bytes.len() > MAX_DATASET_LEN {
94 return Err(ThreadDatasetError::TooLarge(bytes.len()));
95 }
96
97 let mut ext_pan_id = None;
98 let mut offset = 0usize;
99 while offset < bytes.len() {
100 // Every TLV needs at least a type byte and a length byte.
101 let Some(&tlv_type) = bytes.get(offset) else {
102 return Err(ThreadDatasetError::Malformed);
103 };
104 let Some(&tlv_len) = bytes.get(offset + 1) else {
105 return Err(ThreadDatasetError::Malformed);
106 };
107 let value_start = offset + 2;
108 let value_len = usize::from(tlv_len);
109 let value_end = value_start
110 .checked_add(value_len)
111 .ok_or(ThreadDatasetError::Malformed)?;
112 if value_end > bytes.len() {
113 return Err(ThreadDatasetError::Malformed);
114 }
115
116 if tlv_type == EXT_PAN_ID_TYPE && value_len == EXT_PAN_ID_LEN {
117 let mut id = [0u8; EXT_PAN_ID_LEN];
118 id.copy_from_slice(&bytes[value_start..value_end]);
119 ext_pan_id = Some(id);
120 }
121
122 offset = value_end;
123 }
124
125 match ext_pan_id {
126 Some(ext_pan_id) => Ok(Self { bytes, ext_pan_id }),
127 None => Err(ThreadDatasetError::NoExtPanId),
128 }
129 }
130
131 /// Raw dataset bytes (the opaque octet-string for
132 /// `AddOrUpdateThreadNetwork`).
133 #[must_use]
134 pub fn as_bytes(&self) -> &[u8] {
135 &self.bytes
136 }
137
138 /// Extended PAN ID (Thread dataset TLV type 2, 8 bytes) — the
139 /// `ConnectNetwork` `network_id`.
140 ///
141 /// Captured once during [`ThreadDataset::new`], which only ever
142 /// constructs a value after locating exactly this TLV.
143 #[must_use]
144 pub fn ext_pan_id(&self) -> [u8; 8] {
145 self.ext_pan_id
146 }
147}
148
149#[cfg(test)]
150#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
151mod tests {
152 use super::*;
153
154 fn hex(s: &str) -> Vec<u8> {
155 (0..s.len())
156 .step_by(2)
157 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
158 .collect()
159 }
160
161 // Reference dataset from Task 1's capture vector.
162 const DS: &str = "0e08000000000001000000030000184a0300001235060004001fffe002087896217f787f6ebe0708fdec3f34f3cd2020051071dccee3f164f15da92254e0b9c8a3a5030f4f70656e5468726561642d38396437010289d70410dc4b544c7a58671a2ce4f876f5d6dcd90c0402a0f7f8";
163
164 #[test]
165 fn parses_and_extracts_ext_pan_id() {
166 let d = ThreadDataset::new(hex(DS)).unwrap();
167 assert_eq!(
168 d.ext_pan_id(),
169 [0x78, 0x96, 0x21, 0x7f, 0x78, 0x7f, 0x6e, 0xbe]
170 );
171 assert_eq!(d.as_bytes(), hex(DS).as_slice());
172 }
173
174 #[test]
175 fn rejects_empty() {
176 assert!(matches!(
177 ThreadDataset::new(vec![]),
178 Err(ThreadDatasetError::Empty)
179 ));
180 }
181
182 #[test]
183 fn rejects_oversize() {
184 assert!(matches!(
185 ThreadDataset::new(vec![0u8; 300]),
186 Err(ThreadDatasetError::TooLarge(300))
187 ));
188 }
189
190 #[test]
191 fn rejects_truncated_tlv() {
192 // type 0x02 claims len 8 but only 2 value bytes follow.
193 assert!(matches!(
194 ThreadDataset::new(vec![0x02, 0x08, 0x01, 0x02]),
195 Err(ThreadDatasetError::Malformed)
196 ));
197 }
198
199 #[test]
200 fn rejects_no_ext_pan_id() {
201 // one well-formed TLV (type 3, len 0) but no ext-pan-id.
202 assert!(matches!(
203 ThreadDataset::new(vec![0x03, 0x00]),
204 Err(ThreadDatasetError::NoExtPanId)
205 ));
206 }
207
208 #[test]
209 fn debug_redacts_network_key() {
210 // DS (Task 1's capture vector) carries a type-0x04 (Network Key)
211 // TLV with value dc4b544c7a58671a2ce4f876f5d6dcd9 — a recognizable
212 // pattern that must never appear in `{:?}` output.
213 let d = ThreadDataset::new(hex(DS)).unwrap();
214 let rendered = format!("{d:?}");
215 assert!(
216 !rendered.contains("dc4b544c7a58671a2ce4f876f5d6dcd9"),
217 "Debug must not contain the raw dataset bytes (network key): {rendered}",
218 );
219 assert!(rendered.contains("ThreadDataset"), "got {rendered}");
220 assert!(
221 rendered.contains("len"),
222 "dataset length should appear: {rendered}"
223 );
224 // ext_pan_id (78:96:21:7f:78:7f:6e:be per `parses_and_extracts_ext_pan_id`)
225 // is not secret and IS expected to appear, rendered by `{:02x?}`.
226 assert!(
227 rendered.contains("78, 96, 21, 7f, 78, 7f, 6e, be"),
228 "ext_pan_id should appear in Debug output: {rendered}"
229 );
230 }
231}