1use std::collections::HashMap;
2use std::env;
3use std::path::Path;
4use std::process::Command;
5use std::sync::OnceLock;
6
7use anyhow::{anyhow, Context, Result};
8use camino::Utf8Path;
9use camino::Utf8PathBuf;
10use fn_error_context::context;
11use regex::Regex;
12use serde::Deserialize;
13
14use bootc_utils::CommandRunExt;
15
16#[derive(Debug, Deserialize)]
17struct DevicesOutput {
18 blockdevices: Vec<Device>,
19}
20
21#[allow(dead_code)]
22#[derive(Debug, Deserialize)]
23pub struct Device {
24 pub name: String,
25 pub serial: Option<String>,
26 pub model: Option<String>,
27 pub partlabel: Option<String>,
28 pub parttype: Option<String>,
29 pub partuuid: Option<String>,
30 pub children: Option<Vec<Device>>,
31 pub size: u64,
32 #[serde(rename = "maj:min")]
33 pub maj_min: Option<String>,
34 pub start: Option<u64>,
37
38 pub label: Option<String>,
40 pub fstype: Option<String>,
41 pub path: Option<String>,
42}
43
44impl Device {
45 #[allow(dead_code)]
46 pub fn path(&self) -> String {
48 self.path.clone().unwrap_or(format!("/dev/{}", &self.name))
49 }
50
51 #[allow(dead_code)]
52 pub fn has_children(&self) -> bool {
53 self.children.as_ref().map_or(false, |v| !v.is_empty())
54 }
55
56 fn backfill_start(&mut self) -> Result<()> {
59 let Some(majmin) = self.maj_min.as_deref() else {
60 return Ok(());
62 };
63 let sysfs_start_path = format!("/sys/dev/block/{majmin}/start");
64 if Utf8Path::new(&sysfs_start_path).try_exists()? {
65 let start = std::fs::read_to_string(&sysfs_start_path)
66 .with_context(|| format!("Reading {sysfs_start_path}"))?;
67 tracing::debug!("backfilled start to {start}");
68 self.start = Some(
69 start
70 .trim()
71 .parse()
72 .context("Parsing sysfs start property")?,
73 );
74 }
75 Ok(())
76 }
77
78 pub fn backfill_missing(&mut self) -> Result<()> {
80 self.backfill_start()?;
82 for child in self.children.iter_mut().flatten() {
84 child.backfill_missing()?;
85 }
86 Ok(())
87 }
88}
89
90#[context("Listing device {dev}")]
91pub fn list_dev(dev: &Utf8Path) -> Result<Device> {
92 let mut devs: DevicesOutput = Command::new("lsblk")
93 .args(["-J", "-b", "-O"])
94 .arg(dev)
95 .log_debug()
96 .run_and_parse_json()?;
97 for dev in devs.blockdevices.iter_mut() {
98 dev.backfill_missing()?;
99 }
100 devs.blockdevices
101 .into_iter()
102 .next()
103 .ok_or_else(|| anyhow!("no device output from lsblk for {dev}"))
104}
105
106#[derive(Debug, Deserialize)]
107struct SfDiskOutput {
108 partitiontable: PartitionTable,
109}
110
111#[derive(Debug, Deserialize)]
112#[allow(dead_code)]
113pub struct Partition {
114 pub node: String,
115 pub start: u64,
116 pub size: u64,
117 #[serde(rename = "type")]
118 pub parttype: String,
119 pub uuid: Option<String>,
120 pub name: Option<String>,
121}
122
123#[derive(Debug, Deserialize, PartialEq, Eq)]
124#[serde(rename_all = "kebab-case")]
125pub enum PartitionType {
126 Dos,
127 Gpt,
128 Unknown(String),
129}
130
131#[derive(Debug, Deserialize)]
132#[allow(dead_code)]
133pub struct PartitionTable {
134 pub label: PartitionType,
135 pub id: String,
136 pub device: String,
137 pub partitions: Vec<Partition>,
143}
144
145impl PartitionTable {
146 #[allow(dead_code)]
148 pub fn find<'a>(&'a self, devname: &str) -> Option<&'a Partition> {
149 self.partitions.iter().find(|p| p.node.as_str() == devname)
150 }
151
152 pub fn path(&self) -> &Utf8Path {
153 self.device.as_str().into()
154 }
155
156 #[allow(dead_code)]
158 pub fn find_partno(&self, partno: u32) -> Result<&Partition> {
159 let r = self
160 .partitions
161 .get(partno.checked_sub(1).expect("1 based partition offset") as usize)
162 .ok_or_else(|| anyhow::anyhow!("Missing partition for index {partno}"))?;
163 Ok(r)
164 }
165}
166
167impl Partition {
168 #[allow(dead_code)]
169 pub fn path(&self) -> &Utf8Path {
170 self.node.as_str().into()
171 }
172}
173
174#[context("Listing partitions of {dev}")]
175pub fn partitions_of(dev: &Utf8Path) -> Result<PartitionTable> {
176 let o: SfDiskOutput = Command::new("sfdisk")
177 .args(["-J", dev.as_str()])
178 .run_and_parse_json()?;
179 Ok(o.partitiontable)
180}
181
182pub struct LoopbackDevice {
183 pub dev: Option<Utf8PathBuf>,
184}
185
186impl LoopbackDevice {
187 pub fn new(path: &Path) -> Result<Self> {
189 let direct_io = match env::var("BOOTC_DIRECT_IO") {
190 Ok(val) => {
191 if val == "on" {
192 "on"
193 } else {
194 "off"
195 }
196 }
197 Err(_e) => "off",
198 };
199
200 let dev = Command::new("losetup")
201 .args([
202 "--show",
203 format!("--direct-io={direct_io}").as_str(),
204 "-P",
205 "--find",
206 ])
207 .arg(path)
208 .run_get_string()?;
209 let dev = Utf8PathBuf::from(dev.trim());
210 tracing::debug!("Allocated loopback {dev}");
211 Ok(Self { dev: Some(dev) })
212 }
213
214 pub fn path(&self) -> &Utf8Path {
216 self.dev.as_deref().unwrap()
218 }
219
220 fn impl_close(&mut self) -> Result<()> {
222 let Some(dev) = self.dev.take() else {
224 tracing::trace!("loopback device already deallocated");
225 return Ok(());
226 };
227 Command::new("losetup").args(["-d", dev.as_str()]).run()
228 }
229
230 pub fn close(mut self) -> Result<()> {
232 self.impl_close()
233 }
234}
235
236impl Drop for LoopbackDevice {
237 fn drop(&mut self) {
238 let _ = self.impl_close();
240 }
241}
242
243fn split_lsblk_line(line: &str) -> HashMap<String, String> {
246 static REGEX: OnceLock<Regex> = OnceLock::new();
247 let regex = REGEX.get_or_init(|| Regex::new(r#"([A-Z-_]+)="([^"]+)""#).unwrap());
248 let mut fields: HashMap<String, String> = HashMap::new();
249 for cap in regex.captures_iter(line) {
250 fields.insert(cap[1].to_string(), cap[2].to_string());
251 }
252 fields
253}
254
255pub fn find_parent_devices(device: &str) -> Result<Vec<String>> {
259 let output = Command::new("lsblk")
260 .arg("--pairs")
262 .arg("--paths")
263 .arg("--inverse")
264 .arg("--output")
265 .arg("NAME,TYPE")
266 .arg(device)
267 .run_get_string()?;
268 let mut parents = Vec::new();
269 for line in output.lines().skip(1) {
271 let dev = split_lsblk_line(line);
272 let name = dev
273 .get("NAME")
274 .with_context(|| format!("device in hierarchy of {device} missing NAME"))?;
275 let kind = dev
276 .get("TYPE")
277 .with_context(|| format!("device in hierarchy of {device} missing TYPE"))?;
278 if kind == "disk" || kind == "loop" {
279 parents.push(name.clone());
280 } else if kind == "mpath" {
281 parents.push(name.clone());
282 break;
284 }
285 }
286 Ok(parents)
287}
288
289pub fn parse_size_mib(mut s: &str) -> Result<u64> {
291 let suffixes = [
292 ("MiB", 1u64),
293 ("M", 1u64),
294 ("GiB", 1024),
295 ("G", 1024),
296 ("TiB", 1024 * 1024),
297 ("T", 1024 * 1024),
298 ];
299 let mut mul = 1u64;
300 for (suffix, imul) in suffixes {
301 if let Some((sv, rest)) = s.rsplit_once(suffix) {
302 if !rest.is_empty() {
303 anyhow::bail!("Trailing text after size: {rest}");
304 }
305 s = sv;
306 mul = imul;
307 }
308 }
309 let v = s.parse::<u64>()?;
310 Ok(v * mul)
311}
312
313#[cfg(test)]
314mod test {
315 use super::*;
316
317 #[test]
318 fn test_parse_size_mib() {
319 let ident_cases = [0, 10, 9, 1024].into_iter().map(|k| (k.to_string(), k));
320 let cases = [
321 ("0M", 0),
322 ("10M", 10),
323 ("10MiB", 10),
324 ("1G", 1024),
325 ("9G", 9216),
326 ("11T", 11 * 1024 * 1024),
327 ]
328 .into_iter()
329 .map(|(k, v)| (k.to_string(), v));
330 for (s, v) in ident_cases.chain(cases) {
331 assert_eq!(parse_size_mib(&s).unwrap(), v as u64, "Parsing {s}");
332 }
333 }
334
335 #[test]
336 fn test_parse_lsblk() {
337 let fixture = include_str!("../tests/fixtures/lsblk.json");
338 let devs: DevicesOutput = serde_json::from_str(&fixture).unwrap();
339 let dev = devs.blockdevices.into_iter().next().unwrap();
340 let children = dev.children.as_deref().unwrap();
341 assert_eq!(children.len(), 3);
342 let first_child = &children[0];
343 assert_eq!(
344 first_child.parttype.as_deref().unwrap(),
345 "21686148-6449-6e6f-744e-656564454649"
346 );
347 assert_eq!(
348 first_child.partuuid.as_deref().unwrap(),
349 "3979e399-262f-4666-aabc-7ab5d3add2f0"
350 );
351 }
352
353 #[test]
354 fn test_parse_sfdisk() -> Result<()> {
355 let fixture = indoc::indoc! { r#"
356 {
357 "partitiontable": {
358 "label": "gpt",
359 "id": "A67AA901-2C72-4818-B098-7F1CAC127279",
360 "device": "/dev/loop0",
361 "unit": "sectors",
362 "firstlba": 34,
363 "lastlba": 20971486,
364 "sectorsize": 512,
365 "partitions": [
366 {
367 "node": "/dev/loop0p1",
368 "start": 2048,
369 "size": 8192,
370 "type": "9E1A2D38-C612-4316-AA26-8B49521E5A8B",
371 "uuid": "58A4C5F0-BD12-424C-B563-195AC65A25DD",
372 "name": "PowerPC-PReP-boot"
373 },{
374 "node": "/dev/loop0p2",
375 "start": 10240,
376 "size": 20961247,
377 "type": "0FC63DAF-8483-4772-8E79-3D69D8477DE4",
378 "uuid": "F51ABB0D-DA16-4A21-83CB-37F4C805AAA0",
379 "name": "root"
380 }
381 ]
382 }
383 }
384 "# };
385 let table: SfDiskOutput = serde_json::from_str(&fixture).unwrap();
386 assert_eq!(
387 table.partitiontable.find("/dev/loop0p2").unwrap().size,
388 20961247
389 );
390 Ok(())
391 }
392}