Skip to main content

bootc_internal_blockdev/
blockdev.rs

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