Skip to main content

max / alloy

6.8 KB · 197 lines History Blame Raw
1 //! `lsblk -J` in, [`Volume`]s out.
2 //!
3 //! Total on the text it is given and spawns nothing, which is what lets the
4 //! capture in `fixtures` stand in for the tool. Names the model and serde only.
5 //!
6 //! `install` carries a second `LsBlkOutput`/`LsBlkDevice` pair of its own, and
7 //! the two are deliberately not shared: this one carries `fstype` and `label`,
8 //! and the two `VIRTUAL_PREFIXES` lists differ because `install` must hide
9 //! device-mapper nodes while this must hide unconnected nbd slots. Merging them
10 //! while the lists diverge is how a `dm-` node gets offered as an install
11 //! target.
12
13 use anyhow::{Context, Result};
14 use serde::Deserialize;
15
16 use super::model::{Drive, Volume};
17
18 #[derive(Deserialize)]
19 struct LsBlkOutput {
20 blockdevices: Vec<LsBlkDevice>,
21 }
22
23 #[derive(Deserialize)]
24 struct LsBlkDevice {
25 name: String,
26 path: String,
27 size: u64,
28 #[serde(rename = "type")]
29 kind: String,
30 #[serde(default)]
31 fstype: Option<String>,
32 #[serde(default)]
33 label: Option<String>,
34 #[serde(default)]
35 model: Option<String>,
36 #[serde(default)]
37 rm: bool,
38 #[serde(default)]
39 ro: bool,
40 #[serde(default)]
41 tran: Option<String>,
42 /// An unmounted device reports `[null]` rather than `[]`, so the nulls are
43 /// filtered rather than assumed away.
44 #[serde(default)]
45 mountpoints: Vec<Option<String>>,
46 #[serde(default)]
47 children: Vec<LsBlkDevice>,
48 }
49
50 /// Device-name prefixes that lsblk types as `disk` without being one.
51 ///
52 /// The same list `install` carries, and for the same reason: on this machine
53 /// `lsblk` reports sixteen zero-byte `nbd` nodes, which would bury the one USB
54 /// stick the user plugged in.
55 const VIRTUAL_PREFIXES: [&str; 4] = ["loop", "zram", "ram", "nbd"];
56
57 fn is_virtual(name: &str) -> bool {
58 VIRTUAL_PREFIXES
59 .iter()
60 .any(|prefix| name.starts_with(prefix))
61 }
62
63 /// lsblk's first mountpoint, with the nulls dropped.
64 fn first_mountpoint(device: &LsBlkDevice) -> Option<String> {
65 device
66 .mountpoints
67 .iter()
68 .flatten()
69 .find(|at| !at.trim().is_empty())
70 .cloned()
71 }
72
73 /// An empty string and a null both mean "unknown" here. Some USB bridges report
74 /// the first where lsblk reports the second, and neither should render as a
75 /// blank column with a stray gap.
76 fn meaningful(raw: Option<String>) -> Option<String> {
77 raw.filter(|value| !value.trim().is_empty())
78 }
79
80 /// Flatten lsblk's tree into the mountable things on it.
81 ///
82 /// A whole disk becomes a row only when it carries a filesystem itself, which
83 /// is what a hybrid ISO written to a stick looks like: `/dev/sdb` with
84 /// `fstype: iso9660` and partitions beneath it. Listing both the disk and its
85 /// partitions there is correct rather than duplication, because they are
86 /// separately mountable and the user cannot tell which one they want without
87 /// seeing both.
88 pub(super) fn parse_volumes(raw: &str) -> Result<Vec<Volume>> {
89 let parsed: LsBlkOutput = serde_json::from_str(raw).context("lsblk emitted invalid JSON")?;
90
91 let mut volumes = Vec::new();
92 for device in parsed.blockdevices {
93 // A zero-byte disk is a slot rather than a disk: an unconnected nbd
94 // node, or a card reader with no card in it.
95 if device.kind != "disk" || is_virtual(&device.name) || device.size == 0 {
96 continue;
97 }
98
99 let drive = Drive {
100 path: device.path.clone(),
101 model: meaningful(device.model.clone()),
102 removable: device.rm,
103 transport: meaningful(device.tran.clone()),
104 };
105
106 collect(&device, &drive, &mut volumes);
107 }
108 Ok(volumes)
109 }
110
111 /// Walk a device and its children, keeping anything that carries a filesystem
112 /// or is a partition.
113 ///
114 /// Recursive rather than one level deep, and not for generality: a LUKS volume
115 /// sits at `disk > part > crypt`, and the mounted filesystem is the crypt node
116 /// two levels down. Stopping at one level would list the container and miss the
117 /// thing that is actually mounted.
118 fn collect(device: &LsBlkDevice, drive: &Drive, out: &mut Vec<Volume>) {
119 let carries_filesystem = device.fstype.is_some();
120 let is_partition = device.kind != "disk";
121
122 if carries_filesystem || is_partition {
123 out.push(Volume {
124 path: device.path.clone(),
125 name: device.name.clone(),
126 size: device.size,
127 fstype: meaningful(device.fstype.clone()),
128 label: meaningful(device.label.clone()),
129 mountpoint: first_mountpoint(device),
130 read_only: device.ro,
131 kind: device.kind.clone(),
132 drive: drive.clone(),
133 });
134 }
135
136 for child in &device.children {
137 collect(child, drive, out);
138 }
139 }
140
141 #[cfg(test)]
142 mod tests {
143 // Nothing here names an item of this module directly: every test reads the
144 // parser's output through the shared fixture, which is the only way the
145 // capture is read once rather than three times. So no `use super::*`.
146 use crate::disk::fixtures::{find, volumes};
147 use crate::disk::model::Blocked;
148
149 /// The zero-byte nbd nodes must not reach the list. Sixteen of them ship on
150 /// this machine, and a user looking for their stick would have to scroll
151 /// past all of them.
152 #[test]
153 fn virtual_and_empty_devices_are_dropped() {
154 let volumes = volumes();
155 assert!(
156 volumes.iter().all(|volume| !volume.path.contains("nbd")),
157 "an nbd node reached the list: {volumes:?}"
158 );
159 }
160
161 /// A hybrid ISO carries a filesystem on the whole disk and has partitions
162 /// under it. Both are separately mountable, so both are listed.
163 #[test]
164 fn a_hybrid_iso_lists_the_disk_and_its_partitions() {
165 let volumes = volumes();
166 let whole = find(&volumes, "/dev/sdb");
167 assert_eq!(whole.fstype.as_deref(), Some("iso9660"));
168 assert_eq!(whole.label.as_deref(), Some("ALLOY"));
169 assert!(
170 volumes.iter().any(|volume| volume.path == "/dev/sdb2"),
171 "the EFI partition should be listed beside the disk"
172 );
173 }
174
175 /// `[null]` is not `[]`, and reading it as a mountpoint would render the
176 /// string "null" in the column a user checks before ejecting.
177 #[test]
178 fn a_null_mountpoint_is_not_mounted() {
179 let volumes = volumes();
180 assert_eq!(find(&volumes, "/dev/sdb").mountpoint, None);
181 assert_eq!(
182 find(&volumes, "/dev/sda1").mountpoint.as_deref(),
183 Some("/media/max/T9")
184 );
185 }
186
187 /// A partition with no filesystem is listed rather than dropped, and says
188 /// why it will not mount.
189 #[test]
190 fn a_partition_with_no_filesystem_is_listed_and_blocked() {
191 let volumes = volumes();
192 let bare = find(&volumes, "/dev/sdb1");
193 assert_eq!(bare.fstype, None);
194 assert_eq!(bare.mount_blocker(), Some(Blocked::NoFilesystem));
195 }
196 }
197