Skip to main content

max / alloy

Ship the alloy usb view: the bus and the Type-C connectors, from sysfs The view half of the USBGuard task, which is shippable on its own and takes no package. Two tabs, because a charger and a keyboard are answered by different subsystems: the bus knows what enumerated and what each interface claims to be, the connectors know which way power is flowing and whether any alternate mode came up. Measured on fw13 rather than read off documentation, and three of the findings are in the code: - a serial of sixteen 0xFF bytes is not a serial, and a permanent allow rule keyed on filler matches every device that ships the same filler - device class 0x11 is billboard, a device saying its alternate mode failed; this machine has one - alternate modes are read from the partner, not the port, which advertises what it supports and marks it all active The two lists are deliberately not joined: no connector symlink exists under /sys/bus/usb/devices on this kernel, so the mapping would be a guess.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 15:08 UTC
Commit: c1ee5d4940059eb27aef8581cc7406317d371229
Parent: 65574a0
3 files changed, +571 insertions, -0 deletions
@@ -23,6 +23,7 @@
23 23 alloy pkg install # what is layered, and where a package belongs
24 24 alloy pkg box # containers: the isolation dial [shipped]
25 25 alloy disk # storage: lsblk front, udisks mounts and partitions [shipped]
26 + alloy usb # attachments and Type-C connectors, straight from sysfs [shipped]
26 27 alloy update # system updates: rpm-ostree front
27 28 alloy sync # syncthing front (see CONTINUITY.md) [shipped]
28 29 alloy setup # the first-boot offer: mesh and sync [shipped]
@@ -212,6 +213,45 @@
212 213
213 214 Every command is executed as argv rather than through a shell, and displayed the same way it is run, quoting arguments that contain whitespace so the logged line can be pasted into a shell and mean the same thing there.
214 215
216 + ## `alloy usb`: the view before the policy
217 +
218 + Filed as the front end of USBGuard, then reordered: the view is the point and
219 + enforcement sits behind it. That ordering is not manners. A device-authorization
220 + prompt that fires on plug-in has nowhere to render in a sway session with no
221 + opinion about modal trust, and a silent default-deny is how a machine loses its
222 + keyboard. Making an attachment legible first means the policy, when it arrives,
223 + is turned on from a screen that already shows what it will act on.
224 +
225 + So the shipped half reads sysfs and nothing else. There is no usbguard
226 + dependency, and the screen works on an image that has never heard of it. It is
227 + also the one view that fronts no CLI: `lsusb` reads the same files and drops most
228 + of them, so the reads go straight to `/sys` the way `alloy display` does, and
229 + every parse is a pure function over a string.
230 +
231 + **Two tabs, because a charger and a keyboard are answered by different
232 + subsystems.** The bus knows what enumerated and what each interface claims to be;
233 + the Type-C connectors know which way power is flowing, whether the partner speaks
234 + Power Delivery, and whether any alternate mode came up. Neither list answers the
235 + other's question. Measured on fw13: port 3 reads `drawing power` while the other
236 + three read `supplying power` or `nothing attached`, which is the charger being
237 + visibly a different row without any policy existing.
238 +
239 + **The two lists are deliberately not joined.** The kernel can expose a
240 + `connector` symlink from a USB port to the Type-C port under it, which would let
241 + a row say which connector a device is on. No such link exists anywhere under
242 + `/sys/bus/usb/devices` on fw13 (6.17.9, measured 2026-08-22), so the join is
243 + unavailable on the hardware Alloy runs on. Inferring it from port numbering would
244 + be a guess that reads as a fact, so the lists stay separate.
245 +
246 + Three things the screen surfaces that a person would otherwise need a spec to
247 + see. Device class `0x11` is **billboard**, which exists so a Type-C thing whose
248 + alternate mode failed can enumerate and say so. fw13 has one, a Digital AV
249 + adapter sitting at 12 Mb/s. A **serial of filler bytes is refused** rather than
250 + shown, because a permanent allow rule keyed on sixteen `0xFF` bytes matches every
251 + device that ships the same filler. And alternate modes are read from the
252 + **partner**, not the port: the port advertises what it supports and marks it all
253 + active, so reading that side reports DisplayPort on empty connectors.
254 +
215 255 ## `alloy settings`: two tabs over one form engine
216 256
217 257 The thesis at the top of this document, built. One view, two tabs, and the same form under both:
@@ -335,6 +375,7 @@
335 375 - **In progress.** `alloy settings`, with schemas for the v0-adopted TOML configs (rio, yazi, mako, and others; the sway config takes the text-edit fallback, and `schemas/sway.schema` is the header that declares it). The largest remaining piece: schema-DSL v1 parser, `toml_edit` roundtrip layer, and the form widgets together. The view architecture is settled (see "View architecture" above); build order is (1) schema parser and (2) the `Bind` seam with its `toml_edit` implementation, both pure and testable against `rio.toml.schema`, **both shipped**; (3) `AlloyForm` + `AlloyField` + `TextField` promotion as the `alloy_tui` 1.2 release, **shipped**; (4) tab chrome and the Applications tab, **shipped**; (5) per-field edit plus save and the quit-confirm, **shipped**, then the filterable pick overlay, **shipped**, which was the last field type that could not be changed; (6) the System tab, time rows first, **shipped** — one front, one enum, one bool, one display-only row, which is the smallest complete slice of the command side; (7) the remaining System rows, **shipped** except theme, which needs somewhere to persist a choice and a shell that can re-theme mid-run; (8) presets and live diagnostics, the collapsible sections having landed with the tab; (9) the text-edit fallback, **shipped**, which is also what puts a schema-less app in the list at all: the catalog is built from `.schema` files, so sway reaches it through a header-only schema declaring `syntax = "sway"` rather than through a second catalog; (10) `AlloyTable` read-only.
336 376 - **Written against a real capture, still short one case.** `alloy display` fronts `swaymsg` alone. `wlr-randr` is gone from this line and kanshi never arrived: neither is in the image, and sway re-applies stored `output` config on hotplug by connector name or by the `make model serial` identifier, which is the feature kanshi would have been carried for. The verb's one string serves both consumers, since `output eDP-1 scale 1.25` in a config file and `swaymsg output eDP-1 scale 1.25` at runtime are the same words after the command name; the console runs it and writes the identical text to `~/.config/sway/config.d/50-display.conf`, which the shipped sway config includes after `/etc/sway/config.d/*` so the user's file wins. The parser is written against a verbatim `swaymsg -t get_outputs` capture from the FW12 install (2026-07-29, sway 1.11) and re-checkable inside a session with `reads_this_machines_real_outputs`. The installer seeds that same file from the same generator, so a fresh machine boots at the scale its panel wants rather than at 1.0: there is no compositor to ask during an install, so the panel is read from sysfs and its physical size from EDID, and the scale is that density snapped to the ladder the `s` key walks. A panel that cannot be read seeds nothing, which is 1.0 and one keypress from correct. What is still missing is a multi-output capture: nobody has attached a second display to an Alloy machine, and that is the case parsers break on, so the mode picker is deliberately unbuilt (the one testable panel advertises exactly one mode).
337 377 - **Shipped, and the only screen that is not a question.** `alloy install`'s credits page, added after the summary as a sixth step. It names each project Alloy ships, its SPDX identifier and its URL, off `crates/alloy/credits.toml` embedded in the binary. The install now starts from this screen rather than from the summary, which is the ordering the page argues for: the last screen before a disk is repartitioned should be the one that says whose work is about to be written to it. Curated rather than generated, so it drifts by design and keeping it current is a release step (see [IMAGE.md](IMAGE.md)). Not a source offer and not a license-text viewer: the full texts stay in `/usr/share/licenses` on the installed system.
378 + - **Shipped as a view, with its enforcement half still ahead of it.** `alloy usb`, two tabs over sysfs: the bus, and the Type-C connectors. No usbguard dependency, no CLI fronted, and it works on an image that carries neither. What is left is the policy the view was filed to front: usbguard into both profiles, deny-unknown with an activation the user performs from this screen, and the keyboard gate that suspends enforcement whenever the machine has no usable keyboard. That gate is the part that makes deny-unknown safe, and it has to count i8042 keyboards as usable or it opens permanently on every Framework laptop, whose internal keyboard is not a USB device at all.
338 379 - **Then.** `alloy theme`, which swaps the runtime theme in place (makeover consumer, no re-login). The first-boot flow (see [CONTINUITY.md](CONTINUITY.md)) shipped as `alloy setup`: two rows over the same `mesh` and `sync` backend seams, so there is no third enrollment path to keep in agreement with them. It survives the teardown mesh enrollment costs by refreshing on the first tick after a suspend rather than on its poll counter. The shell's post-suspend refresh arrives as one `tick` call, and a counter would swallow four out of five of them, leaving the row the user just enrolled reading "not enrolled" for several seconds. The screen appears once: the session autostart runs `alloy setup --if-first-boot`, and the console records that it asked in the same config store the theme lives in.
339 380 - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.)
340 381 - **v2+.** Third-party subcommand registration (a well-known directory of ratatui adapters the console discovers at runtime), if a real ecosystem case emerges. Not planned.
@@ -35,6 +35,7 @@
35 35 mod system;
36 36 mod theme;
37 37 mod theme_apply;
38 + mod usb;
38 39 mod wizard;
39 40
40 41 use anyhow::Result;
@@ -181,6 +182,11 @@
181 182 #[command(subcommand)]
182 183 surface: Option<DiskSurface>,
183 184 },
185 + /// Attached USB devices, and what each Type-C connector is carrying
186 + Usb {
187 + #[command(subcommand)]
188 + surface: Option<UsbSurface>,
189 + },
184 190 /// Synchronized folders and the devices they reach
185 191 Sync {
186 192 #[command(subcommand)]
@@ -251,6 +257,19 @@
251 257 All,
252 258 }
253 259
260 + /// Which USB list to open on.
261 + ///
262 + /// Nouns, like [`DiskSurface`]'s. The split is the one the module docs argue:
263 + /// the bus knows what enumerated, the connectors know which way power is
264 + /// flowing, and neither list answers the other's question.
265 + #[derive(Subcommand)]
266 + enum UsbSurface {
267 + /// Devices on the bus, and what each one claims to be
268 + Attachments,
269 + /// Type-C connectors: power role, partner, and any alternate mode
270 + Connectors,
271 + }
272 +
254 273 /// Which list of Bluetooth devices to open on.
255 274 ///
256 275 /// Nouns, like [`DiskSurface`]'s. The split is whether bluez holds keys for the
@@ -431,6 +450,17 @@
431 450 let mut view = disk::DiskView::new(tab, &mut log);
432 451 shell::run(&theme, &mut view, &mut log)
433 452 }
453 + Command::Usb { surface } => {
454 + let tab = match surface {
455 + Some(UsbSurface::Connectors) => usb::Tab::Connectors,
456 + // Bare `alloy usb` lands on attachments: every machine has
457 + // something on the bus, and a desktop with no Type-C reports an
458 + // empty connector list, which is a poor first screen.
459 + Some(UsbSurface::Attachments) | None => usb::Tab::Attachments,
460 + };
461 + let mut view = usb::UsbView::new(tab, &mut log);
462 + shell::run(&theme, &mut view, &mut log)
463 + }
434 464 Command::Sync { surface } => {
435 465 let tab = match surface {
436 466 Some(SyncSurface::Devices) => sync::Tab::Devices,
@@ -1,0 +1,1577 @@
1 + //! Everything the OS knows about an attachment: the `alloy usb` verb.
2 + //!
3 + //! # Why the view comes first
4 + //!
5 + //! This verb was filed as the front end of USBGuard, and the order was then
6 + //! corrected: the view is the point, and the enforcement sits behind it. That
7 + //! ordering is not politeness. A device-authorization prompt that fires when
8 + //! something is plugged in has nowhere to render in a sway session with no
9 + //! opinion about modal trust, and a silent default-deny is how a machine loses
10 + //! its keyboard. Making the attachment legible first means the policy, when it
11 + //! arrives, is turned on from a screen that already shows what it will act on.
12 + //!
13 + //! So this module reads sysfs and nothing else. There is no usbguard dependency
14 + //! here, and the screen works on an image that has never heard of it.
15 + //!
16 + //! # A charger and a keyboard are different rows
17 + //!
18 + //! The requirement this screen is measured against: an attachment that carries
19 + //! only power must be visibly different from one that carries data, before any
20 + //! policy exists. Two facts do that, and they come from two different places in
21 + //! sysfs, which is why the screen has two tabs rather than one list.
22 + //!
23 + //! - **Attachments** ([`ATTACHMENTS`]) is the USB device tree. Anything on it is
24 + //! by definition carrying data — it enumerated. What the rows add is *what
25 + //! kind* of data: the interface classes a device claims are the honest answer
26 + //! to "what is this", and they are per-interface rather than per-device
27 + //! because a webcam claims video twice and a vendor-specific channel once.
28 + //! - **Connectors** ([`CONNECTORS`]) is the Type-C port list, which knows things
29 + //! the bus cannot: which way power is flowing, whether the partner speaks
30 + //! Power Delivery, and whether any alternate mode came up. A port sinking
31 + //! power with a partner attached and nothing new on the bus is a charger, and
32 + //! both halves of that sentence are on screen at once.
33 + //!
34 + //! **What is deliberately not done: joining the two.** The kernel can expose a
35 + //! `connector` symlink from a USB port to the Type-C port it sits on, which
36 + //! would let a row say "this device is on port 2". Measured on fw13
37 + //! (6.17.9, 2026-08-22): no `connector` link exists anywhere under
38 + //! `/sys/bus/usb/devices`, so the join is not available on the hardware Alloy
39 + //! runs on. Rather than infer the mapping from port numbering — which is a guess
40 + //! that reads as a fact — the two lists stay separate and the user does the
41 + //! join with their eyes. If a kernel or a board later ships the link, this is
42 + //! the note that says what to wire up.
43 + //!
44 + //! # Billboard is a device saying its alternate mode failed
45 + //!
46 + //! Device class `0x11` exists so that a Type-C thing whose alternate mode could
47 + //! not be entered can still enumerate and explain itself. Measured on fw13: a
48 + //! `USB Type-C Digital AV Adapter` sits on the bus at 12 Mb/s claiming exactly
49 + //! that class. It is the one device class that is a diagnostic rather than a
50 + //! function, so [`Attachment::note`] surfaces it on the row instead of leaving
51 + //! it as a number in the detail pane.
52 + //!
53 + //! # Reading sysfs rather than fronting a CLI
54 + //!
55 + //! The console's habit is to front someone else's CLI so the log pane can show
56 + //! the argv (see [`cli`](crate::cli)). There is no CLI here to front: `lsusb`
57 + //! reads the same files and drops most of them, and the enforcement half will
58 + //! bring `usbguard` with it. Following `display`, the reads go straight to
59 + //! sysfs and every parse is a pure function over a string, so the tests describe
60 + //! machines this one is not.
61 + //!
62 + //! <!-- wiki: alloy-console -->
63 +
64 + use std::path::Path;
65 +
66 + use alloy_tui::keys::Action;
67 + use alloy_tui::{
68 + AlloyBlock, AlloyList, AlloyTabs, Cursor, Hint, KeyGroup, Severity, Theme, binding, hint, text,
69 + };
70 + use ratatui::Frame;
71 + use ratatui::crossterm::event::{KeyCode, KeyEvent};
72 + use ratatui::layout::{Constraint, Layout, Rect};
73 + use ratatui::text::Line;
74 + use ratatui::widgets::{Paragraph, Wrap};
75 +
76 + use crate::cli::CommandLog;
77 + use crate::shell::{Flow, View, block_title, truncate};
78 +
79 + /// The USB device tree. One directory per device, plus one per interface.
80 + ///
81 + /// Device directories are named for their address (`3-1.1.2`); interface
82 + /// directories carry a colon (`3-1.1.2:1.0`). That punctuation is the whole of
83 + /// how the two are told apart, and it is stable kernel ABI.
84 + const ATTACHMENTS: &str = "/sys/bus/usb/devices";
85 +
86 + /// The Type-C connectors, which are a different subsystem from the bus.
87 + ///
88 + /// `portN` is the connector, `portN-partner` is whatever is plugged into it,
89 + /// and `portN-partner.M` would be an alternate mode the partner entered.
90 + const CONNECTORS: &str = "/sys/class/typec";
91 +
92 + // ---- the model ----
93 +
94 + /// One device on the USB bus.
95 + #[derive(Debug, Clone, PartialEq, Eq)]
96 + pub(crate) struct Attachment {
97 + /// The kernel's own address, and this device's sysfs directory name.
98 + ///
99 + /// `3-1.1.2` reads as bus 3, port 1, through the hub on port 1, port 2. It
100 + /// is the only identifier every device has: ids can collide across two of
101 + /// the same dongle and serials are frequently absent or garbage.
102 + pub(crate) address: String,
103 + pub(crate) vendor_id: String,
104 + pub(crate) product_id: String,
105 + /// `manufacturer`, which many devices do not set.
106 + pub(crate) vendor: String,
107 + /// `product`, likewise.
108 + pub(crate) product: String,
109 + /// `serial`, when it is a serial rather than filler. See [`serial_of`].
110 + pub(crate) serial: Option<String>,
111 + pub(crate) speed: Speed,
112 + /// Negotiated lanes. USB 3.2 Gen 2x2 is the only thing that reports 2, and
113 + /// nothing here has; carried because "10 Gb/s" over one lane and over two
114 + /// are different links and the row should not flatten them.
115 + pub(crate) rx_lanes: u8,
116 + pub(crate) tx_lanes: u8,
117 + /// The device-level class, which is usually `00` — meaning "look at the
118 + /// interfaces" — and occasionally load-bearing. See [`Attachment::note`].
119 + pub(crate) class: Class,
120 + pub(crate) interfaces: Vec<Interface>,
121 + pub(crate) removable: Fixity,
122 + /// Whether the kernel has authorized this device. Always true until
123 + /// something sets a policy, which is exactly what the enforcement half will
124 + /// change, so the field is here before anything writes it.
125 + pub(crate) authorized: bool,
126 + /// `bMaxPower`, verbatim (`500mA`). A budget the device asked for, not a
127 + /// measurement of what it draws.
128 + pub(crate) max_power: String,
129 + /// A root hub is the controller itself, not something someone plugged in.
130 + pub(crate) is_root_hub: bool,
131 + /// How many hubs deep, for the tree indent. Derived from the address.
132 + pub(crate) depth: usize,
133 + }
134 +
135 + /// One interface a device claims, and the driver bound to it.
136 + ///
137 + /// A device is not one thing. The `MiniFuse 2` on this desk claims six
138 + /// interfaces — four audio, one MIDI, one vendor-specific — and a policy that
139 + /// reasons about "an audio device" is reasoning about the interfaces.
140 + #[derive(Debug, Clone, PartialEq, Eq)]
141 + pub(crate) struct Interface {
142 + /// The `:1.0` tail of the directory name.
143 + pub(crate) number: String,
144 + pub(crate) class: Class,
145 + pub(crate) subclass: String,
146 + pub(crate) protocol: String,
147 + /// The bound driver, or `None` for an interface nothing claimed. An
148 + /// unclaimed interface is not an error — `fe/01/01` (DFU) is present on
149 + /// half the devices here and no driver wants it — but it is the shape a
150 + /// device with a missing module also has, so it is shown rather than
151 + /// hidden.
152 + pub(crate) driver: Option<String>,
153 + }
154 +
155 + /// A USB class code, device-level or interface-level.
156 + ///
157 + /// The same numbering serves both positions, which is why one type covers them:
158 + /// `09` means hub in either place. Held as the raw byte so an unknown class
159 + /// still renders as itself rather than as "unknown".
160 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
161 + pub(crate) struct Class(pub(crate) u8);
162 +
163 + impl Class {
164 + /// The USB-IF name, or `None` for a code with no assigned meaning.
165 + ///
166 + /// Only the codes that are actually assigned are here. A device claiming
167 + /// something outside the list renders as its hex, which is more useful than
168 + /// a guess: an unassigned class is a real thing to notice.
169 + pub(crate) const fn name(self) -> Option<&'static str> {
170 + Some(match self.0 {
171 + 0x00 => "per-interface",
172 + 0x01 => "audio",
173 + 0x02 => "communications",
174 + 0x03 => "HID",
175 + 0x05 => "physical",
176 + 0x06 => "image",
177 + 0x07 => "printer",
178 + 0x08 => "mass storage",
179 + 0x09 => "hub",
180 + 0x0a => "CDC data",
181 + 0x0b => "smart card",
182 + 0x0d => "content security",
183 + 0x0e => "video",
184 + 0x0f => "personal healthcare",
185 + 0x10 => "audio/video",
186 + 0x11 => "billboard",
187 + 0x12 => "USB-C bridge",
188 + 0x3c => "I3C",
189 + 0xdc => "diagnostic",
190 + 0xe0 => "wireless",
191 + 0xef => "miscellaneous",
192 + 0xfe => "application-specific",
193 + 0xff => "vendor-specific",
194 + _ => return None,
195 + })
196 + }
197 +
198 + /// How a row spells it: the name when there is one, the hex when there is
199 + /// not, and never both — the detail pane carries the hex for every class.
200 + pub(crate) fn label(self) -> String {
201 + self.name()
202 + .map_or_else(|| format!("{:02x}", self.0), ToString::to_string)
203 + }
204 +
205 + /// Whether this is the class a device uses to report a failed alternate
206 + /// mode. See the module docs.
207 + pub(crate) const fn is_billboard(self) -> bool {
208 + self.0 == 0x11
209 + }
210 + }
211 +
212 + /// The negotiated link speed, from `speed` (in Mb/s, as a decimal string).
213 + ///
214 + /// An enum rather than the number because the number is not what a person
215 + /// wants: `480` is High-Speed and `12` is Full-Speed, and a device that should
216 + /// be one sitting at the other is the single most common USB complaint there
217 + /// is. Naming the rung is what makes that visible.
218 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
219 + pub(crate) enum Speed {
220 + /// 1.5 Mb/s. USB 1.0.
221 + Low,
222 + /// 12 Mb/s. USB 1.1.
223 + Full,
224 + /// 480 Mb/s. USB 2.0.
225 + High,
226 + /// 5 Gb/s. USB 3.0.
227 + Super,
228 + /// 10 Gb/s. USB 3.1 Gen 2.
229 + SuperPlus,
230 + /// 20 Gb/s. USB 3.2 Gen 2x2.
231 + Super20,
232 + /// A rung the kernel reported that this list does not name. Carried so a
233 + /// future speed renders as its number rather than as a lie.
234 + Other(u32),
235 + /// No `speed` file, or one that did not parse.
236 + Unknown,
237 + }
238 +
239 + impl Speed {
240 + /// Parse the `speed` file. Its unit is Mb/s and it is sometimes fractional
241 + /// (`1.5`), which is why this does not go through `u32::from_str`.
242 + pub(crate) fn parse(raw: &str) -> Self {
243 + match raw.trim() {
244 + "1.5" => Self::Low,
245 + "12" => Self::Full,
246 + "480" => Self::High,
247 + "5000" => Self::Super,
248 + "10000" => Self::SuperPlus,
249 + "20000" => Self::Super20,
250 + other => other.parse().map_or(Self::Unknown, Self::Other),
251 + }
252 + }
253 +
254 + /// The rung's name, which is what the row shows.
255 + pub(crate) fn label(self) -> String {
256 + match self {
257 + Self::Low => "1.5 Mb/s".into(),
258 + Self::Full => "12 Mb/s".into(),
259 + Self::High => "480 Mb/s".into(),
260 + Self::Super => "5 Gb/s".into(),
261 + Self::SuperPlus => "10 Gb/s".into(),
262 + Self::Super20 => "20 Gb/s".into(),
263 + Self::Other(mbps) => format!("{mbps} Mb/s"),
264 + Self::Unknown => "-".into(),
265 + }
266 + }
267 +
268 + /// The USB generation that rung belongs to, for the detail pane.
269 + pub(crate) const fn generation(self) -> &'static str {
270 + match self {
271 + Self::Low => "USB 1.0 low-speed",
272 + Self::Full => "USB 1.1 full-speed",
273 + Self::High => "USB 2.0 high-speed",
274 + Self::Super => "USB 3.0 SuperSpeed",
275 + Self::SuperPlus => "USB 3.1 SuperSpeed+",
276 + Self::Super20 => "USB 3.2 SuperSpeed+ 20Gbps",
277 + Self::Other(_) | Self::Unknown => "unknown",
278 + }
279 + }
280 + }
281 +
282 + /// What `removable` says, which is a firmware claim rather than a fact.
283 + ///
284 + /// Named `Fixity` rather than `Removable` so the type is not its own variant.
285 + ///
286 + /// Three values, and `unknown` is the most common: measured on fw13, 14 of 26
287 + /// devices report it. Kept as three rather than collapsed to a bool because
288 + /// "the firmware did not say" and "the firmware said no" are different, and the
289 + /// enforcement half will care — a fixed device appearing as removable is a
290 + /// thing worth noticing.
291 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
292 + pub(crate) enum Fixity {
293 + Fixed,
294 + Removable,
295 + Unknown,
296 + }
297 +
298 + impl Fixity {
299 + pub(crate) fn parse(raw: &str) -> Self {
300 + match raw.trim() {
301 + "fixed" => Self::Fixed,
302 + "removable" => Self::Removable,
303 + _ => Self::Unknown,
304 + }
305 + }
306 +
307 + pub(crate) const fn label(self) -> &'static str {
308 + match self {
309 + Self::Fixed => "fixed",
310 + Self::Removable => "removable",
311 + Self::Unknown => "unstated",
312 + }
313 + }
314 + }
315 +
316 + impl Attachment {
317 + /// The name a row leads with.
318 + ///
319 + /// `product` when the device set one, the ids when it did not. Never the
320 + /// address: that is the second column, and leading with it would make every
321 + /// row start with the same shape.
322 + pub(crate) fn name(&self) -> String {
323 + if !self.product.is_empty() {
324 + return self.product.clone();
325 + }
326 + if !self.vendor.is_empty() {
327 + return self.vendor.clone();
328 + }
329 + format!("{}:{}", self.vendor_id, self.product_id)
330 + }
331 +
332 + /// What this device is, from its interfaces.
333 + ///
334 + /// Interface classes, deduplicated, in first-claimed order. A device with a
335 + /// meaningful device-level class (anything but `00` and `ef`) is described
336 + /// by that instead: `ef` is "miscellaneous", which says nothing, and `00`
337 + /// means "ask the interfaces" outright.
338 + pub(crate) fn kind(&self) -> String {
339 + if self.class.0 != 0x00 && self.class.0 != 0xef {
340 + return self.class.label();
341 + }
342 + let mut seen: Vec<String> = Vec::new();
343 + for interface in &self.interfaces {
344 + let label = interface.class.label();
345 + if !seen.contains(&label) {
346 + seen.push(label);
347 + }
348 + }
349 + if seen.is_empty() {
350 + "-".into()
351 + } else {
352 + seen.join(", ")
353 + }
354 + }
355 +
356 + /// A one-line diagnostic for the row, when the device is telling us
357 + /// something rather than merely being something.
358 + ///
359 + /// Only two so far, and both are real states a person would otherwise have
360 + /// to know a class code to see.
361 + pub(crate) fn note(&self) -> Option<&'static str> {
362 + if self.class.is_billboard() {
363 + return Some("alternate mode did not come up");
364 + }
365 + if !self.authorized {
366 + return Some("not authorized");
367 + }
368 + None
369 + }
370 +
371 + /// Interfaces that enumerated with no driver bound.
372 + ///
373 + /// Not an error on its own — see [`Interface::driver`] — so this feeds the
374 + /// detail pane rather than the status line.
375 + pub(crate) fn unclaimed(&self) -> usize {
376 + self.interfaces
377 + .iter()
378 + .filter(|interface| interface.driver.is_none())
379 + .count()
380 + }
381 + }
382 +
383 + // ---- reading the bus ----
384 +
385 + /// Whether a directory name under [`ATTACHMENTS`] names a device.
386 + ///
387 + /// Interfaces live in the same directory and are told apart by the colon in
388 + /// their name. Root hubs are `usbN` and are devices, so they pass.
389 + fn is_device_name(name: &str) -> bool {
390 + !name.contains(':')
391 + }
392 +
393 + /// Whether this address is a root hub rather than something plugged in.
394 + fn is_root_hub_name(name: &str) -> bool {
395 + name.starts_with("usb")
396 + }
397 +
398 + /// How many hubs deep an address sits, for the tree indent.
399 + ///
400 + /// `3-1.1.2` is three: the dots are hub traversals and the leading `bus-port`
401 + /// is the first level. A root hub is zero. Derived rather than read because
402 + /// sysfs states the parentage as a directory tree that is flattened here, and
403 + /// the address already carries it losslessly.
404 + fn depth_of(name: &str) -> usize {
405 + if is_root_hub_name(name) {
406 + return 0;
407 + }
408 + let Some((_, path)) = name.split_once('-') else {
409 + return 0;
410 + };
411 + path.split('.').count()
412 + }
413 +
414 + /// The `serial` file, when it holds a serial.
415 + ///
416 + /// Devices lie here, and one of them is on this desk: the `MiniFuse 2` reports
417 + /// sixteen `0xFF` bytes, which arrive as `ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ` and are not a
418 + /// serial number. Anything with a non-ASCII-printable byte is refused, and so
419 + /// is the empty string, which the `8BitDo` receiver reports.
420 + ///
421 + /// Refused rather than shown-as-is because a serial is the field a permanent
422 + /// allow rule will be keyed on when the enforcement half lands. A rule keyed on
423 + /// filler matches every device that ships the same filler.
424 + fn serial_of(raw: &str) -> Option<String> {
425 + let trimmed = raw.trim();
426 + if trimmed.is_empty() {
427 + return None;
428 + }
429 + if !trimmed.chars().all(|c| c.is_ascii_graphic() || c == ' ') {
430 + return None;
431 + }
432 + Some(trimmed.to_string())
433 + }
434 +
435 + /// A hex byte from a sysfs class/subclass/protocol file.
436 + fn class_of(raw: &str) -> Class {
437 + Class(u8::from_str_radix(raw.trim(), 16).unwrap_or(0))
438 + }
439 +
440 + /// Read a file under `dir`, trimmed, or the empty string.
441 + ///
442 + /// Every field here is optional in the sense that some device somewhere does
443 + /// not have it, so a missing file is an empty value rather than an error. The
444 + /// alternative — refusing to describe a device because it did not set
445 + /// `manufacturer` — hides exactly the odd hardware this screen is for.
446 + fn field(dir: &Path, name: &str) -> String {
447 + std::fs::read_to_string(dir.join(name))
448 + .map(|raw| raw.trim().to_string())
449 + .unwrap_or_default()
450 + }
451 +
452 + /// Every attachment on this machine, root hubs first, then depth order.
453 + pub(crate) fn attachments() -> Vec<Attachment> {
454 + attachments_in(Path::new(ATTACHMENTS))
455 + }
456 +
457 + /// [`attachments`] against a given sysfs root, which is the whole of it.
458 + ///
459 + /// Split out for the tests, as `display` does: the shapes worth asserting on
460 + /// are a device with a garbage serial and a device claiming six interfaces, and
461 + /// neither can be arranged under the real `/sys`.
462 + fn attachments_in(root: &Path) -> Vec<Attachment> {
463 + let Ok(entries) = std::fs::read_dir(root) else {
464 + return Vec::new();
465 + };
466 + let mut names: Vec<String> = entries
467 + .filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
468 + .filter(|name| is_device_name(name))
469 + .collect();
470 + // Sorted so the list is the machine rather than the directory order, and
471 + // so two runs on an unchanged machine render identically.
472 + names.sort_by_key(|name| address_order(name));
473 +
474 + names.iter().map(|name| attachment_at(root, name)).collect()
475 + }
476 +
477 + /// A sort key that puts `usb3` beside the `3-*` devices it parents.
478 + ///
479 + /// Plain string order interleaves the root hubs into a block of their own and
480 + /// scatters each bus's devices away from it, which reads as eight controllers
481 + /// followed by an unattributed list. Keyed on the bus number first, then on the
482 + /// port path with each segment zero-padded so `3-1.10` sorts after `3-1.2`.
483 + fn address_order(name: &str) -> (u32, Vec<u32>) {
484 + if let Some(bus) = name.strip_prefix("usb") {
485 + return (bus.parse().unwrap_or(0), Vec::new());
486 + }
487 + let Some((bus, path)) = name.split_once('-') else {
488 + return (0, Vec::new());
489 + };
490 + (
491 + bus.parse().unwrap_or(0),
492 + path.split('.')
493 + .map(|segment| segment.parse().unwrap_or(0))
494 + .collect(),
495 + )
496 + }
497 +
498 + /// One device, read from its directory.
499 + fn attachment_at(root: &Path, name: &str) -> Attachment {
500 + let dir = root.join(name);
Lines truncated