bootc_internal_blockdev/
blockdev.rs

1use std::collections::HashSet;
2use std::env;
3use std::path::Path;
4use std::process::{Command, Stdio};
5
6use anyhow::{Context, Result, anyhow};
7use camino::{Utf8Path, Utf8PathBuf};
8use cap_std_ext::cap_std::fs::Dir;
9use fn_error_context::context;
10use serde::Deserialize;
11
12use bootc_utils::CommandRunExt;
13
14/// MBR partition type IDs that indicate an EFI System Partition.
15/// 0x06 is FAT16 (used as ESP on some MBR systems), 0xEF is the
16/// explicit EFI System Partition type.
17/// Refer to <https://en.wikipedia.org/wiki/Partition_type>
18pub const ESP_ID_MBR: &[u8] = &[0x06, 0xEF];
19
20/// EFI System Partition (ESP) for UEFI boot on GPT
21pub const ESP: &str = "c12a7328-f81f-11d2-ba4b-00a0c93ec93b";
22
23/// BIOS boot partition type GUID for GPT
24pub const BIOS_BOOT: &str = "21686148-6449-6e6f-744e-656564454649";
25
26#[derive(Debug, Deserialize)]
27struct DevicesOutput {
28    blockdevices: Vec<Device>,
29}
30
31#[allow(dead_code)]
32#[derive(Debug, Clone, Deserialize)]
33pub struct Device {
34    pub name: String,
35    pub serial: Option<String>,
36    pub model: Option<String>,
37    pub partlabel: Option<String>,
38    pub parttype: Option<String>,
39    pub partuuid: Option<String>,
40    /// Partition number (1-indexed). None for whole disk devices.
41    pub partn: Option<u32>,
42    pub children: Option<Vec<Device>>,
43    pub size: u64,
44    #[serde(rename = "maj:min")]
45    pub maj_min: Option<String>,
46    // NOTE this one is not available on older util-linux, and
47    // will also not exist for whole blockdevs (as opposed to partitions).
48    pub start: Option<u64>,
49
50    // Filesystem-related properties
51    pub label: Option<String>,
52    pub fstype: Option<String>,
53    pub uuid: Option<String>,
54    pub path: Option<String>,
55    /// Partition table type (e.g., "gpt", "dos"). Only present on whole disk devices.
56    pub pttype: Option<String>,
57}
58
59impl Device {
60    // RHEL8's lsblk doesn't have PATH, so we do it
61    pub fn path(&self) -> String {
62        self.path.clone().unwrap_or(format!("/dev/{}", &self.name))
63    }
64
65    /// Alias for path() for compatibility
66    #[allow(dead_code)]
67    pub fn node(&self) -> String {
68        self.path()
69    }
70
71    #[allow(dead_code)]
72    pub fn has_children(&self) -> bool {
73        self.children.as_ref().is_some_and(|v| !v.is_empty())
74    }
75
76    // Check if the device is mpath
77    pub fn is_mpath(&self) -> Result<bool> {
78        let dm_path = Utf8PathBuf::from_path_buf(std::fs::canonicalize(self.path())?)
79            .map_err(|_| anyhow::anyhow!("Non-UTF8 path"))?;
80        let dm_name = dm_path.file_name().unwrap_or("");
81        let uuid_path = Utf8PathBuf::from(format!("/sys/class/block/{dm_name}/dm/uuid"));
82
83        if uuid_path.exists() {
84            let uuid = std::fs::read_to_string(&uuid_path)
85                .with_context(|| format!("Failed to read {uuid_path}"))?;
86            if uuid.trim_start().starts_with("mpath-") {
87                return Ok(true);
88            }
89        }
90        Ok(false)
91    }
92
93    /// Get the numeric partition index of the ESP (e.g. "1", "2").
94    ///
95    /// We read `/sys/class/block/<name>/partition` rather than parsing device
96    /// names because naming conventions vary across disk types (sd, nvme, dm, etc.).
97    /// On multipath devices the sysfs `partition` attribute doesn't exist, so we
98    /// fall back to the `partn` field reported by lsblk.
99    pub fn get_esp_partition_number(&self) -> Result<String> {
100        let esp_device = self.find_partition_of_esp()?;
101        let devname = &esp_device.name;
102
103        let partition_path = Utf8PathBuf::from(format!("/sys/class/block/{devname}/partition"));
104        if partition_path.exists() {
105            return std::fs::read_to_string(&partition_path)
106                .with_context(|| format!("Failed to read {partition_path}"));
107        }
108
109        // On multipath the partition attribute is not existing
110        if self.is_mpath()? {
111            if let Some(partn) = esp_device.partn {
112                return Ok(partn.to_string());
113            }
114        }
115        anyhow::bail!("Not supported for {devname}")
116    }
117
118    /// Find BIOS boot partition among children.
119    pub fn find_partition_of_bios_boot(&self) -> Option<&Device> {
120        self.find_partition_of_type(BIOS_BOOT)
121    }
122
123    /// Find all ESP partitions across all root devices backing this device.
124    /// Calls find_all_roots() to discover physical disks, then searches each for an ESP.
125    /// Returns None if no ESPs are found.
126    pub fn find_colocated_esps(&self) -> Result<Option<Vec<Device>>> {
127        let mut esps = Vec::new();
128        for root in &self.find_all_roots()? {
129            if let Some(esp) = root.find_partition_of_esp_optional()? {
130                esps.push(esp.clone());
131            }
132        }
133        Ok((!esps.is_empty()).then_some(esps))
134    }
135
136    /// Find a single ESP partition among all root devices backing this device.
137    ///
138    /// Walks the parent chain to find all backing disks, then looks for ESP
139    /// partitions on each. Returns the first ESP found. This is the common
140    /// case for composefs/UKI boot paths where exactly one ESP is expected.
141    pub fn find_first_colocated_esp(&self) -> Result<Device> {
142        self.find_colocated_esps()?
143            .and_then(|mut v| Some(v.remove(0)))
144            .ok_or_else(|| anyhow!("No ESP partition found among backing devices"))
145    }
146
147    /// Find all BIOS boot partitions across all root devices backing this device.
148    /// Calls find_all_roots() to discover physical disks, then searches each for a BIOS boot partition.
149    /// Returns None if no BIOS boot partitions are found.
150    pub fn find_colocated_bios_boot(&self) -> Result<Option<Vec<Device>>> {
151        let bios_boots: Vec<_> = self
152            .find_all_roots()?
153            .iter()
154            .filter_map(|root| root.find_partition_of_bios_boot())
155            .cloned()
156            .collect();
157        Ok((!bios_boots.is_empty()).then_some(bios_boots))
158    }
159
160    /// Find a child partition by partition type (case-insensitive).
161    pub fn find_partition_of_type(&self, parttype: &str) -> Option<&Device> {
162        self.children.as_ref()?.iter().find(|child| {
163            child
164                .parttype
165                .as_ref()
166                .is_some_and(|pt| pt.eq_ignore_ascii_case(parttype))
167        })
168    }
169
170    /// Find the EFI System Partition (ESP) among children.
171    ///
172    /// For GPT disks, this matches by the ESP partition type GUID.
173    /// For MBR (dos) disks, this matches by the MBR partition type IDs (0x06 or 0xEF).
174    ///
175    /// If no ESP is found among direct children, this recurses into children
176    /// that have their own partition table (e.g. firmware RAID arrays where the
177    /// hierarchy is disk → md array → partitions).
178    ///
179    /// Returns `Ok(None)` when there are no children or no ESP partition
180    /// is present. Returns `Err` only for genuinely unexpected conditions
181    /// (e.g. an unsupported partition table type).
182    pub fn find_partition_of_esp_optional(&self) -> Result<Option<&Device>> {
183        let Some(children) = self.children.as_ref() else {
184            return Ok(None);
185        };
186        let direct = match self.pttype.as_deref() {
187            Some("dos") => children.iter().find(|child| {
188                child
189                    .parttype
190                    .as_ref()
191                    .and_then(|pt| {
192                        let pt = pt.strip_prefix("0x").unwrap_or(pt);
193                        u8::from_str_radix(pt, 16).ok()
194                    })
195                    .is_some_and(|pt| ESP_ID_MBR.contains(&pt))
196            }),
197            // When pttype is None (e.g. older lsblk or partition devices), default
198            // to GPT UUID matching which will simply not match MBR hex types.
199            Some("gpt") | None => self.find_partition_of_type(ESP),
200            Some(other) => return Err(anyhow!("Unsupported partition table type: {other}")),
201        };
202        if direct.is_some() {
203            return Ok(direct);
204        }
205        // Recurse into children that carry their own partition table, such as
206        // firmware RAID arrays (disk → md array → partitions).
207        for child in children {
208            if child.pttype.is_some() {
209                if let Some(esp) = child.find_partition_of_esp_optional()? {
210                    return Ok(Some(esp));
211                }
212            }
213        }
214        Ok(None)
215    }
216
217    /// Find the EFI System Partition (ESP) among children, or error if absent.
218    ///
219    /// This is a convenience wrapper around [`Self::find_partition_of_esp_optional`]
220    /// for callers that require an ESP to be present.
221    pub fn find_partition_of_esp(&self) -> Result<&Device> {
222        self.find_partition_of_esp_optional()?
223            .ok_or_else(|| anyhow!("ESP partition not found on {}", self.path()))
224    }
225
226    /// Find a child partition by partition number (1-indexed).
227    pub fn find_device_by_partno(&self, partno: u32) -> Result<&Device> {
228        self.children
229            .as_ref()
230            .ok_or_else(|| anyhow!("Device has no children"))?
231            .iter()
232            .find(|child| child.partn == Some(partno))
233            .ok_or_else(|| anyhow!("Missing partition for index {partno}"))
234    }
235
236    /// Re-query this device's information from lsblk, updating all fields.
237    /// This is useful after partitioning when the device's children have changed.
238    pub fn refresh(&mut self) -> Result<()> {
239        let path = self.path();
240        let new_device = list_dev(Utf8Path::new(&path))?;
241        *self = new_device;
242        Ok(())
243    }
244
245    /// Read a sysfs property for this device and parse it as the target type.
246    fn read_sysfs_property<T>(&self, property: &str) -> Result<Option<T>>
247    where
248        T: std::str::FromStr,
249        T::Err: std::error::Error + Send + Sync + 'static,
250    {
251        let Some(majmin) = self.maj_min.as_deref() else {
252            return Ok(None);
253        };
254        let sysfs_path = format!("/sys/dev/block/{majmin}/{property}");
255        if !Utf8Path::new(&sysfs_path).try_exists()? {
256            return Ok(None);
257        }
258        let value = std::fs::read_to_string(&sysfs_path)
259            .with_context(|| format!("Reading {sysfs_path}"))?;
260        let parsed = value
261            .trim()
262            .parse()
263            .with_context(|| format!("Parsing sysfs {property} property"))?;
264        tracing::debug!("backfilled {property} to {value}");
265        Ok(Some(parsed))
266    }
267
268    /// Older versions of util-linux may be missing some properties. Backfill them if they're missing.
269    pub fn backfill_missing(&mut self) -> Result<()> {
270        // The "start" parameter was only added in a version of util-linux that's only
271        // in Fedora 40 as of this writing.
272        if self.start.is_none() {
273            self.start = self.read_sysfs_property("start")?;
274        }
275        // The "partn" column was added in util-linux 2.39, which is newer than
276        // what CentOS 9 / RHEL 9 ship (2.37). Note: sysfs uses "partition" not "partn".
277        if self.partn.is_none() {
278            self.partn = self.read_sysfs_property("partition")?;
279        }
280        // Recurse to child devices
281        for child in self.children.iter_mut().flatten() {
282            child.backfill_missing()?;
283        }
284        Ok(())
285    }
286
287    /// Query parent devices via `lsblk --inverse`.
288    ///
289    /// Returns `Ok(None)` if this device is already a root device (no parents).
290    /// In the returned `Vec<Device>`, each device's `children` field contains
291    /// *its own* parents (grandparents, etc.), forming the full chain to the
292    /// root device(s). A device can have multiple parents (e.g. RAID, LVM).
293    pub fn list_parents(&self) -> Result<Option<Vec<Device>>> {
294        let path = self.path();
295        let output: DevicesOutput = Command::new("lsblk")
296            .args(["-J", "-b", "-O", "--inverse"])
297            .arg(&path)
298            .log_debug()
299            .run_and_parse_json()?;
300
301        let device = output
302            .blockdevices
303            .into_iter()
304            .next()
305            .ok_or_else(|| anyhow!("no device output from lsblk --inverse for {path}"))?;
306
307        match device.children {
308            Some(mut children) if !children.is_empty() => {
309                for child in &mut children {
310                    child.backfill_missing()?;
311                }
312                Ok(Some(children))
313            }
314            _ => Ok(None),
315        }
316    }
317
318    /// Walk the parent chain to find all root (whole disk) devices,
319    /// and fail if more than one root is found.
320    ///
321    /// This is a convenience wrapper around `find_all_roots` for callers
322    /// that expect exactly one backing device (e.g. non-RAID setups).
323    pub fn require_single_root(&self) -> Result<Device> {
324        let mut roots = self.find_all_roots()?;
325        match roots.len() {
326            1 => Ok(roots.remove(0)),
327            n => anyhow::bail!(
328                "Expected a single root device for {}, but found {n}",
329                self.path()
330            ),
331        }
332    }
333
334    /// Walk the parent chain to find all root (whole disk) devices.
335    ///
336    /// Returns all root devices with their children (partitions) populated.
337    /// This handles devices backed by multiple parents (e.g. RAID arrays)
338    /// by following all branches of the parent tree.
339    /// If this device is already a root device, returns a single-element list.
340    pub fn find_all_roots(&self) -> Result<Vec<Device>> {
341        let Some(parents) = self.list_parents()? else {
342            // Already a root device; re-query to ensure children are populated
343            return Ok(vec![list_dev(Utf8Path::new(&self.path()))?]);
344        };
345
346        let mut roots = Vec::new();
347        let mut seen = HashSet::new();
348        let mut queue = parents;
349        while let Some(mut device) = queue.pop() {
350            match device.children.take() {
351                Some(grandparents) if !grandparents.is_empty() => {
352                    queue.extend(grandparents);
353                }
354                _ => {
355                    // Deduplicate: in complex topologies (e.g. multipath)
356                    // multiple branches can converge on the same physical disk.
357                    let name = device.name.clone();
358                    if seen.insert(name) {
359                        // Found a new root; re-query to populate its actual children
360                        roots.push(list_dev(Utf8Path::new(&device.path()))?);
361                    }
362                }
363            }
364        }
365        Ok(roots)
366    }
367}
368
369#[context("Listing device {dev}")]
370pub fn list_dev(dev: &Utf8Path) -> Result<Device> {
371    let mut devs: DevicesOutput = Command::new("lsblk")
372        .args(["-J", "-b", "-O"])
373        .arg(dev)
374        .log_debug()
375        .run_and_parse_json()?;
376    for dev in devs.blockdevices.iter_mut() {
377        dev.backfill_missing()?;
378    }
379    devs.blockdevices
380        .into_iter()
381        .next()
382        .ok_or_else(|| anyhow!("no device output from lsblk for {dev}"))
383}
384
385/// List the device containing the filesystem mounted at the given directory.
386pub fn list_dev_by_dir(dir: &Dir) -> Result<Device> {
387    let fsinfo = bootc_mount::inspect_filesystem_of_dir(dir)?;
388    list_dev(&Utf8PathBuf::from(&fsinfo.source))
389}
390
391pub struct LoopbackDevice {
392    pub dev: Option<Utf8PathBuf>,
393    // Handle to the cleanup helper process
394    cleanup_handle: Option<LoopbackCleanupHandle>,
395}
396
397/// Handle to manage the cleanup helper process for loopback devices
398struct LoopbackCleanupHandle {
399    /// Child process handle
400    child: std::process::Child,
401}
402
403impl LoopbackDevice {
404    // Create a new loopback block device targeting the provided file path.
405    pub fn new(path: &Path) -> Result<Self> {
406        let direct_io = match env::var("BOOTC_DIRECT_IO") {
407            Ok(val) => {
408                if val == "on" {
409                    "on"
410                } else {
411                    "off"
412                }
413            }
414            Err(_e) => "off",
415        };
416
417        let dev = Command::new("losetup")
418            .args([
419                "--show",
420                format!("--direct-io={direct_io}").as_str(),
421                "-P",
422                "--find",
423            ])
424            .arg(path)
425            .run_get_string()?;
426        let dev = Utf8PathBuf::from(dev.trim());
427        tracing::debug!("Allocated loopback {dev}");
428
429        // Try to spawn cleanup helper, but don't fail if it doesn't work
430        let cleanup_handle = match Self::spawn_cleanup_helper(dev.as_str()) {
431            Ok(handle) => Some(handle),
432            Err(e) => {
433                tracing::warn!(
434                    "Failed to spawn loopback cleanup helper for {}: {}. \
435                     Loopback device may not be cleaned up if process is interrupted.",
436                    dev,
437                    e
438                );
439                None
440            }
441        };
442
443        Ok(Self {
444            dev: Some(dev),
445            cleanup_handle,
446        })
447    }
448
449    // Access the path to the loopback block device.
450    pub fn path(&self) -> &Utf8Path {
451        // SAFETY: The option cannot be destructured until we are dropped
452        self.dev.as_deref().unwrap()
453    }
454
455    /// Spawn a cleanup helper process that will clean up the loopback device
456    /// if the parent process dies unexpectedly
457    fn spawn_cleanup_helper(device_path: &str) -> Result<LoopbackCleanupHandle> {
458        // Try multiple strategies to find the bootc binary
459        let bootc_path = bootc_utils::reexec::executable_path()
460            .context("Failed to locate bootc binary for cleanup helper")?;
461
462        // Create the helper process
463        let mut cmd = Command::new(bootc_path);
464        cmd.args([
465            "internals",
466            "loopback-cleanup-helper",
467            "--device",
468            device_path,
469        ]);
470
471        // Set environment variable to indicate this is a cleanup helper
472        cmd.env("BOOTC_LOOPBACK_CLEANUP_HELPER", "1");
473
474        // Set up stdio to redirect to /dev/null
475        cmd.stdin(Stdio::null());
476        cmd.stdout(Stdio::null());
477        // Don't redirect stderr so we can see error messages
478
479        // Spawn the process
480        let child = cmd
481            .spawn()
482            .context("Failed to spawn loopback cleanup helper")?;
483
484        Ok(LoopbackCleanupHandle { child })
485    }
486
487    // Shared backend for our `close` and `drop` implementations.
488    fn impl_close(&mut self) -> Result<()> {
489        // SAFETY: This is the only place we take the option
490        let Some(dev) = self.dev.take() else {
491            tracing::trace!("loopback device already deallocated");
492            return Ok(());
493        };
494
495        // Kill the cleanup helper since we're cleaning up normally
496        if let Some(mut cleanup_handle) = self.cleanup_handle.take() {
497            // Send SIGTERM to the child process and let it do the cleanup
498            let _ = cleanup_handle.child.kill();
499        }
500
501        Command::new("losetup")
502            .args(["-d", dev.as_str()])
503            .run_capture_stderr()
504    }
505
506    /// Consume this device, unmounting it.
507    pub fn close(mut self) -> Result<()> {
508        self.impl_close()
509    }
510}
511
512impl Drop for LoopbackDevice {
513    fn drop(&mut self) {
514        // Best effort to unmount if we're dropped without invoking `close`
515        let _ = self.impl_close();
516    }
517}
518
519/// Main function for the loopback cleanup helper process
520/// This function does not return - it either exits normally or via signal
521pub async fn run_loopback_cleanup_helper(device_path: &str) -> Result<()> {
522    // Check if we're running as a cleanup helper
523    if std::env::var("BOOTC_LOOPBACK_CLEANUP_HELPER").is_err() {
524        anyhow::bail!("This function should only be called as a cleanup helper");
525    }
526
527    // Set up death signal notification - we want to be notified when parent dies
528    rustix::process::set_parent_process_death_signal(Some(rustix::process::Signal::TERM))
529        .context("Failed to set parent death signal")?;
530
531    // Wait for SIGTERM (either from parent death or normal cleanup)
532    tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
533        .expect("Failed to create signal stream")
534        .recv()
535        .await;
536
537    // Clean up the loopback device
538    let output = std::process::Command::new("losetup")
539        .args(["-d", device_path])
540        .output();
541
542    match output {
543        Ok(output) if output.status.success() => {
544            // Log to systemd journal instead of stderr
545            tracing::info!("Cleaned up leaked loopback device {}", device_path);
546            std::process::exit(0);
547        }
548        Ok(output) => {
549            let stderr = String::from_utf8_lossy(&output.stderr);
550            tracing::error!(
551                "Failed to clean up loopback device {}: {}. Stderr: {}",
552                device_path,
553                output.status,
554                stderr.trim()
555            );
556            std::process::exit(1);
557        }
558        Err(e) => {
559            tracing::error!(
560                "Error executing losetup to clean up loopback device {}: {}",
561                device_path,
562                e
563            );
564            std::process::exit(1);
565        }
566    }
567}
568
569/// Parse a string into mibibytes
570pub fn parse_size_mib(mut s: &str) -> Result<u64> {
571    let suffixes = [
572        ("MiB", 1u64),
573        ("M", 1u64),
574        ("GiB", 1024),
575        ("G", 1024),
576        ("TiB", 1024 * 1024),
577        ("T", 1024 * 1024),
578    ];
579    let mut mul = 1u64;
580    for (suffix, imul) in suffixes {
581        if let Some((sv, rest)) = s.rsplit_once(suffix) {
582            if !rest.is_empty() {
583                anyhow::bail!("Trailing text after size: {rest}");
584            }
585            s = sv;
586            mul = imul;
587        }
588    }
589    let v = s.parse::<u64>()?;
590    Ok(v * mul)
591}
592
593#[cfg(test)]
594mod test {
595    use super::*;
596
597    #[test]
598    fn test_parse_size_mib() {
599        let ident_cases = [0, 10, 9, 1024].into_iter().map(|k| (k.to_string(), k));
600        let cases = [
601            ("0M", 0),
602            ("10M", 10),
603            ("10MiB", 10),
604            ("1G", 1024),
605            ("9G", 9216),
606            ("11T", 11 * 1024 * 1024),
607        ]
608        .into_iter()
609        .map(|(k, v)| (k.to_string(), v));
610        for (s, v) in ident_cases.chain(cases) {
611            assert_eq!(parse_size_mib(&s).unwrap(), v as u64, "Parsing {s}");
612        }
613    }
614
615    #[test]
616    fn test_parse_lsblk() {
617        let fixture = include_str!("../tests/fixtures/lsblk.json");
618        let devs: DevicesOutput = serde_json::from_str(fixture).unwrap();
619        let dev = devs.blockdevices.into_iter().next().unwrap();
620        // The parent device has no partition number
621        assert_eq!(dev.partn, None);
622        let children = dev.children.as_deref().unwrap();
623        assert_eq!(children.len(), 3);
624        let first_child = &children[0];
625        assert_eq!(first_child.partn, Some(1));
626        assert_eq!(
627            first_child.parttype.as_deref().unwrap(),
628            "21686148-6449-6e6f-744e-656564454649"
629        );
630        assert_eq!(
631            first_child.partuuid.as_deref().unwrap(),
632            "3979e399-262f-4666-aabc-7ab5d3add2f0"
633        );
634        // Verify find_device_by_partno works
635        let part2 = dev.find_device_by_partno(2).unwrap();
636        assert_eq!(part2.partn, Some(2));
637        assert_eq!(part2.parttype.as_deref().unwrap(), ESP);
638        // Verify find_partition_of_esp works
639        let esp = dev.find_partition_of_esp().unwrap();
640        assert_eq!(esp.partn, Some(2));
641        // Verify find_partition_of_bios_boot works (vda1 is BIOS-BOOT)
642        let bios = dev.find_partition_of_bios_boot().unwrap();
643        assert_eq!(bios.partn, Some(1));
644        assert_eq!(bios.parttype.as_deref().unwrap(), BIOS_BOOT);
645    }
646
647    #[test]
648    fn test_parse_lsblk_mbr() {
649        let fixture = include_str!("../tests/fixtures/lsblk-mbr.json");
650        let devs: DevicesOutput = serde_json::from_str(fixture).unwrap();
651        let dev = devs.blockdevices.into_iter().next().unwrap();
652        // The parent device has no partition number and is MBR
653        assert_eq!(dev.partn, None);
654        assert_eq!(dev.pttype.as_deref().unwrap(), "dos");
655        let children = dev.children.as_deref().unwrap();
656        assert_eq!(children.len(), 3);
657        // First partition: FAT16 boot partition (MBR type 0x06, an ESP type)
658        let first_child = &children[0];
659        assert_eq!(first_child.partn, Some(1));
660        assert_eq!(first_child.parttype.as_deref().unwrap(), "0x06");
661        assert_eq!(first_child.partuuid.as_deref().unwrap(), "a1b2c3d4-01");
662        assert_eq!(first_child.fstype.as_deref().unwrap(), "vfat");
663        // MBR partitions have no partlabel
664        assert!(first_child.partlabel.is_none());
665        // Second partition: Linux root (MBR type 0x83)
666        let second_child = &children[1];
667        assert_eq!(second_child.partn, Some(2));
668        assert_eq!(second_child.parttype.as_deref().unwrap(), "0x83");
669        assert_eq!(second_child.partuuid.as_deref().unwrap(), "a1b2c3d4-02");
670        // Third partition: EFI System Partition (MBR type 0xef)
671        let third_child = &children[2];
672        assert_eq!(third_child.partn, Some(3));
673        assert_eq!(third_child.parttype.as_deref().unwrap(), "0xef");
674        assert_eq!(third_child.partuuid.as_deref().unwrap(), "a1b2c3d4-03");
675        // Verify find_device_by_partno works on MBR
676        let part1 = dev.find_device_by_partno(1).unwrap();
677        assert_eq!(part1.partn, Some(1));
678        // find_partition_of_esp returns the first matching ESP type (0x06 on partition 1)
679        let esp = dev.find_partition_of_esp().unwrap();
680        assert_eq!(esp.partn, Some(1));
681    }
682
683    /// Helper to construct a minimal MBR disk Device with given child partition types.
684    fn make_mbr_disk(parttypes: &[&str]) -> Device {
685        Device {
686            name: "vda".into(),
687            serial: None,
688            model: None,
689            partlabel: None,
690            parttype: None,
691            partuuid: None,
692            partn: None,
693            size: 10737418240,
694            maj_min: None,
695            start: None,
696            label: None,
697            fstype: None,
698            uuid: None,
699            path: Some("/dev/vda".into()),
700            pttype: Some("dos".into()),
701            children: Some(
702                parttypes
703                    .iter()
704                    .enumerate()
705                    .map(|(i, pt)| Device {
706                        name: format!("vda{}", i + 1),
707                        serial: None,
708                        model: None,
709                        partlabel: None,
710                        parttype: Some(pt.to_string()),
711                        partuuid: None,
712                        partn: Some(i as u32 + 1),
713                        size: 1048576,
714                        maj_min: None,
715                        start: Some(2048),
716                        label: None,
717                        fstype: None,
718                        uuid: None,
719                        path: None,
720                        pttype: Some("dos".into()),
721                        children: None,
722                    })
723                    .collect(),
724            ),
725        }
726    }
727
728    #[test]
729    fn test_parse_lsblk_vroc() {
730        let fixture = include_str!("../tests/fixtures/lsblk-vroc.json");
731        let devs: DevicesOutput = serde_json::from_str(fixture).unwrap();
732        assert_eq!(devs.blockdevices.len(), 2);
733
734        // find_partition_of_esp recurses through the md126 RAID array to
735        // locate the ESP (md126p1) even though it is not a direct child of
736        // the NVMe disk.
737        for nvme in &devs.blockdevices {
738            let esp = nvme.find_partition_of_esp().unwrap();
739            assert_eq!(esp.name, "md126p1");
740            assert_eq!(esp.partn, Some(1));
741            assert_eq!(esp.parttype.as_deref().unwrap(), ESP);
742            assert_eq!(esp.fstype.as_deref().unwrap(), "vfat");
743        }
744    }
745
746    #[test]
747    fn test_parse_lsblk_swraid() {
748        let fixture = include_str!("../tests/fixtures/lsblk-swraid.json");
749        let devs: DevicesOutput = serde_json::from_str(fixture).unwrap();
750        assert_eq!(devs.blockdevices.len(), 2);
751
752        // In a software RAID (mdadm) setup each disk is individually
753        // partitioned with its own GPT table and ESP.  The root partition
754        // (sda3/sdb3) is a linux_raid_member assembled into md0.
755        // find_partition_of_esp should locate the ESP as a direct child of
756        // each disk — no recursion through an md array is needed here.
757        let sda = &devs.blockdevices[0];
758        let esp = sda.find_partition_of_esp().unwrap();
759        assert_eq!(esp.name, "sda1");
760        assert_eq!(esp.partn, Some(1));
761        assert_eq!(esp.parttype.as_deref().unwrap(), ESP);
762        assert_eq!(esp.fstype.as_deref().unwrap(), "vfat");
763
764        let sdb = &devs.blockdevices[1];
765        let esp = sdb.find_partition_of_esp().unwrap();
766        assert_eq!(esp.name, "sdb1");
767        assert_eq!(esp.partn, Some(1));
768        assert_eq!(esp.parttype.as_deref().unwrap(), ESP);
769        assert_eq!(esp.fstype.as_deref().unwrap(), "vfat");
770
771        // Verify the md0 RAID array is visible as a child of the root
772        // partition on each disk.
773        let sda3 = sda
774            .children
775            .as_ref()
776            .unwrap()
777            .iter()
778            .find(|c| c.name == "sda3")
779            .unwrap();
780        assert_eq!(sda3.fstype.as_deref().unwrap(), "linux_raid_member");
781        let md0 = sda3
782            .children
783            .as_ref()
784            .unwrap()
785            .iter()
786            .find(|c| c.name == "md0")
787            .unwrap();
788        assert_eq!(md0.fstype.as_deref().unwrap(), "ext4");
789    }
790
791    #[test]
792    fn test_mbr_esp_detection() {
793        // 0x06 (FAT16) is recognized as ESP
794        let dev = make_mbr_disk(&["0x06"]);
795        assert_eq!(dev.find_partition_of_esp().unwrap().partn, Some(1));
796
797        // 0xef (EFI System Partition) is recognized as ESP
798        let dev = make_mbr_disk(&["0x83", "0xef"]);
799        assert_eq!(dev.find_partition_of_esp().unwrap().partn, Some(2));
800
801        // No ESP types present: 0x83 (Linux) and 0x82 (swap)
802        let dev = make_mbr_disk(&["0x83", "0x82"]);
803        assert!(dev.find_partition_of_esp().is_err());
804    }
805}