Skip to main content

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}