liblitho 0.2.0

cli tool to flash/clone the images to storage devices
Documentation
//! Linux system-disk and mount-busy checks.

use crate::devices::{DeviceError, DeviceMount};
use crate::platform::traits::DeviceSafety;
use std::fs;
use std::path::Path;
use std::process::Command;

use super::path::{whole_disk_path, whole_disk_path_from_source};
use super::LinuxPlatform;

impl DeviceSafety for LinuxPlatform {
    fn refuse_system_disk(path: &str) -> Result<(), DeviceError> {
        let target_whole = whole_disk_path(path)?;
        let root_sources = root_filesystem_sources()?;
        for source in &root_sources {
            let source_whole = whole_disk_path_from_source(source)?;
            if source_whole == target_whole {
                return Err(DeviceError::system_disk(format!(
                    "Refusing {path}: it is the system disk (root filesystem is on {source})"
                )));
            }
        }
        Ok(())
    }

    fn list_mounts(device_path: &str) -> Result<Vec<DeviceMount>, DeviceError> {
        busy_mounts_for_device(device_path).map(|mounts| {
            mounts
                .into_iter()
                .map(|(source, mount_point)| DeviceMount {
                    source,
                    mount_point,
                })
                .collect()
        })
    }

    fn busy_hint() -> &'static str {
        "Confirm the operation to unmount volumes automatically, or run `sudo umount <partition>` first."
    }
}

fn normalize_mount_source(source: &str) -> String {
    source
        .split_once('[')
        .map(|(base, _)| base)
        .unwrap_or(source)
        .trim()
        .to_string()
}

fn root_filesystem_sources() -> Result<Vec<String>, DeviceError> {
    match root_filesystem_source_from_findmnt() {
        Ok(source) => expand_block_sources(&normalize_mount_source(&source)),
        Err(findmnt_err) => {
            let source = root_filesystem_source_from_proc_mounts()
                .map_err(|proc_err| DeviceError::query(format!("{findmnt_err}; fallback: {proc_err}")))?;
            expand_block_sources(&normalize_mount_source(&source))
        }
    }
}

fn root_filesystem_source_from_findmnt() -> Result<String, DeviceError> {
    let output = Command::new("findmnt")
        .args(["-n", "-o", "SOURCE", "--target", "/"])
        .output()
        .map_err(|e| DeviceError::query(format!("Failed to run findmnt: {e}")))?;
    if !output.status.success() {
        return Err(DeviceError::query(format!(
            "findmnt failed: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        )));
    }
    let source = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if source.is_empty() {
        return Err(DeviceError::invalid_path(
            "findmnt returned an empty root source",
        ));
    }
    Ok(source)
}

fn root_filesystem_source_from_proc_mounts() -> Result<String, DeviceError> {
    let contents = fs::read_to_string("/proc/mounts")
        .map_err(|e| DeviceError::query(format!("Failed to read /proc/mounts: {e}")))?;
    for line in contents.lines() {
        let mut parts = line.split_whitespace();
        let Some(source) = parts.next() else {
            continue;
        };
        let Some(mount_point) = parts.next() else {
            continue;
        };
        if mount_point == "/" {
            return Ok(source.to_string());
        }
    }
    Err(DeviceError::query("Root mount not found in /proc/mounts"))
}

fn expand_block_sources(source: &str) -> Result<Vec<String>, DeviceError> {
    if !source.starts_with("/dev/") {
        return Err(DeviceError::query(format!(
            "Root filesystem source {source} is not a block device path"
        )));
    }

    let block_name = Path::new(source)
        .file_name()
        .and_then(|n| n.to_str())
        .ok_or_else(|| DeviceError::invalid_path(format!("Invalid root device source: {source}")))?;

    if block_name.starts_with("dm-") || source.contains("/mapper/") {
        if let Some(dm_name) = dm_sysfs_name(source).or_else(|| {
            fs::read_link(source)
                .ok()
                .and_then(|link| {
                    link.file_name()
                        .and_then(|n| n.to_str())
                        .map(str::to_string)
                })
                .filter(|name| name.starts_with("dm-"))
        }) {
            let slaves_dir = format!("/sys/block/{dm_name}/slaves");
            if let Ok(entries) = fs::read_dir(&slaves_dir) {
                let slaves: Vec<String> = entries
                    .filter_map(|entry| entry.ok())
                    .filter_map(|entry| entry.file_name().into_string().ok())
                    .map(|name| format!("/dev/{name}"))
                    .collect();
                if !slaves.is_empty() {
                    return Ok(slaves);
                }
            }
        }
    }

    Ok(vec![source.to_string()])
}

fn dm_sysfs_name(source: &str) -> Option<String> {
    let path = Path::new(source);
    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
        if name.starts_with("dm-") {
            return Some(name.to_string());
        }
    }

    let link = fs::read_link(source).ok()?;
    let link_name = link.file_name().and_then(|n| n.to_str())?;
    if link_name.starts_with("dm-") {
        return Some(link_name.to_string());
    }

    None
}

fn busy_mounts_for_device(device_path: &str) -> Result<Vec<(String, String)>, DeviceError> {
    let target_whole = whole_disk_path(device_path)?;
    let contents = fs::read_to_string("/proc/mounts")
        .map_err(|e| DeviceError::query(format!("Failed to read /proc/mounts: {e}")))?;

    let mut mounts = Vec::new();
    for line in contents.lines() {
        let mut parts = line.split_whitespace();
        let Some(source) = parts.next() else {
            continue;
        };
        let Some(mount_point) = parts.next() else {
            continue;
        };
        if !source.starts_with("/dev/") {
            continue;
        }
        let source_whole = whole_disk_path_from_source(source)?;
        if source_whole == target_whole {
            mounts.push((source.to_string(), mount_point.to_string()));
        }
    }
    Ok(mounts)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::platform::linux::path::whole_disk_path;

    fn busy_mounts_from_lines(
        contents: &str,
        device_path: &str,
    ) -> Result<Vec<(String, String)>, DeviceError> {
        let target_whole = whole_disk_path(device_path)?;
        let mut mounts = Vec::new();
        for line in contents.lines() {
            let mut parts = line.split_whitespace();
            let Some(source) = parts.next() else {
                continue;
            };
            let Some(mount_point) = parts.next() else {
                continue;
            };
            if !source.starts_with("/dev/") {
                continue;
            }
            let source_whole = whole_disk_path_from_source(source)?;
            if source_whole == target_whole {
                mounts.push((source.to_string(), mount_point.to_string()));
            }
        }
        Ok(mounts)
    }

    #[test]
    fn busy_mounts_detects_partition_on_target_disk() {
        let proc_mounts = r#"
/dev/nvme0n1p2 / ext4 rw,relatime 0 0
/dev/sdb1 /mnt/usb vfat rw,relatime 0 0
/dev/sdb2 /media/backup ext4 rw,relatime 0 0
"#;
        let mounts = busy_mounts_from_lines(proc_mounts, "/dev/sdb").unwrap();
        assert_eq!(mounts.len(), 2);
        assert!(mounts
            .iter()
            .any(|(s, m)| s == "/dev/sdb1" && m == "/mnt/usb"));
        assert!(mounts
            .iter()
            .any(|(s, m)| s == "/dev/sdb2" && m == "/media/backup"));
    }

    #[test]
    fn busy_mounts_ignores_other_disks() {
        let proc_mounts = "/dev/nvme0n1p2 / ext4 rw,relatime 0 0\n";
        let mounts = busy_mounts_from_lines(proc_mounts, "/dev/sdb").unwrap();
        assert!(mounts.is_empty());
    }
}