Skip to main content

bootc_internal_blockdev/
blockdev.rs

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