Skip to main content

qrcode_parse/
wifi.rs

1//! WiFi configuration (`WIFI:`) parsing and encoding.
2//!
3//! The wire format is `WIFI:T:<auth>;S:<ssid>;P:<password>;;` with optional
4//! `;H:true` for hidden networks, though the parser accepts the fields in any
5//! order. The characters `\\ ; , " :` are backslash-escaped inside the SSID and
6//! password. The `qrcode-rs` facade uses this same module for its convenience
7//! constructor, so [`encode_wifi`] and [`WifiConfig::parse`] stay symmetric.
8
9#[cfg(not(feature = "std"))]
10#[allow(unused_imports)]
11use alloc::{string::String, vec, vec::Vec};
12
13use crate::ParseError;
14
15/// WiFi authentication mode for a [`WifiConfig`].
16#[non_exhaustive]
17#[derive(Debug, Copy, Clone, PartialEq, Eq)]
18pub enum WifiSecurity {
19    /// WPA / WPA2 / WPA3 (all encoded as `T:WPA`).
20    Wpa,
21    /// WEP (`T:WEP`).
22    Wep,
23    /// Open / no passphrase (`T:nopass`).
24    None,
25}
26
27impl WifiSecurity {
28    /// The `T:` wire value for this security mode.
29    #[must_use]
30    pub fn as_str(&self) -> &'static str {
31        match self {
32            Self::Wpa => "WPA",
33            Self::Wep => "WEP",
34            Self::None => "nopass",
35        }
36    }
37
38    fn from_wire(value: &str) -> Self {
39        match value.to_ascii_uppercase().as_str() {
40            "WPA" | "WPA2" | "WPA3" => Self::Wpa,
41            "WEP" => Self::Wep,
42            _ => Self::None, // "nopass", empty, or unknown → open
43        }
44    }
45}
46
47/// A parsed WiFi configuration recovered from a `WIFI:` QR payload.
48///
49/// `#[non_exhaustive]`: fields may grow in 1.x without a breaking change;
50/// construct via [`WifiConfig::parse`] and read via the accessors.
51#[non_exhaustive]
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct WifiConfig {
54    /// The network name (SSID), unescaped.
55    ssid: String,
56    /// The passphrase, if any (`P:` field present and non-empty).
57    password: Option<String>,
58    /// The authentication mode (`T:` field).
59    security: WifiSecurity,
60    /// Whether the network is hidden (`H:true`).
61    hidden: bool,
62}
63
64impl WifiConfig {
65    /// Parses a `WIFI:` QR payload into a [`WifiConfig`].
66    ///
67    /// Fields may appear in any order; the `WIFI:` prefix is required. The SSID
68    /// (`S:`) field is mandatory; all others are optional.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`ParseError::InvalidFormat`] if the `WIFI:` prefix is missing,
73    /// or [`ParseError::MissingField`] if the SSID field is absent.
74    pub fn parse(s: &str) -> Result<Self, ParseError> {
75        let rest = strip_wifi_prefix(s).ok_or(ParseError::InvalidFormat)?;
76
77        let mut ssid: Option<String> = None;
78        let mut password = None;
79        let mut security = WifiSecurity::None;
80        let mut hidden = false;
81
82        for field in split_fields(rest) {
83            if field.is_empty() {
84                continue;
85            }
86            let Some((key, value)) = field.split_once(':') else {
87                continue; // skip a malformed keyless field
88            };
89            match key {
90                "S" => ssid = Some(unescape(value)),
91                "T" => security = WifiSecurity::from_wire(value),
92                "P" => {
93                    let pw = unescape(value);
94                    password = if pw.is_empty() { None } else { Some(pw) };
95                }
96                "H" => hidden = value.eq_ignore_ascii_case("true"),
97                _ => {}
98            }
99        }
100
101        let ssid = ssid.ok_or(ParseError::MissingField("S (ssid)"))?;
102        Ok(Self { ssid, password, security, hidden })
103    }
104
105    /// The network name (SSID).
106    #[must_use]
107    pub fn ssid(&self) -> &str {
108        &self.ssid
109    }
110
111    /// The passphrase, if a non-empty `P:` field was present.
112    #[must_use]
113    pub fn password(&self) -> Option<&str> {
114        self.password.as_deref()
115    }
116
117    /// The authentication mode.
118    #[must_use]
119    pub fn security(&self) -> WifiSecurity {
120        self.security
121    }
122
123    /// Whether the network is hidden (`H:true`).
124    #[must_use]
125    pub fn hidden(&self) -> bool {
126        self.hidden
127    }
128}
129
130/// Encodes a WiFi configuration into the `WIFI:` wire format consumed by phone
131/// cameras.
132///
133/// This is the single source of truth shared with the `qrcode-rs` facade's
134/// `QrCode::for_wifi` constructor.
135pub fn encode_wifi(ssid: &str, password: &str, auth: &str) -> String {
136    let mut payload = String::from("WIFI:T:");
137    payload.push_str(auth);
138    payload.push_str(";S:");
139    push_escaped(&mut payload, ssid);
140    payload.push_str(";P:");
141    push_escaped(&mut payload, password);
142    payload.push_str(";;");
143    payload
144}
145
146/// Strips a case-insensitive `WIFI:` prefix, returning the remainder.
147fn strip_wifi_prefix(s: &str) -> Option<&str> {
148    const PREFIX: &[u8] = b"WIFI:";
149    let bytes = s.as_bytes();
150    if bytes.len() >= PREFIX.len() && bytes[..PREFIX.len()].eq_ignore_ascii_case(PREFIX) {
151        // "WIFI:" is ASCII, so byte index 5 is a valid char boundary.
152        Some(&s[PREFIX.len()..])
153    } else {
154        None
155    }
156}
157
158/// Splits the payload body on unescaped `;`, preserving escape sequences inside
159/// each field. Returns the raw (still-escaped) field strings.
160fn split_fields(rest: &str) -> Vec<String> {
161    let mut fields = Vec::new();
162    let mut cur = String::new();
163    let mut chars = rest.chars();
164    while let Some(c) = chars.next() {
165        if c == '\\' {
166            cur.push('\\');
167            if let Some(next) = chars.next() {
168                cur.push(next);
169            }
170        } else if c == ';' {
171            fields.push(core::mem::take(&mut cur));
172        } else {
173            cur.push(c);
174        }
175    }
176    fields.push(cur);
177    fields
178}
179
180/// Reverses [`push_escaped`]: `\<c>` → `c`, copying other chars verbatim.
181fn unescape(s: &str) -> String {
182    let mut out = String::with_capacity(s.len());
183    let mut chars = s.chars();
184    while let Some(c) = chars.next() {
185        if c == '\\' {
186            if let Some(next) = chars.next() {
187                out.push(next);
188            }
189        } else {
190            out.push(c);
191        }
192    }
193    out
194}
195
196/// Backslash-escapes the characters that are special in a WiFi QR payload.
197fn push_escaped(out: &mut String, s: &str) {
198    for c in s.chars() {
199        if matches!(c, ';' | ',' | '"' | '\\' | ':') {
200            out.push('\\');
201        }
202        out.push(c);
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn round_trip_special_chars() {
212        // The classic escape example from the encoder doctest.
213        let original = "a;b,c\"d\\e:f";
214        let payload = encode_wifi(original, "p\\a;ss", "WPA");
215        let cfg = WifiConfig::parse(&payload).unwrap();
216        assert_eq!(cfg.ssid(), original);
217        assert_eq!(cfg.password(), Some("p\\a;ss"));
218        assert_eq!(cfg.security(), WifiSecurity::Wpa);
219        assert!(!cfg.hidden());
220    }
221
222    #[test]
223    fn parse_accepts_any_field_order() {
224        // The de-facto standard order is S,T,P,H — different from our encoder.
225        let s = "WIFI:S:MyNet;T:WEP;P:secret;H:true;;";
226        let cfg = WifiConfig::parse(s).unwrap();
227        assert_eq!(cfg.ssid(), "MyNet");
228        assert_eq!(cfg.password(), Some("secret"));
229        assert_eq!(cfg.security(), WifiSecurity::Wep);
230        assert!(cfg.hidden());
231    }
232
233    #[test]
234    fn parse_unescapes_semicolon_in_ssid() {
235        let cfg = WifiConfig::parse("WIFI:T:WPA;S:My\\;Net;P:pw;;").unwrap();
236        assert_eq!(cfg.ssid(), "My;Net");
237    }
238
239    #[test]
240    fn parse_nopass_security() {
241        let cfg = WifiConfig::parse("WIFI:S:Open;T:nopass;;").unwrap();
242        assert_eq!(cfg.security(), WifiSecurity::None);
243        assert_eq!(cfg.password(), None);
244    }
245
246    #[test]
247    fn parse_missing_prefix_errors() {
248        assert_eq!(WifiConfig::parse("S:Net;T:WPA;;"), Err(ParseError::InvalidFormat));
249    }
250
251    #[test]
252    fn parse_missing_ssid_errors() {
253        assert_eq!(WifiConfig::parse("WIFI:T:WPA;P:pw;;"), Err(ParseError::MissingField("S (ssid)")));
254    }
255
256    #[test]
257    fn security_round_trips() {
258        for sec in [WifiSecurity::Wpa, WifiSecurity::Wep, WifiSecurity::None] {
259            let payload = encode_wifi("net", "", sec.as_str());
260            assert_eq!(WifiConfig::parse(&payload).unwrap().security(), sec);
261        }
262    }
263
264    #[test]
265    fn escaped_helper_preserves_behavior() {
266        // Guards the moved `push_escaped` (formerly lib.rs `push_escaped_wifi`).
267        let mut out = String::new();
268        push_escaped(&mut out, "a;b,c\"d\\e:f");
269        assert_eq!(out, "a\\;b\\,c\\\"d\\\\e\\:f");
270    }
271}