|
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);
|