bootc_internal_blockdev/
blockdev.rs

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    // NOTE this one is not available on older util-linux, and
35    // will also not exist for whole blockdevs (as opposed to partitions).
36    pub start: Option<u64>,
37
38    // Filesystem-related properties
39    pub label: Option<String>,
40    pub fstype: Option<String>,
41    pub path: Option<String>,
42}
43
44impl Device {
45    #[allow(dead_code)]
46    // RHEL8's lsblk doesn't have PATH, so we do it
47    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    // The "start" parameter was only added in a version of util-linux that's only
57    // in Fedora 40 as of this writing.
58    fn backfill_start(&mut self) -> Result<()> {
59        let Some(majmin) = self.maj_min.as_deref() else {
60            // This shouldn't happen
61            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    /// Older versions of util-linux may be missing some properties. Backfill them if they're missing.
79    pub fn backfill_missing(&mut self) -> Result<()> {
80        // Add new properties to backfill here
81        self.backfill_start()?;
82        // And recurse to child devices
83        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    // We're not using these fields
138    // pub unit: String,
139    // pub firstlba: u64,
140    // pub lastlba: u64,
141    // pub sectorsize: u64,
142    pub partitions: Vec<Partition>,
143}
144
145impl PartitionTable {
146    /// Find the partition with the given device name
147    #[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    // Find the partition with the given offset (starting at 1)
157    #[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    // Create a new loopback block device targeting the provided file path.
188    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    // Access the path to the loopback block device.
215    pub fn path(&self) -> &Utf8Path {
216        // SAFETY: The option cannot be destructured until we are dropped
217        self.dev.as_deref().unwrap()
218    }
219
220    // Shared backend for our `close` and `drop` implementations.
221    fn impl_close(&mut self) -> Result<()> {
222        // SAFETY: This is the only place we take the option
223        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    /// Consume this device, unmounting it.
231    pub fn close(mut self) -> Result<()> {
232        self.impl_close()
233    }
234}
235
236impl Drop for LoopbackDevice {
237    fn drop(&mut self) {
238        // Best effort to unmount if we're dropped without invoking `close`
239        let _ = self.impl_close();
240    }
241}
242
243/// Parse key-value pairs from lsblk --pairs.
244/// Newer versions of lsblk support JSON but the one in CentOS 7 doesn't.
245fn 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
255/// This is a bit fuzzy, but... this function will return every block device in the parent
256/// hierarchy of `device` capable of containing other partitions. So e.g. parent devices of type
257/// "part" doesn't match, but "disk" and "mpath" does.
258pub fn find_parent_devices(device: &str) -> Result<Vec<String>> {
259    let output = Command::new("lsblk")
260        // Older lsblk, e.g. in CentOS 7.6, doesn't support PATH, but --paths option
261        .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    // skip first line, which is the device itself
270    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            // we don't need to know what disks back the multipath
283            break;
284        }
285    }
286    Ok(parents)
287}
288
289/// Parse a string into mibibytes
290pub 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}