Skip to main content

max / alloy

Give net a module layer, so the four layers stop being interleaved net.rs held model, backend, diagnosis, parsing and view in one file with the parsers sitting between NmCli and Mock. The layers are files now: model, parse, backend, diagnose, view, with the facade re-exporting exactly what status.rs and main.rs name. Tests move with their subject; the Interface builder, the network capture and the three stand-in backends go to a shared fixtures module, since a const in one sibling cannot be named from another.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
Author: Max Johnson <me@maxj.phd> · 2026-09-08 17:07 UTC
Signed with PGP, not checked
Commit: d6eaab3deb3d22a63864cc1a4ed0721af53e3df1
Parent: 7374868
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 + }