max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
10 files changed,
+1919 insertions,
-997 deletions
| @@ -5,1264 +5,18 @@ | |||
| 5 | 5 | //! carried over unchanged, because it is what lets the console be developed | |
| 6 | 6 | //! and demoed on a machine whose real network state you would rather not | |
| 7 | 7 | //! touch. | |
| 8 | - | ||
| 9 | - | use alloy_tui::keys::Action; | |
| 10 | - | use alloy_tui::{ | |
| 11 | - | AlloyBlock, AlloyList, Cursor, Hint, KeyGroup, Severity, TextField, Theme, binding, hint, text, | |
| 12 | - | unavailable, | |
| 13 | - | }; | |
| 14 | - | use anyhow::Result; | |
| 15 | - | use ratatui::Frame; | |
| 16 | - | use ratatui::crossterm::event::{KeyCode, KeyEvent}; | |
| 17 | - | use ratatui::layout::Rect; | |
| 18 | - | use ratatui::text::{Line, Span}; | |
| 19 | - | ||
| 20 | - | use crate::cli::{CommandLog, Invocation, Secret}; | |
| 21 | - | use crate::shell::{Flow, View, block_title}; | |
| 22 | - | ||
| 23 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 24 | - | pub(crate) enum Kind { | |
| 25 | - | Wired, | |
| 26 | - | Wireless, | |
| 27 | - | Loopback, | |
| 28 | - | Other, | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | impl Kind { | |
| 32 | - | /// Map NetworkManager's device type. NM's vocabulary is open-ended | |
| 33 | - | /// (`bridge`, `tun`, `wireguard`, `bond`, ...); everything Alloy does not | |
| 34 | - | /// name specifically is `Other` and still listed, because hiding an | |
| 35 | - | /// interface the user can see in `nmcli` would make the console look | |
| 36 | - | /// broken. | |
| 37 | - | fn from_nm(raw: &str) -> Self { | |
| 38 | - | match raw { | |
| 39 | - | "ethernet" => Kind::Wired, | |
| 40 | - | "wifi" => Kind::Wireless, | |
| 41 | - | "loopback" => Kind::Loopback, | |
| 42 | - | _ => Kind::Other, | |
| 43 | - | } | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | const fn label(self) -> &'static str { | |
| 47 | - | match self { | |
| 48 | - | Kind::Wired => "wired", | |
| 49 | - | Kind::Wireless => "wireless", | |
| 50 | - | Kind::Loopback => "loopback", | |
| 51 | - | Kind::Other => "other", | |
| 52 | - | } | |
| 53 | - | } | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 57 | - | pub(crate) enum State { | |
| 58 | - | Connected, | |
| 59 | - | Disconnected, | |
| 60 | - | Unavailable, | |
| 61 | - | Unmanaged, | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | impl State { | |
| 65 | - | /// NM reports `GENERAL.STATE` as `"100 (connected)"`. The numeric code is | |
| 66 | - | /// the stable part — the parenthesized text is localized — so parse the | |
| 67 | - | /// number and ignore the rest. | |
| 68 | - | fn from_nm(raw: &str) -> Self { | |
| 69 | - | let code = raw | |
| 70 | - | .split_whitespace() | |
| 71 | - | .next() | |
| 72 | - | .and_then(|n| n.parse::<u16>().ok()) | |
| 73 | - | .unwrap_or(0); | |
| 74 | - | match code { | |
| 75 | - | 100 => State::Connected, | |
| 76 | - | 30 => State::Disconnected, | |
| 77 | - | 20 => State::Unavailable, | |
| 78 | - | _ => State::Unmanaged, | |
| 79 | - | } | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | const fn label(self) -> &'static str { | |
| 83 | - | match self { | |
| 84 | - | State::Connected => "connected", | |
| 85 | - | State::Disconnected => "disconnected", | |
| 86 | - | State::Unavailable => "unavailable", | |
| 87 | - | State::Unmanaged => "unmanaged", | |
| 88 | - | } | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | const fn severity(self) -> Severity { | |
| 92 | - | match self { | |
| 93 | - | State::Connected => Severity::Healthy, | |
| 94 | - | State::Disconnected => Severity::Warn, | |
| 95 | - | State::Unavailable | State::Unmanaged => Severity::Info, | |
| 96 | - | } | |
| 97 | - | } | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | #[derive(Debug, Clone)] | |
| 101 | - | pub(crate) struct Interface { | |
| 102 | - | pub name: String, | |
| 103 | - | pub kind: Kind, | |
| 104 | - | pub state: State, | |
| 105 | - | pub connection: Option<String>, | |
| 106 | - | pub addresses: Vec<String>, | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | /// A wireless network in range. | |
| 110 | - | /// | |
| 111 | - | /// Distinct from [`Interface`], and the distinction is the whole join flow: an | |
| 112 | - | /// interface is a device this machine has, a network is something in the air | |
| 113 | - | /// near it. Everything the screen did before this was about the first. | |
| 114 | - | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 115 | - | pub(crate) struct Network { | |
| 116 | - | pub ssid: String, | |
| 117 | - | /// NM's 0-100 signal figure. Kept as the number rather than the bars, | |
| 118 | - | /// because the bars are a rendering of it and this is the sort key. | |
| 119 | - | pub signal: u8, | |
| 120 | - | /// NM's security column verbatim (`WPA2`, `WPA3`, `WPA1 WPA2`, `802.1X`), | |
| 121 | - | /// or `None` for an open network. | |
| 122 | - | /// | |
| 123 | - | /// Verbatim rather than parsed into an enum: the console's only decision is | |
| 124 | - | /// whether to ask for a passphrase, which is `is_some`, and NM's vocabulary | |
| 125 | - | /// here grows with every new standard. Showing what NM said keeps the row | |
| 126 | - | /// honest about a network the console does not have a word for. | |
| 127 | - | pub security: Option<String>, | |
| 128 | - | /// Whether this is the network the machine is already on. | |
| 129 | - | pub in_use: bool, | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | /// A source of interface state, and the actions on it. | |
| 133 | - | /// | |
| 134 | - | /// The actions are `Option<Invocation>` for the same reason `alloy pkg`'s | |
| 135 | - | /// start/stop are: a backend that cannot do the thing says so by returning | |
| 136 | - | /// nothing, and the view offers the key only where there is something behind | |
| 137 | - | /// it. Backends build argv and run nothing; the view executes through the | |
| 138 | - | /// command log, which is what keeps "every action shows its invocation" | |
| 139 | - | /// structural rather than remembered. | |
| 140 | - | pub(crate) trait Backend { | |
| 141 | - | fn name(&self) -> &'static str; | |
| 142 | - | fn list(&self, log: &mut CommandLog) -> Result<Vec<Interface>>; | |
| 143 | - | ||
| 144 | - | /// Bring a device up on whichever saved connection NM picks for it. | |
| 145 | - | /// | |
| 146 | - | /// Defaults to nothing, which is the honest answer for a backend that only | |
| 147 | - | /// reads. The view offers the key only where there is something behind it. | |
| 148 | - | fn connect(&self, _iface: &Interface) -> Option<Invocation> { | |
| 149 | - | None | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | /// Take a device down without touching what it is configured to use. | |
| 153 | - | fn disconnect(&self, _iface: &Interface) -> Option<Invocation> { | |
| 154 | - | None | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | /// Turn the wifi radio on or off. | |
| 158 | - | fn set_wifi(&self, _on: bool) -> Option<Invocation> { | |
| 159 | - | None | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | /// Whether the wifi radio is on, or `None` when the backend cannot say. | |
| 163 | - | /// | |
| 164 | - | /// Read on refresh rather than assumed, because a radio that is off is the | |
| 165 | - | /// explanation for every wireless device sitting at `unavailable`, and a | |
| 166 | - | /// toggle that does not know which way it is pointing is a coin flip. | |
| 167 | - | fn wifi_enabled(&self, _log: &mut CommandLog) -> Option<bool> { | |
| 168 | - | None | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | /// The networks in range. | |
| 172 | - | /// | |
| 173 | - | /// Three answers rather than two, and the middle one is why this is not a | |
| 174 | - | /// plain `Result`. `None` is a backend that cannot scan at all, which is | |
| 175 | - | /// the mock and is a fact about the backend; `Some(Err)` is a backend that | |
| 176 | - | /// tried and failed, which is a fact about this moment. The view offers the | |
| 177 | - | /// key on the first and reports the message on the second. | |
| 178 | - | fn networks(&self, _log: &mut CommandLog) -> Option<Result<Vec<Network>>> { | |
| 179 | - | None | |
| 180 | - | } | |
| 181 | - | ||
| 182 | - | /// Join a network by name, with a passphrase for a secured one. | |
| 183 | - | /// | |
| 184 | - | /// The passphrase is moved in rather than borrowed: it ends up inside the | |
| 185 | - | /// [`Invocation`], which owns its [`Secret`] and scrubs it on drop. A | |
| 186 | - | /// borrow would leave the caller holding the only copy and no reason to | |
| 187 | - | /// think it mattered. | |
| 188 | - | fn join(&self, _ssid: &str, _passphrase: Option<Secret>) -> Option<Invocation> { | |
| 189 | - | None | |
| 190 | - | } | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | /// Pick a backend: the real one when `nmcli` answers, the mock otherwise. | |
| 194 | - | /// | |
| 195 | - | /// The probe is a real invocation rather than a `which` check — an `nmcli` | |
| 196 | - | /// binary that cannot reach a NetworkManager daemon (a container, a live ISO | |
| 197 | - | /// mid-boot) is worse than no `nmcli` at all, and only running it reveals that. | |
| 198 | - | pub(crate) fn detect() -> Box<dyn Backend> { | |
| 199 | - | if Invocation::new("nmcli").arg("--version").probe() { | |
| 200 | - | Box::new(NmCli) | |
| 201 | - | } else { | |
| 202 | - | Box::new(Mock) | |
| 203 | - | } | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | pub(crate) struct NmCli; | |
| 207 | - | ||
| 208 | - | impl NmCli { | |
| 209 | - | /// One invocation for the whole device table. `nmcli device show` with no | |
| 210 | - | /// device dumps every device, which keeps the log pane to a single | |
| 211 | - | /// copy-pasteable line instead of one per interface. | |
| 212 | - | fn invocation() -> Invocation { | |
| 213 | - | Invocation::new("nmcli").args([ | |
| 214 | - | "-t", | |
| 215 | - | "-f", | |
| 216 | - | "GENERAL.DEVICE,GENERAL.TYPE,GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP6.ADDRESS", | |
| 217 | - | "device", | |
| 218 | - | "show", | |
| 219 | - | ]) | |
| 220 | - | } | |
| 221 | - | } | |
| 222 | - | ||
| 223 | - | impl Backend for NmCli { | |
| 224 | - | fn name(&self) -> &'static str { | |
| 225 | - | "nmcli" | |
| 226 | - | } | |
| 227 | - | ||
| 228 | - | fn list(&self, log: &mut CommandLog) -> Result<Vec<Interface>> { | |
| 229 | - | Ok(parse_device_show(&Self::invocation().run(log)?)) | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | // `device connect` and not `connection up`: the user selected a device, and | |
| 233 | - | // NM picks the connection it is configured for. Naming a connection here | |
| 234 | - | // would mean the console deciding which profile a device should use, which | |
| 235 | - | // is a different screen and a different polkit action. | |
| 236 | - | fn connect(&self, iface: &Interface) -> Option<Invocation> { | |
| 237 | - | actionable(iface).then(|| Invocation::new("nmcli").args(["device", "connect", &iface.name])) | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | fn disconnect(&self, iface: &Interface) -> Option<Invocation> { | |
| 241 | - | actionable(iface) | |
| 242 | - | .then(|| Invocation::new("nmcli").args(["device", "disconnect", &iface.name])) | |
| 243 | - | } | |
| 244 | - | ||
| 245 | - | fn set_wifi(&self, on: bool) -> Option<Invocation> { | |
| 246 | - | Some(Invocation::new("nmcli").args(["radio", "wifi", if on { "on" } else { "off" }])) | |
| 247 | - | } | |
| 248 | - | ||
| 249 | - | fn wifi_enabled(&self, log: &mut CommandLog) -> Option<bool> { | |
| 250 | - | let raw = Invocation::new("nmcli") | |
| 251 | - | .args(["radio", "wifi"]) | |
| 252 | - | .run(log) | |
| 253 | - | .ok()?; | |
| 254 | - | parse_radio(&raw) | |
| 255 | - | } | |
| 256 | - | ||
| 257 | - | // `--rescan yes` and not the default. Without it nmcli answers out of NM's | |
| 258 | - | // cache, which on a device that has been sitting disconnected is empty or | |
| 259 | - | // minutes stale, and a scan screen that shows what was in the air a while | |
| 260 | - | // ago is worse than one that takes a moment. The cost is the moment: the | |
| 261 | - | // command blocks while the radio sweeps. | |
| 262 | - | fn networks(&self, log: &mut CommandLog) -> Option<Result<Vec<Network>>> { | |
| 263 | - | Some( | |
| 264 | - | Invocation::new("nmcli") | |
| 265 | - | .args([ | |
| 266 | - | "-t", | |
| 267 | - | "-f", | |
| 268 | - | "IN-USE,SSID,SIGNAL,SECURITY", | |
| 269 | - | "device", | |
| 270 | - | "wifi", | |
| 271 | - | "list", | |
| 272 | - | "--rescan", | |
| 273 | - | "yes", | |
| 274 | - | ]) | |
| 275 | - | .run(log) | |
| 276 | - | .map(|raw| parse_wifi_list(&raw)), | |
| 277 | - | ) | |
| 278 | - | } | |
| 279 | - | ||
| 280 | - | /// `nmcli --ask device wifi connect <ssid>`, with the passphrase piped. | |
| 281 | - | /// | |
| 282 | - | /// **The passphrase does not go in argv, and that is the whole reason for | |
| 283 | - | /// `--ask`.** `nmcli device wifi connect SSID password PW` is the form | |
| 284 | - | /// everyone writes, and it puts the passphrase where `ps` shows it to every | |
| 285 | - | /// user on the machine for as long as the command runs. `--ask` makes nmcli | |
| 286 | - | /// prompt for the secret instead, and a prompt reads stdin, which | |
| 287 | - | /// [`Invocation::stdin`] already carries privately. Same reasoning as | |
| 288 | - | /// `chpasswd` in the installer, and the same mechanism. | |
| 289 | - | /// | |
| 290 | - | /// Measured on nmcli 1.46 rather than assumed, because "it prompts" and "it | |
| 291 | - | /// reads a pipe" are different claims and only the second one is any use | |
| 292 | - | /// here: fed a pipe, `--ask` consumes it and carries on, so a console with | |
| 293 | - | /// no terminal to prompt on can still answer. | |
| 294 | - | /// | |
| 295 | - | /// `--ask` is passed for an open network too. Nothing is prompted there and | |
| 296 | - | /// the empty pipe is never read, so the cost is nothing; what it buys is | |
| 297 | - | /// that a network the console read as open but which actually wants a | |
| 298 | - | /// secret asks for one, rather than failing with the sort of message that | |
| 299 | - | /// sends someone to a search engine. | |
| 300 | - | fn join(&self, ssid: &str, passphrase: Option<Secret>) -> Option<Invocation> { | |
| 301 | - | let invocation = | |
| 302 | - | Invocation::new("nmcli").args(["--ask", "device", "wifi", "connect", ssid]); | |
| 303 | - | Some(match passphrase { | |
| 304 | - | Some(secret) => invocation.stdin(secret), | |
| 305 | - | None => invocation, | |
| 306 | - | }) | |
| 307 | - | } | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | /// Whether connect and disconnect mean anything for a device. | |
| 311 | - | /// | |
| 312 | - | /// Loopback is never brought up or down, and an unmanaged device is one NM has | |
| 313 | - | /// been told to keep its hands off — asking it to connect one is asking for an | |
| 314 | - | /// error the user cannot act on. Both are still listed, because an inventory | |
| 315 | - | /// that hides what it cannot act on stops being an inventory. | |
| 316 | - | fn actionable(iface: &Interface) -> bool { | |
| 317 | - | iface.kind != Kind::Loopback && iface.state != State::Unmanaged | |
| 318 | - | } | |
| 319 | - | ||
| 320 | - | /// Why this machine is showing no wireless device. | |
| 321 | - | /// | |
| 322 | - | /// An absence is the one state a device list cannot explain by listing. Four | |
| 323 | - | /// different faults render identically as "nothing here", and they want four | |
| 324 | - | /// different repairs, so the screen says which one it is rather than leaving | |
| 325 | - | /// the user to tell them apart. | |
| 326 | - | /// | |
| 327 | - | /// Found the hard way 2026-09-03: fw12 showed loopback alone, and the cause was | |
| 328 | - | /// that no Alloy image had ever carried `NetworkManager-wifi`. Nothing errored, | |
| 329 | - | /// no unit failed, and the console was correct in every respect except that it | |
| 330 | - | /// had nothing to say about the absence. | |
| 331 | - | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 332 | - | pub(crate) enum NoWireless { | |
| 333 | - | /// The image has no NetworkManager wifi plugin, so no wireless device can | |
| 334 | - | /// ever appear, whatever the hardware is. | |
| 335 | - | NoPlugin, | |
| 336 | - | /// The kernel made an interface and NetworkManager is not presenting it. | |
| 337 | - | /// Carries the interface name, because the next command a person runs | |
| 338 | - | /// wants it. | |
| 339 | - | Unmanaged(String), | |
| 340 | - | /// The radio is off. Listed after the two above because NM normally keeps a | |
| 341 | - | /// blocked device visible as `unavailable`, so this explains an absence | |
| 342 | - | /// only when the device went away entirely. | |
| 343 | - | RadioOff, | |
| 344 | - | /// No wireless hardware. The one state that is not a fault. | |
| 345 | - | NoHardware, | |
| 346 | - | } | |
| 347 | - | ||
| 348 | - | impl NoWireless { | |
| 349 | - | /// The line the screen shows. Says what is wrong and what to do about it, | |
| 350 | - | /// in that order, because a diagnosis with no next step is a complaint. | |
| 351 | - | pub(crate) fn message(&self) -> String { | |
| 352 | - | match self { | |
| 353 | - | Self::NoPlugin => "no wireless device: this image ships no NetworkManager wifi \ | |
| 354 | - | plugin, so one can never appear. Layer NetworkManager-wifi and wpa_supplicant" | |
| 355 | - | .to_string(), | |
| 356 | - | Self::Unmanaged(iface) => format!( | |
| 357 | - | "no wireless device: the kernel has {iface} and NetworkManager does not \ | |
| 358 | - | present it. Check wpa_supplicant and rfkill" | |
| 359 | - | ), | |
| 360 | - | Self::RadioOff => "no wireless device: the radio is off; press w".to_string(), | |
| 361 | - | Self::NoHardware => { | |
| 362 | - | "no wireless device: this machine has no wireless hardware".to_string() | |
| 363 | - | } | |
| 364 | - | } | |
| 365 | - | } | |
| 366 | - | } | |
| 367 | - | ||
| 368 | - | /// Decide which of the four it is, from facts gathered elsewhere. | |
| 369 | - | /// | |
| 370 | - | /// Pure on purpose. The probing is filesystem work and belongs at the edge; the | |
| 371 | - | /// ordering is the part worth testing, and it is an ordering rather than a set | |
| 372 | - | /// because the causes nest: an image with no plugin also has no managed device | |
| 373 | - | /// and would otherwise report the vaguest of the four. | |
| 374 | - | fn no_wireless( | |
| 375 | - | has_wireless: bool, | |
| 376 | - | radio: Option<bool>, | |
| 377 | - | plugin: bool, | |
| 378 | - | kernel_iface: Option<&str>, | |
| 379 | - | ) -> Option<NoWireless> { | |
| 380 | - | if has_wireless { | |
| 381 | - | return None; | |
| 382 | - | } | |
| 383 | - | if !plugin { | |
| 384 | - | return Some(NoWireless::NoPlugin); | |
| 385 | - | } | |
| 386 | - | if let Some(iface) = kernel_iface { | |
| 387 | - | return Some(NoWireless::Unmanaged(iface.to_string())); | |
| 388 | - | } | |
| 389 | - | if radio == Some(false) { | |
| 390 | - | return Some(NoWireless::RadioOff); | |
| 391 | - | } | |
| 392 | - | Some(NoWireless::NoHardware) | |
| 393 | - | } | |
| 394 | - | ||
| 395 | - | /// Is NetworkManager's wifi device plugin installed? | |
| 396 | - | /// | |
| 397 | - | /// The plugin is a separate package on Fedora (`NetworkManager-wifi`) and the | |
| 398 | - | /// base image is a server base that does not carry it. Reading the file rather | |
| 399 | - | /// than asking rpm: this answers what NM can actually load, which is the thing | |
| 400 | - | /// that decides whether a device appears. | |
| 401 | - | fn wifi_plugin_present() -> bool { | |
| 402 | - | let Ok(entries) = std::fs::read_dir("/usr/lib64/NetworkManager") else { | |
| 403 | - | return false; | |
| 404 | - | }; | |
| 405 | - | entries | |
| 406 | - | .filter_map(Result::ok) | |
| 407 | - | .any(|entry| entry.path().join("libnm-device-plugin-wifi.so").exists()) | |
| 408 | - | } | |
| 409 | - | ||
| 410 | - | /// The first wireless interface the kernel has made, if any. | |
| 411 | - | /// | |
| 412 | - | /// `/sys/class/net/<name>/wireless` exists exactly when the driver registered | |
| 413 | - | /// a wireless device, which is independent of whether NM presents it. That | |
| 414 | - | /// independence is the whole point: it separates "the driver did not load" | |
| 415 | - | /// from "NetworkManager is not showing you what the driver made". | |
| 416 | - | fn kernel_wireless_iface() -> Option<String> { | |
| 417 | - | let entries = std::fs::read_dir("/sys/class/net").ok()?; | |
| 418 | - | let mut names: Vec<String> = entries | |
| 419 | - | .filter_map(Result::ok) | |
| 420 | - | .filter(|entry| entry.path().join("wireless").exists()) | |
| 421 | - | .filter_map(|entry| entry.file_name().into_string().ok()) | |
| 422 | - | .collect(); | |
| 423 | - | names.sort(); | |
| 424 | - | names.into_iter().next() | |
| 425 | - | } | |
| 426 | - | ||
| 427 | - | /// Parse `nmcli radio wifi`, which answers `enabled` or `disabled`. | |
| 428 | - | /// | |
| 429 | - | /// Anything else is `None` rather than a guess: `missing` is what nmcli says | |
| 430 | - | /// when there is no wifi hardware, and reading that as "off" would offer a | |
| 431 | - | /// toggle for a radio that is not there. | |
| 432 | - | fn parse_radio(raw: &str) -> Option<bool> { | |
| 433 | - | match raw.trim() { | |
| 434 | - | "enabled" => Some(true), | |
| 435 | - | "disabled" => Some(false), | |
| 436 | - | _ => None, | |
| 437 | - | } | |
| 438 | - | } | |
| 439 | - | ||
| 440 | - | /// Parse `nmcli -t -f IN-USE,SSID,SIGNAL,SECURITY device wifi list`. | |
| 441 | - | /// | |
| 442 | - | /// **This is terse *tabular* output, which is a different format from the | |
| 443 | - | /// multiline output [`parse_device_show`] reads, in the one way that matters.** | |
| 444 | - | /// Tabular fields are colon-separated, so a colon inside a value has to be | |
| 445 | - | /// escaped, and nmcli escapes it as `\:` (and a literal backslash as `\\`). | |
| 446 | - | /// Multiline output has no such problem and escapes nothing, which is why the | |
| 447 | - | /// other parser takes its values verbatim and this one cannot. An SSID is | |
| 448 | - | /// arbitrary bytes chosen by whoever runs the access point, so a colon in one | |
| 449 | - | /// is not a curiosity: split naively and `Cafe: Free Wifi` becomes a network | |
| 450 | - | /// called `Cafe` with a signal of ` Free Wifi`. | |
| 451 | - | /// | |
| 452 | - | /// Hidden networks come back with an empty SSID and are dropped. There is no | |
| 453 | - | /// name to show and `device wifi connect` takes a name, so a blank row would be | |
| 454 | - | /// a row that cannot be acted on. | |
| 455 | - | /// | |
| 456 | - | /// One row per SSID, strongest wins. A network with three access points is | |
| 457 | - | /// three rows here, identical but for the signal, and NM connects to a *name* | |
| 458 | - | /// rather than to the row that was selected — so showing the same name three | |
| 459 | - | /// times would offer three choices that do the same thing. | |
| 460 | - | fn parse_wifi_list(raw: &str) -> Vec<Network> { | |
| 461 | - | let mut networks: Vec<Network> = Vec::new(); | |
| 462 | - | ||
| 463 | - | for line in raw.lines() { | |
| 464 | - | let fields = split_terse(line); | |
| 465 | - | let [in_use, ssid, signal, security] = fields.as_slice() else { | |
| 466 | - | continue; | |
| 467 | - | }; | |
| 468 | - | if ssid.is_empty() { | |
| 469 | - | continue; | |
| 470 | - | } | |
| 471 | - | ||
| 472 | - | let network = Network { | |
| 473 | - | ssid: ssid.clone(), | |
| 474 | - | // A row whose signal will not parse is kept at zero rather than | |
| 475 | - | // dropped: the network is there and joinable, and the number is | |
| 476 | - | // only the sort key. | |
| 477 | - | signal: signal.trim().parse().unwrap_or(0), | |
| 478 | - | security: (!security.trim().is_empty()).then(|| security.trim().to_string()), | |
| 479 | - | in_use: in_use.trim() == "*", | |
| 480 | - | }; | |
| 481 | - | ||
| 482 | - | match networks.iter_mut().find(|seen| seen.ssid == network.ssid) { | |
| 483 | - | // `in_use` is sticky across the merge. It is a property of the | |
| 484 | - | // network rather than of the access point, and the row we are on | |
| 485 | - | // is not necessarily the strongest one. | |
| 486 | - | Some(seen) => { | |
| 487 | - | seen.in_use |= network.in_use; | |
| 488 | - | if network.signal > seen.signal { | |
| 489 | - | seen.signal = network.signal; | |
| 490 | - | seen.security = network.security; | |
| 491 | - | } | |
| 492 | - | } | |
| 493 | - | None => networks.push(network), | |
| 494 | - | } | |
| 495 | - | } | |
| 496 | - | ||
| 497 | - | networks.sort_by(|a, b| b.signal.cmp(&a.signal).then_with(|| a.ssid.cmp(&b.ssid))); | |
| 498 | - | networks | |
| 499 | - | } | |
| 500 | - | ||
| 501 | - | /// Split one line of nmcli terse tabular output into its fields. | |
| 502 | - | /// | |
| 503 | - | /// `\:` is a colon inside a value and `\\` is a backslash. Everything else | |
| 504 | - | /// passes through, including a trailing lone backslash, which nmcli does not |
Lines truncated
| @@ -1,0 +1,321 @@ | |||
| 1 | + | //! The backend seam: a trait, the nmcli front, and the mock behind it. | |
| 2 | + | ||
| 3 | + | use alloy_tui::Severity; | |
| 4 | + | use anyhow::Result; | |
| 5 | + | ||
| 6 | + | use super::model::{Interface, Kind, Network, State}; | |
| 7 | + | use super::parse::{parse_device_show, parse_radio, parse_wifi_list}; | |
| 8 | + | use crate::cli::{CommandLog, Invocation, Secret}; | |
| 9 | + | ||
| 10 | + | /// A source of interface state, and the actions on it. | |
| 11 | + | /// | |
| 12 | + | /// The actions are `Option<Invocation>` for the same reason `alloy pkg`'s | |
| 13 | + | /// start/stop are: a backend that cannot do the thing says so by returning | |
| 14 | + | /// nothing, and the view offers the key only where there is something behind | |
| 15 | + | /// it. Backends build argv and run nothing; the view executes through the | |
| 16 | + | /// command log, which is what keeps "every action shows its invocation" | |
| 17 | + | /// structural rather than remembered. | |
| 18 | + | pub(crate) trait Backend { | |
| 19 | + | fn name(&self) -> &'static str; | |
| 20 | + | fn list(&self, log: &mut CommandLog) -> Result<Vec<Interface>>; | |
| 21 | + | ||
| 22 | + | /// Bring a device up on whichever saved connection NM picks for it. | |
| 23 | + | /// | |
| 24 | + | /// Defaults to nothing, which is the honest answer for a backend that only | |
| 25 | + | /// reads. The view offers the key only where there is something behind it. | |
| 26 | + | fn connect(&self, _iface: &Interface) -> Option<Invocation> { | |
| 27 | + | None | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// Take a device down without touching what it is configured to use. | |
| 31 | + | fn disconnect(&self, _iface: &Interface) -> Option<Invocation> { | |
| 32 | + | None | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// Turn the wifi radio on or off. | |
| 36 | + | fn set_wifi(&self, _on: bool) -> Option<Invocation> { | |
| 37 | + | None | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// Whether the wifi radio is on, or `None` when the backend cannot say. | |
| 41 | + | /// | |
| 42 | + | /// Read on refresh rather than assumed, because a radio that is off is the | |
| 43 | + | /// explanation for every wireless device sitting at `unavailable`, and a | |
| 44 | + | /// toggle that does not know which way it is pointing is a coin flip. | |
| 45 | + | fn wifi_enabled(&self, _log: &mut CommandLog) -> Option<bool> { | |
| 46 | + | None | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// The networks in range. | |
| 50 | + | /// | |
| 51 | + | /// Three answers rather than two, and the middle one is why this is not a | |
| 52 | + | /// plain `Result`. `None` is a backend that cannot scan at all, which is | |
| 53 | + | /// the mock and is a fact about the backend; `Some(Err)` is a backend that | |
| 54 | + | /// tried and failed, which is a fact about this moment. The view offers the | |
| 55 | + | /// key on the first and reports the message on the second. | |
| 56 | + | fn networks(&self, _log: &mut CommandLog) -> Option<Result<Vec<Network>>> { | |
| 57 | + | None | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// Join a network by name, with a passphrase for a secured one. | |
| 61 | + | /// | |
| 62 | + | /// The passphrase is moved in rather than borrowed: it ends up inside the | |
| 63 | + | /// [`Invocation`], which owns its [`Secret`] and scrubs it on drop. A | |
| 64 | + | /// borrow would leave the caller holding the only copy and no reason to | |
| 65 | + | /// think it mattered. | |
| 66 | + | fn join(&self, _ssid: &str, _passphrase: Option<Secret>) -> Option<Invocation> { | |
| 67 | + | None | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// Pick a backend: the real one when `nmcli` answers, the mock otherwise. | |
| 72 | + | /// | |
| 73 | + | /// The probe is a real invocation rather than a `which` check — an `nmcli` | |
| 74 | + | /// binary that cannot reach a NetworkManager daemon (a container, a live ISO | |
| 75 | + | /// mid-boot) is worse than no `nmcli` at all, and only running it reveals that. | |
| 76 | + | pub(crate) fn detect() -> Box<dyn Backend> { | |
| 77 | + | if Invocation::new("nmcli").arg("--version").probe() { | |
| 78 | + | Box::new(NmCli) | |
| 79 | + | } else { | |
| 80 | + | Box::new(Mock) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | pub(crate) struct NmCli; | |
| 85 | + | ||
| 86 | + | impl NmCli { | |
| 87 | + | /// One invocation for the whole device table. `nmcli device show` with no | |
| 88 | + | /// device dumps every device, which keeps the log pane to a single | |
| 89 | + | /// copy-pasteable line instead of one per interface. | |
| 90 | + | fn invocation() -> Invocation { | |
| 91 | + | Invocation::new("nmcli").args([ | |
| 92 | + | "-t", | |
| 93 | + | "-f", | |
| 94 | + | "GENERAL.DEVICE,GENERAL.TYPE,GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP6.ADDRESS", | |
| 95 | + | "device", | |
| 96 | + | "show", | |
| 97 | + | ]) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | impl Backend for NmCli { | |
| 102 | + | fn name(&self) -> &'static str { | |
| 103 | + | "nmcli" | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | fn list(&self, log: &mut CommandLog) -> Result<Vec<Interface>> { | |
| 107 | + | Ok(parse_device_show(&Self::invocation().run(log)?)) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // `device connect` and not `connection up`: the user selected a device, and | |
| 111 | + | // NM picks the connection it is configured for. Naming a connection here | |
| 112 | + | // would mean the console deciding which profile a device should use, which | |
| 113 | + | // is a different screen and a different polkit action. | |
| 114 | + | fn connect(&self, iface: &Interface) -> Option<Invocation> { | |
| 115 | + | actionable(iface).then(|| Invocation::new("nmcli").args(["device", "connect", &iface.name])) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | fn disconnect(&self, iface: &Interface) -> Option<Invocation> { | |
| 119 | + | actionable(iface) | |
| 120 | + | .then(|| Invocation::new("nmcli").args(["device", "disconnect", &iface.name])) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | fn set_wifi(&self, on: bool) -> Option<Invocation> { | |
| 124 | + | Some(Invocation::new("nmcli").args(["radio", "wifi", if on { "on" } else { "off" }])) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | fn wifi_enabled(&self, log: &mut CommandLog) -> Option<bool> { | |
| 128 | + | let raw = Invocation::new("nmcli") | |
| 129 | + | .args(["radio", "wifi"]) | |
| 130 | + | .run(log) | |
| 131 | + | .ok()?; | |
| 132 | + | parse_radio(&raw) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // `--rescan yes` and not the default. Without it nmcli answers out of NM's | |
| 136 | + | // cache, which on a device that has been sitting disconnected is empty or | |
| 137 | + | // minutes stale, and a scan screen that shows what was in the air a while | |
| 138 | + | // ago is worse than one that takes a moment. The cost is the moment: the | |
| 139 | + | // command blocks while the radio sweeps. | |
| 140 | + | fn networks(&self, log: &mut CommandLog) -> Option<Result<Vec<Network>>> { | |
| 141 | + | Some( | |
| 142 | + | Invocation::new("nmcli") | |
| 143 | + | .args([ | |
| 144 | + | "-t", | |
| 145 | + | "-f", | |
| 146 | + | "IN-USE,SSID,SIGNAL,SECURITY", | |
| 147 | + | "device", | |
| 148 | + | "wifi", | |
| 149 | + | "list", | |
| 150 | + | "--rescan", | |
| 151 | + | "yes", | |
| 152 | + | ]) | |
| 153 | + | .run(log) | |
| 154 | + | .map(|raw| parse_wifi_list(&raw)), | |
| 155 | + | ) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// `nmcli --ask device wifi connect <ssid>`, with the passphrase piped. | |
| 159 | + | /// | |
| 160 | + | /// **The passphrase does not go in argv, and that is the whole reason for | |
| 161 | + | /// `--ask`.** `nmcli device wifi connect SSID password PW` is the form | |
| 162 | + | /// everyone writes, and it puts the passphrase where `ps` shows it to every | |
| 163 | + | /// user on the machine for as long as the command runs. `--ask` makes nmcli | |
| 164 | + | /// prompt for the secret instead, and a prompt reads stdin, which | |
| 165 | + | /// [`Invocation::stdin`] already carries privately. Same reasoning as | |
| 166 | + | /// `chpasswd` in the installer, and the same mechanism. | |
| 167 | + | /// | |
| 168 | + | /// Measured on nmcli 1.46 rather than assumed, because "it prompts" and "it | |
| 169 | + | /// reads a pipe" are different claims and only the second one is any use | |
| 170 | + | /// here: fed a pipe, `--ask` consumes it and carries on, so a console with | |
| 171 | + | /// no terminal to prompt on can still answer. | |
| 172 | + | /// | |
| 173 | + | /// `--ask` is passed for an open network too. Nothing is prompted there and | |
| 174 | + | /// the empty pipe is never read, so the cost is nothing; what it buys is | |
| 175 | + | /// that a network the console read as open but which actually wants a | |
| 176 | + | /// secret asks for one, rather than failing with the sort of message that | |
| 177 | + | /// sends someone to a search engine. | |
| 178 | + | fn join(&self, ssid: &str, passphrase: Option<Secret>) -> Option<Invocation> { | |
| 179 | + | let invocation = | |
| 180 | + | Invocation::new("nmcli").args(["--ask", "device", "wifi", "connect", ssid]); | |
| 181 | + | Some(match passphrase { | |
| 182 | + | Some(secret) => invocation.stdin(secret), | |
| 183 | + | None => invocation, | |
| 184 | + | }) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | /// Whether connect and disconnect mean anything for a device. | |
| 189 | + | /// | |
| 190 | + | /// Loopback is never brought up or down, and an unmanaged device is one NM has | |
| 191 | + | /// been told to keep its hands off — asking it to connect one is asking for an | |
| 192 | + | /// error the user cannot act on. Both are still listed, because an inventory | |
| 193 | + | /// that hides what it cannot act on stops being an inventory. | |
| 194 | + | pub(super) fn actionable(iface: &Interface) -> bool { | |
| 195 | + | iface.kind != Kind::Loopback && iface.state != State::Unmanaged | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | /// Fixed sample state, for machines without NetworkManager. | |
| 199 | + | pub(crate) struct Mock; | |
| 200 | + | ||
| 201 | + | impl Backend for Mock { | |
| 202 | + | fn name(&self) -> &'static str { | |
| 203 | + | "mock" | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | fn list(&self, log: &mut CommandLog) -> Result<Vec<Interface>> { | |
| 207 | + | // Logged as a comment rather than a command: the pane's contract is | |
| 208 | + | // that every line is something you could run, and there is nothing to | |
| 209 | + | // run here. The `#` marks it as commentary in the same way a shell | |
| 210 | + | // would. | |
| 211 | + | log.record( | |
| 212 | + | "# no NetworkManager; showing mock interfaces", | |
| 213 | + | Severity::Warn, | |
| 214 | + | ); | |
| 215 | + | Ok(vec![ | |
| 216 | + | Interface { | |
| 217 | + | name: "wlp1s0".into(), | |
| 218 | + | kind: Kind::Wireless, | |
| 219 | + | state: State::Connected, | |
| 220 | + | connection: Some("Example Network".into()), | |
| 221 | + | addresses: vec!["192.168.1.42/24".into()], | |
| 222 | + | }, | |
| 223 | + | Interface { | |
| 224 | + | name: "enp2s0".into(), | |
| 225 | + | kind: Kind::Wired, | |
| 226 | + | state: State::Disconnected, | |
| 227 | + | connection: None, | |
| 228 | + | addresses: vec![], | |
| 229 | + | }, | |
| 230 | + | Interface { | |
| 231 | + | name: "lo".into(), | |
| 232 | + | kind: Kind::Loopback, | |
| 233 | + | state: State::Unmanaged, | |
| 234 | + | connection: None, | |
| 235 | + | addresses: vec!["127.0.0.1/8".into()], | |
| 236 | + | }, | |
| 237 | + | ]) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // Every action takes the trait's default of nothing. The mock exists so the | |
| 241 | + | // console can be developed on a machine with no NetworkManager, and | |
| 242 | + | // inventing a command for it would put a line in the log pane that is not a | |
| 243 | + | // thing anyone could run. | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | #[cfg(test)] | |
| 247 | + | mod tests { | |
| 248 | + | use super::super::fixtures::iface; | |
| 249 | + | use super::*; | |
| 250 | + | ||
| 251 | + | // The argv the log pane shows and the user can paste. `device connect` and | |
| 252 | + | // not `connection up`: the user picked a device, and which profile it uses | |
| 253 | + | // is a different screen and a different polkit action. | |
| 254 | + | #[test] | |
| 255 | + | fn connect_and_disconnect_name_the_device() { | |
| 256 | + | let wifi = iface("wlp1s0", Kind::Wireless, State::Disconnected); | |
| 257 | + | assert_eq!( | |
| 258 | + | NmCli.connect(&wifi).unwrap().display(), | |
| 259 | + | "nmcli device connect wlp1s0", | |
| 260 | + | ); | |
| 261 | + | assert_eq!( | |
| 262 | + | NmCli.disconnect(&wifi).unwrap().display(), | |
| 263 | + | "nmcli device disconnect wlp1s0", | |
| 264 | + | ); | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | #[test] | |
| 268 | + | fn the_radio_switch_names_the_direction() { | |
| 269 | + | assert_eq!( | |
| 270 | + | NmCli.set_wifi(true).unwrap().display(), | |
| 271 | + | "nmcli radio wifi on" | |
| 272 | + | ); | |
| 273 | + | assert_eq!( | |
| 274 | + | NmCli.set_wifi(false).unwrap().display(), | |
| 275 | + | "nmcli radio wifi off", | |
| 276 | + | ); | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | // Loopback is never brought up or down and an unmanaged device is one NM | |
| 280 | + | // has been told to leave alone. Both are still listed; neither takes the | |
| 281 | + | // key. | |
| 282 | + | #[test] | |
| 283 | + | fn loopback_and_unmanaged_devices_take_no_action() { | |
| 284 | + | let lo = iface("lo", Kind::Loopback, State::Unmanaged); | |
| 285 | + | assert!(NmCli.connect(&lo).is_none()); | |
| 286 | + | assert!(NmCli.disconnect(&lo).is_none()); | |
| 287 | + | ||
| 288 | + | let bridge = iface("br0", Kind::Other, State::Unmanaged); | |
| 289 | + | assert!(NmCli.connect(&bridge).is_none()); | |
| 290 | + | ||
| 291 | + | let wired = iface("enp2s0", Kind::Wired, State::Disconnected); | |
| 292 | + | assert!(NmCli.connect(&wired).is_some(), "an ordinary device does"); | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | // The argv the log pane shows. `--ask` with nothing after the SSID is the | |
| 296 | + | // whole point: the passphrase is on stdin, so there is no `password <pw>` | |
| 297 | + | // for `ps` to show to every user on the machine. | |
| 298 | + | #[test] | |
| 299 | + | fn joining_never_puts_the_passphrase_in_argv() { | |
| 300 | + | let secret = Secret::new(b"correct horse battery".to_vec()); | |
| 301 | + | let invocation = NmCli | |
| 302 | + | .join("Example Network", Some(secret)) | |
| 303 | + | .expect("nmcli can join"); | |
| 304 | + | let shown = invocation.display(); | |
| 305 | + | assert_eq!( | |
| 306 | + | shown, | |
| 307 | + | "nmcli --ask device wifi connect 'Example Network' # input withheld", | |
| 308 | + | ); | |
| 309 | + | assert!(!shown.contains("correct horse"), "{shown}"); | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | #[test] | |
| 313 | + | fn an_open_network_is_joined_with_no_input_at_all() { | |
| 314 | + | let invocation = NmCli.join("Airport WiFi", None).expect("nmcli can join"); | |
| 315 | + | assert_eq!( | |
| 316 | + | invocation.display(), | |
| 317 | + | "nmcli --ask device wifi connect 'Airport WiFi'", | |
| 318 | + | "no pipe, so no `input withheld` note either", | |
| 319 | + | ); | |
| 320 | + | } | |
| 321 | + | } |
| @@ -1,0 +1,188 @@ | |||
| 1 | + | //! Why this machine is showing no wireless device. | |
| 2 | + | //! | |
| 3 | + | //! The decision is pure and lives here with the probes that feed it, because | |
| 4 | + | //! the ordering is the part worth testing and the probing is filesystem work | |
| 5 | + | //! that belongs at the edge. | |
| 6 | + | ||
| 7 | + | /// Why this machine is showing no wireless device. | |
| 8 | + | /// | |
| 9 | + | /// An absence is the one state a device list cannot explain by listing. Four | |
| 10 | + | /// different faults render identically as "nothing here", and they want four | |
| 11 | + | /// different repairs, so the screen says which one it is rather than leaving | |
| 12 | + | /// the user to tell them apart. | |
| 13 | + | /// | |
| 14 | + | /// Found the hard way 2026-09-03: fw12 showed loopback alone, and the cause was | |
| 15 | + | /// that no Alloy image had ever carried `NetworkManager-wifi`. Nothing errored, | |
| 16 | + | /// no unit failed, and the console was correct in every respect except that it | |
| 17 | + | /// had nothing to say about the absence. | |
| 18 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 19 | + | pub(crate) enum NoWireless { | |
| 20 | + | /// The image has no NetworkManager wifi plugin, so no wireless device can | |
| 21 | + | /// ever appear, whatever the hardware is. | |
| 22 | + | NoPlugin, | |
| 23 | + | /// The kernel made an interface and NetworkManager is not presenting it. | |
| 24 | + | /// Carries the interface name, because the next command a person runs | |
| 25 | + | /// wants it. | |
| 26 | + | Unmanaged(String), | |
| 27 | + | /// The radio is off. Listed after the two above because NM normally keeps a | |
| 28 | + | /// blocked device visible as `unavailable`, so this explains an absence | |
| 29 | + | /// only when the device went away entirely. | |
| 30 | + | RadioOff, | |
| 31 | + | /// No wireless hardware. The one state that is not a fault. | |
| 32 | + | NoHardware, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | impl NoWireless { | |
| 36 | + | /// The line the screen shows. Says what is wrong and what to do about it, | |
| 37 | + | /// in that order, because a diagnosis with no next step is a complaint. | |
| 38 | + | pub(crate) fn message(&self) -> String { | |
| 39 | + | match self { | |
| 40 | + | Self::NoPlugin => "no wireless device: this image ships no NetworkManager wifi \ | |
| 41 | + | plugin, so one can never appear. Layer NetworkManager-wifi and wpa_supplicant" | |
| 42 | + | .to_string(), | |
| 43 | + | Self::Unmanaged(iface) => format!( | |
| 44 | + | "no wireless device: the kernel has {iface} and NetworkManager does not \ | |
| 45 | + | present it. Check wpa_supplicant and rfkill" | |
| 46 | + | ), | |
| 47 | + | Self::RadioOff => "no wireless device: the radio is off; press w".to_string(), | |
| 48 | + | Self::NoHardware => { | |
| 49 | + | "no wireless device: this machine has no wireless hardware".to_string() | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// Decide which of the four it is, from facts gathered elsewhere. | |
| 56 | + | /// | |
| 57 | + | /// Pure on purpose. The probing is filesystem work and belongs at the edge; the | |
| 58 | + | /// ordering is the part worth testing, and it is an ordering rather than a set | |
| 59 | + | /// because the causes nest: an image with no plugin also has no managed device | |
| 60 | + | /// and would otherwise report the vaguest of the four. | |
| 61 | + | pub(super) fn no_wireless( | |
| 62 | + | has_wireless: bool, | |
| 63 | + | radio: Option<bool>, | |
| 64 | + | plugin: bool, | |
| 65 | + | kernel_iface: Option<&str>, | |
| 66 | + | ) -> Option<NoWireless> { | |
| 67 | + | if has_wireless { | |
| 68 | + | return None; | |
| 69 | + | } | |
| 70 | + | if !plugin { | |
| 71 | + | return Some(NoWireless::NoPlugin); | |
| 72 | + | } | |
| 73 | + | if let Some(iface) = kernel_iface { | |
| 74 | + | return Some(NoWireless::Unmanaged(iface.to_string())); | |
| 75 | + | } | |
| 76 | + | if radio == Some(false) { | |
| 77 | + | return Some(NoWireless::RadioOff); | |
| 78 | + | } | |
| 79 | + | Some(NoWireless::NoHardware) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// Is NetworkManager's wifi device plugin installed? | |
| 83 | + | /// | |
| 84 | + | /// The plugin is a separate package on Fedora (`NetworkManager-wifi`) and the | |
| 85 | + | /// base image is a server base that does not carry it. Reading the file rather | |
| 86 | + | /// than asking rpm: this answers what NM can actually load, which is the thing | |
| 87 | + | /// that decides whether a device appears. | |
| 88 | + | pub(super) fn wifi_plugin_present() -> bool { | |
| 89 | + | let Ok(entries) = std::fs::read_dir("/usr/lib64/NetworkManager") else { | |
| 90 | + | return false; | |
| 91 | + | }; | |
| 92 | + | entries | |
| 93 | + | .filter_map(Result::ok) | |
| 94 | + | .any(|entry| entry.path().join("libnm-device-plugin-wifi.so").exists()) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// The first wireless interface the kernel has made, if any. | |
| 98 | + | /// | |
| 99 | + | /// `/sys/class/net/<name>/wireless` exists exactly when the driver registered | |
| 100 | + | /// a wireless device, which is independent of whether NM presents it. That | |
| 101 | + | /// independence is the whole point: it separates "the driver did not load" | |
| 102 | + | /// from "NetworkManager is not showing you what the driver made". | |
| 103 | + | pub(super) fn kernel_wireless_iface() -> Option<String> { | |
| 104 | + | let entries = std::fs::read_dir("/sys/class/net").ok()?; | |
| 105 | + | let mut names: Vec<String> = entries | |
| 106 | + | .filter_map(Result::ok) | |
| 107 | + | .filter(|entry| entry.path().join("wireless").exists()) | |
| 108 | + | .filter_map(|entry| entry.file_name().into_string().ok()) | |
| 109 | + | .collect(); | |
| 110 | + | names.sort(); | |
| 111 | + | names.into_iter().next() | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | #[cfg(test)] | |
| 115 | + | mod tests { | |
| 116 | + | use super::*; | |
| 117 | + | ||
| 118 | + | #[test] | |
| 119 | + | fn a_wireless_device_needs_no_explanation() { | |
| 120 | + | assert_eq!(no_wireless(true, Some(true), true, None), None); | |
| 121 | + | // Even with every other signal looking wrong: the device is there, so | |
| 122 | + | // there is nothing to explain and the screen stays quiet. | |
| 123 | + | assert_eq!(no_wireless(true, Some(false), false, Some("wlan0")), None); | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | #[test] | |
| 127 | + | fn a_missing_plugin_outranks_every_other_cause() { | |
| 128 | + | // The state fw12 was in on 2026-09-03, and the reason the ordering is | |
| 129 | + | // an ordering: with no plugin there is also no managed device and no | |
| 130 | + | // radio to read, so the vaguer causes would all match too. | |
| 131 | + | assert_eq!( | |
| 132 | + | no_wireless(false, None, false, None), | |
| 133 | + | Some(NoWireless::NoPlugin) | |
| 134 | + | ); | |
| 135 | + | assert_eq!( | |
| 136 | + | no_wireless(false, Some(false), false, Some("wlp1s0")), | |
| 137 | + | Some(NoWireless::NoPlugin) | |
| 138 | + | ); | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | #[test] | |
| 142 | + | fn a_kernel_interface_nm_does_not_show_names_itself() { | |
| 143 | + | assert_eq!( | |
| 144 | + | no_wireless(false, Some(true), true, Some("wlp192s0")), | |
| 145 | + | Some(NoWireless::Unmanaged("wlp192s0".to_string())) | |
| 146 | + | ); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | #[test] | |
| 150 | + | fn a_dark_radio_explains_an_absent_device_only_when_nothing_else_does() { | |
| 151 | + | assert_eq!( | |
| 152 | + | no_wireless(false, Some(false), true, None), | |
| 153 | + | Some(NoWireless::RadioOff) | |
| 154 | + | ); | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | #[test] | |
| 158 | + | fn no_hardware_is_the_answer_when_nothing_is_wrong() { | |
| 159 | + | assert_eq!( | |
| 160 | + | no_wireless(false, Some(true), true, None), | |
| 161 | + | Some(NoWireless::NoHardware) | |
| 162 | + | ); | |
| 163 | + | // A backend that cannot report the radio (the mock) is not evidence of | |
| 164 | + | // a fault either. | |
| 165 | + | assert_eq!( | |
| 166 | + | no_wireless(false, None, true, None), | |
| 167 | + | Some(NoWireless::NoHardware) | |
| 168 | + | ); | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | #[test] | |
| 172 | + | fn every_message_says_what_to_do_next() { | |
| 173 | + | // A diagnosis with no next step is a complaint. NoHardware is the one | |
| 174 | + | // exception and is exempt: there is nothing to do about a machine that | |
| 175 | + | // has no radio. | |
| 176 | + | for reason in [ | |
| 177 | + | NoWireless::NoPlugin, | |
| 178 | + | NoWireless::Unmanaged("wlan0".to_string()), | |
| 179 | + | NoWireless::RadioOff, | |
| 180 | + | ] { | |
| 181 | + | let message = reason.message(); | |
| 182 | + | assert!( | |
| 183 | + | message.contains("Layer") || message.contains("Check") || message.contains("press"), | |
| 184 | + | "{message}" | |
| 185 | + | ); | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | } |
| @@ -1,0 +1,92 @@ | |||
| 1 | + | //! Test fixtures shared by more than one child of [`super`]. | |
| 2 | + | //! | |
| 3 | + | //! An `Interface` builder, a captured network list, and three stand-in | |
| 4 | + | //! backends. They live here rather than in one sibling's test module because | |
| 5 | + | //! a `const` or a helper in one sibling cannot be named from another. | |
| 6 | + | ||
| 7 | + | use anyhow::Result; | |
| 8 | + | ||
| 9 | + | use super::backend::Backend; | |
| 10 | + | use super::model::{Interface, Kind, Network, State}; | |
| 11 | + | use super::parse::parse_wifi_list; | |
| 12 | + | use crate::cli::{CommandLog, Invocation, Secret}; | |
| 13 | + | ||
| 14 | + | // Constructed rather than captured, unlike the `device show` capture in | |
| 15 | + | // `parse`'s own tests, and the difference is worth stating: no access point | |
| 16 | + | // was in range of the machine this was written on. Every shape in it is | |
| 17 | + | // nmcli's documented terse-tabular behaviour — `*` for in-use, an empty SSID | |
| 18 | + | // for a hidden network, an empty security field for an open one, and `\:` for | |
| 19 | + | // a colon inside a value — and the escaping is the half a hand-written | |
| 20 | + | // fixture is most likely to get wrong, so it is what the parse tests are | |
| 21 | + | // mostly about. | |
| 22 | + | pub(super) const WIFI_LIST: &str = "\ | |
| 23 | + | *:Example Network:82:WPA2 | |
| 24 | + | :Cafe\\: Free Wifi:64:WPA2 | |
| 25 | + | :Example Network:41:WPA2 | |
| 26 | + | ::37:WPA2 | |
| 27 | + | :Airport WiFi:22: | |
| 28 | + | "; | |
| 29 | + | ||
| 30 | + | pub(super) fn iface(name: &str, kind: Kind, state: State) -> Interface { | |
| 31 | + | Interface { | |
| 32 | + | name: name.into(), | |
| 33 | + | kind, | |
| 34 | + | state, | |
| 35 | + | connection: None, | |
| 36 | + | addresses: Vec::new(), | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// A backend that can scan and join, for the mode machine. | |
| 41 | + | /// | |
| 42 | + | /// Joining always succeeds here. What the failure paths do is a property of | |
| 43 | + | /// [`Invocation`] and of the error text nmcli produces, neither of which a | |
| 44 | + | /// fake backend would be testing. | |
| 45 | + | pub(super) struct JoinableBackend; | |
| 46 | + | ||
| 47 | + | impl Backend for JoinableBackend { | |
| 48 | + | fn name(&self) -> &'static str { | |
| 49 | + | "joinable" | |
| 50 | + | } | |
| 51 | + | fn list(&self, _log: &mut CommandLog) -> Result<Vec<Interface>> { | |
| 52 | + | Ok(vec![iface("wlp1s0", Kind::Wireless, State::Disconnected)]) | |
| 53 | + | } | |
| 54 | + | fn wifi_enabled(&self, _log: &mut CommandLog) -> Option<bool> { | |
| 55 | + | Some(true) | |
| 56 | + | } | |
| 57 | + | fn networks(&self, _log: &mut CommandLog) -> Option<Result<Vec<Network>>> { | |
| 58 | + | Some(Ok(parse_wifi_list(WIFI_LIST))) | |
| 59 | + | } | |
| 60 | + | fn join(&self, ssid: &str, passphrase: Option<Secret>) -> Option<Invocation> { | |
| 61 | + | // `true` rather than nmcli: the view runs whatever comes back, and a | |
| 62 | + | // test that shells out to a network manager is a test that fails on | |
| 63 | + | // the machine it is run on. | |
| 64 | + | let invocation = Invocation::new("true").arg(ssid); | |
| 65 | + | Some(match passphrase { | |
| 66 | + | Some(secret) => invocation.stdin(secret), | |
| 67 | + | None => invocation, | |
| 68 | + | }) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | pub(super) struct EmptyBackend; | |
| 73 | + | ||
| 74 | + | pub(super) struct FailingBackend; | |
| 75 | + | ||
| 76 | + | impl Backend for FailingBackend { | |
| 77 | + | fn name(&self) -> &'static str { | |
| 78 | + | "failing" | |
| 79 | + | } | |
| 80 | + | fn list(&self, _log: &mut CommandLog) -> Result<Vec<Interface>> { | |
| 81 | + | anyhow::bail!("nmcli went away") | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | impl Backend for EmptyBackend { | |
| 86 | + | fn name(&self) -> &'static str { | |
| 87 | + | "empty" | |
| 88 | + | } | |
| 89 | + | fn list(&self, _log: &mut CommandLog) -> Result<Vec<Interface>> { | |
| 90 | + | Ok(Vec::new()) | |
| 91 | + | } | |
| 92 | + | } |
| @@ -1,0 +1,112 @@ | |||
| 1 | + | //! What the screen shows: interface kinds, states, and a wireless network. | |
| 2 | + | ||
| 3 | + | use alloy_tui::Severity; | |
| 4 | + | ||
| 5 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 6 | + | pub(crate) enum Kind { | |
| 7 | + | Wired, | |
| 8 | + | Wireless, | |
| 9 | + | Loopback, | |
| 10 | + | Other, | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | impl Kind { | |
| 14 | + | /// Map NetworkManager's device type. NM's vocabulary is open-ended | |
| 15 | + | /// (`bridge`, `tun`, `wireguard`, `bond`, ...); everything Alloy does not | |
| 16 | + | /// name specifically is `Other` and still listed, because hiding an | |
| 17 | + | /// interface the user can see in `nmcli` would make the console look | |
| 18 | + | /// broken. | |
| 19 | + | pub(super) fn from_nm(raw: &str) -> Self { | |
| 20 | + | match raw { | |
| 21 | + | "ethernet" => Kind::Wired, | |
| 22 | + | "wifi" => Kind::Wireless, | |
| 23 | + | "loopback" => Kind::Loopback, | |
| 24 | + | _ => Kind::Other, | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | pub(super) const fn label(self) -> &'static str { | |
| 29 | + | match self { | |
| 30 | + | Kind::Wired => "wired", | |
| 31 | + | Kind::Wireless => "wireless", | |
| 32 | + | Kind::Loopback => "loopback", | |
| 33 | + | Kind::Other => "other", | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 39 | + | pub(crate) enum State { | |
| 40 | + | Connected, | |
| 41 | + | Disconnected, | |
| 42 | + | Unavailable, | |
| 43 | + | Unmanaged, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | impl State { | |
| 47 | + | /// NM reports `GENERAL.STATE` as `"100 (connected)"`. The numeric code is | |
| 48 | + | /// the stable part — the parenthesized text is localized — so parse the | |
| 49 | + | /// number and ignore the rest. | |
| 50 | + | pub(super) fn from_nm(raw: &str) -> Self { | |
| 51 | + | let code = raw | |
| 52 | + | .split_whitespace() | |
| 53 | + | .next() | |
| 54 | + | .and_then(|n| n.parse::<u16>().ok()) | |
| 55 | + | .unwrap_or(0); | |
| 56 | + | match code { | |
| 57 | + | 100 => State::Connected, | |
| 58 | + | 30 => State::Disconnected, | |
| 59 | + | 20 => State::Unavailable, | |
| 60 | + | _ => State::Unmanaged, | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | pub(super) const fn label(self) -> &'static str { | |
| 65 | + | match self { | |
| 66 | + | State::Connected => "connected", | |
| 67 | + | State::Disconnected => "disconnected", | |
| 68 | + | State::Unavailable => "unavailable", | |
| 69 | + | State::Unmanaged => "unmanaged", | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | pub(super) const fn severity(self) -> Severity { | |
| 74 | + | match self { | |
| 75 | + | State::Connected => Severity::Healthy, | |
| 76 | + | State::Disconnected => Severity::Warn, | |
| 77 | + | State::Unavailable | State::Unmanaged => Severity::Info, | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | #[derive(Debug, Clone)] | |
| 83 | + | pub(crate) struct Interface { | |
| 84 | + | pub name: String, | |
| 85 | + | pub kind: Kind, | |
| 86 | + | pub state: State, | |
| 87 | + | pub connection: Option<String>, | |
| 88 | + | pub addresses: Vec<String>, | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// A wireless network in range. | |
| 92 | + | /// | |
| 93 | + | /// Distinct from [`Interface`], and the distinction is the whole join flow: an | |
| 94 | + | /// interface is a device this machine has, a network is something in the air | |
| 95 | + | /// near it. Everything the screen did before this was about the first. | |
| 96 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 97 | + | pub(crate) struct Network { | |
| 98 | + | pub ssid: String, | |
| 99 | + | /// NM's 0-100 signal figure. Kept as the number rather than the bars, | |
| 100 | + | /// because the bars are a rendering of it and this is the sort key. | |
| 101 | + | pub signal: u8, | |
| 102 | + | /// NM's security column verbatim (`WPA2`, `WPA3`, `WPA1 WPA2`, `802.1X`), | |
| 103 | + | /// or `None` for an open network. | |
| 104 | + | /// | |
| 105 | + | /// Verbatim rather than parsed into an enum: the console's only decision is | |
| 106 | + | /// whether to ask for a passphrase, which is `is_some`, and NM's vocabulary | |
| 107 | + | /// here grows with every new standard. Showing what NM said keeps the row | |
| 108 | + | /// honest about a network the console does not have a word for. | |
| 109 | + | pub security: Option<String>, | |
| 110 | + | /// Whether this is the network the machine is already on. | |
| 111 | + | pub in_use: bool, | |
| 112 | + | } |
| @@ -1,0 +1,179 @@ | |||
| 1 | + | //! Pure readers for nmcli's output formats. | |
| 2 | + | //! | |
| 3 | + | //! Nothing here runs a command or touches the filesystem. The backend hands | |
| 4 | + | //! captured stdout in and gets model types back, which is what lets the | |
| 5 | + | //! awkward parts of nmcli's two output formats be tested against real | |
| 6 | + | //! captures rather than against a machine that happens to be on a network. | |
| 7 | + | ||
| 8 | + | use super::model::{Interface, Kind, Network, State}; | |
| 9 | + | ||
| 10 | + | /// Parse `nmcli radio wifi`, which answers `enabled` or `disabled`. | |
| 11 | + | /// | |
| 12 | + | /// Anything else is `None` rather than a guess: `missing` is what nmcli says | |
| 13 | + | /// when there is no wifi hardware, and reading that as "off" would offer a | |
| 14 | + | /// toggle for a radio that is not there. | |
| 15 | + | pub(super) fn parse_radio(raw: &str) -> Option<bool> { | |
| 16 | + | match raw.trim() { | |
| 17 | + | "enabled" => Some(true), | |
| 18 | + | "disabled" => Some(false), | |
| 19 | + | _ => None, | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// Parse `nmcli -t -f IN-USE,SSID,SIGNAL,SECURITY device wifi list`. | |
| 24 | + | /// | |
| 25 | + | /// **This is terse *tabular* output, which is a different format from the | |
| 26 | + | /// multiline output [`parse_device_show`] reads, in the one way that matters.** | |
| 27 | + | /// Tabular fields are colon-separated, so a colon inside a value has to be | |
| 28 | + | /// escaped, and nmcli escapes it as `\:` (and a literal backslash as `\\`). | |
| 29 | + | /// Multiline output has no such problem and escapes nothing, which is why the | |
| 30 | + | /// other parser takes its values verbatim and this one cannot. An SSID is | |
| 31 | + | /// arbitrary bytes chosen by whoever runs the access point, so a colon in one | |
| 32 | + | /// is not a curiosity: split naively and `Cafe: Free Wifi` becomes a network | |
| 33 | + | /// called `Cafe` with a signal of ` Free Wifi`. | |
| 34 | + | /// | |
| 35 | + | /// Hidden networks come back with an empty SSID and are dropped. There is no | |
| 36 | + | /// name to show and `device wifi connect` takes a name, so a blank row would be | |
| 37 | + | /// a row that cannot be acted on. | |
| 38 | + | /// | |
| 39 | + | /// One row per SSID, strongest wins. A network with three access points is | |
| 40 | + | /// three rows here, identical but for the signal, and NM connects to a *name* | |
| 41 | + | /// rather than to the row that was selected — so showing the same name three | |
| 42 | + | /// times would offer three choices that do the same thing. | |
| 43 | + | pub(super) fn parse_wifi_list(raw: &str) -> Vec<Network> { | |
| 44 | + | let mut networks: Vec<Network> = Vec::new(); | |
| 45 | + | ||
| 46 | + | for line in raw.lines() { | |
| 47 | + | let fields = split_terse(line); | |
| 48 | + | let [in_use, ssid, signal, security] = fields.as_slice() else { | |
| 49 | + | continue; | |
| 50 | + | }; | |
| 51 | + | if ssid.is_empty() { | |
| 52 | + | continue; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | let network = Network { | |
| 56 | + | ssid: ssid.clone(), | |
| 57 | + | // A row whose signal will not parse is kept at zero rather than | |
| 58 | + | // dropped: the network is there and joinable, and the number is | |
| 59 | + | // only the sort key. | |
| 60 | + | signal: signal.trim().parse().unwrap_or(0), | |
| 61 | + | security: (!security.trim().is_empty()).then(|| security.trim().to_string()), | |
| 62 | + | in_use: in_use.trim() == "*", | |
| 63 | + | }; | |
| 64 | + | ||
| 65 | + | match networks.iter_mut().find(|seen| seen.ssid == network.ssid) { | |
| 66 | + | // `in_use` is sticky across the merge. It is a property of the | |
| 67 | + | // network rather than of the access point, and the row we are on | |
| 68 | + | // is not necessarily the strongest one. | |
| 69 | + | Some(seen) => { | |
| 70 | + | seen.in_use |= network.in_use; | |
| 71 | + | if network.signal > seen.signal { | |
| 72 | + | seen.signal = network.signal; | |
| 73 | + | seen.security = network.security; | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | None => networks.push(network), | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | networks.sort_by(|a, b| b.signal.cmp(&a.signal).then_with(|| a.ssid.cmp(&b.ssid))); | |
| 81 | + | networks | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// Split one line of nmcli terse tabular output into its fields. | |
| 85 | + | /// | |
| 86 | + | /// `\:` is a colon inside a value and `\\` is a backslash. Everything else | |
| 87 | + | /// passes through, including a trailing lone backslash, which nmcli does not | |
| 88 | + | /// emit and which is dropped rather than treated as the start of an escape | |
| 89 | + | /// nobody finished. | |
| 90 | + | fn split_terse(line: &str) -> Vec<String> { | |
| 91 | + | let mut fields = vec![String::new()]; | |
| 92 | + | let mut escaped = false; | |
| 93 | + | ||
| 94 | + | for c in line.chars() { | |
| 95 | + | match c { | |
| 96 | + | '\\' if !escaped => escaped = true, | |
| 97 | + | ':' if !escaped => fields.push(String::new()), | |
| 98 | + | _ => { | |
| 99 | + | escaped = false; | |
| 100 | + | if let Some(field) = fields.last_mut() { | |
| 101 | + | field.push(c); | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | fields | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /// Parse `nmcli -t -f ... device show` output. | |
| 111 | + | /// | |
| 112 | + | /// Terse mode emits `KEY:value` per line with devices separated by blank | |
| 113 | + | /// lines. Keys never contain a colon, so splitting on the first one is | |
| 114 | + | /// unambiguous and values are taken verbatim. | |
| 115 | + | /// | |
| 116 | + | /// That verbatim part is worth stating, because nmcli's terse *tabular* output | |
| 117 | + | /// (`device status`) does escape colons as `\:` — it has to, since its fields | |
| 118 | + | /// are colon-separated. Multiline output does not, and an IPv6 address here | |
| 119 | + | /// arrives as plain `fe80::1`. Unescaping it anyway would corrupt any value | |
| 120 | + | /// containing a legitimate backslash. | |
| 121 | + | pub(super) fn parse_device_show(raw: &str) -> Vec<Interface> { | |
| 122 | + | let mut interfaces = Vec::new(); | |
| 123 | + | let mut current: Option<Interface> = None; | |
| 124 | + | ||
| 125 | + | for line in raw.lines() { | |
| 126 | + | let line = line.trim_end(); | |
| 127 | + | if line.is_empty() { | |
| 128 | + | continue; | |
| 129 | + | } | |
| 130 | + | let Some((key, value)) = line.split_once(':') else { | |
| 131 | + | continue; | |
| 132 | + | }; | |
| 133 | + | let value = value.to_string(); | |
| 134 | + | ||
| 135 | + | // A device block starts at GENERAL.DEVICE. Keying off that rather than | |
| 136 | + | // the blank-line separator means a missing separator merges nothing: | |
| 137 | + | // the next DEVICE always opens a new record. | |
| 138 | + | if key == "GENERAL.DEVICE" { | |
| 139 | + | if let Some(iface) = current.take() { | |
| 140 | + | interfaces.push(iface); | |
| 141 | + | } | |
| 142 | + | current = Some(Interface { | |
| 143 | + | name: value, | |
| 144 | + | kind: Kind::Other, | |
| 145 | + | state: State::Unmanaged, | |
| 146 | + | connection: None, | |
| 147 | + | addresses: Vec::new(), | |
| 148 | + | }); | |
| 149 | + | continue; | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | let Some(iface) = current.as_mut() else { | |
| 153 | + | continue; | |
| 154 | + | }; | |
| 155 | + | ||
| 156 | + | match key { | |
| 157 | + | "GENERAL.TYPE" => iface.kind = Kind::from_nm(&value), | |
| 158 | + | "GENERAL.STATE" => iface.state = State::from_nm(&value), | |
| 159 | + | // NM writes `--` for an absent connection, which would otherwise | |
| 160 | + | // render as a connection literally named "--". | |
| 161 | + | "GENERAL.CONNECTION" if value != "--" && !value.is_empty() => { | |
| 162 | + | iface.connection = Some(value); | |
| 163 | + | } | |
| 164 | + | // Address keys are indexed: IP4.ADDRESS[1], IP6.ADDRESS[2], ... | |
| 165 | + | _ if !value.is_empty() | |
| 166 | + | && (key.starts_with("IP4.ADDRESS") || key.starts_with("IP6.ADDRESS")) => | |
| 167 | + | { | |
| 168 | + | iface.addresses.push(value); | |
| 169 | + | } | |
| 170 | + | _ => {} | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | interfaces.extend(current); | |
| 175 | + | interfaces | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | #[cfg(test)] | |
| 179 | + | mod tests; |
| @@ -1,0 +1,194 @@ | |||
| 1 | + | //! Tests for [`super`]. | |
| 2 | + | ||
| 3 | + | use super::super::fixtures::WIFI_LIST; | |
| 4 | + | use super::*; | |
| 5 | + | ||
| 6 | + | // Captured verbatim from `nmcli -t -f GENERAL.DEVICE,GENERAL.TYPE, | |
| 7 | + | // GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP6.ADDRESS device show` on | |
| 8 | + | // a NetworkManager 1.5x box, hostname and SSID aside. Kept real rather | |
| 9 | + | // than tidied: the awkward parts below (nested parens in the state, an | |
| 10 | + | // empty trailing connection, a bare `::1`) are all things nmcli actually | |
| 11 | + | // emits, and a hand-written fixture is where a parser goes to pass tests | |
| 12 | + | // it would fail in production. | |
| 13 | + | const SAMPLE: &str = "\ | |
| 14 | + | GENERAL.DEVICE:wlp192s0 | |
| 15 | + | GENERAL.TYPE:wifi | |
| 16 | + | GENERAL.STATE:100 (connected) | |
| 17 | + | GENERAL.CONNECTION:Example Network | |
| 18 | + | IP4.ADDRESS[1]:192.168.0.16/24 | |
| 19 | + | IP6.ADDRESS[1]:fe80::59a3:bc22:d95f:c06b/64 | |
| 20 | + | ||
| 21 | + | GENERAL.DEVICE:tailscale0 | |
| 22 | + | GENERAL.TYPE:tun | |
| 23 | + | GENERAL.STATE:100 (connected (externally)) | |
| 24 | + | GENERAL.CONNECTION:tailscale0 | |
| 25 | + | IP4.ADDRESS[1]:100.103.89.95/32 | |
| 26 | + | IP6.ADDRESS[1]:fd7a:115c:a1e0::af3b:595f/128 | |
| 27 | + | IP6.ADDRESS[2]:fe80::ccae:60fc:a1c5:3b13/64 | |
| 28 | + | ||
| 29 | + | GENERAL.DEVICE:lo | |
| 30 | + | GENERAL.TYPE:loopback | |
| 31 | + | GENERAL.STATE:100 (connected (externally)) | |
| 32 | + | GENERAL.CONNECTION:lo | |
| 33 | + | IP4.ADDRESS[1]:127.0.0.1/8 | |
| 34 | + | IP6.ADDRESS[1]:::1/128 | |
| 35 | + | ||
| 36 | + | GENERAL.DEVICE:p2p-dev-wlp192s0 | |
| 37 | + | GENERAL.TYPE:wifi-p2p | |
| 38 | + | GENERAL.STATE:30 (disconnected) | |
| 39 | + | GENERAL.CONNECTION: | |
| 40 | + | "; | |
| 41 | + | ||
| 42 | + | #[test] | |
| 43 | + | fn parses_every_device_block() { | |
| 44 | + | let ifaces = parse_device_show(SAMPLE); | |
| 45 | + | assert_eq!(ifaces.len(), 4); | |
| 46 | + | assert_eq!(ifaces[0].name, "wlp192s0"); | |
| 47 | + | assert_eq!(ifaces[0].kind, Kind::Wireless); | |
| 48 | + | assert_eq!(ifaces[0].state, State::Connected); | |
| 49 | + | assert_eq!(ifaces[0].connection.as_deref(), Some("Example Network")); | |
| 50 | + | assert_eq!( | |
| 51 | + | ifaces[3].name, "p2p-dev-wlp192s0", | |
| 52 | + | "the last block is not dropped for want of a trailing blank line" | |
| 53 | + | ); | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // IPv6 values carry colons and arrive unescaped, so the split has to be on | |
| 57 | + | // the *first* colon only. Splitting on every colon shows `fe80` as the | |
| 58 | + | // address; `::1/128` is the case that breaks a naive rsplit as well. | |
| 59 | + | #[test] | |
| 60 | + | fn ipv6_addresses_survive_the_key_value_split() { | |
| 61 | + | let ifaces = parse_device_show(SAMPLE); | |
| 62 | + | assert_eq!( | |
| 63 | + | ifaces[0].addresses, | |
| 64 | + | vec!["192.168.0.16/24", "fe80::59a3:bc22:d95f:c06b/64"] | |
| 65 | + | ); | |
| 66 | + | assert_eq!( | |
| 67 | + | ifaces[1].addresses, | |
| 68 | + | vec![ | |
| 69 | + | "100.103.89.95/32", | |
| 70 | + | "fd7a:115c:a1e0::af3b:595f/128", | |
| 71 | + | "fe80::ccae:60fc:a1c5:3b13/64", | |
| 72 | + | ], | |
| 73 | + | "every indexed address is collected, not just the first" | |
| 74 | + | ); | |
| 75 | + | assert_eq!(ifaces[2].addresses[1], "::1/128"); | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // NM leaves the connection field empty for a device with no active | |
| 79 | + | // connection. Empty must read as absent, not as a connection named "". | |
| 80 | + | #[test] | |
| 81 | + | fn treats_an_empty_connection_as_absent() { | |
| 82 | + | let ifaces = parse_device_show(SAMPLE); | |
| 83 | + | assert_eq!(ifaces[3].connection, None); | |
| 84 | + | assert!(ifaces[3].addresses.is_empty()); | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // `--` is NM's other placeholder for "none", used where a field is | |
| 88 | + | // tabulated rather than left blank. | |
| 89 | + | #[test] | |
| 90 | + | fn treats_double_dash_connection_as_absent() { | |
| 91 | + | let raw = "GENERAL.DEVICE:enp2s0\nGENERAL.TYPE:ethernet\nGENERAL.CONNECTION:--\n"; | |
| 92 | + | assert_eq!(parse_device_show(raw)[0].connection, None); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // The state field nests parentheses: "100 (connected (externally))". Only | |
| 96 | + | // the leading numeric code is stable across locales, so that is what is | |
| 97 | + | // parsed; anything reading the text would misclassify this as unmanaged. | |
| 98 | + | #[test] | |
| 99 | + | fn parses_state_from_the_numeric_code_not_the_text() { | |
| 100 | + | let ifaces = parse_device_show(SAMPLE); | |
| 101 | + | assert_eq!(ifaces[1].state, State::Connected); | |
| 102 | + | assert_eq!(ifaces[3].state, State::Disconnected); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | #[test] | |
| 106 | + | fn empty_output_yields_no_interfaces() { | |
| 107 | + | assert!(parse_device_show("").is_empty()); | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // NM's device-type vocabulary is open-ended; an unknown type must still | |
| 111 | + | // list rather than vanish. | |
| 112 | + | #[test] | |
| 113 | + | fn unknown_device_types_are_listed_as_other() { | |
| 114 | + | let raw = "GENERAL.DEVICE:wg0\nGENERAL.TYPE:wireguard\nGENERAL.STATE:100 (connected)\n"; | |
| 115 | + | let ifaces = parse_device_show(raw); | |
| 116 | + | assert_eq!(ifaces.len(), 1); | |
| 117 | + | assert_eq!(ifaces[0].kind, Kind::Other); | |
| 118 | + | assert_eq!(ifaces[0].state, State::Connected); | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // `missing` is what nmcli says when there is no wifi hardware. Reading it | |
| 122 | + | // as "off" would offer a toggle for a radio that is not there. | |
| 123 | + | #[test] | |
| 124 | + | fn the_radio_reads_only_the_two_answers_it_understands() { | |
| 125 | + | assert_eq!(parse_radio("enabled\n"), Some(true)); | |
| 126 | + | assert_eq!(parse_radio("disabled\n"), Some(false)); | |
| 127 | + | assert_eq!(parse_radio("missing\n"), None); | |
| 128 | + | assert_eq!(parse_radio(""), None); | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | #[test] | |
| 132 | + | fn a_colon_inside_an_ssid_survives_the_split() { | |
| 133 | + | let networks = parse_wifi_list(WIFI_LIST); | |
| 134 | + | assert!( | |
| 135 | + | networks.iter().any(|n| n.ssid == "Cafe: Free Wifi"), | |
| 136 | + | "{networks:?}", | |
| 137 | + | ); | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | #[test] | |
| 141 | + | fn terse_escapes_are_undone_and_nothing_else_is() { | |
| 142 | + | assert_eq!(split_terse("a:b"), vec!["a", "b"]); | |
| 143 | + | assert_eq!(split_terse("a\\:b:c"), vec!["a:b", "c"]); | |
| 144 | + | assert_eq!(split_terse("a\\\\:b"), vec!["a\\", "b"]); | |
| 145 | + | assert_eq!( | |
| 146 | + | split_terse("::"), | |
| 147 | + | vec!["", "", ""], | |
| 148 | + | "empty fields are fields", | |
| 149 | + | ); | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // Three access points carrying one SSID is one row: NM connects to a name, | |
| 153 | + | // so offering the name three times offers the same choice three times. | |
| 154 | + | #[test] | |
| 155 | + | fn one_row_per_ssid_at_the_strongest_signal() { | |
| 156 | + | let networks = parse_wifi_list(WIFI_LIST); | |
| 157 | + | let example: Vec<&Network> = networks | |
| 158 | + | .iter() | |
| 159 | + | .filter(|n| n.ssid == "Example Network") | |
| 160 | + | .collect(); | |
| 161 | + | assert_eq!(example.len(), 1, "{networks:?}"); | |
| 162 | + | assert_eq!(example[0].signal, 82); | |
| 163 | + | assert!(example[0].in_use, "the merge keeps in-use"); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // A hidden network has no name to show and `device wifi connect` takes a | |
| 167 | + | // name, so the row could not be acted on. | |
| 168 | + | #[test] | |
| 169 | + | fn hidden_networks_are_dropped() { | |
| 170 | + | let networks = parse_wifi_list(WIFI_LIST); | |
| 171 | + | assert!(networks.iter().all(|n| !n.ssid.is_empty()), "{networks:?}"); | |
| 172 | + | assert_eq!(networks.len(), 3); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // An empty security field is an open network, which is the one case where | |
| 176 | + | // the console must not ask for a passphrase — and the one case where the | |
| 177 | + | // row is a warning rather than a fact. | |
| 178 | + | #[test] | |
| 179 | + | fn an_empty_security_field_reads_as_open() { | |
| 180 | + | let networks = parse_wifi_list(WIFI_LIST); | |
| 181 | + | let open = networks | |
| 182 | + | .iter() | |
| 183 | + | .find(|n| n.ssid == "Airport WiFi") | |
| 184 | + | .expect("the open network is listed"); | |
| 185 | + | assert_eq!(open.security, None); | |
| 186 | + | assert_eq!(networks[0].security.as_deref(), Some("WPA2")); | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | #[test] | |
| 190 | + | fn strongest_first() { | |
| 191 | + | let networks = parse_wifi_list(WIFI_LIST); | |
| 192 | + | let signals: Vec<u8> = networks.iter().map(|n| n.signal).collect(); | |
| 193 | + | assert_eq!(signals, vec![82, 64, 22]); | |
| 194 | + | } |
| @@ -1,741 +1,0 @@ | |||
| 1 | - | //! Tests for [`super`]. | |
| 2 | - | ||
| 3 | - | use super::*; | |
| 4 | - | ||
| 5 | - | #[test] | |
| 6 | - | fn a_wireless_device_needs_no_explanation() { | |
| 7 | - | assert_eq!(no_wireless(true, Some(true), true, None), None); | |
| 8 | - | // Even with every other signal looking wrong: the device is there, so | |
| 9 | - | // there is nothing to explain and the screen stays quiet. | |
| 10 | - | assert_eq!(no_wireless(true, Some(false), false, Some("wlan0")), None); | |
| 11 | - | } | |
| 12 | - | ||
| 13 | - | #[test] | |
| 14 | - | fn a_missing_plugin_outranks_every_other_cause() { | |
| 15 | - | // The state fw12 was in on 2026-09-03, and the reason the ordering is | |
| 16 | - | // an ordering: with no plugin there is also no managed device and no | |
| 17 | - | // radio to read, so the vaguer causes would all match too. | |
| 18 | - | assert_eq!( | |
| 19 | - | no_wireless(false, None, false, None), | |
| 20 | - | Some(NoWireless::NoPlugin) | |
| 21 | - | ); | |
| 22 | - | assert_eq!( | |
| 23 | - | no_wireless(false, Some(false), false, Some("wlp1s0")), | |
| 24 | - | Some(NoWireless::NoPlugin) | |
| 25 | - | ); | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | #[test] | |
| 29 | - | fn a_kernel_interface_nm_does_not_show_names_itself() { | |
| 30 | - | assert_eq!( | |
| 31 | - | no_wireless(false, Some(true), true, Some("wlp192s0")), | |
| 32 | - | Some(NoWireless::Unmanaged("wlp192s0".to_string())) | |
| 33 | - | ); | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | #[test] | |
| 37 | - | fn a_dark_radio_explains_an_absent_device_only_when_nothing_else_does() { | |
| 38 | - | assert_eq!( | |
| 39 | - | no_wireless(false, Some(false), true, None), | |
| 40 | - | Some(NoWireless::RadioOff) | |
| 41 | - | ); | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | #[test] | |
| 45 | - | fn no_hardware_is_the_answer_when_nothing_is_wrong() { | |
| 46 | - | assert_eq!( | |
| 47 | - | no_wireless(false, Some(true), true, None), | |
| 48 | - | Some(NoWireless::NoHardware) | |
| 49 | - | ); | |
| 50 | - | // A backend that cannot report the radio (the mock) is not evidence of | |
| 51 | - | // a fault either. | |
| 52 | - | assert_eq!( | |
| 53 | - | no_wireless(false, None, true, None), | |
| 54 | - | Some(NoWireless::NoHardware) | |
| 55 | - | ); | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | #[test] | |
| 59 | - | fn every_message_says_what_to_do_next() { | |
| 60 | - | // A diagnosis with no next step is a complaint. NoHardware is the one | |
| 61 | - | // exception and is exempt: there is nothing to do about a machine that | |
| 62 | - | // has no radio. | |
| 63 | - | for reason in [ | |
| 64 | - | NoWireless::NoPlugin, | |
| 65 | - | NoWireless::Unmanaged("wlan0".to_string()), | |
| 66 | - | NoWireless::RadioOff, | |
| 67 | - | ] { | |
| 68 | - | let message = reason.message(); | |
| 69 | - | assert!( | |
| 70 | - | message.contains("Layer") || message.contains("Check") || message.contains("press"), | |
| 71 | - | "{message}" | |
| 72 | - | ); | |
| 73 | - | } | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | // Captured verbatim from `nmcli -t -f GENERAL.DEVICE,GENERAL.TYPE, | |
| 77 | - | // GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP6.ADDRESS device show` on | |
| 78 | - | // a NetworkManager 1.5x box, hostname and SSID aside. Kept real rather | |
| 79 | - | // than tidied: the awkward parts below (nested parens in the state, an | |
| 80 | - | // empty trailing connection, a bare `::1`) are all things nmcli actually | |
| 81 | - | // emits, and a hand-written fixture is where a parser goes to pass tests | |
| 82 | - | // it would fail in production. | |
| 83 | - | const SAMPLE: &str = "\ | |
| 84 | - | GENERAL.DEVICE:wlp192s0 | |
| 85 | - | GENERAL.TYPE:wifi | |
| 86 | - | GENERAL.STATE:100 (connected) | |
| 87 | - | GENERAL.CONNECTION:Example Network | |
| 88 | - | IP4.ADDRESS[1]:192.168.0.16/24 | |
| 89 | - | IP6.ADDRESS[1]:fe80::59a3:bc22:d95f:c06b/64 | |
| 90 | - | ||
| 91 | - | GENERAL.DEVICE:tailscale0 | |
| 92 | - | GENERAL.TYPE:tun | |
| 93 | - | GENERAL.STATE:100 (connected (externally)) | |
| 94 | - | GENERAL.CONNECTION:tailscale0 | |
| 95 | - | IP4.ADDRESS[1]:100.103.89.95/32 | |
| 96 | - | IP6.ADDRESS[1]:fd7a:115c:a1e0::af3b:595f/128 | |
| 97 | - | IP6.ADDRESS[2]:fe80::ccae:60fc:a1c5:3b13/64 | |
| 98 | - | ||
| 99 | - | GENERAL.DEVICE:lo | |
| 100 | - | GENERAL.TYPE:loopback | |
| 101 | - | GENERAL.STATE:100 (connected (externally)) | |
| 102 | - | GENERAL.CONNECTION:lo | |
| 103 | - | IP4.ADDRESS[1]:127.0.0.1/8 | |
| 104 | - | IP6.ADDRESS[1]:::1/128 | |
| 105 | - | ||
| 106 | - | GENERAL.DEVICE:p2p-dev-wlp192s0 | |
| 107 | - | GENERAL.TYPE:wifi-p2p | |
| 108 | - | GENERAL.STATE:30 (disconnected) | |
| 109 | - | GENERAL.CONNECTION: | |
| 110 | - | "; | |
| 111 | - | ||
| 112 | - | #[test] | |
| 113 | - | fn parses_every_device_block() { | |
| 114 | - | let ifaces = parse_device_show(SAMPLE); | |
| 115 | - | assert_eq!(ifaces.len(), 4); | |
| 116 | - | assert_eq!(ifaces[0].name, "wlp192s0"); | |
| 117 | - | assert_eq!(ifaces[0].kind, Kind::Wireless); | |
| 118 | - | assert_eq!(ifaces[0].state, State::Connected); | |
| 119 | - | assert_eq!(ifaces[0].connection.as_deref(), Some("Example Network")); | |
| 120 | - | assert_eq!( | |
| 121 | - | ifaces[3].name, "p2p-dev-wlp192s0", | |
| 122 | - | "the last block is not dropped for want of a trailing blank line" | |
| 123 | - | ); | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | // IPv6 values carry colons and arrive unescaped, so the split has to be on | |
| 127 | - | // the *first* colon only. Splitting on every colon shows `fe80` as the | |
| 128 | - | // address; `::1/128` is the case that breaks a naive rsplit as well. | |
| 129 | - | #[test] | |
| 130 | - | fn ipv6_addresses_survive_the_key_value_split() { | |
| 131 | - | let ifaces = parse_device_show(SAMPLE); | |
| 132 | - | assert_eq!( | |
| 133 | - | ifaces[0].addresses, | |
| 134 | - | vec!["192.168.0.16/24", "fe80::59a3:bc22:d95f:c06b/64"] | |
| 135 | - | ); | |
| 136 | - | assert_eq!( | |
| 137 | - | ifaces[1].addresses, | |
| 138 | - | vec![ | |
| 139 | - | "100.103.89.95/32", | |
| 140 | - | "fd7a:115c:a1e0::af3b:595f/128", | |
| 141 | - | "fe80::ccae:60fc:a1c5:3b13/64", | |
| 142 | - | ], | |
| 143 | - | "every indexed address is collected, not just the first" | |
| 144 | - | ); | |
| 145 | - | assert_eq!(ifaces[2].addresses[1], "::1/128"); | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | // NM leaves the connection field empty for a device with no active | |
| 149 | - | // connection. Empty must read as absent, not as a connection named "". | |
| 150 | - | #[test] | |
| 151 | - | fn treats_an_empty_connection_as_absent() { | |
| 152 | - | let ifaces = parse_device_show(SAMPLE); | |
| 153 | - | assert_eq!(ifaces[3].connection, None); | |
| 154 | - | assert!(ifaces[3].addresses.is_empty()); | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | // `--` is NM's other placeholder for "none", used where a field is | |
| 158 | - | // tabulated rather than left blank. | |
| 159 | - | #[test] | |
| 160 | - | fn treats_double_dash_connection_as_absent() { | |
| 161 | - | let raw = "GENERAL.DEVICE:enp2s0\nGENERAL.TYPE:ethernet\nGENERAL.CONNECTION:--\n"; | |
| 162 | - | assert_eq!(parse_device_show(raw)[0].connection, None); | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | // The state field nests parentheses: "100 (connected (externally))". Only | |
| 166 | - | // the leading numeric code is stable across locales, so that is what is | |
| 167 | - | // parsed; anything reading the text would misclassify this as unmanaged. | |
| 168 | - | #[test] | |
| 169 | - | fn parses_state_from_the_numeric_code_not_the_text() { | |
| 170 | - | let ifaces = parse_device_show(SAMPLE); | |
| 171 | - | assert_eq!(ifaces[1].state, State::Connected); | |
| 172 | - | assert_eq!(ifaces[3].state, State::Disconnected); | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | #[test] | |
| 176 | - | fn empty_output_yields_no_interfaces() { | |
| 177 | - | assert!(parse_device_show("").is_empty()); | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | // NM's device-type vocabulary is open-ended; an unknown type must still | |
| 181 | - | // list rather than vanish. | |
| 182 | - | #[test] | |
| 183 | - | fn unknown_device_types_are_listed_as_other() { | |
| 184 | - | let raw = "GENERAL.DEVICE:wg0\nGENERAL.TYPE:wireguard\nGENERAL.STATE:100 (connected)\n"; | |
| 185 | - | let ifaces = parse_device_show(raw); | |
| 186 | - | assert_eq!(ifaces.len(), 1); | |
| 187 | - | assert_eq!(ifaces[0].kind, Kind::Other); | |
| 188 | - | assert_eq!(ifaces[0].state, State::Connected); | |
| 189 | - | } | |
| 190 | - | ||
| 191 | - | fn mock_view() -> (NetView, CommandLog) { | |
| 192 | - | let mut log = CommandLog::new(); | |
| 193 | - | let mut view = NetView { | |
| 194 | - | backend: Box::new(Mock), | |
| 195 | - | interfaces: Vec::new(), | |
| 196 | - | cursor: Cursor::new(), | |
| 197 | - | error: None, | |
| 198 | - | wifi: None, | |
| 199 | - | mode: Mode::Devices, | |
| 200 | - | pending: None, | |
| 201 | - | no_wireless: None, | |
| 202 | - | }; | |
| 203 | - | view.refresh(&mut log); | |
| 204 | - | (view, log) | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | // Cursor's own tests cover the clamping; this checks the wiring, that | |
| 208 | - | // refresh actually tells the cursor the new length. Without that call the | |
| 209 | - | // cursor keeps pointing at a row that no longer exists. | |
| 210 | - | #[test] | |
| 211 | - | fn refresh_resizes_the_cursor_when_the_list_shrinks() { | |
| 212 | - | let (mut view, mut log) = mock_view(); | |
| 213 | - | view.cursor.move_by(2); | |
| 214 | - | assert_eq!(view.cursor.selected(), Some(2)); | |
| 215 | - | ||
| 216 | - | view.backend = Box::new(EmptyBackend); | |
| 217 | - | view.refresh(&mut log); | |
| 218 | - | assert_eq!( | |
| 219 | - | view.cursor.selected(), | |
| 220 | - | None, | |
| 221 | - | "no selection in an empty list" | |
| 222 | - | ); | |
| 223 | - | } | |
| 224 | - | ||
| 225 | - | // A failed refresh must leave the last good list on screen rather than | |
| 226 | - | // blanking it, and surface the error in the status area. | |
| 227 | - | #[test] | |
| 228 | - | fn a_failed_refresh_keeps_the_previous_interfaces() { | |
| 229 | - | let (mut view, mut log) = mock_view(); | |
| 230 | - | assert_eq!(view.interfaces.len(), 3); | |
| 231 | - | ||
| 232 | - | view.backend = Box::new(FailingBackend); | |
| 233 | - | view.refresh(&mut log); | |
| 234 | - | assert_eq!(view.interfaces.len(), 3, "the stale list is still shown"); | |
| 235 | - | assert!(view.error.is_some(), "the failure is surfaced"); | |
| 236 | - | } | |
| 237 | - | ||
| 238 | - | fn iface(name: &str, kind: Kind, state: State) -> Interface { | |
| 239 | - | Interface { | |
| 240 | - | name: name.into(), | |
| 241 | - | kind, | |
| 242 | - | state, | |
| 243 | - | connection: None, | |
| 244 | - | addresses: Vec::new(), | |
| 245 | - | } | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | // The argv the log pane shows and the user can paste. `device connect` and | |
| 249 | - | // not `connection up`: the user picked a device, and which profile it uses | |
| 250 | - | // is a different screen and a different polkit action. | |
| 251 | - | #[test] | |
| 252 | - | fn connect_and_disconnect_name_the_device() { | |
| 253 | - | let wifi = iface("wlp1s0", Kind::Wireless, State::Disconnected); | |
| 254 | - | assert_eq!( | |
| 255 | - | NmCli.connect(&wifi).unwrap().display(), | |
| 256 | - | "nmcli device connect wlp1s0", | |
| 257 | - | ); | |
| 258 | - | assert_eq!( | |
| 259 | - | NmCli.disconnect(&wifi).unwrap().display(), | |
| 260 | - | "nmcli device disconnect wlp1s0", | |
| 261 | - | ); | |
| 262 | - | } | |
| 263 | - | ||
| 264 | - | #[test] | |
| 265 | - | fn the_radio_switch_names_the_direction() { | |
| 266 | - | assert_eq!( | |
| 267 | - | NmCli.set_wifi(true).unwrap().display(), | |
| 268 | - | "nmcli radio wifi on" | |
| 269 | - | ); | |
| 270 | - | assert_eq!( | |
| 271 | - | NmCli.set_wifi(false).unwrap().display(), | |
| 272 | - | "nmcli radio wifi off", | |
| 273 | - | ); | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | // Loopback is never brought up or down and an unmanaged device is one NM | |
| 277 | - | // has been told to leave alone. Both are still listed; neither takes the | |
| 278 | - | // key. | |
| 279 | - | #[test] | |
| 280 | - | fn loopback_and_unmanaged_devices_take_no_action() { | |
| 281 | - | let lo = iface("lo", Kind::Loopback, State::Unmanaged); | |
| 282 | - | assert!(NmCli.connect(&lo).is_none()); | |
| 283 | - | assert!(NmCli.disconnect(&lo).is_none()); | |
| 284 | - | ||
| 285 | - | let bridge = iface("br0", Kind::Other, State::Unmanaged); | |
| 286 | - | assert!(NmCli.connect(&bridge).is_none()); | |
| 287 | - | ||
| 288 | - | let wired = iface("enp2s0", Kind::Wired, State::Disconnected); | |
| 289 | - | assert!(NmCli.connect(&wired).is_some(), "an ordinary device does"); | |
| 290 | - | } | |
| 291 | - | ||
| 292 | - | // `missing` is what nmcli says when there is no wifi hardware. Reading it | |
| 293 | - | // as "off" would offer a toggle for a radio that is not there. | |
| 294 | - | #[test] | |
| 295 | - | fn the_radio_reads_only_the_two_answers_it_understands() { | |
| 296 | - | assert_eq!(parse_radio("enabled\n"), Some(true)); | |
| 297 | - | assert_eq!(parse_radio("disabled\n"), Some(false)); | |
| 298 | - | assert_eq!(parse_radio("missing\n"), None); | |
| 299 | - | assert_eq!(parse_radio(""), None); | |
| 300 | - | } | |
| 301 | - | ||
| 302 | - | // One key, whichever way the device is pointing, because the row already | |
| 303 | - | // says which state it is in. | |
| 304 | - | #[test] | |
| 305 | - | fn the_key_picks_the_action_the_row_is_not_already_in() { | |
| 306 | - | let connected = iface("wlp1s0", Kind::Wireless, State::Connected); | |
| 307 | - | let down = iface("wlp1s0", Kind::Wireless, State::Disconnected); | |
| 308 | - | assert!(NmCli.disconnect(&connected).is_some()); | |
| 309 | - | assert!(NmCli.connect(&down).is_some()); | |
| 310 | - | } | |
| 311 | - | ||
| 312 | - | // A backend that only reads offers no keys, and the footer must not | |
| 313 | - | // advertise one that does nothing. | |
| 314 | - | #[test] | |
| 315 | - | fn a_read_only_backend_offers_no_action_keys() { | |
| 316 | - | let (view, _log) = mock_view(); | |
| 317 | - | let labels: Vec<&str> = view.hints().iter().map(|hint| hint.label).collect(); | |
| 318 | - | assert!(labels.contains(&"select"), "{labels:?}"); | |
| 319 | - | assert!(labels.contains(&"refresh"), "{labels:?}"); | |
| 320 | - | assert!(!labels.contains(&"connect/disconnect"), "{labels:?}"); | |
| 321 | - | assert!(!labels.contains(&"wifi radio"), "{labels:?}"); | |
| 322 | - | } | |
| 323 | - | ||
| 324 | - | // Pressing the key on a device nothing can act on must say so. "Nothing | |
| 325 | - | // happened" is the one outcome a console must never produce. | |
| 326 | - | #[test] | |
| 327 | - | fn acting_on_a_device_with_no_action_reports_why() { | |
| 328 | - | let (mut view, mut log) = mock_view(); | |
| 329 | - | // Row 2 of the mock is `lo`, unmanaged loopback. | |
| 330 | - | view.cursor.move_by(2); | |
| 331 | - | view.toggle(&mut log); | |
| 332 | - | let message = view.error.as_ref().expect("something was said"); | |
| 333 | - | assert!(message.contains("lo"), "{message}"); | |
| 334 | - | } | |
| 335 | - | ||
| 336 | - | #[test] | |
| 337 | - | fn switching_a_radio_that_is_not_there_reports_why() { | |
| 338 | - | let (mut view, mut log) = mock_view(); | |
| 339 | - | assert_eq!(view.wifi, None); | |
| 340 | - | view.toggle_wifi(&mut log); | |
| 341 | - | assert!( | |
| 342 | - | view.error | |
| 343 | - | .as_ref() | |
| 344 | - | .is_some_and(|m| m.contains("no wifi radio")), | |
| 345 | - | "{:?}", | |
| 346 | - | view.error, | |
| 347 | - | ); | |
| 348 | - | } | |
| 349 | - | ||
| 350 | - | // The radio being off is the explanation for every wireless device sitting | |
| 351 | - | // at `unavailable`, so it is worth the footer line even when nothing failed. | |
| 352 | - | #[test] | |
| 353 | - | fn a_radio_that_is_off_is_reported_without_an_error() { | |
| 354 | - | let (mut view, _log) = mock_view(); | |
| 355 | - | view.wifi = Some(false); | |
| 356 | - | let (severity, message) = view.status().expect("the footer says so"); | |
| 357 | - | assert_eq!(severity, Severity::Warn); | |
| 358 | - | assert!(message.contains("wifi radio off"), "{message}"); | |
| 359 | - | ||
| 360 | - | view.wifi = Some(true); | |
| 361 | - | assert!(view.status().is_none(), "a radio that is on says nothing"); | |
| 362 | - | } | |
| 363 | - | ||
| 364 | - | // Constructed rather than captured, unlike SAMPLE above, and the difference | |
| 365 | - | // is worth stating: no access point was in range of the machine this was | |
| 366 | - | // written on. Every shape in it is nmcli's documented terse-tabular | |
| 367 | - | // behaviour — `*` for in-use, an empty SSID for a hidden network, an empty | |
| 368 | - | // security field for an open one, and `\:` for a colon inside a value — and | |
| 369 | - | // the escaping is the half a hand-written fixture is most likely to get | |
| 370 | - | // wrong, so it is what the tests below are mostly about. | |
| 371 | - | const WIFI_LIST: &str = "\ | |
| 372 | - | *:Example Network:82:WPA2 | |
| 373 | - | :Cafe\\: Free Wifi:64:WPA2 | |
| 374 | - | :Example Network:41:WPA2 | |
| 375 | - | ::37:WPA2 | |
| 376 | - | :Airport WiFi:22: | |
| 377 | - | "; | |
| 378 | - | ||
| 379 | - | #[test] | |
| 380 | - | fn a_colon_inside_an_ssid_survives_the_split() { | |
| 381 | - | let networks = parse_wifi_list(WIFI_LIST); | |
| 382 | - | assert!( | |
| 383 | - | networks.iter().any(|n| n.ssid == "Cafe: Free Wifi"), | |
| 384 | - | "{networks:?}", | |
| 385 | - | ); | |
| 386 | - | } | |
| 387 | - | ||
| 388 | - | #[test] | |
| 389 | - | fn terse_escapes_are_undone_and_nothing_else_is() { | |
| 390 | - | assert_eq!(split_terse("a:b"), vec!["a", "b"]); | |
| 391 | - | assert_eq!(split_terse("a\\:b:c"), vec!["a:b", "c"]); | |
| 392 | - | assert_eq!(split_terse("a\\\\:b"), vec!["a\\", "b"]); | |
| 393 | - | assert_eq!( | |
| 394 | - | split_terse("::"), | |
| 395 | - | vec!["", "", ""], | |
| 396 | - | "empty fields are fields", | |
| 397 | - | ); | |
| 398 | - | } | |
| 399 | - | ||
| 400 | - | // Three access points carrying one SSID is one row: NM connects to a name, | |
| 401 | - | // so offering the name three times offers the same choice three times. | |
| 402 | - | #[test] | |
| 403 | - | fn one_row_per_ssid_at_the_strongest_signal() { | |
| 404 | - | let networks = parse_wifi_list(WIFI_LIST); | |
| 405 | - | let example: Vec<&Network> = networks | |
| 406 | - | .iter() | |
| 407 | - | .filter(|n| n.ssid == "Example Network") | |
| 408 | - | .collect(); | |
| 409 | - | assert_eq!(example.len(), 1, "{networks:?}"); | |
| 410 | - | assert_eq!(example[0].signal, 82); | |
| 411 | - | assert!(example[0].in_use, "the merge keeps in-use"); | |
| 412 | - | } | |
| 413 | - | ||
| 414 | - | // A hidden network has no name to show and `device wifi connect` takes a | |
| 415 | - | // name, so the row could not be acted on. | |
| 416 | - | #[test] | |
| 417 | - | fn hidden_networks_are_dropped() { | |
| 418 | - | let networks = parse_wifi_list(WIFI_LIST); | |
| 419 | - | assert!(networks.iter().all(|n| !n.ssid.is_empty()), "{networks:?}"); | |
| 420 | - | assert_eq!(networks.len(), 3); | |
| 421 | - | } | |
| 422 | - | ||
| 423 | - | // An empty security field is an open network, which is the one case where | |
| 424 | - | // the console must not ask for a passphrase — and the one case where the | |
| 425 | - | // row is a warning rather than a fact. | |
| 426 | - | #[test] | |
| 427 | - | fn an_empty_security_field_reads_as_open() { | |
| 428 | - | let networks = parse_wifi_list(WIFI_LIST); | |
| 429 | - | let open = networks | |
| 430 | - | .iter() | |
| 431 | - | .find(|n| n.ssid == "Airport WiFi") | |
| 432 | - | .expect("the open network is listed"); | |
| 433 | - | assert_eq!(open.security, None); | |
| 434 | - | assert_eq!(networks[0].security.as_deref(), Some("WPA2")); | |
| 435 | - | } | |
| 436 | - | ||
| 437 | - | #[test] | |
| 438 | - | fn strongest_first() { | |
| 439 | - | let networks = parse_wifi_list(WIFI_LIST); | |
| 440 | - | let signals: Vec<u8> = networks.iter().map(|n| n.signal).collect(); | |
| 441 | - | assert_eq!(signals, vec![82, 64, 22]); | |
| 442 | - | } | |
| 443 | - | ||
| 444 | - | // The argv the log pane shows. `--ask` with nothing after the SSID is the | |
| 445 | - | // whole point: the passphrase is on stdin, so there is no `password <pw>` | |
| 446 | - | // for `ps` to show to every user on the machine. | |
| 447 | - | #[test] | |
| 448 | - | fn joining_never_puts_the_passphrase_in_argv() { | |
| 449 | - | let secret = Secret::new(b"correct horse battery".to_vec()); | |
| 450 | - | let invocation = NmCli | |
| 451 | - | .join("Example Network", Some(secret)) | |
| 452 | - | .expect("nmcli can join"); | |
| 453 | - | let shown = invocation.display(); | |
| 454 | - | assert_eq!( | |
| 455 | - | shown, | |
| 456 | - | "nmcli --ask device wifi connect 'Example Network' # input withheld", | |
| 457 | - | ); | |
| 458 | - | assert!(!shown.contains("correct horse"), "{shown}"); | |
| 459 | - | } | |
| 460 | - | ||
| 461 | - | #[test] | |
| 462 | - | fn an_open_network_is_joined_with_no_input_at_all() { | |
| 463 | - | let invocation = NmCli.join("Airport WiFi", None).expect("nmcli can join"); | |
| 464 | - | assert_eq!( | |
| 465 | - | invocation.display(), | |
| 466 | - | "nmcli --ask device wifi connect 'Airport WiFi'", | |
| 467 | - | "no pipe, so no `input withheld` note either", | |
| 468 | - | ); | |
| 469 | - | } | |
| 470 | - | ||
| 471 | - | /// A backend that can scan and join, for the mode machine. | |
| 472 | - | /// | |
| 473 | - | /// Joining always succeeds here. What the failure paths do is a property of | |
| 474 | - | /// [`Invocation`] and of the error text nmcli produces, neither of which a | |
| 475 | - | /// fake backend would be testing. | |
| 476 | - | struct JoinableBackend; | |
| 477 | - | ||
| 478 | - | impl Backend for JoinableBackend { | |
| 479 | - | fn name(&self) -> &'static str { | |
| 480 | - | "joinable" | |
| 481 | - | } | |
| 482 | - | fn list(&self, _log: &mut CommandLog) -> Result<Vec<Interface>> { | |
| 483 | - | Ok(vec![iface("wlp1s0", Kind::Wireless, State::Disconnected)]) | |
| 484 | - | } | |
| 485 | - | fn wifi_enabled(&self, _log: &mut CommandLog) -> Option<bool> { | |
| 486 | - | Some(true) | |
| 487 | - | } | |
| 488 | - | fn networks(&self, _log: &mut CommandLog) -> Option<Result<Vec<Network>>> { | |
| 489 | - | Some(Ok(parse_wifi_list(WIFI_LIST))) | |
| 490 | - | } | |
| 491 | - | fn join(&self, ssid: &str, passphrase: Option<Secret>) -> Option<Invocation> { | |
| 492 | - | // `true` rather than nmcli: the view runs whatever comes back, and a | |
| 493 | - | // test that shells out to a network manager is a test that fails on | |
| 494 | - | // the machine it is run on. | |
| 495 | - | let invocation = Invocation::new("true").arg(ssid); | |
| 496 | - | Some(match passphrase { | |
| 497 | - | Some(secret) => invocation.stdin(secret), | |
| 498 | - | None => invocation, | |
| 499 | - | }) | |
| 500 | - | } |
Lines truncated
| @@ -1,0 +1,645 @@ | |||
| 1 | + | //! The `alloy net` screen: the device inventory and the join flow over it. | |
| 2 | + | ||
| 3 | + | use alloy_tui::keys::Action; | |
| 4 | + | use alloy_tui::{ | |
| 5 | + | AlloyBlock, AlloyList, Cursor, Hint, KeyGroup, Severity, TextField, Theme, binding, hint, text, | |
| 6 | + | unavailable, | |
| 7 | + | }; | |
| 8 | + | use anyhow::Result; | |
| 9 | + | use ratatui::Frame; | |
| 10 | + | use ratatui::crossterm::event::{KeyCode, KeyEvent}; | |
| 11 | + | use ratatui::layout::Rect; | |
| 12 | + | use ratatui::text::{Line, Span}; | |
| 13 | + | ||
| 14 | + | use super::backend::{Backend, actionable, detect}; | |
| 15 | + | use super::diagnose::{NoWireless, kernel_wireless_iface, no_wireless, wifi_plugin_present}; | |
| 16 | + | use super::model::{Interface, Kind, Network, State}; | |
| 17 | + | use crate::cli::{CommandLog, Secret}; | |
| 18 | + | use crate::shell::{Flow, View, block_title}; | |
| 19 | + | ||
| 20 | + | /// What the screen is showing, and what its keys mean. | |
| 21 | + | /// | |
| 22 | + | /// A mode rather than a tab, and rather than a second view. The three are one | |
| 23 | + | /// question asked in three steps — which device, which network, what is the | |
| 24 | + | /// passphrase — so Esc walking back through them is the whole navigation model, | |
| 25 | + | /// and [`View::cancel`] gives that for free. Tabs would put a passphrase field | |
| 26 | + | /// on a tab someone can page away from mid-word. | |
| 27 | + | /// | |
| 28 | + | /// The passphrase lives in a [`TextField`] here for as long as the user is | |
| 29 | + | /// typing it, and moves into a [`Secret`] at the moment the command is built. | |
| 30 | + | /// That is the same arrangement the installer's account pane settled on: a | |
| 31 | + | /// scrubbing buffer helps only once there is a buffer to scrub, and until then | |
| 32 | + | /// the value is a `String` that the widget owns. | |
| 33 | + | enum Mode { | |
| 34 | + | /// The interface inventory. What this screen was before the join flow. | |
| 35 | + | Devices, | |
| 36 | + | /// The networks in range, from the last scan. | |
| 37 | + | Networks { | |
| 38 | + | networks: Vec<Network>, | |
| 39 | + | cursor: Cursor, | |
| 40 | + | }, | |
| 41 | + | /// Asking for the passphrase of a network already chosen. | |
| 42 | + | Passphrase { ssid: String, field: TextField }, | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// Redacted, and hand-written for that reason. | |
| 46 | + | /// | |
| 47 | + | /// A derived `Debug` would print the passphrase, and the places a `{:?}` ends | |
| 48 | + | /// up are exactly the ones nobody audits: a test failure message, a panic, a | |
| 49 | + | /// log line added in a hurry. [`Secret`] makes the same choice for the same | |
| 50 | + | /// reason, and this is the buffer that feeds it. | |
| 51 | + | impl std::fmt::Debug for Mode { | |
| 52 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 53 | + | match self { | |
| 54 | + | Mode::Devices => f.write_str("Devices"), | |
| 55 | + | Mode::Networks { networks, .. } => write!(f, "Networks({})", networks.len()), | |
| 56 | + | Mode::Passphrase { ssid, .. } => write!(f, "Passphrase({ssid}, <redacted>)"), | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// The `alloy net` screen. | |
| 62 | + | pub(crate) struct NetView { | |
| 63 | + | backend: Box<dyn Backend>, | |
| 64 | + | interfaces: Vec<Interface>, | |
| 65 | + | cursor: Cursor, | |
| 66 | + | error: Option<String>, | |
| 67 | + | /// Wifi radio state, or `None` when the backend cannot say. Re-read on | |
| 68 | + | /// every refresh, since toggling it is one of the two things this screen | |
| 69 | + | /// does. | |
| 70 | + | wifi: Option<bool>, | |
| 71 | + | mode: Mode, | |
| 72 | + | /// The network a join is in flight for, so its outcome can be reported | |
| 73 | + | /// against a name. | |
| 74 | + | /// | |
| 75 | + | /// Held here rather than read back out of [`Mode`] when the answer arrives, | |
| 76 | + | /// because the mode has moved on by then: the passphrase is gone the moment | |
| 77 | + | /// it becomes a [`Secret`], which is the point. | |
| 78 | + | pending: Option<String>, | |
| 79 | + | /// Why there is no wireless device, when there is none. Recomputed on every | |
| 80 | + | /// refresh, since layering the plugin or plugging a dongle both change the | |
| 81 | + | /// answer without the screen being reopened. | |
| 82 | + | no_wireless: Option<NoWireless>, | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | impl NetView { | |
| 86 | + | pub(crate) fn new(log: &mut CommandLog) -> Self { | |
| 87 | + | let mut view = Self { | |
| 88 | + | backend: detect(), | |
| 89 | + | interfaces: Vec::new(), | |
| 90 | + | cursor: Cursor::new(), | |
| 91 | + | error: None, | |
| 92 | + | wifi: None, | |
| 93 | + | mode: Mode::Devices, | |
| 94 | + | pending: None, | |
| 95 | + | no_wireless: None, | |
| 96 | + | }; | |
| 97 | + | view.refresh(log); | |
| 98 | + | view | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// Whether a scan is worth offering: the backend can do one, and there is a | |
| 102 | + | /// wireless device for it to happen on. | |
| 103 | + | /// | |
| 104 | + | /// The radio being off is deliberately *not* part of this. A key that | |
| 105 | + | /// disappears when the radio is switched off teaches that the console | |
| 106 | + | /// cannot scan; a key that says "wifi radio is off; press w" teaches which | |
| 107 | + | /// key to press. | |
| 108 | + | fn can_scan(&self) -> bool { | |
| 109 | + | self.wifi.is_some() && self.interfaces.iter().any(|i| i.kind == Kind::Wireless) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /// Look for networks, and show them if any came back. | |
| 113 | + | fn scan(&mut self, log: &mut CommandLog) { | |
| 114 | + | if self.wifi == Some(false) { | |
| 115 | + | self.error = Some("wifi radio is off; press w".to_string()); | |
| 116 | + | return; | |
| 117 | + | } | |
| 118 | + | let Some(result) = self.backend.networks(log) else { | |
| 119 | + | self.error = Some(format!("{} cannot scan", self.backend.name())); | |
| 120 | + | return; | |
| 121 | + | }; | |
| 122 | + | match result { | |
| 123 | + | Ok(networks) if networks.is_empty() => { | |
| 124 | + | // Not an error: a scan that finds nothing is a true answer, and | |
| 125 | + | // an empty list behind a mode switch reads as a broken screen. | |
| 126 | + | self.error = Some("no networks in range".to_string()); | |
| 127 | + | } | |
| 128 | + | Ok(networks) => { | |
| 129 | + | let mut cursor = Cursor::new(); | |
| 130 | + | cursor.resize(networks.len()); | |
| 131 | + | self.mode = Mode::Networks { networks, cursor }; | |
| 132 | + | self.error = None; | |
| 133 | + | } | |
| 134 | + | Err(err) => self.error = Some(err.to_string()), | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// Act on the selected network: join an open one, ask about a secured one. | |
| 139 | + | fn choose(&mut self) -> Flow { | |
| 140 | + | let Mode::Networks { networks, cursor } = &self.mode else { | |
| 141 | + | return Flow::Continue; | |
| 142 | + | }; | |
| 143 | + | let Some(network) = cursor.selected().and_then(|index| networks.get(index)) else { | |
| 144 | + | return Flow::Continue; | |
| 145 | + | }; | |
| 146 | + | ||
| 147 | + | if network.security.is_none() { | |
| 148 | + | let ssid = network.ssid.clone(); | |
| 149 | + | return self.join(&ssid, None); | |
| 150 | + | } | |
| 151 | + | self.mode = Mode::Passphrase { | |
| 152 | + | ssid: network.ssid.clone(), | |
| 153 | + | field: TextField::new(), | |
| 154 | + | }; | |
| 155 | + | Flow::Continue | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// Join with what has been typed. | |
| 159 | + | /// | |
| 160 | + | /// The passphrase leaves the [`TextField`] here and does not go back: the | |
| 161 | + | /// field is emptied in the same breath as the [`Secret`] is built, so a | |
| 162 | + | /// prompt that fails and is asked again starts from nothing rather than | |
| 163 | + | /// from a value still sitting in a widget. | |
| 164 | + | fn submit(&mut self) -> Flow { | |
| 165 | + | let Mode::Passphrase { ssid, field } = &mut self.mode else { | |
| 166 | + | return Flow::Continue; | |
| 167 | + | }; | |
| 168 | + | let ssid = ssid.clone(); | |
| 169 | + | let secret = Secret::new(field.value().as_bytes().to_vec()); | |
| 170 | + | field.set(""); | |
| 171 | + | self.join(&ssid, Some(secret)) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /// Hand the join to the shell, which runs it with an agent to answer polkit. | |
| 175 | + | /// | |
| 176 | + | /// [`Flow::AuthorizeInline`] rather than running it here, and this screen is | |
| 177 | + | /// the reason that flow exists. Joining a network NM has not saved is | |
| 178 | + | /// `settings.modify.system`, which `50-alloy-settings.rules` deliberately | |
| 179 | + | /// does not grant ("saving a new connection is a real administrative act"), | |
| 180 | + | /// so it always wants an answer. Tier 2 cannot give one: it suspends, a | |
| 181 | + | /// suspended child inherits the terminal's stdio, and there is then no pipe | |
| 182 | + | /// for the passphrase. So the command runs beside the event loop with the | |
| 183 | + | /// console's own polkit agent registered, and both questions — polkit's and | |
| 184 | + | /// the passphrase — are asked on screen. | |
| 185 | + | fn join(&mut self, ssid: &str, passphrase: Option<Secret>) -> Flow { | |
| 186 | + | let Some(invocation) = self.backend.join(ssid, passphrase) else { | |
| 187 | + | self.error = Some(format!("{} cannot join a network", self.backend.name())); | |
| 188 | + | return Flow::Continue; | |
| 189 | + | }; | |
| 190 | + | self.pending = Some(ssid.to_string()); | |
| 191 | + | Flow::AuthorizeInline(invocation) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /// Whether the selected interface has a connect action behind it, which on | |
| 195 | + | /// the mock and on loopback it does not. | |
| 196 | + | fn can_connect(&self) -> bool { | |
| 197 | + | self.selected() | |
| 198 | + | .is_some_and(|iface| self.backend.connect(iface).is_some()) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | fn selected(&self) -> Option<&Interface> { | |
| 202 | + | self.interfaces.get(self.cursor.selected()?) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | /// Connect a disconnected device, disconnect a connected one. | |
| 206 | + | /// | |
| 207 | + | /// One key rather than two, the same call `alloy pkg` made for boxes: the | |
| 208 | + | /// states are exclusive and the row already says which one it is in. | |
| 209 | + | fn toggle(&mut self, log: &mut CommandLog) { | |
| 210 | + | let Some(iface) = self.selected().cloned() else { | |
| 211 | + | return; | |
| 212 | + | }; | |
| 213 | + | let invocation = if iface.state == State::Connected { | |
| 214 | + | self.backend.disconnect(&iface) | |
| 215 | + | } else { | |
| 216 | + | self.backend.connect(&iface) | |
| 217 | + | }; | |
| 218 | + | ||
| 219 | + | let Some(invocation) = invocation else { | |
| 220 | + | // Says which of the two reasons it is. "Nothing happened" is the | |
| 221 | + | // one outcome a console must never produce. | |
| 222 | + | self.error = Some(if actionable(&iface) { | |
| 223 | + | format!("{} cannot act on {}", self.backend.name(), iface.name) | |
| 224 | + | } else { | |
| 225 | + | format!("{} is {}", iface.name, iface.state.label()) | |
| 226 | + | }); | |
| 227 | + | return; | |
| 228 | + | }; | |
| 229 | + | ||
| 230 | + | // A wireless device cannot come up while the radio is off, and nmcli's | |
| 231 | + | // own error for it names neither the radio nor the key that fixes it. | |
| 232 | + | if iface.kind == Kind::Wireless && self.wifi == Some(false) { | |
| 233 | + | self.error = Some("wifi radio is off; press w".to_string()); | |
| 234 | + | return; | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | self.finish(invocation.run(log).map(drop), log); | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | /// Flip the wifi radio. | |
| 241 | + | fn toggle_wifi(&mut self, log: &mut CommandLog) { | |
| 242 | + | let Some(on) = self.wifi else { | |
| 243 | + | self.error = Some("no wifi radio to switch".to_string()); | |
| 244 | + | return; | |
| 245 | + | }; | |
| 246 | + | let Some(invocation) = self.backend.set_wifi(!on) else { | |
| 247 | + | self.error = Some(format!("{} cannot switch the radio", self.backend.name())); | |
| 248 | + | return; | |
| 249 | + | }; | |
| 250 | + | self.finish(invocation.run(log).map(drop), log); | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | /// Record how an action went, then re-read. | |
| 254 | + | /// | |
| 255 | + | /// The re-read is the console confirming what it did rather than the user | |
| 256 | + | /// asking, so it is quiet: without that a single keypress writes its action | |
| 257 | + | /// plus two reads into a two-row pane and scrolls the thing the user | |
| 258 | + | /// pressed a key for off the top. | |
| 259 | + | fn finish(&mut self, result: Result<()>, log: &mut CommandLog) { | |
| 260 | + | match result { | |
| 261 | + | Ok(()) => { | |
| 262 | + | self.error = None; | |
| 263 | + | log.quiet(|log| self.refresh(log)); | |
| 264 | + | } | |
| 265 | + | Err(err) => self.error = Some(err.to_string()), | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | fn refresh(&mut self, log: &mut CommandLog) { | |
| 270 | + | match self.backend.list(log) { | |
| 271 | + | Ok(interfaces) => { | |
| 272 | + | self.interfaces = interfaces; | |
| 273 | + | // Refresh can shrink the list (an interface went away); the | |
| 274 | + | // cursor clamps itself back into range. | |
| 275 | + | self.cursor.resize(self.interfaces.len()); | |
| 276 | + | self.error = None; | |
| 277 | + | } | |
| 278 | + | Err(err) => self.error = Some(err.to_string()), | |
| 279 | + | } | |
| 280 | + | self.wifi = self.backend.wifi_enabled(log); | |
| 281 | + | self.no_wireless = no_wireless( | |
| 282 | + | self.interfaces.iter().any(|i| i.kind == Kind::Wireless), | |
| 283 | + | self.wifi, | |
| 284 | + | wifi_plugin_present(), | |
| 285 | + | kernel_wireless_iface().as_deref(), | |
| 286 | + | ); | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | fn row<'a>(theme: &Theme, iface: &'a Interface) -> Line<'a> { | |
| 290 | + | let address = iface | |
| 291 | + | .addresses | |
| 292 | + | .first() | |
| 293 | + | .cloned() | |
| 294 | + | .or_else(|| iface.connection.clone()) | |
| 295 | + | .unwrap_or_default(); | |
| 296 | + | ||
| 297 | + | Line::from(vec![ | |
| 298 | + | text::bold(theme, format!("{:<12}", iface.name)), | |
| 299 | + | text::muted(theme, format!("{:<10}", iface.kind.label())), | |
| 300 | + | Span::styled( | |
| 301 | + | format!("{:<14}", iface.state.label()), | |
| 302 | + | iface.state.severity().style(theme), | |
| 303 | + | ), | |
| 304 | + | text::secondary(theme, address), | |
| 305 | + | ]) | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | /// One network in the scan list. | |
| 309 | + | /// | |
| 310 | + | /// The security column says `open` rather than staying blank, because blank | |
| 311 | + | /// is what a missing value looks like and this one is a warning: an open | |
| 312 | + | /// network is the row where nothing will be asked for and nothing will be | |
| 313 | + | /// encrypted. | |
| 314 | + | fn network_row<'a>(theme: &Theme, network: &'a Network) -> Line<'a> { | |
| 315 | + | let security = network.security.clone().unwrap_or_else(|| "open".into()); | |
| 316 | + | Line::from(vec![ | |
| 317 | + | text::bold( | |
| 318 | + | theme, | |
| 319 | + | format!("{:<3}", if network.in_use { "*" } else { "" }), | |
| 320 | + | ), | |
| 321 | + | text::primary(theme, format!("{:<32}", network.ssid)), | |
| 322 | + | text::muted(theme, format!("{:>3}% ", network.signal)), | |
| 323 | + | Span::styled( | |
| 324 | + | security, | |
| 325 | + | if network.security.is_some() { | |
| 326 | + | Severity::Healthy.style(theme) | |
| 327 | + | } else { | |
| 328 | + | Severity::Warn.style(theme) | |
| 329 | + | }, | |
| 330 | + | ), | |
| 331 | + | ]) | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | /// The passphrase field: dots, and a caret on the one under it. | |
| 335 | + | /// | |
| 336 | + | /// Masked here rather than in [`TextField`] for the reason the installer | |
| 337 | + | /// gives for its own copy of this: a widget that knows how to hide itself | |
| 338 | + | /// has to be trusted to do it everywhere, and a plain buffer only has to be | |
| 339 | + | /// drawn carefully in the places that draw it. | |
| 340 | + | fn passphrase_line<'a>(theme: &Theme, field: &TextField) -> Line<'a> { | |
| 341 | + | let (before, under, after) = field.split(); | |
| 342 | + | Line::from(vec![ | |
| 343 | + | text::muted(theme, " passphrase "), | |
| 344 | + | text::primary(theme, "•".repeat(before.chars().count())), | |
| 345 | + | Span::styled( | |
| 346 | + | under.map_or(' ', |_| '•').to_string(), | |
| 347 | + | Severity::Healthy.style(theme), | |
| 348 | + | ), | |
| 349 | + | text::primary(theme, "•".repeat(after.chars().count())), | |
| 350 | + | ]) | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | impl View for NetView { | |
| 355 | + | fn title(&self) -> String { | |
| 356 | + | match &self.mode { | |
| 357 | + | Mode::Devices => format!("network ({})", self.backend.name()), | |
| 358 | + | Mode::Networks { .. } => format!("networks in range ({})", self.backend.name()), | |
| 359 | + | Mode::Passphrase { ssid, .. } => format!("join {ssid}"), | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | fn hints(&self) -> Vec<Hint> { | |
| 364 | + | match &self.mode { | |
| 365 | + | Mode::Devices => { | |
| 366 | + | let mut hints = vec![hint("j/k", "select")]; | |
| 367 | + | // The footer has one row and shows what is live. What the pane | |
| 368 | + | // *can* do, including the parts it cannot do right now, is | |
| 369 | + | // `?`'s job: see `keys`. | |
| 370 | + | if self.can_connect() { | |
| 371 | + | hints.push(hint("s", "connect/disconnect")); | |
| 372 | + | } | |
| 373 | + | if self.can_scan() { | |
| 374 | + | hints.push(hint("n", "join a network")); | |
| 375 | + | } | |
| 376 | + | if self.wifi.is_some() { | |
| 377 | + | hints.push(hint("w", "wifi radio")); | |
| 378 | + | } | |
| 379 | + | hints.push(hint("r", "refresh")); | |
| 380 | + | hints | |
| 381 | + | } | |
| 382 | + | Mode::Networks { .. } => vec![ | |
| 383 | + | hint("j/k", "select"), | |
| 384 | + | hint("enter", "join"), | |
| 385 | + | hint("n", "scan again"), | |
| 386 | + | hint("esc", "back"), | |
| 387 | + | ], | |
| 388 | + | Mode::Passphrase { .. } => vec![hint("enter", "join"), hint("esc", "back")], | |
| 389 | + | } | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | /// Every key this pane has, including the ones that are unavailable on the | |
| 393 | + | /// current selection. | |
| 394 | + | /// | |
| 395 | + | /// The footer drops those; this must not. A key that vanishes takes its own | |
| 396 | + | /// existence with it, so a user on loopback never learns the pane can | |
| 397 | + | /// connect anything at all, and the rows they *can* use shift under them | |
| 398 | + | /// each time the selection moves. | |
| 399 | + | fn keys(&self) -> Vec<KeyGroup<'static>> { | |
| 400 | + | match &self.mode { | |
| 401 | + | Mode::Devices => { | |
| 402 | + | let connect = if self.can_connect() { | |
| 403 | + | binding("s", "connect/disconnect") | |
| 404 | + | } else { | |
| 405 | + | unavailable("s", "connect/disconnect", "nothing to connect here") | |
| 406 | + | }; | |
| 407 | + | let scan = if self.can_scan() { | |
| 408 | + | binding("n", "join a network") | |
| 409 | + | } else { | |
| 410 | + | unavailable("n", "join a network", "no wifi device") | |
| 411 | + | }; | |
| 412 | + | let wifi = if self.wifi.is_some() { | |
| 413 | + | binding("w", "wifi radio") | |
| 414 | + | } else { | |
| 415 | + | unavailable("w", "wifi radio", "no wifi device") | |
| 416 | + | }; | |
| 417 | + | vec![KeyGroup::new( | |
| 418 | + | "this pane", | |
| 419 | + | vec![ | |
| 420 | + | binding("j/k", "select"), | |
| 421 | + | connect, | |
| 422 | + | scan, | |
| 423 | + | wifi, | |
| 424 | + | binding("r", "refresh"), | |
| 425 | + | ], | |
| 426 | + | )] | |
| 427 | + | } | |
| 428 | + | Mode::Networks { .. } => vec![KeyGroup::new( | |
| 429 | + | "networks in range", | |
| 430 | + | vec![ | |
| 431 | + | binding("j/k", "select"), | |
| 432 | + | binding("enter", "join"), | |
| 433 | + | binding("n", "scan again"), | |
| 434 | + | binding("esc", "back to devices"), | |
| 435 | + | ], | |
| 436 | + | )], | |
| 437 | + | // No `j/k` here, and no listing of them as unavailable either: they | |
| 438 | + | // are letters someone is typing into a passphrase, and naming them | |
| 439 | + | // in the overlay would say they do something. | |
| 440 | + | Mode::Passphrase { .. } => vec![KeyGroup::new( | |
| 441 | + | "passphrase", | |
| 442 | + | vec![ | |
| 443 | + | binding("enter", "join"), | |
| 444 | + | binding("esc", "back to the network list"), | |
| 445 | + | ], | |
| 446 | + | )], | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | /// One screen, no tabs. | |
| 451 | + | fn unanswered(&self) -> &'static [Action] { | |
| 452 | + | &[Action::NextTab, Action::PrevTab] | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | fn status(&self) -> Option<(Severity, String)> { | |
| 456 | + | if let Some(message) = &self.error { | |
| 457 | + | return Some((Severity::Error, message.clone())); | |
| 458 | + | } | |
| 459 | + | // A radio that is off explains every wireless device sitting at | |
| 460 | + | // `unavailable`, so it is worth a line even when nothing failed. | |
| 461 | + | (self.wifi == Some(false)).then(|| (Severity::Warn, "wifi radio off".to_string())) | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | /// The join finished, one way or the other. | |
| 465 | + | /// | |
| 466 | + | /// Success returns to the device list, because that is where the answer is: | |
| 467 | + | /// the interface the user was looking at now says `connected` and names the | |
| 468 | + | /// network. Staying on the scan would mean reporting the outcome in a | |
| 469 | + | /// sentence beside a list that has not changed. | |
| 470 | + | fn authorized(&mut self, outcome: Result<String>, log: &mut CommandLog) { | |
| 471 | + | // Named "the network" rather than left blank when there is no name to | |
| 472 | + | // hand. Nothing reaches this without a join in flight, and a sentence | |
| 473 | + | // with a hole in it is what that assumption looks like when it stops | |
| 474 | + | // being true. | |
| 475 | + | let ssid = self | |
| 476 | + | .pending | |
| 477 | + | .take() | |
| 478 | + | .unwrap_or_else(|| "the network".to_string()); | |
| 479 | + | match outcome { | |
| 480 | + | // The output is dropped rather than reported, and that is not | |
| 481 | + | // tidiness. Fed a pipe, nmcli's `--ask` prompt echoes what it reads, | |
| 482 | + | // so the passphrase can be in the stdout of a command that carried | |
| 483 | + | // it privately in every other respect. Failures are reported out of | |
| 484 | + | // stderr, which the echo does not reach. | |
| 485 | + | Ok(_) => { | |
| 486 | + | self.mode = Mode::Devices; | |
| 487 | + | self.error = None; | |
| 488 | + | log.quiet(|log| self.refresh(log)); | |
| 489 | + | } | |
| 490 | + | Err(err) => { | |
| 491 | + | // Reaching this with an authentication message means the agent | |
| 492 | + | // could not be registered or could not answer — the fallback | |
| 493 | + | // path in `shell::authorize_inline`, which says so in the log | |
| 494 | + | // pane. Worth its own sentence, because "not authorized" and | |
| 495 | + | // "nobody could be asked" send someone to different places. | |
| 496 | + | self.error = Some(if crate::cli::wants_authentication(&err) { | |
| 497 | + | format!("joining {ssid} was not authorized") | |
| 498 | + | } else { | |
| 499 | + | err.to_string() | |
| 500 | + | }); |
Lines truncated
| @@ -1,0 +1,333 @@ | |||
| 1 | + | //! Tests for [`super`]. | |
| 2 | + | ||
| 3 | + | use super::super::backend::{Mock, NmCli}; | |
| 4 | + | use super::super::fixtures::{EmptyBackend, FailingBackend, JoinableBackend, iface}; | |
| 5 | + | use super::super::model::{Kind, State}; | |
| 6 | + | use super::*; | |
| 7 | + | ||
| 8 | + | fn mock_view() -> (NetView, CommandLog) { | |
| 9 | + | let mut log = CommandLog::new(); | |
| 10 | + | let mut view = NetView { | |
| 11 | + | backend: Box::new(Mock), | |
| 12 | + | interfaces: Vec::new(), | |
| 13 | + | cursor: Cursor::new(), | |
| 14 | + | error: None, | |
| 15 | + | wifi: None, | |
| 16 | + | mode: Mode::Devices, | |
| 17 | + | pending: None, | |
| 18 | + | no_wireless: None, | |
| 19 | + | }; | |
| 20 | + | view.refresh(&mut log); | |
| 21 | + | (view, log) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // Cursor's own tests cover the clamping; this checks the wiring, that | |
| 25 | + | // refresh actually tells the cursor the new length. Without that call the | |
| 26 | + | // cursor keeps pointing at a row that no longer exists. | |
| 27 | + | #[test] | |
| 28 | + | fn refresh_resizes_the_cursor_when_the_list_shrinks() { | |
| 29 | + | let (mut view, mut log) = mock_view(); | |
| 30 | + | view.cursor.move_by(2); | |
| 31 | + | assert_eq!(view.cursor.selected(), Some(2)); | |
| 32 | + | ||
| 33 | + | view.backend = Box::new(EmptyBackend); | |
| 34 | + | view.refresh(&mut log); | |
| 35 | + | assert_eq!( | |
| 36 | + | view.cursor.selected(), | |
| 37 | + | None, | |
| 38 | + | "no selection in an empty list" | |
| 39 | + | ); | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // A failed refresh must leave the last good list on screen rather than | |
| 43 | + | // blanking it, and surface the error in the status area. | |
| 44 | + | #[test] | |
| 45 | + | fn a_failed_refresh_keeps_the_previous_interfaces() { | |
| 46 | + | let (mut view, mut log) = mock_view(); | |
| 47 | + | assert_eq!(view.interfaces.len(), 3); | |
| 48 | + | ||
| 49 | + | view.backend = Box::new(FailingBackend); | |
| 50 | + | view.refresh(&mut log); | |
| 51 | + | assert_eq!(view.interfaces.len(), 3, "the stale list is still shown"); | |
| 52 | + | assert!(view.error.is_some(), "the failure is surfaced"); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // One key, whichever way the device is pointing, because the row already | |
| 56 | + | // says which state it is in. | |
| 57 | + | #[test] | |
| 58 | + | fn the_key_picks_the_action_the_row_is_not_already_in() { | |
| 59 | + | let connected = iface("wlp1s0", Kind::Wireless, State::Connected); | |
| 60 | + | let down = iface("wlp1s0", Kind::Wireless, State::Disconnected); | |
| 61 | + | assert!(NmCli.disconnect(&connected).is_some()); | |
| 62 | + | assert!(NmCli.connect(&down).is_some()); | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // A backend that only reads offers no keys, and the footer must not | |
| 66 | + | // advertise one that does nothing. | |
| 67 | + | #[test] | |
| 68 | + | fn a_read_only_backend_offers_no_action_keys() { | |
| 69 | + | let (view, _log) = mock_view(); | |
| 70 | + | let labels: Vec<&str> = view.hints().iter().map(|hint| hint.label).collect(); | |
| 71 | + | assert!(labels.contains(&"select"), "{labels:?}"); | |
| 72 | + | assert!(labels.contains(&"refresh"), "{labels:?}"); | |
| 73 | + | assert!(!labels.contains(&"connect/disconnect"), "{labels:?}"); | |
| 74 | + | assert!(!labels.contains(&"wifi radio"), "{labels:?}"); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // Pressing the key on a device nothing can act on must say so. "Nothing | |
| 78 | + | // happened" is the one outcome a console must never produce. | |
| 79 | + | #[test] | |
| 80 | + | fn acting_on_a_device_with_no_action_reports_why() { | |
| 81 | + | let (mut view, mut log) = mock_view(); | |
| 82 | + | // Row 2 of the mock is `lo`, unmanaged loopback. | |
| 83 | + | view.cursor.move_by(2); | |
| 84 | + | view.toggle(&mut log); | |
| 85 | + | let message = view.error.as_ref().expect("something was said"); | |
| 86 | + | assert!(message.contains("lo"), "{message}"); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | #[test] | |
| 90 | + | fn switching_a_radio_that_is_not_there_reports_why() { | |
| 91 | + | let (mut view, mut log) = mock_view(); | |
| 92 | + | assert_eq!(view.wifi, None); | |
| 93 | + | view.toggle_wifi(&mut log); | |
| 94 | + | assert!( | |
| 95 | + | view.error | |
| 96 | + | .as_ref() | |
| 97 | + | .is_some_and(|m| m.contains("no wifi radio")), | |
| 98 | + | "{:?}", | |
| 99 | + | view.error, | |
| 100 | + | ); | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // The radio being off is the explanation for every wireless device sitting | |
| 104 | + | // at `unavailable`, so it is worth the footer line even when nothing failed. | |
| 105 | + | #[test] | |
| 106 | + | fn a_radio_that_is_off_is_reported_without_an_error() { | |
| 107 | + | let (mut view, _log) = mock_view(); | |
| 108 | + | view.wifi = Some(false); | |
| 109 | + | let (severity, message) = view.status().expect("the footer says so"); | |
| 110 | + | assert_eq!(severity, Severity::Warn); | |
| 111 | + | assert!(message.contains("wifi radio off"), "{message}"); | |
| 112 | + | ||
| 113 | + | view.wifi = Some(true); | |
| 114 | + | assert!(view.status().is_none(), "a radio that is on says nothing"); | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | fn joinable_view() -> (NetView, CommandLog) { | |
| 118 | + | let mut log = CommandLog::new(); | |
| 119 | + | let mut view = NetView { | |
| 120 | + | backend: Box::new(JoinableBackend), | |
| 121 | + | interfaces: Vec::new(), | |
| 122 | + | cursor: Cursor::new(), | |
| 123 | + | error: None, | |
| 124 | + | wifi: None, | |
| 125 | + | mode: Mode::Devices, | |
| 126 | + | pending: None, | |
| 127 | + | no_wireless: None, | |
| 128 | + | }; | |
| 129 | + | view.refresh(&mut log); | |
| 130 | + | (view, log) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | #[test] | |
| 134 | + | fn scanning_shows_the_networks_it_found() { | |
| 135 | + | let (mut view, mut log) = joinable_view(); | |
| 136 | + | view.scan(&mut log); | |
| 137 | + | match &view.mode { | |
| 138 | + | Mode::Networks { networks, cursor } => { | |
| 139 | + | assert_eq!(networks.len(), 3); | |
| 140 | + | assert_eq!(cursor.selected(), Some(0)); | |
| 141 | + | } | |
| 142 | + | other => panic!("expected the network list, got {other:?}"), | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // A secured network asks before it joins; an open one does not. | |
| 147 | + | #[test] | |
| 148 | + | fn a_secured_network_asks_for_a_passphrase_first() { | |
| 149 | + | let (mut view, mut log) = joinable_view(); | |
| 150 | + | view.scan(&mut log); | |
| 151 | + | view.choose(); | |
| 152 | + | match &view.mode { | |
| 153 | + | Mode::Passphrase { ssid, field } => { | |
| 154 | + | assert_eq!(ssid, "Example Network"); | |
| 155 | + | assert_eq!(field.value(), "", "the field starts empty"); | |
| 156 | + | } | |
| 157 | + | other => panic!("expected the passphrase question, got {other:?}"), | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | #[test] | |
| 162 | + | fn an_open_network_joins_without_being_asked_anything() { | |
| 163 | + | let (mut view, mut log) = joinable_view(); | |
| 164 | + | view.scan(&mut log); | |
| 165 | + | // The open network is last: 22%, weakest of the three. | |
| 166 | + | if let Mode::Networks { cursor, .. } = &mut view.mode { | |
| 167 | + | cursor.move_by(2); | |
| 168 | + | } | |
| 169 | + | match view.choose() { | |
| 170 | + | Flow::AuthorizeInline(invocation) => { | |
| 171 | + | assert_eq!(invocation.display(), "true 'Airport WiFi'"); | |
| 172 | + | } | |
| 173 | + | other => panic!("expected the join to be raised, got {other:?}"), | |
| 174 | + | } | |
| 175 | + | assert_eq!(view.pending.as_deref(), Some("Airport WiFi")); | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // The shell runs the join and hands the answer back. Success lands on the | |
| 179 | + | // devices, because that is where the evidence is: the interface now says | |
| 180 | + | // connected and names the network. | |
| 181 | + | #[test] | |
| 182 | + | fn a_join_that_worked_returns_to_the_devices() { | |
| 183 | + | let (mut view, mut log) = joinable_view(); | |
| 184 | + | view.scan(&mut log); | |
| 185 | + | view.choose(); | |
| 186 | + | view.submit(); | |
| 187 | + | view.authorized(Ok(String::new()), &mut log); | |
| 188 | + | assert!(matches!(view.mode, Mode::Devices), "{:?}", view.mode); | |
| 189 | + | assert_eq!(view.error, None); | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // "not authorized" and "nobody could be asked" send someone to different | |
| 193 | + | // places, so they are not the same sentence. | |
| 194 | + | #[test] | |
| 195 | + | fn a_refused_join_says_it_was_refused_and_names_the_network() { | |
| 196 | + | let (mut view, mut log) = joinable_view(); | |
| 197 | + | view.scan(&mut log); | |
| 198 | + | view.choose(); | |
| 199 | + | view.submit(); | |
| 200 | + | view.authorized(Err(anyhow::anyhow!(crate::cli::INTERACTIVE_AUTH)), &mut log); | |
| 201 | + | let message = view.error.as_ref().expect("something was said"); | |
| 202 | + | assert!(message.contains("Example Network"), "{message}"); | |
| 203 | + | assert!(message.contains("not authorized"), "{message}"); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | #[test] | |
| 207 | + | fn any_other_failure_is_reported_as_nmcli_worded_it() { | |
| 208 | + | let (mut view, mut log) = joinable_view(); | |
| 209 | + | view.scan(&mut log); | |
| 210 | + | view.choose(); | |
| 211 | + | view.submit(); | |
| 212 | + | view.authorized(Err(anyhow::anyhow!("No network with SSID found")), &mut log); | |
| 213 | + | assert_eq!(view.error.as_deref(), Some("No network with SSID found"),); | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // The pane's whole promise is that it shows what ran. This is the one value | |
| 217 | + | // it must show having run *without* showing what it was. | |
| 218 | + | #[test] | |
| 219 | + | fn the_passphrase_never_reaches_the_log_pane() { | |
| 220 | + | let (mut view, mut log) = joinable_view(); | |
| 221 | + | view.scan(&mut log); | |
| 222 | + | view.choose(); | |
| 223 | + | for c in "hunter2-and-a-half".chars() { | |
| 224 | + | view.handle(KeyEvent::from(KeyCode::Char(c)), &mut log); | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // What the shell will show, since it is the shell that logs an | |
| 228 | + | // authorized command: the argv, and a note that something was piped in. | |
| 229 | + | let raised = view.handle(KeyEvent::from(KeyCode::Enter), &mut log); | |
| 230 | + | let Flow::AuthorizeInline(invocation) = raised else { | |
| 231 | + | panic!("expected the join to be raised, got {raised:?}"); | |
| 232 | + | }; | |
| 233 | + | let shown = invocation.display(); | |
| 234 | + | assert!(!shown.contains("hunter2"), "{shown}"); | |
| 235 | + | assert!(shown.contains("input withheld"), "{shown}"); | |
| 236 | + | ||
| 237 | + | let transcript: String = log | |
| 238 | + | .entries() | |
| 239 | + | .iter() | |
| 240 | + | .map(|entry| entry.command.clone()) | |
| 241 | + | .collect::<Vec<_>>() | |
| 242 | + | .join("\n"); | |
| 243 | + | assert!(!transcript.contains("hunter2"), "{transcript}"); | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // The field is emptied as the secret is built, so a second prompt after a | |
| 247 | + | // refusal starts from nothing rather than from what is still in the widget. | |
| 248 | + | #[test] | |
| 249 | + | fn submitting_empties_the_field_it_read() { | |
| 250 | + | let (mut view, mut log) = joinable_view(); | |
| 251 | + | view.scan(&mut log); | |
| 252 | + | view.choose(); | |
| 253 | + | for c in "hunter2".chars() { | |
| 254 | + | view.handle(KeyEvent::from(KeyCode::Char(c)), &mut log); | |
| 255 | + | } | |
| 256 | + | view.submit(); | |
| 257 | + | match &view.mode { | |
| 258 | + | Mode::Passphrase { field, .. } => assert_eq!(field.value(), ""), | |
| 259 | + | other => panic!("still asking, {other:?}"), | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // `q` and `?` are letters while a passphrase is open. Without this the | |
| 264 | + | // console quits itself partway through the value it just asked for. | |
| 265 | + | #[test] | |
| 266 | + | fn the_passphrase_field_claims_the_character_keys() { | |
| 267 | + | let (mut view, mut log) = joinable_view(); | |
| 268 | + | assert!(!view.text_entry(), "the device list does not"); | |
| 269 | + | view.scan(&mut log); | |
| 270 | + | assert!(!view.text_entry(), "nor does the network list"); | |
| 271 | + | view.choose(); | |
| 272 | + | assert!(view.text_entry()); | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // Esc backs out one question at a time and leaves only from the first, | |
| 276 | + | // which is exactly what `View::cancel`'s default is written around. | |
| 277 | + | #[test] | |
| 278 | + | fn esc_walks_back_through_the_questions() { | |
| 279 | + | let (mut view, mut log) = joinable_view(); | |
| 280 | + | view.scan(&mut log); | |
| 281 | + | assert!(matches!(view.cancel(), Flow::Continue)); | |
| 282 | + | assert!(matches!(view.mode, Mode::Devices)); | |
| 283 | + | ||
| 284 | + | view.scan(&mut log); | |
| 285 | + | view.choose(); | |
| 286 | + | assert!(matches!(view.cancel(), Flow::Continue)); | |
| 287 | + | assert!( | |
| 288 | + | matches!(view.mode, Mode::Devices), | |
| 289 | + | "and the typed passphrase goes with it", | |
| 290 | + | ); | |
| 291 | + | ||
| 292 | + | assert!( | |
| 293 | + | matches!(view.cancel(), Flow::Exit), | |
| 294 | + | "with nothing left to back out of, Esc leaves", | |
| 295 | + | ); | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | // The key is offered because there is a radio, not because it is on. A key | |
| 299 | + | // that vanishes when the radio is switched off teaches that the console | |
| 300 | + | // cannot scan. | |
| 301 | + | #[test] | |
| 302 | + | fn the_scan_key_survives_the_radio_being_off() { | |
| 303 | + | let (mut view, mut log) = joinable_view(); | |
| 304 | + | assert!(view.can_scan()); | |
| 305 | + | view.wifi = Some(false); | |
| 306 | + | assert!(view.can_scan(), "still offered"); | |
| 307 | + | ||
| 308 | + | view.scan(&mut log); | |
| 309 | + | assert!( | |
| 310 | + | matches!(view.mode, Mode::Devices), | |
| 311 | + | "but it does not scan {:?}", | |
| 312 | + | view.mode, | |
| 313 | + | ); | |
| 314 | + | assert!( | |
| 315 | + | view.error.as_ref().is_some_and(|m| m.contains("press w")), | |
| 316 | + | "and it names the key that fixes it: {:?}", | |
| 317 | + | view.error, | |
| 318 | + | ); | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | #[test] | |
| 322 | + | fn a_backend_that_cannot_scan_says_so() { | |
| 323 | + | let (mut view, mut log) = mock_view(); | |
| 324 | + | assert!(!view.can_scan(), "the mock has no radio to scan with"); | |
| 325 | + | view.scan(&mut log); | |
| 326 | + | assert!( | |
| 327 | + | view.error | |
| 328 | + | .as_ref() | |
| 329 | + | .is_some_and(|m| m.contains("cannot scan")), | |
| 330 | + | "{:?}", | |
| 331 | + | view.error, | |
| 332 | + | ); | |
| 333 | + | } |