Skip to main content

nm_wifi/
network.rs

1#[cfg(any(test, not(feature = "demo")))]
2use std::collections::HashMap;
3use std::error::Error;
4
5#[cfg(any(test, not(feature = "demo")))]
6use dbus::arg::{PropMap, RefArg, Variant};
7
8use crate::wifi::WifiNetwork;
9
10#[cfg(feature = "demo")]
11pub(crate) mod demo;
12#[cfg(not(feature = "demo"))]
13pub(crate) mod networkmanager;
14
15pub enum ConnectionRequest<'a> {
16    Open {
17        network: &'a WifiNetwork,
18    },
19    Secured {
20        network: &'a WifiNetwork,
21        passphrase: &'a str,
22    },
23}
24
25#[cfg(any(test, not(feature = "demo")))]
26fn variant<T: RefArg + 'static>(value: T) -> Variant<Box<dyn RefArg>> {
27    Variant(Box::new(value))
28}
29
30#[cfg(any(test, not(feature = "demo")))]
31fn base_connection_settings(ssid: &str) -> HashMap<&'static str, PropMap> {
32    let mut connection = PropMap::new();
33    connection
34        .insert("type".to_string(), variant("802-11-wireless".to_string()));
35    connection.insert("id".to_string(), variant(format!("nm-wifi-{ssid}")));
36
37    let mut wireless = PropMap::new();
38    wireless.insert("ssid".to_string(), variant(ssid.as_bytes().to_vec()));
39    wireless.insert("mode".to_string(), variant("infrastructure".to_string()));
40
41    let mut ipv4 = PropMap::new();
42    ipv4.insert("method".to_string(), variant("auto".to_string()));
43
44    let mut ipv6 = PropMap::new();
45    ipv6.insert("method".to_string(), variant("auto".to_string()));
46
47    let mut settings = HashMap::new();
48    settings.insert("connection", connection);
49    settings.insert("802-11-wireless", wireless);
50    settings.insert("ipv4", ipv4);
51    settings.insert("ipv6", ipv6);
52    settings
53}
54
55#[cfg(any(test, not(feature = "demo")))]
56fn open_network_connection_settings(
57    ssid: &str,
58) -> HashMap<&'static str, PropMap> {
59    base_connection_settings(ssid)
60}
61
62#[cfg(any(test, not(feature = "demo")))]
63fn secured_network_connection_settings(
64    ssid: &str,
65    password: &str,
66    key_mgmt: &str,
67) -> HashMap<&'static str, PropMap> {
68    let mut settings = base_connection_settings(ssid);
69
70    let mut wireless_security = PropMap::new();
71    wireless_security
72        .insert("key-mgmt".to_string(), variant(key_mgmt.to_string()));
73    wireless_security.insert("psk".to_string(), variant(password.to_string()));
74
75    if let Some(wireless) = settings.get_mut("802-11-wireless") {
76        wireless.insert(
77            "security".to_string(),
78            variant("802-11-wireless-security".to_string()),
79        );
80    }
81
82    settings.insert("802-11-wireless-security", wireless_security);
83    settings
84}
85
86#[cfg(feature = "demo")]
87pub use demo::demo_networks;
88
89#[cfg(feature = "demo")]
90pub fn get_connected_ssid() -> Result<Option<String>, Box<dyn Error>> {
91    demo::get_connected_ssid()
92}
93
94#[cfg(not(feature = "demo"))]
95pub fn get_connected_ssid() -> Result<Option<String>, Box<dyn Error>> {
96    networkmanager::get_connected_ssid()
97}
98
99#[cfg(feature = "demo")]
100pub fn get_wifi_adapter_name() -> Result<Option<String>, Box<dyn Error>> {
101    demo::get_wifi_adapter_name()
102}
103
104#[cfg(not(feature = "demo"))]
105pub fn get_wifi_adapter_name() -> Result<Option<String>, Box<dyn Error>> {
106    networkmanager::get_wifi_adapter_name()
107}
108
109#[cfg(feature = "demo")]
110pub async fn scan_wifi_networks() -> Result<Vec<WifiNetwork>, Box<dyn Error>> {
111    demo::scan_wifi_networks().await
112}
113
114#[cfg(not(feature = "demo"))]
115pub async fn scan_wifi_networks() -> Result<Vec<WifiNetwork>, Box<dyn Error>> {
116    networkmanager::scan_wifi_networks().await
117}
118
119#[cfg(feature = "demo")]
120pub fn connect_to_network(
121    request: ConnectionRequest<'_>,
122) -> Result<(), Box<dyn Error>> {
123    demo::connect_to_network(request)
124}
125
126#[cfg(not(feature = "demo"))]
127pub fn connect_to_network(
128    request: ConnectionRequest<'_>,
129) -> Result<(), Box<dyn Error>> {
130    networkmanager::connect_to_network(request)
131}
132
133#[cfg(feature = "demo")]
134pub fn disconnect_from_network(
135    network: &WifiNetwork,
136) -> Result<(), Box<dyn Error>> {
137    demo::disconnect_from_network(network)
138}
139
140#[cfg(not(feature = "demo"))]
141pub fn disconnect_from_network(
142    network: &WifiNetwork,
143) -> Result<(), Box<dyn Error>> {
144    networkmanager::disconnect_from_network(network)
145}
146
147#[cfg(test)]
148mod tests {
149    #[cfg(not(feature = "demo"))]
150    use std::time::Duration;
151
152    #[cfg(feature = "demo")]
153    use super::ConnectionRequest;
154    #[cfg(feature = "demo")]
155    use super::demo::{connect_to_network, demo_networks, scan_wifi_networks};
156    #[cfg(not(feature = "demo"))]
157    use super::networkmanager::{
158        AP_FLAGS_PRIVACY,
159        AP_SEC_KEY_MGMT_8021X,
160        AP_SEC_KEY_MGMT_PSK,
161        AP_SEC_KEY_MGMT_SAE,
162        SecurityKind,
163        choose_wifi_adapter_name,
164        classify_access_point_security,
165        classify_security,
166        scan_wait_duration,
167        should_disconnect_device,
168    };
169    use super::{
170        open_network_connection_settings,
171        secured_network_connection_settings,
172    };
173    #[cfg(not(feature = "demo"))]
174    use crate::wifi::WifiNetwork;
175    use crate::wifi::WifiSecurity;
176
177    #[cfg(not(feature = "demo"))]
178    #[test]
179    fn adapter_selection_prefers_connected_wifi_interfaces() {
180        assert_eq!(
181            choose_wifi_adapter_name(
182                Some("wlp2s0".to_string()),
183                vec!["wlan1".to_string(), "wlp2s0".to_string()]
184            ),
185            Some("wlp2s0".to_string())
186        );
187    }
188
189    #[cfg(not(feature = "demo"))]
190    #[test]
191    fn adapter_selection_falls_back_to_first_available_wifi_interface() {
192        assert_eq!(
193            choose_wifi_adapter_name(
194                None,
195                vec!["wlan1".to_string(), "wlp2s0".to_string()]
196            ),
197            Some("wlan1".to_string())
198        );
199    }
200
201    #[cfg(not(feature = "demo"))]
202    #[test]
203    fn disconnect_matching_requires_the_selected_ssid() {
204        assert!(should_disconnect_device(Some("home"), "home"));
205        assert!(!should_disconnect_device(Some("guest"), "home"));
206        assert!(!should_disconnect_device(None, "home"));
207    }
208
209    #[cfg(not(feature = "demo"))]
210    fn network(security: WifiSecurity) -> WifiNetwork {
211        WifiNetwork {
212            ssid: "test".to_string(),
213            signal_strength: 60,
214            security,
215            frequency: 2412,
216            connected: false,
217        }
218    }
219
220    #[cfg(not(feature = "demo"))]
221    #[test]
222    fn open_networks_are_classified_as_open() {
223        assert_eq!(
224            classify_security(&network(WifiSecurity::Open), None),
225            SecurityKind::Open
226        );
227    }
228
229    #[cfg(not(feature = "demo"))]
230    #[test]
231    fn access_points_with_psk_flags_are_classified_as_wpa_personal() {
232        assert_eq!(
233            classify_access_point_security(0, 0, AP_SEC_KEY_MGMT_PSK),
234            WifiSecurity::WpaPsk
235        );
236    }
237
238    #[cfg(not(feature = "demo"))]
239    #[test]
240    fn access_points_with_sae_flags_are_classified_as_wpa3_personal() {
241        assert_eq!(
242            classify_access_point_security(0, 0, AP_SEC_KEY_MGMT_SAE),
243            WifiSecurity::WpaSae
244        );
245    }
246
247    #[cfg(not(feature = "demo"))]
248    #[test]
249    fn enterprise_access_points_are_not_treated_as_personal_networks() {
250        assert_eq!(
251            classify_access_point_security(0, 0, AP_SEC_KEY_MGMT_8021X),
252            WifiSecurity::Enterprise
253        );
254    }
255
256    #[cfg(not(feature = "demo"))]
257    #[test]
258    fn privacy_without_supported_key_management_is_unsupported() {
259        assert_eq!(
260            classify_access_point_security(AP_FLAGS_PRIVACY, 0, 0),
261            WifiSecurity::Unsupported
262        );
263    }
264
265    #[test]
266    fn open_network_settings_include_wireless_and_ip_defaults() {
267        let settings = open_network_connection_settings("cafe");
268
269        assert!(settings.contains_key("connection"));
270        assert!(settings.contains_key("802-11-wireless"));
271        assert!(settings.contains_key("ipv4"));
272        assert!(settings.contains_key("ipv6"));
273    }
274
275    #[test]
276    fn psk_network_settings_include_wireless_security() {
277        let settings =
278            secured_network_connection_settings("home", "hunter2", "wpa-psk");
279
280        assert!(settings.contains_key("802-11-wireless-security"));
281        assert_eq!(
282            settings
283                .get("802-11-wireless")
284                .and_then(|wireless| wireless.get("security"))
285                .and_then(|value| value.0.as_str()),
286            Some("802-11-wireless-security")
287        );
288        assert_eq!(
289            settings
290                .get("802-11-wireless-security")
291                .and_then(|security| security.get("key-mgmt"))
292                .and_then(|value| value.0.as_str()),
293            Some("wpa-psk")
294        );
295    }
296
297    #[test]
298    fn sae_network_settings_use_sae_key_management() {
299        let settings =
300            secured_network_connection_settings("home", "hunter2", "sae");
301
302        assert_eq!(
303            settings
304                .get("802-11-wireless-security")
305                .and_then(|security| security.get("key-mgmt"))
306                .and_then(|value| value.0.as_str()),
307            Some("sae")
308        );
309    }
310
311    #[cfg(not(feature = "demo"))]
312    #[test]
313    fn psk_networks_are_classified_when_password_is_present() {
314        assert_eq!(
315            classify_security(&network(WifiSecurity::WpaPsk), Some("hunter2")),
316            SecurityKind::WpaPsk
317        );
318    }
319
320    #[cfg(not(feature = "demo"))]
321    #[test]
322    fn sae_networks_are_classified_when_password_is_present() {
323        assert_eq!(
324            classify_security(&network(WifiSecurity::WpaSae), Some("hunter2")),
325            SecurityKind::WpaSae
326        );
327    }
328
329    #[cfg(not(feature = "demo"))]
330    #[test]
331    fn enterprise_networks_remain_unsupported_even_with_a_password() {
332        assert_eq!(
333            classify_security(
334                &network(WifiSecurity::Enterprise),
335                Some("correcthorsebatterystaple")
336            ),
337            SecurityKind::Unsupported
338        );
339    }
340
341    #[cfg(not(feature = "demo"))]
342    #[test]
343    fn unsupported_connect_cases_are_detected() {
344        assert_eq!(
345            classify_security(&network(WifiSecurity::Unsupported), None),
346            SecurityKind::Unsupported
347        );
348    }
349
350    #[cfg(not(feature = "demo"))]
351    #[test]
352    fn recent_scans_do_not_force_an_extra_wait() {
353        assert_eq!(scan_wait_duration(5_000), Duration::from_millis(0));
354    }
355
356    #[cfg(not(feature = "demo"))]
357    #[test]
358    fn stale_scans_wait_longer_than_the_old_fixed_delay() {
359        assert_eq!(scan_wait_duration(20_000), Duration::from_millis(750));
360        assert_eq!(scan_wait_duration(-1), Duration::from_millis(750));
361    }
362
363    #[cfg(feature = "demo")]
364    #[tokio::test]
365    async fn demo_scan_returns_mock_networks() {
366        let networks = scan_wifi_networks().await.expect("demo scan works");
367        assert!(networks.iter().any(|network| network.ssid == "CatCat"));
368        assert!(
369            networks
370                .iter()
371                .any(|network| network.security == WifiSecurity::WpaSae)
372        );
373    }
374
375    #[cfg(feature = "demo")]
376    #[test]
377    fn demo_connect_accepts_matching_passwords() {
378        let network = demo_networks()
379            .into_iter()
380            .find(|network| network.ssid == "CatCat")
381            .expect("demo network exists");
382
383        let result = connect_to_network(ConnectionRequest::Secured {
384            network: &network,
385            passphrase: "AcerolaAcai",
386        });
387
388        assert!(result.is_ok());
389    }
390
391    #[cfg(feature = "demo")]
392    #[test]
393    fn demo_connect_rejects_invalid_passwords() {
394        let network = demo_networks()
395            .into_iter()
396            .find(|network| network.ssid == "CatCat")
397            .expect("demo network exists");
398
399        let result = connect_to_network(ConnectionRequest::Secured {
400            network: &network,
401            passphrase: "wrong-password",
402        });
403
404        assert_eq!(
405            result.expect_err("demo connect should fail").to_string(),
406            "Demo mode: invalid password"
407        );
408    }
409}