liblitho 0.2.0

cli tool to flash/clone the images to storage devices
Documentation
//! Linux whole-disk path validation (`/dev/sdb`, `nvme0n1`, …).

use crate::devices::DeviceError;
use crate::platform::traits::DevicePathOps;
use std::path::Path;

use super::LinuxPlatform;

impl DevicePathOps for LinuxPlatform {
    fn validate_whole_disk_path(path: &str) -> Result<(), DeviceError> {
        validate_linux_block_device_path(path)
    }

    fn paths_equivalent(listed: &str, selected: &str) -> bool {
        listed == selected
    }
}

/// Require an exact whole-block device path such as `/dev/sdb` (no normalization).
pub fn validate_linux_block_device_path(path: &str) -> Result<(), DeviceError> {
    let trimmed = path.trim();
    if trimmed.is_empty() {
        return Err(DeviceError::invalid_path("Device path is empty."));
    }
    if !trimmed.starts_with("/dev/") {
        return Err(DeviceError::invalid_path(format!(
            "Device must be a full block device path (e.g. /dev/sdb), got: {trimmed}"
        )));
    }
    let name = trimmed
        .strip_prefix("/dev/")
        .ok_or_else(|| DeviceError::invalid_path(format!("Invalid device path: {trimmed}")))?;
    if name.is_empty() {
        return Err(DeviceError::invalid_path(format!("Invalid device path: {trimmed}")));
    }
    if is_partition_block_name(name) {
        return Err(DeviceError::invalid_path(format!(
            "Partitions are not allowed; use the whole block device (got {trimmed})"
        )));
    }
    if is_rejected_block_name(name) {
        return Err(DeviceError::invalid_path(format!(
            "Device type is not allowed for flash/clone: {trimmed}"
        )));
    }
    if !is_whole_block_device_name(name) {
        return Err(DeviceError::invalid_path(format!(
            "Not a recognized whole block device: {trimmed}"
        )));
    }
    if !Path::new(trimmed).exists() {
        return Err(DeviceError::invalid_path(format!(
            "Device path does not exist: {trimmed}"
        )));
    }
    Ok(())
}

/// Map a block device name or path to its parent whole-disk path (`/dev/sdb`, …).
pub fn whole_disk_path(path_or_name: &str) -> Result<String, DeviceError> {
    let name = if path_or_name.starts_with("/dev/") {
        path_or_name
            .strip_prefix("/dev/")
            .ok_or_else(|| {
                DeviceError::invalid_path(format!("Invalid device path: {path_or_name}"))
            })?
    } else {
        path_or_name
    };
    Ok(format!("/dev/{}", whole_disk_name_from_block(name)))
}

pub(crate) fn whole_disk_path_from_source(source: &str) -> Result<String, DeviceError> {
    if !source.starts_with("/dev/") {
        return Err(DeviceError::query(format!(
            "Unsupported root device source: {source}"
        )));
    }
    whole_disk_path(source)
}

fn scsi_disk_stem(name: &str) -> Option<&str> {
    name.strip_prefix("sd")
        .or_else(|| name.strip_prefix("vd"))
        .or_else(|| name.strip_prefix("hd"))
}

pub(crate) fn is_partition_block_name(name: &str) -> bool {
    if name.starts_with("mmcblk") {
        return name.rsplit_once('p').is_some_and(|(_, suffix)| {
            !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit())
        });
    }
    if name.starts_with("nvme") {
        return name.rsplit_once('p').is_some_and(|(prefix, suffix)| {
            prefix.contains('n') && !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit())
        });
    }
    if let Some(stem) = scsi_disk_stem(name) {
        return stem.chars().any(|c| c.is_ascii_digit());
    }
    false
}

fn is_rejected_block_name(name: &str) -> bool {
    name.starts_with("loop") || name.starts_with("dm-") || name.starts_with("ram")
}

fn whole_disk_name_from_block(name: &str) -> String {
    if is_partition_block_name(name) {
        if let Some((stem, suffix)) = name.rsplit_once('p') {
            if (name.starts_with("mmcblk") || name.starts_with("nvme"))
                && !suffix.is_empty()
                && suffix.chars().all(|c| c.is_ascii_digit())
            {
                return stem.to_string();
            }
        }
        let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
        if !prefix.is_empty() && prefix.len() < name.len() {
            return prefix;
        }
    }
    name.to_string()
}

pub(crate) fn is_whole_block_device_name(name: &str) -> bool {
    if is_partition_block_name(name) || is_rejected_block_name(name) {
        return false;
    }
    if let Some(stem) = scsi_disk_stem(name) {
        return !stem.is_empty()
            && stem.chars().all(|c| c.is_ascii_lowercase())
            && !stem.chars().any(|c| c.is_ascii_digit());
    }
    if let Some(rest) = name.strip_prefix("mmcblk") {
        return rest.chars().all(|c| c.is_ascii_digit());
    }
    if let Some(rest) = name.strip_prefix("nvme") {
        return !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit() || c == 'n');
    }
    false
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn accepts_whole_block_paths() {
        for name in ["sdb", "mmcblk0", "nvme0n1", "vda"] {
            assert!(is_whole_block_device_name(name), "{name}");
            assert!(!is_partition_block_name(name), "{name}");
        }
    }

    #[test]
    fn rejects_partitions_and_loops() {
        for name in ["sdb1", "mmcblk0p1", "nvme0n1p2", "loop0", "dm-0"] {
            assert!(
                is_partition_block_name(name) || is_rejected_block_name(name),
                "{name}"
            );
        }
    }

    #[test]
    fn validate_requires_dev_prefix() {
        assert!(validate_linux_block_device_path("sdb").is_err());
        assert!(validate_linux_block_device_path("/dev/sdb1").is_err());
    }

    #[test]
    fn whole_disk_path_strips_partitions() {
        assert_eq!(whole_disk_path("/dev/sdb1").unwrap(), "/dev/sdb");
        assert_eq!(whole_disk_path("/dev/nvme0n1p2").unwrap(), "/dev/nvme0n1");
        assert_eq!(whole_disk_path("/dev/mmcblk0p1").unwrap(), "/dev/mmcblk0");
        assert_eq!(whole_disk_path("/dev/sdb").unwrap(), "/dev/sdb");
    }
}