Skip to main content

wifi_ctrl/sta/
types.rs

1use super::{ParseResult, SocketResult};
2use super::{Result, SocketHandle, config, config::unprintf, warn};
3use crate::error::ClientError;
4
5use serde::Serialize;
6use std::collections::HashMap;
7use std::fmt::Display;
8use std::str::FromStr;
9use std::sync::Arc;
10
11#[derive(Serialize, Debug, Clone)]
12/// The result from scanning for networks.
13pub struct ScanResult {
14    pub mac: String,
15    pub frequency: String,
16    pub signal: isize,
17    pub flags: String,
18    pub name: String,
19}
20
21impl ScanResult {
22    fn from_line(line: &str) -> Option<Self> {
23        let (mac, rest) = line.split_once('\t')?;
24        let (frequency, rest) = rest.split_once('\t')?;
25        let (signal, rest) = rest.split_once('\t')?;
26        let signal = isize::from_str(signal).ok()?;
27        let (flags, escaped_name) = rest.split_once('\t')?;
28        let name = unprintf(escaped_name).ok()?;
29        Some(ScanResult {
30            mac: mac.to_string(),
31            frequency: frequency.to_string(),
32            signal,
33            flags: flags.to_string(),
34            name,
35        })
36    }
37
38    // Overide to allow tabs in the raw string to avoid double escaping everything
39    #[allow(clippy::tabs_in_doc_comments)]
40    /// Parses lines from a scan result
41    ///```
42    ///use wifi_ctrl::sta::ScanResult;
43    ///let results = ScanResult::vec_from_str(r#"bssid / frequency / signal level / flags / ssid
44    ///00:5f:67:90:da:64	2417	-35	[WPA-PSK-CCMP][WPA2-PSK-CCMP][ESS]	TP-Link DA64
45    ///e0:91:f5:7d:11:c0	2462	-33	[WPA2-PSK-CCMP][WPS][ESS]	¯\\_(\xe3\x83\x84)_/¯
46    ///"#).unwrap();
47    ///assert_eq!(results[0].mac, "00:5f:67:90:da:64");
48    ///assert_eq!(results[0].name, "TP-Link DA64");
49    ///assert_eq!(results[1].signal, -33);
50    ///assert_eq!(results[1].name, r#"¯\_(ツ)_/¯"#);
51    ///```
52    pub fn vec_from_str(response: &str) -> ParseResult<Arc<Vec<ScanResult>>> {
53        let mut results = Vec::new();
54        for line in response.lines().skip(1) {
55            // skip lines we can't parse so one odd entry doesn't fail the
56            // whole scan
57            if let Some(scan_result) = ScanResult::from_line(line) {
58                results.push(scan_result);
59            } else {
60                warn!("Invalid result from scan: {line}");
61            }
62        }
63        results.sort_by_key(|a| a.signal);
64        Ok(Arc::new(results))
65    }
66}
67
68#[derive(Serialize, Debug, Clone)]
69/// A known WiFi network.
70pub struct NetworkResult {
71    pub network_id: usize,
72    pub ssid: String,
73    pub flags: String,
74}
75
76fn parse_get_network(resp: &str) -> ParseResult<String> {
77    let escaped = resp.trim_matches('\"');
78    Ok(unprintf(escaped)?)
79}
80
81impl NetworkResult {
82    pub(crate) async fn request_results<const N: usize>(
83        socket_handle: &mut SocketHandle<N>,
84    ) -> SocketResult<Result<Vec<NetworkResult>>> {
85        let response: String = match socket_handle
86            .request("LIST_NETWORKS", TryInto::try_into)
87            .await?
88        {
89            Ok(x) => x,
90            Err(e) => return Ok(Err(e)),
91        };
92        let mut results = Vec::new();
93        let split = response.split('\n').skip(1);
94        for line in split {
95            let mut line_split = line.split_whitespace();
96            if let Some(network_id) = line_split.next() {
97                if let Ok(network_id) = usize::from_str(network_id) {
98                    let ssid = match socket_handle
99                        .request(&format!("GET_NETWORK {network_id} ssid"), parse_get_network)
100                        .await?
101                    {
102                        Ok(x) => x,
103                        Err(e) => return Ok(Err(e)),
104                    };
105                    if let Some(flags) = line_split.last() {
106                        results.push(NetworkResult {
107                            flags: flags.into(),
108                            ssid,
109                            network_id,
110                        })
111                    }
112                } else {
113                    warn!("Invalid network_id: {network_id}")
114                }
115            }
116        }
117        Ok(Ok(results))
118    }
119}
120
121/// Parsed output of `wpa_cli status`.
122///
123/// The commonly-present fields are typed for convenience; everything the
124/// supplicant reports is also available untouched via [`Status::raw`] /
125/// [`Status::get`], so newer or driver-specific keys are never lost. Missing
126/// keys simply leave the corresponding field as `None` rather than failing the
127/// whole parse.
128#[derive(Serialize, Debug, Clone, Default)]
129pub struct Status {
130    pub wpa_state: Option<String>,
131    pub ssid: Option<String>,
132    pub bssid: Option<String>,
133    pub id: Option<usize>,
134    pub freq: Option<u32>,
135    pub address: Option<String>,
136    pub ip_address: Option<String>,
137    pub key_mgmt: Option<String>,
138    pub mode: Option<String>,
139    /// Every key/value pair from the response, including those surfaced as
140    /// typed fields above.
141    pub raw: HashMap<String, String>,
142}
143
144impl Status {
145    /// Look up a raw status field the typed accessors don't cover.
146    pub fn get(&self, key: &str) -> Option<&str> {
147        self.raw.get(key).map(String::as_str)
148    }
149}
150
151pub(crate) fn parse_status(response: &str) -> ParseResult<Status> {
152    let raw: HashMap<String, String> = config::from_str(response)?;
153    Ok(Status {
154        wpa_state: raw.get("wpa_state").cloned(),
155        ssid: raw.get("ssid").cloned(),
156        bssid: raw.get("bssid").cloned(),
157        id: raw.get("id").and_then(|v| v.parse().ok()),
158        freq: raw.get("freq").and_then(|v| v.parse().ok()),
159        address: raw.get("address").cloned(),
160        ip_address: raw.get("ip_address").cloned(),
161        key_mgmt: raw.get("key_mgmt").cloned(),
162        mode: raw.get("mode").cloned(),
163        raw,
164    })
165}
166
167#[derive(Debug)]
168/// Key management types for WiFi networks (eg: WPA-PSK, WPA-EAP, etc). In theory, more than one may
169/// be configured, but I believe `wpa_supplicant` defaults to all of them if omitted. Therefore, in
170/// practice, this is mostly important for setting `key_mgmt` to `None` for an open network.
171pub enum KeyMgmt {
172    None,
173    WpaPsk,
174    WpaEap,
175    IEEE8021X,
176}
177
178impl Display for KeyMgmt {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        let str = match self {
181            KeyMgmt::None => "NONE".to_string(),
182            KeyMgmt::WpaPsk => "WPA-PSK".to_string(),
183            KeyMgmt::WpaEap => "WPA-EAP".to_string(),
184            KeyMgmt::IEEE8021X => "IEEE8021X".to_string(),
185        };
186        write!(f, "{}", str)
187    }
188}
189
190/// A WPA pre-shared key, validated at construction.
191///
192/// wpa_supplicant takes the `psk` field in two distinct forms and encodes them
193/// differently on the control socket: a passphrase is sent as a quoted string,
194/// while a precomputed 256-bit key is sent as raw, unquoted hex. An unquoted
195/// value is always read as a raw key, so a passphrase can never fall back to
196/// hex encoding the way an SSID can. The two constructors let the caller say
197/// which form it means instead of the library guessing from the string's shape.
198///
199/// Use [`Psk::passphrase`] or [`Psk::raw`] when the kind is known; parse with
200/// [`str::parse`] to get `wpa_supplicant.conf` semantics (exactly 64 hex digits
201/// is a raw key, anything else a passphrase — unambiguous because a passphrase
202/// is at most 63 characters).
203///
204/// ```
205/// use wifi_ctrl::sta::Psk;
206///
207/// // The constructors say which form is meant.
208/// let psk = Psk::passphrase("correct horse battery")?;
209/// let _key = Psk::raw([0x42; 32]);
210///
211/// // Parsing follows wpa_supplicant.conf: anything but 64 hex digits is a passphrase,
212/// // and passphrases outside 8-63 printable-ASCII characters are rejected.
213/// let _parsed: Psk = "correct horse battery".parse()?;
214/// assert!("short".parse::<Psk>().is_err());
215///
216/// // Debug never reveals key material.
217/// assert_eq!(format!("{psk:?}"), "Psk(<redacted>)");
218/// # Ok::<(), wifi_ctrl::error::ClientError>(())
219/// ```
220// No PartialEq/Eq: nothing compares PSKs, and a derived comparison over key
221// material would not be constant-time. Add a constant-time compare (e.g.
222// `subtle::ConstantTimeEq`) if equality is ever needed.
223#[derive(Clone)]
224pub struct Psk(PskInner);
225
226#[derive(Clone)]
227enum PskInner {
228    Passphrase(String),
229    Raw([u8; 32]),
230}
231
232impl Psk {
233    /// A WPA passphrase: 8-63 printable-ASCII characters (spaces included).
234    ///
235    /// A literal double-quote is rejected even though WPA formally allows it:
236    /// the control socket has no escape for one inside a quoted value, so it
237    /// has no safe representation. Anything else outside printable ASCII has
238    /// no valid representation either. Both yield [`ClientError::InvalidPsk`].
239    ///
240    /// ```
241    /// # use wifi_ctrl::sta::Psk;
242    /// assert!(Psk::passphrase("password123").is_ok());
243    /// assert!(Psk::passphrase("2short").is_err());
244    /// assert!(Psk::passphrase("pass\"word123").is_err()); // no escape for a quote
245    /// ```
246    pub fn passphrase(passphrase: impl Into<String>) -> Result<Self> {
247        let passphrase = passphrase.into();
248        let quotable = passphrase
249            .bytes()
250            .all(|b| (0x20..=0x7e).contains(&b) && b != b'"');
251        if !quotable || !(8..=63).contains(&passphrase.len()) {
252            return Err(ClientError::InvalidPsk);
253        }
254        Ok(Psk(PskInner::Passphrase(passphrase)))
255    }
256
257    /// A precomputed 256-bit key, as produced by `wpa_passphrase` or
258    /// equivalent PBKDF2 derivation.
259    pub fn raw(key: [u8; 32]) -> Self {
260        Psk(PskInner::Raw(key))
261    }
262
263    /// Encode the value of `SET_NETWORK <id> psk <value>`: a passphrase is
264    /// quoted, a raw key is bare lowercase hex.
265    pub(crate) fn to_field(&self) -> String {
266        match &self.0 {
267            PskInner::Passphrase(passphrase) => format!("\"{passphrase}\""),
268            PskInner::Raw(key) => hex::encode(key),
269        }
270    }
271}
272
273impl FromStr for Psk {
274    type Err = ClientError;
275
276    fn from_str(s: &str) -> Result<Self> {
277        if s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) {
278            let mut key = [0u8; 32];
279            hex::decode_to_slice(s, &mut key).expect("64 hex digits");
280            return Ok(Psk::raw(key));
281        }
282        Self::passphrase(s)
283    }
284}
285
286/// Never print key material: `Request` (and `SetNetwork` inside it) is logged
287/// at debug level with `{:?}`.
288impl std::fmt::Debug for Psk {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        write!(f, "Psk(<redacted>)")
291    }
292}
293
294/// A BSSID (access-point MAC address).
295///
296/// wpa_supplicant expects the `bssid` field raw and unquoted (a quoted MAC
297/// fails to parse). Parsing up front and re-emitting the canonical
298/// `xx:xx:xx:xx:xx:xx` form via [`Display`] means caller input is never echoed
299/// into an unquoted command position.
300///
301/// ```
302/// use wifi_ctrl::sta::Bssid;
303///
304/// let bssid: Bssid = "CC:7B:5C:1A:D2:21".parse()?;
305/// // Always re-emitted in canonical lowercase form.
306/// assert_eq!(bssid.to_string(), "cc:7b:5c:1a:d2:21");
307/// assert_eq!(bssid, Bssid::from([0xcc, 0x7b, 0x5c, 0x1a, 0xd2, 0x21]));
308///
309/// assert!("cc:7b:5c:1a:d2".parse::<Bssid>().is_err());
310/// # Ok::<(), wifi_ctrl::error::ClientError>(())
311/// ```
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub struct Bssid([u8; 6]);
314
315impl From<[u8; 6]> for Bssid {
316    fn from(mac: [u8; 6]) -> Self {
317        Bssid(mac)
318    }
319}
320
321impl FromStr for Bssid {
322    type Err = ClientError;
323
324    fn from_str(s: &str) -> Result<Self> {
325        let mut mac = [0u8; 6];
326        let mut octets = s.split(':');
327        for byte in mac.iter_mut() {
328            let octet = octets.next().ok_or(ClientError::InvalidBssid)?;
329            // from_str_radix alone would admit signs and whitespace
330            if octet.len() != 2 || !octet.bytes().all(|b| b.is_ascii_hexdigit()) {
331                return Err(ClientError::InvalidBssid);
332            }
333            *byte = u8::from_str_radix(octet, 16).map_err(|_| ClientError::InvalidBssid)?;
334        }
335        if octets.next().is_some() {
336            return Err(ClientError::InvalidBssid);
337        }
338        Ok(Bssid(mac))
339    }
340}
341
342impl Display for Bssid {
343    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344        let [a, b, c, d, e, g] = self.0;
345        write!(f, "{a:02x}:{b:02x}:{c:02x}:{d:02x}:{e:02x}:{g:02x}")
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn parse_status_types_known_fields_and_keeps_raw() {
355        let resp = "\
356bssid=cc:7b:5c:1a:d2:21
357freq=2412
358ssid=my-network
359id=3
360mode=station
361wpa_state=COMPLETED
362address=aa:bb:cc:dd:ee:ff
363ip_address=192.168.1.42
364some_future_key=42";
365        let status = parse_status(resp).unwrap();
366        assert_eq!(status.wpa_state.as_deref(), Some("COMPLETED"));
367        assert_eq!(status.ssid.as_deref(), Some("my-network"));
368        assert_eq!(status.id, Some(3));
369        assert_eq!(status.freq, Some(2412));
370        assert_eq!(status.ip_address.as_deref(), Some("192.168.1.42"));
371        // key_mgmt absent from the response -> None, not a parse failure
372        assert_eq!(status.key_mgmt, None);
373        // unknown keys are preserved via the raw escape hatch
374        assert_eq!(status.get("some_future_key"), Some("42"));
375    }
376
377    #[test]
378    fn parse_status_tolerates_sparse_response() {
379        let status = parse_status("wpa_state=SCANNING").unwrap();
380        assert_eq!(status.wpa_state.as_deref(), Some("SCANNING"));
381        assert_eq!(status.ssid, None);
382        assert_eq!(status.id, None);
383    }
384}