* choose ESP matching the primary data disk when multiple ESPs exist, falling back gracefully for single-disk layouts * keep new helper to normalize device names and reuse the idempotent mount logic * apply cargo fmt across the tree
396 lines
12 KiB
Rust
396 lines
12 KiB
Rust
// REGION: API
|
|
// api: device::Disk { path: String, size_bytes: u64, rotational: bool, model: Option<String>, serial: Option<String> }
|
|
// api: device::DeviceFilter { include: Vec<regex::Regex>, exclude: Vec<regex::Regex>, min_size_gib: u64 }
|
|
// api: device::DeviceProvider::list_block_devices(&self) -> crate::Result<Vec<Disk>>
|
|
// api: device::DeviceProvider::probe_properties(&self, disk: &mut Disk) -> crate::Result<()>
|
|
// api: device::discover(filter: &DeviceFilter) -> crate::Result<Vec<Disk>>
|
|
// 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<String>,
|
|
/// Optional serial string (if available).
|
|
pub serial: Option<String>,
|
|
}
|
|
|
|
/// 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<Regex>,
|
|
/// Exclusion regexes (any match disqualifies).
|
|
pub exclude: Vec<Regex>,
|
|
/// 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<Vec<Disk>>;
|
|
/// 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<Vec<Disk>> {
|
|
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<Vec<Disk>> {
|
|
let provider = SysProvider::new();
|
|
discover_with_provider(&provider, filter)
|
|
}
|
|
|
|
fn discover_with_provider<P: DeviceProvider>(
|
|
provider: &P,
|
|
filter: &DeviceFilter,
|
|
) -> Result<Vec<Disk>> {
|
|
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<Disk> = 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::<Vec<_>>()
|
|
);
|
|
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<String> {
|
|
Path::new(dev_path)
|
|
.file_name()
|
|
.map(|s| s.to_string_lossy().to_string())
|
|
}
|
|
|
|
/// Returns Ok(true) if /sys/class/block/<name>/removable == "1"
|
|
fn is_removable_sysfs(name: &str) -> Result<bool> {
|
|
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<u64> {
|
|
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<bool> {
|
|
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<String>, Option<String>) {
|
|
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<String> {
|
|
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<Disk>,
|
|
}
|
|
|
|
impl DeviceProvider for MockProvider {
|
|
fn list_block_devices(&self) -> Result<Vec<Disk>> {
|
|
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),
|
|
}
|
|
}
|
|
}
|