Skip to main content

max / alloy

Add the alloy disk verb: attached storage, mounted or not Closes the audit finding that plugging in a USB stick did nothing. udisks2 was in the image, nothing drove it, and yazi ships no mount path, so a stick stayed invisible until somebody mounted it by hand as root. Reads with lsblk, acts with udisksctl, and the two are detected separately because a machine can have the first without the second. A machine with lsblk alone still gets the inventory, with the action keys rendered unavailable and a reason, rather than a screen that lists rows and does nothing when they are pressed. Nothing escalates: udisks answers a session user through polkit for removable media, where mount would mean run0 and an authentication prompt for plugging in a stick. Two things the real hardware taught, both now pinned by tests against a captured fixture: The RM bit is not the question. A USB SSD reports rm: false, because that bit means the medium can leave the drive rather than the drive can leave the machine, so removability is the flag or a hot-plug transport. Going by the bit alone put the external drive on the wrong tab. lsblk gives active swap a mountpoint of [SWAP], a bracketed pseudo-use rather than a directory. Read as an ordinary mountpoint it made swap look like a filesystem the console would offer to unmount. Found by running the parser against fw13, not by reasoning about it. It deliberately does not write installer media. alloy image already does, handing the terminal to build/build-image.sh --write, which refuses partitions, refuses anything mounted, and verifies with cmp. Two paths to writing a disk is one more than is safe. The Containerfile now asserts udisksctl and the service unit rather than trusting the base to keep carrying them, the same correction the bluez preset entry got. 18 new tests, 727 passing across the crate, clippy clean.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 17:11 UTC
Signed with PGP, not checked
Commit: adf00a2dad4dd8c3b1926fae19d26dcf50d8db50
Parent: c78ac71
6 files changed, +565 insertions, -1 deletion
@@ -1534,6 +1534,26 @@
1534 1534 fi
1535 1535
1536 1536 # =====================================================================
1537 + # udisks — assert the daemon `alloy disk` drives is actually here.
1538 + #
1539 + # udisks2 arrives from the base rather than from a line in this file, and an
1540 + # inherited dependency nothing states is the pattern that already went wrong
1541 + # twice: bluez was enabled by Fedora's preset with no decision recorded, and the
1542 + # emoji alias named a font the image never installed. `alloy disk` reads with
1543 + # lsblk and acts with udisksctl, so if the base ever drops it the verb becomes a
1544 + # read-only screen whose every action key refuses. That is a change worth failing
1545 + # the build over rather than discovering on a plugged-in stick.
1546 + #
1547 + # Not `dnf install`: the base already carries it, and adding the line would claim
1548 + # ownership of a dependency this file does not actually choose. The assertion is
1549 + # the honest version.
1550 + RUN set -eux; \
1551 + command -v udisksctl >/dev/null \
1552 + || { echo "no udisksctl; alloy disk could list volumes but never mount one" >&2; exit 1; }; \
1553 + test -f /usr/lib/systemd/system/udisks2.service \
1554 + || { echo "udisks2 ships no service unit; nothing would answer udisksctl" >&2; exit 1; }; \
1555 + echo "udisks: present, D-Bus activated, drives alloy disk"
1556 +
1537 1557 # polkit rules — assert the grant is not inert.
1538 1558 #
1539 1559 # usr/share/polkit-1/rules.d/50-alloy-settings.rules turns five actions
@@ -21,6 +21,7 @@
21 21 alloy display # outputs: swaymsg front, scale and enable [shipped]
22 22 alloy pkg install # what is layered, and where a package belongs
23 23 alloy pkg box # containers: the isolation dial [shipped]
24 + alloy disk # attached storage: lsblk front, udisks acts [shipped]
24 25 alloy update # system updates: rpm-ostree front
25 26 alloy sync # syncthing front (see CONTINUITY.md) [shipped]
26 27 alloy setup # the first-boot offer: mesh and sync [shipped]
M docs/STACK.md +15 -1
@@ -433,7 +433,7 @@
433 433
434 434 ## Peripherals
435 435
436 - Two absences the 2026-07-29 feature audit turned up, both of which this document was obliged to argue and had not. Printing was missing entirely, including from the rejected lists. Bluetooth was in the image and enabled, by inheritance from Fedora's own preset rather than by any decision recorded here.
436 + Three absences the 2026-07-29 feature audit turned up, all of which this document was obliged to argue and had not. Printing was missing entirely, including from the rejected lists. Bluetooth was in the image and enabled, by inheritance from Fedora's own preset rather than by any decision recorded here. Removable media had the daemon and no surface, so plugging in a stick did nothing.
437 437
438 438 ### Printing: **CUPS, driverless only**
439 439
@@ -455,6 +455,20 @@
455 455
456 456 Anyone who needs it installs `sane-backends` through the package view. Most network scanners also scan to a folder or to email without a host driver at all, which is the path worth trying first.
457 457
458 + ### Removable media: **udisks2, reached through `alloy disk`**
459 +
460 + **Plugging in a USB stick used to do nothing.** udisks2 came from the base, nothing drove it, and yazi ships no mount path, so a stick or a Framework expansion card stayed invisible until somebody mounted it by hand as root. The 2026-07-29 audit filed that and `alloy disk` is the answer: an `lsblk` inventory with `udisksctl` behind mount, unmount and eject.
461 +
462 + **udisks rather than `mount(8)`, and the reason is the privilege prompt.** udisks answers a session user through polkit for removable media, so mounting a stick asks for nothing. Doing the same through `mount` means `run0`, which is an authentication prompt for the most ordinary thing a laptop does. Nothing in this verb escalates.
463 +
464 + **Two tools, detected separately.** lsblk is util-linux and is on everything; udisks2 is a daemon that has to be answering. A machine with the first and not the second still gets the inventory, with the action keys shown as unavailable and a reason. The Containerfile asserts both rather than trusting the base to keep carrying udisks, on the same principle as the bluez preset entry: an inherited dependency nobody stated is one that disappears without a build failing.
465 +
466 + **The removable list is a filter and says so.** The default tab is drives you can unplug, because that is the question the verb exists to answer, and the second tab is every volume on the machine. A user whose disk is simply missing from a list has no way to tell a filter from a hardware fault.
467 +
468 + Worth recording, because it is not obvious and it is measurable: **the kernel's `RM` bit is not the same question as "can I unplug this".** A USB SSD reports `rm: false`, since that bit means the medium can leave the drive rather than the drive can leave the machine. Removability here is the flag or a hot-plug transport.
469 +
470 + Rejected: **automounting on plug**, which needs a daemon to own and mounts filesystems nobody asked it to; the mount is a keypress. **A yazi mount plugin**, which binds the capability to one file manager and leaves the shell with nothing. **Writing installer media here** — `alloy image` already does it, and it hands the terminal to `build/build-image.sh --write`, which refuses partitions, refuses anything mounted, and verifies with `cmp`. Two paths to writing a disk is one more than is safe.
471 +
458 472 ### Bluetooth: **bluez, kept and now chosen**
459 473
460 474 **It was already on, which was the defect.** Fedora's `90-default.preset` carries `enable bluetooth.*`, so every Alloy install has had a Bluetooth daemon that this document never mentioned and nobody decided on. Same correction as sshd and avahi: the state does not change, the accountability does. The preset entry is now Alloy's.
@@ -19,6 +19,7 @@
19 19 | `alloy mesh` | Mesh VPN peers and exit node (alias: `alloy tail`) |
20 20 | `alloy sync` | Synchronized folders, devices, and pending invitations |
21 21 | `alloy pkg` | What is installed and where it lives |
22 + | `alloy disk` | Attached storage: mount, unmount, eject |
22 23 | `alloy update` | The booted image, the staged one, and rollback |
23 24 | `alloy settings` | System settings and application configs |
24 25 | `alloy config <path>` | One config file, opened directly |
@@ -10,6 +10,7 @@
10 10 mod bind;
11 11 mod cli;
12 12 mod credits;
13 + mod disk;
13 14 mod display;
14 15 mod image;
15 16 mod install;
@@ -123,6 +124,11 @@
123 124 /// The file to edit. A schema must declare it as its target.
124 125 path: std::path::PathBuf,
125 126 },
127 + /// Attached storage, and what is mounted
128 + Disk {
129 + #[command(subcommand)]
130 + surface: Option<DiskSurface>,
131 + },
126 132 /// Synchronized folders and the devices they reach
127 133 Sync {
128 134 #[command(subcommand)]
@@ -182,6 +188,17 @@
182 188
183 189 /// Which tab of the sync view to open on.
184 190 ///
191 + /// Which list of volumes to open on.
192 + ///
193 + /// Nouns, like [`SyncSurface`]'s.
194 + #[derive(Subcommand)]
195 + enum DiskSurface {
196 + /// Volumes on drives you can unplug
197 + Removable,
198 + /// Every volume on this machine
199 + All,
200 + }
201 +
185 202 /// Nouns, like [`PkgSurface`]'s: each heads a list of what is already there
186 203 /// rather than naming an action.
187 204 #[derive(Subcommand)]
@@ -312,6 +329,17 @@
312 329 let mut view = settings::SettingsView::config(&path)?;
313 330 shell::run(&theme, &mut view, &mut log)
314 331 }
332 + Command::Disk { surface } => {
333 + let tab = match surface {
334 + Some(DiskSurface::All) => disk::Tab::All,
335 + // Bare `alloy disk` lands on removable: the verb exists because
336 + // plugging a stick in did nothing, and that is the list it
337 + // appears on.
338 + Some(DiskSurface::Removable) | None => disk::Tab::Removable,
339 + };
340 + let mut view = disk::DiskView::new(tab, &mut log);
341 + shell::run(&theme, &mut view, &mut log)
342 + }
315 343 Command::Sync { surface } => {
316 344 let tab = match surface {
317 345 Some(SyncSurface::Devices) => sync::Tab::Devices,
@@ -1,0 +1,1395 @@
1 + //! Attached storage and what is mounted: the `alloy disk` verb.
2 + //!
3 + //! The 2026-07-29 feature audit filed this as "plugging in a USB stick does
4 + //! nothing": udisks2 is in the image, nothing drives it, yazi ships no mount
5 + //! path, and a stick or a Framework expansion card stays invisible until
6 + //! somebody mounts it by hand as root. This is the surface that was missing.
7 + //!
8 + //! # What this verb is not
9 + //!
10 + //! **It does not write installer media.** `alloy image` already does, and the
11 + //! constraint it works under (docs/CONSOLE.md, "It does not write the disk")
12 + //! applies with more force here: `build/build-image.sh --write` refuses
13 + //! partitions, refuses anything mounted, and verifies with `cmp` against a
14 + //! negative control. Re-deriving that behind a second verb is how a disk-eating
15 + //! bug gets written. A user who wants to make an install drive wants
16 + //! `alloy image`, and the empty state says so rather than leaving them to guess.
17 + //!
18 + //! # Two tools, one screen
19 + //!
20 + //! Reading is `lsblk`, acting is `udisksctl`, and they are detected separately
21 + //! because a machine can have the first without the second. lsblk is in
22 + //! util-linux and is on anything; udisks2 is a daemon that has to be running. A
23 + //! machine with lsblk alone still gets the inventory, with the action keys shown
24 + //! as unavailable and a reason, rather than a screen that lists rows and then
25 + //! does nothing when they are pressed.
26 + //!
27 + //! Nothing here escalates. udisks answers a session user through polkit for
28 + //! removable media, which is the whole reason it is the right tool: the
29 + //! alternative is `mount(8)` behind `run0`, which is a privilege prompt for
30 + //! plugging in a USB stick.
31 + //!
32 + //! # Removable is not the `RM` flag
33 + //!
34 + //! Measured on fw13, 2026-08-05: a Samsung PSSD T9 over USB reports `rm: false`
35 + //! and `tran: "usb"`. It is an external drive a person unplugs, and the kernel's
36 + //! removable bit says otherwise, because that bit means "the medium can leave
37 + //! the drive" (a card reader, an optical drive) rather than "the drive can leave
38 + //! the machine". Going by `RM` alone would put the one disk the user came here
39 + //! for on the wrong tab. So [`Drive::detachable`] is the flag OR a hot-plug
40 + //! transport, and the T9 is the fixture that pins it.
41 + //!
42 + //! # Nothing is filtered away
43 + //!
44 + //! The default tab is the removable one because that is what the verb is for,
45 + //! but the other tab is every volume on the machine, on the rule install.rs
46 + //! already states: a user whose disk is simply missing has no way to tell a
47 + //! filter from a hardware fault. Rows that cannot be acted on are listed and say
48 + //! why.
49 + //!
50 + //! <!-- wiki: alloy-console -->
51 +
52 + use anyhow::{Context, Result};
53 + use serde::Deserialize;
54 +
55 + use alloy_tui::keys::Action;
56 + use alloy_tui::{
57 + AlloyBlock, AlloyList, AlloyTabs, Cursor, FocusRing, Hint, KeyGroup, Severity, Theme, binding,
58 + hint, text, unavailable,
59 + };
60 + use ratatui::Frame;
61 + use ratatui::crossterm::event::{KeyCode, KeyEvent};
62 + use ratatui::layout::{Constraint, Layout, Rect};
63 + use ratatui::text::{Line, Span};
64 +
65 + use crate::cli::{CommandLog, Invocation};
66 + use crate::install::format_size;
67 + use crate::shell::{Confirm, Flow, View, block_title, truncate};
68 +
69 + // ---- the model ----
70 +
71 + /// The drive a volume sits on. Carried by value on each volume rather than
72 + /// referenced, because the list is small and a row needs its drive's identity to
73 + /// render at all.
74 + #[derive(Debug, Clone, PartialEq, Eq)]
75 + pub(crate) struct Drive {
76 + /// Whole-device path, which is what `udisksctl power-off` takes.
77 + pub path: String,
78 + pub model: Option<String>,
79 + /// The kernel's `RM` bit. Not the same question as [`Self::detachable`].
80 + pub removable: bool,
81 + /// `usb`, `nvme`, `sata`, `mmc`. `None` where lsblk cannot attribute it.
82 + pub transport: Option<String>,
83 + }
84 +
85 + /// Transports whose devices a person unplugs.
86 + ///
87 + /// `ieee1394` is here for completeness rather than from a measurement; the two
88 + /// that matter are usb and mmc, which is a Framework expansion card.
89 + const HOTPLUG_TRANSPORTS: [&str; 3] = ["usb", "mmc", "ieee1394"];
90 +
91 + impl Drive {
92 + /// Whether this drive is one a person unplugs.
93 + ///
94 + /// See the module docs: the `RM` bit alone gets an external USB SSD wrong,
95 + /// and that is the disk most likely to be the reason someone opened this
96 + /// screen.
97 + pub(crate) fn detachable(&self) -> bool {
98 + self.removable
99 + || self
100 + .transport
101 + .as_deref()
102 + .is_some_and(|tran| HOTPLUG_TRANSPORTS.contains(&tran))
103 + }
104 +
105 + fn model_or_dash(&self) -> &str {
106 + self.model.as_deref().unwrap_or("-")
107 + }
108 + }
109 +
110 + /// One mountable thing: a partition, or a whole disk carrying a filesystem
111 + /// directly, which is what a hybrid ISO written to a stick looks like.
112 + #[derive(Debug, Clone, PartialEq, Eq)]
113 + pub(crate) struct Volume {
114 + pub path: String,
115 + pub name: String,
116 + pub size: u64,
117 + /// `None` for a partition with no recognisable filesystem. Those are listed
118 + /// rather than dropped: a stick with an unformatted partition is a thing a
119 + /// user needs to see to understand why it will not mount.
120 + pub fstype: Option<String>,
121 + pub label: Option<String>,
122 + pub mountpoint: Option<String>,
123 + pub read_only: bool,
124 + pub drive: Drive,
125 + }
126 +
127 + /// Mounts the console will not offer to unmount.
128 + ///
129 + /// Not a security boundary, since udisks would refuse most of these anyway.
130 + /// It is about the refusal being legible: "/ holds the running system" said
131 + /// here beats udisks' own error arriving three seconds later with a D-Bus
132 + /// prefix on it.
133 + const SYSTEM_MOUNTS: [&str; 5] = ["/", "/boot", "/boot/efi", "/var", "/sysroot"];
134 +
135 + /// Why a volume cannot be mounted or unmounted right now.
136 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
137 + pub(crate) enum Blocked {
138 + /// No filesystem lsblk could name, so there is nothing to mount.
139 + NoFilesystem,
140 + /// Already mounted, so mounting again is not the action wanted.
141 + Mounted,
142 + /// Not mounted, so unmounting is not the action wanted.
143 + NotMounted,
144 + /// Part of the running system.
145 + System,
146 + }
147 +
148 + impl Blocked {
149 + /// Phrased to follow the volume path, so the whole line reads as one
150 + /// sentence: "/dev/sdb1 has no filesystem to mount".
151 + pub(crate) const fn reason(self) -> &'static str {
152 + match self {
153 + Self::NoFilesystem => "has no filesystem to mount",
154 + Self::Mounted => "is already mounted",
155 + Self::NotMounted => "is not mounted",
156 + Self::System => "holds part of the running system",
157 + }
158 + }
159 + }
160 +
161 + impl Volume {
162 + /// Whether this volume is part of the running system.
163 + ///
164 + /// Two shapes, and the second was found by running the parser against this
165 + /// machine rather than by reasoning about it. lsblk reports active swap with
166 + /// a mountpoint of `[SWAP]`: a bracketed pseudo-use rather than a directory.
167 + /// It is genuinely in use and genuinely not unmountable, and without this it
168 + /// reads as an ordinary mounted filesystem sitting at a path, which the
169 + /// console would then offer to unmount. Any bracketed value is treated the
170 + /// same way, since the bracket is lsblk's own marker for "not a path".
171 + fn is_system(&self) -> bool {
172 + self.mountpoint
173 + .as_deref()
174 + .is_some_and(|at| at.starts_with('[') || SYSTEM_MOUNTS.contains(&at))
175 + }
176 +
177 + /// Why `m` would do nothing here, or `None` if it would mount.
178 + pub(crate) fn mount_blocker(&self) -> Option<Blocked> {
179 + if self.mountpoint.is_some() {
180 + Some(Blocked::Mounted)
181 + } else if self.fstype.is_none() {
182 + Some(Blocked::NoFilesystem)
183 + } else {
184 + None
185 + }
186 + }
187 +
188 + /// Why `u` would do nothing here, or `None` if it would unmount.
189 + ///
190 + /// System is reported ahead of not-mounted so a root filesystem says what it
191 + /// is rather than being described by whether it happens to be mounted.
192 + pub(crate) fn unmount_blocker(&self) -> Option<Blocked> {
193 + if self.is_system() {
194 + Some(Blocked::System)
195 + } else if self.mountpoint.is_none() {
196 + Some(Blocked::NotMounted)
197 + } else {
198 + None
199 + }
200 + }
201 +
202 + fn fstype_or_dash(&self) -> &str {
203 + self.fstype.as_deref().unwrap_or("-")
204 + }
205 +
206 + /// The label, or the drive's model where there is none. A stick with no
207 + /// filesystem label is far more recognisable as "SanDisk 3.2Gen1" than as an
208 + /// empty column.
209 + fn describe(&self) -> String {
210 + match self.label.as_deref() {
211 + Some(label) if !label.trim().is_empty() => label.to_string(),
212 + _ => self.drive.model_or_dash().to_string(),
213 + }
214 + }
215 +
216 + fn where_at(&self) -> &str {
217 + self.mountpoint.as_deref().unwrap_or("not mounted")
218 + }
219 + }
220 +
221 + // ---- backends ----
222 +
223 + /// Backends build argv and run nothing. The view executes through the log,
224 + /// which is what makes docs/CONSOLE.md's coverage promise structural rather
225 + /// than a habit.
226 + pub(crate) trait Backend {
227 + fn name(&self) -> &'static str;
228 +
229 + fn list(&self, log: &mut CommandLog) -> Result<Vec<Volume>>;
230 +
231 + fn mount(&self, _volume: &Volume) -> Option<Invocation> {
232 + None
233 + }
234 +
235 + fn unmount(&self, _volume: &Volume) -> Option<Invocation> {
236 + None
237 + }
238 +
239 + /// Powers off the whole drive the volume sits on, which is what "eject"
240 + /// means for something without a physical tray.
241 + fn eject(&self, _volume: &Volume) -> Option<Invocation> {
242 + None
243 + }
244 + }
245 +
246 + /// Pick a backend: the real one when `lsblk` answers, the mock otherwise.
247 + ///
248 + /// A `--version` probe rather than a `which` check, matching `net`, `mesh` and
249 + /// `install`.
250 + pub(crate) fn detect() -> Box<dyn Backend> {
251 + if Invocation::new("lsblk").arg("--version").probe() {
252 + Box::new(LsBlk {
253 + udisks: udisks_present(),
254 + })
255 + } else {
256 + Box::new(Mock)
257 + }
258 + }
259 +
260 + /// Whether udisksctl is here *and* its daemon is answering.
261 + ///
262 + /// `status` rather than `--version`, deliberately: udisksctl is a client and
263 + /// exits nonzero with "Error connecting to the udisks daemon" when udisksd is
264 + /// not running, which is exactly the state a bare container is in. Probing the
265 + /// binary alone would offer action keys that fail the moment they are pressed.
266 + fn udisks_present() -> bool {
267 + Invocation::new("udisksctl").arg("status").probe()
268 + }
269 +
270 + pub(crate) struct LsBlk {
271 + /// Whether the action keys are offered. Read once at construction: udisksd
272 + /// starting mid-session is not worth a probe on every frame.
273 + udisks: bool,
274 + }
275 +
276 + impl LsBlk {
277 + /// `-b` for bytes so the size arrives as a number to format, and an explicit
278 + /// column list because lsblk's default set carries neither `PATH` nor
279 + /// `TRAN`. `FSTYPE` and `LABEL` are what this verb adds over the column set
280 + /// `install` asks for.
281 + fn invocation() -> Invocation {
282 + Invocation::new("lsblk").args([
283 + "-J",
284 + "-b",
285 + "-o",
286 + "PATH,NAME,TYPE,SIZE,FSTYPE,LABEL,MOUNTPOINTS,RM,RO,TRAN,MODEL",
287 + ])
288 + }
289 + }
290 +
291 + impl Backend for LsBlk {
292 + fn name(&self) -> &'static str {
293 + "lsblk"
294 + }
295 +
296 + fn list(&self, log: &mut CommandLog) -> Result<Vec<Volume>> {
297 + parse_volumes(&Self::invocation().run(log)?)
298 + }
299 +
300 + fn mount(&self, volume: &Volume) -> Option<Invocation> {
301 + self.udisks
302 + .then(|| Invocation::new("udisksctl").args(["mount", "-b", volume.path.as_str()]))
303 + }
304 +
305 + fn unmount(&self, volume: &Volume) -> Option<Invocation> {
306 + self.udisks
307 + .then(|| Invocation::new("udisksctl").args(["unmount", "-b", volume.path.as_str()]))
308 + }
309 +
310 + fn eject(&self, volume: &Volume) -> Option<Invocation> {
311 + self.udisks.then(|| {
312 + Invocation::new("udisksctl").args(["power-off", "-b", volume.drive.path.as_str()])
313 + })
314 + }
315 + }
316 +
317 + /// Fixed sample volumes, for machines without lsblk.
318 + pub(crate) struct Mock;
319 +
320 + impl Backend for Mock {
321 + fn name(&self) -> &'static str {
322 + "mock"
323 + }
324 +
325 + fn list(&self, log: &mut CommandLog) -> Result<Vec<Volume>> {
326 + log.record("# no lsblk; showing mock volumes", Severity::Warn);
327 + let stick = Drive {
328 + path: "/dev/sdb".to_string(),
329 + model: Some("SanDisk 3.2Gen1".to_string()),
330 + removable: true,
331 + transport: Some("usb".to_string()),
332 + };
333 + let internal = Drive {
334 + path: "/dev/nvme0n1".to_string(),
335 + model: Some("WD_BLACK SN770".to_string()),
336 + removable: false,
337 + transport: Some("nvme".to_string()),
338 + };
339 + Ok(vec![
340 + Volume {
341 + path: "/dev/sdb1".to_string(),
342 + name: "sdb1".to_string(),
343 + size: 61_504_880_640,
344 + fstype: Some("vfat".to_string()),
345 + label: Some("ALLOY".to_string()),
346 + mountpoint: None,
347 + read_only: false,
348 + drive: stick,
349 + },
350 + Volume {
351 + path: "/dev/nvme0n1p2".to_string(),
352 + name: "nvme0n1p2".to_string(),
353 + size: 1_000_204_886_016,
354 + fstype: Some("btrfs".to_string()),
355 + label: None,
356 + mountpoint: Some("/".to_string()),
357 + read_only: false,
358 + drive: internal,
359 + },
360 + ])
361 + }
362 + }
363 +
364 + // ---- parsing ----
365 +
366 + #[derive(Deserialize)]
367 + struct LsBlkOutput {
368 + blockdevices: Vec<LsBlkDevice>,
369 + }
370 +
371 + #[derive(Deserialize)]
372 + struct LsBlkDevice {
373 + name: String,
374 + path: String,
375 + size: u64,
376 + #[serde(rename = "type")]
377 + kind: String,
378 + #[serde(default)]
379 + fstype: Option<String>,
380 + #[serde(default)]
381 + label: Option<String>,
382 + #[serde(default)]
383 + model: Option<String>,
384 + #[serde(default)]
385 + rm: bool,
386 + #[serde(default)]
387 + ro: bool,
388 + #[serde(default)]
389 + tran: Option<String>,
390 + /// An unmounted device reports `[null]` rather than `[]`, so the nulls are
391 + /// filtered rather than assumed away.
392 + #[serde(default)]
393 + mountpoints: Vec<Option<String>>,
394 + #[serde(default)]
395 + children: Vec<LsBlkDevice>,
396 + }
397 +
398 + /// Device-name prefixes that lsblk types as `disk` without being one.
399 + ///
400 + /// The same list `install` carries, and for the same reason: on this machine
401 + /// `lsblk` reports sixteen zero-byte `nbd` nodes, which would bury the one USB
402 + /// stick the user plugged in.
403 + const VIRTUAL_PREFIXES: [&str; 4] = ["loop", "zram", "ram", "nbd"];
404 +
405 + fn is_virtual(name: &str) -> bool {
406 + VIRTUAL_PREFIXES
407 + .iter()
408 + .any(|prefix| name.starts_with(prefix))
409 + }
410 +
411 + /// lsblk's first mountpoint, with the nulls dropped.
412 + fn first_mountpoint(device: &LsBlkDevice) -> Option<String> {
413 + device
414 + .mountpoints
415 + .iter()
416 + .flatten()
417 + .find(|at| !at.trim().is_empty())
418 + .cloned()
419 + }
420 +
421 + /// An empty string and a null both mean "unknown" here. Some USB bridges report
422 + /// the first where lsblk reports the second, and neither should render as a
423 + /// blank column with a stray gap.
424 + fn meaningful(raw: Option<String>) -> Option<String> {
425 + raw.filter(|value| !value.trim().is_empty())
426 + }
427 +
428 + /// Flatten lsblk's tree into the mountable things on it.
429 + ///
430 + /// A whole disk becomes a row only when it carries a filesystem itself, which
431 + /// is what a hybrid ISO written to a stick looks like: `/dev/sdb` with
432 + /// `fstype: iso9660` and partitions beneath it. Listing both the disk and its
433 + /// partitions there is correct rather than duplication, because they are
434 + /// separately mountable and the user cannot tell which one they want without
435 + /// seeing both.
436 + fn parse_volumes(raw: &str) -> Result<Vec<Volume>> {
437 + let parsed: LsBlkOutput = serde_json::from_str(raw).context("lsblk emitted invalid JSON")?;
438 +
439 + let mut volumes = Vec::new();
440 + for device in parsed.blockdevices {
441 + // A zero-byte disk is a slot rather than a disk: an unconnected nbd
442 + // node, or a card reader with no card in it.
443 + if device.kind != "disk" || is_virtual(&device.name) || device.size == 0 {
444 + continue;
445 + }
446 +
447 + let drive = Drive {
448 + path: device.path.clone(),
449 + model: meaningful(device.model.clone()),
450 + removable: device.rm,
451 + transport: meaningful(device.tran.clone()),
452 + };
453 +
454 + collect(&device, &drive, &mut volumes);
455 + }
456 + Ok(volumes)
457 + }
458 +
459 + /// Walk a device and its children, keeping anything that carries a filesystem
460 + /// or is a partition.
461 + ///
462 + /// Recursive rather than one level deep, and not for generality: a LUKS volume
463 + /// sits at `disk > part > crypt`, and the mounted filesystem is the crypt node
464 + /// two levels down. Stopping at one level would list the container and miss the
465 + /// thing that is actually mounted.
466 + fn collect(device: &LsBlkDevice, drive: &Drive, out: &mut Vec<Volume>) {
467 + let carries_filesystem = device.fstype.is_some();
468 + let is_partition = device.kind != "disk";
469 +
470 + if carries_filesystem || is_partition {
471 + out.push(Volume {
472 + path: device.path.clone(),
473 + name: device.name.clone(),
474 + size: device.size,
475 + fstype: meaningful(device.fstype.clone()),
476 + label: meaningful(device.label.clone()),
477 + mountpoint: first_mountpoint(device),
478 + read_only: device.ro,
479 + drive: drive.clone(),
480 + });
481 + }
482 +
483 + for child in &device.children {
484 + collect(child, drive, out);
485 + }
486 + }
487 +
488 + // ---- the view ----
489 +
490 + /// Which list is showing.
491 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
492 + pub(crate) enum Tab {
493 + /// Volumes on drives a person unplugs. The default, because it is what the
494 + /// verb exists for.
495 + Removable,
496 + /// Every volume on the machine, so the tab above is visibly a filter rather
497 + /// than the whole truth.
498 + All,
499 + }
500 +
Lines truncated