// REGION: API // api: device::Disk { path: String, size_bytes: u64, rotational: bool, model: Option, serial: Option } // api: device::DeviceFilter { include: Vec, exclude: Vec, min_size_gib: u64 } // api: device::DeviceProvider::list_block_devices(&self) -> crate::Result> // api: device::DeviceProvider::probe_properties(&self, disk: &mut Disk) -> crate::Result<()> // api: device::discover(filter: &DeviceFilter) -> crate::Result> // REGION: API-END // // REGION: RESPONSIBILITIES // - Enumerate candidate block devices under /dev. // - Filter using include/exclude regex and minimum size threshold. // - Probe device properties (size, rotational, model, serial). // Non-goals: partitioning, mkfs, or mounting. // REGION: RESPONSIBILITIES-END // // REGION: EXTENSION_POINTS // ext: pluggable DeviceProvider to allow mocking in tests and alternative discovery backends. // ext: future allowlist policies for removable media, device classes, or path patterns. // REGION: EXTENSION_POINTS-END // // REGION: SAFETY // safety: must not modify devices; read-only probing only. // safety: ensure pseudodevices (/dev/ram*, /dev/zram*, /dev/loop*, /dev/fd*, /dev/dm-*, /dev/md*) are excluded by default. // REGION: SAFETY-END // // REGION: ERROR_MAPPING // errmap: IO and parsing errors -> crate::Error::Device with context. // REGION: ERROR_MAPPING-END //! Device discovery and filtering for zosstorage. //! //! Exposes abstractions to enumerate and filter block devices under /dev, //! with compiled include/exclude regexes and size thresholds. //! //! See device::Disk and device::discover. #![allow(dead_code)] use crate::{Error, Result}; use regex::Regex; use std::fs; use std::path::{Path, PathBuf}; use tracing::{debug, trace, warn}; /// Eligible block device discovered on the system. #[derive(Debug, Clone)] pub struct Disk { /// Absolute device path (e.g., "/dev/nvme0n1"). pub path: String, /// Device size in bytes. pub size_bytes: u64, /// True for spinning disks; false for SSD/NVMe when detectable. pub rotational: bool, /// Optional model string (if available). pub model: Option, /// Optional serial string (if available). pub serial: Option, } /// Compiled device filters derived from configuration patterns. #[derive(Debug, Clone)] pub struct DeviceFilter { /// Inclusion regexes (any match qualifies). If empty, default include any. pub include: Vec, /// Exclusion regexes (any match disqualifies). pub exclude: Vec, /// Minimum size in GiB to consider eligible. pub min_size_gib: u64, /// Allow removable devices (e.g., USB sticks). Default false. pub allow_removable: bool, } impl DeviceFilter { fn matches(&self, dev_path: &str, size_bytes: u64) -> bool { // size filter let size_gib = size_bytes as f64 / 1073741824.0; if size_gib < self.min_size_gib as f64 { return false; } // include filter if !self.include.is_empty() { if !self.include.iter().any(|re| re.is_match(dev_path)) { return false; } } // exclude filter if self.exclude.iter().any(|re| re.is_match(dev_path)) { return false; } true } } /// Abstract provider to enable testing without real /dev access. pub trait DeviceProvider { /// List candidate block devices (whole disks only; not partitions). fn list_block_devices(&self) -> Result>; /// Probe and update additional properties for a disk. fn probe_properties(&self, _disk: &mut Disk) -> Result<()> { Ok(()) } } /// System-backed provider using /proc and /sys for discovery. struct SysProvider; impl SysProvider { fn new() -> Self { SysProvider } } impl DeviceProvider for SysProvider { fn list_block_devices(&self) -> Result> { let mut disks = Vec::new(); let content = fs::read_to_string("/proc/partitions") .map_err(|e| Error::Device(format!("/proc/partitions read error: {}", e)))?; for line in content.lines() { let line = line.trim(); if line.is_empty() || line.starts_with("major") { continue; } // Format: major minor #blocks name let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() < 4 { continue; } let name = parts[3]; // Exclude common pseudo and virtual device names if is_ignored_name(name) { trace!("skipping pseudo/ignored device: {}", name); continue; } // Skip partitions; we want whole-disk devices only if is_partition_sysfs(name) { trace!("skipping partition device: {}", name); continue; } // Ensure /dev node exists let dev_path = format!("/dev/{}", name); if !Path::new(&dev_path).exists() { trace!("skipping: missing device node {}", dev_path); continue; } // Read size in 512-byte sectors from sysfs, then convert to bytes let size_bytes = match read_disk_size_bytes(name) { Ok(sz) => sz, Err(e) => { warn!("failed to read size for {}: {}", name, e); continue; } }; let rotational = read_rotational(name).unwrap_or(false); let (model, serial) = read_model_serial(name); let disk = Disk { path: dev_path, size_bytes, rotational, model, serial, }; disks.push(disk); } Ok(disks) } fn probe_properties(&self, _disk: &mut Disk) -> Result<()> { // Properties are filled during enumeration above. Ok(()) } } /// Discover eligible disks according to the filter policy. /// /// Returns Error::Device when no eligible disks are found. pub fn discover(filter: &DeviceFilter) -> Result> { let provider = SysProvider::new(); discover_with_provider(&provider, filter) } fn discover_with_provider( provider: &P, filter: &DeviceFilter, ) -> Result> { let mut candidates = provider.list_block_devices()?; // Probe properties if provider needs to enrich for d in &mut candidates { provider.probe_properties(d)?; } // Apply filters (including removable policy) let filtered: Vec = candidates .into_iter() .filter(|d| { if !filter.allow_removable { if let Some(name) = base_name(&d.path) { if is_removable_sysfs(&name).unwrap_or(false) { trace!("excluding removable device by policy: {}", d.path); return false; } } } filter.matches(&d.path, d.size_bytes) }) .collect(); if filtered.is_empty() { return Err(Error::Device( "no eligible disks found after applying filters".to_string(), )); } debug!( "eligible disks: {:?}", filtered.iter().map(|d| &d.path).collect::>() ); Ok(filtered) } // ========================= // Sysfs helper functions // ========================= fn is_ignored_name(name: &str) -> bool { // Pseudo and virtual device common patterns name.starts_with("loop") || name.starts_with("ram") || name.starts_with("zram") || name.starts_with("fd") || name.starts_with("dm-") || name.starts_with("md") || name.starts_with("sr") } fn sys_block_path(name: &str) -> PathBuf { PathBuf::from(format!("/sys/class/block/{}", name)) } fn base_name(dev_path: &str) -> Option { Path::new(dev_path) .file_name() .map(|s| s.to_string_lossy().to_string()) } /// Returns Ok(true) if /sys/class/block//removable == "1" fn is_removable_sysfs(name: &str) -> Result { let p = sys_block_path(name).join("removable"); let s = fs::read_to_string(&p) .map_err(|e| Error::Device(format!("read {} failed: {}", p.display(), e)))?; Ok(s.trim() == "1") } fn is_partition_sysfs(name: &str) -> bool { let p = sys_block_path(name).join("partition"); p.exists() } fn read_disk_size_bytes(name: &str) -> Result { let p = sys_block_path(name).join("size"); let sectors = fs::read_to_string(&p) .map_err(|e| Error::Device(format!("read {} failed: {}", p.display(), e)))?; let sectors: u64 = sectors .trim() .parse() .map_err(|e| Error::Device(format!("parse sectors for {} failed: {}", name, e)))?; Ok(sectors.saturating_mul(512)) } fn read_rotational(name: &str) -> Result { let p = sys_block_path(name).join("queue/rotational"); let s = fs::read_to_string(&p) .map_err(|e| Error::Device(format!("read {} failed: {}", p.display(), e)))?; Ok(s.trim() == "1") } fn read_model_serial(name: &str) -> (Option, Option) { let base = sys_block_path(name).join("device"); let model = read_optional_string(base.join("model")); // Some devices expose "vendor" + "model"; if model missing, try "device/model" anyway let serial = read_optional_string(base.join("serial")); (model, serial) } fn read_optional_string(p: PathBuf) -> Option { match fs::read_to_string(&p) { Ok(mut s) => { // Trim trailing newline/spaces while s.ends_with('\n') || s.ends_with('\r') { s.pop(); } if s.is_empty() { None } else { Some(s) } } Err(_) => None, } } // ========================= // Tests (mock provider) // ========================= #[cfg(test)] mod tests { use super::*; use regex::Regex; struct MockProvider { disks: Vec, } impl DeviceProvider for MockProvider { fn list_block_devices(&self) -> Result> { Ok(self.disks.clone()) } } fn re(s: &str) -> Regex { Regex::new(s).unwrap() } #[test] fn filter_by_size_and_include_exclude() { let provider = MockProvider { disks: vec![ Disk { path: "/dev/sda".into(), size_bytes: 500 * 1024 * 1024 * 1024, rotational: true, model: None, serial: None, }, // 500 GiB Disk { path: "/dev/nvme0n1".into(), size_bytes: 128 * 1024 * 1024 * 1024, rotational: false, model: None, serial: None, }, // 128 GiB Disk { path: "/dev/loop0".into(), size_bytes: 8 * 1024 * 1024 * 1024, rotational: false, model: None, serial: None, }, // 8 GiB pseudo (but mock provider supplies it) ], }; let filter = DeviceFilter { include: vec![re(r"^/dev/(sd|nvme)")], exclude: vec![re(r"/dev/loop")], min_size_gib: 200, // >= 200 GiB allow_removable: true, }; let out = discover_with_provider(&provider, &filter).expect("discover ok"); assert_eq!(out.len(), 1); assert_eq!(out[0].path, "/dev/sda"); } #[test] fn no_match_returns_error() { let provider = MockProvider { disks: vec![ Disk { path: "/dev/sdb".into(), size_bytes: 50 * 1024 * 1024 * 1024, rotational: true, model: None, serial: None, }, // 50 GiB ], }; let filter = DeviceFilter { include: vec![re(r"^/dev/nvme")], exclude: vec![], min_size_gib: 200, allow_removable: true, }; let err = discover_with_provider(&provider, &filter).unwrap_err(); match err { Error::Device(msg) => assert!(msg.contains("no eligible disks")), other => panic!("unexpected error: {:?}", other), } } }