534 lines
17 KiB
Rust
534 lines
17 KiB
Rust
#![allow(dead_code, unused_imports, unused_variables)]
|
|
use crate::error::{exit_code, WipeError};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct LsblkOutput {
|
|
pub blockdevices: Vec<LsblkDevice>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct LsblkDevice {
|
|
pub name: String,
|
|
pub kname: Option<String>,
|
|
pub path: Option<String>,
|
|
#[serde(rename = "type")]
|
|
pub devtype: Option<String>,
|
|
pub size: Option<String>,
|
|
#[serde(rename = "maj:min")]
|
|
pub maj_min: Option<String>,
|
|
pub rota: Option<bool>,
|
|
pub tran: Option<String>,
|
|
pub model: Option<String>,
|
|
pub serial: Option<String>,
|
|
pub fstype: Option<String>,
|
|
#[serde(default)]
|
|
pub mountpoints: Option<Vec<Option<String>>>,
|
|
#[serde(default)]
|
|
pub children: Option<Vec<LsblkDevice>>,
|
|
// Additional fields that lsblk may emit
|
|
#[serde(default)]
|
|
pub mountpoint: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct DeviceInfo {
|
|
pub path: PathBuf,
|
|
pub resolved_path: PathBuf,
|
|
pub kname: String,
|
|
pub devtype: String,
|
|
pub size_bytes: Option<u64>,
|
|
pub rota: Option<bool>,
|
|
pub tran: Option<String>,
|
|
pub model: Option<String>,
|
|
pub serial: Option<String>,
|
|
pub fstype: Option<String>,
|
|
pub is_whole_disk: bool,
|
|
pub partitions: Vec<PartitionInfo>,
|
|
pub mountpoints: Vec<String>,
|
|
pub all_mountpoints: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct PartitionInfo {
|
|
pub path: PathBuf,
|
|
pub kname: String,
|
|
pub size_bytes: Option<u64>,
|
|
pub fstype: Option<String>,
|
|
pub mountpoints: Vec<String>,
|
|
}
|
|
|
|
/// Validate device path per spec #8:
|
|
/// - path in /dev
|
|
/// - exists
|
|
/// - resolve symlink
|
|
/// - target is block device
|
|
/// - avoid symlink escape
|
|
pub fn validate_device_path(input: &Path) -> Result<PathBuf, WipeError> {
|
|
// Must be absolute and under /dev
|
|
if !input.is_absolute() {
|
|
return Err(WipeError::new(
|
|
exit_code::INVALID_ARGS,
|
|
format!("device path must be absolute: {}", input.display()),
|
|
));
|
|
}
|
|
let input_str = input.to_string_lossy();
|
|
if !input_str.starts_with("/dev/") {
|
|
return Err(WipeError::new(
|
|
exit_code::INVALID_ARGS,
|
|
format!("device path must be in /dev: {}", input.display()),
|
|
));
|
|
}
|
|
|
|
if !input.exists() {
|
|
return Err(WipeError::new(
|
|
exit_code::TARGET_NOT_FOUND,
|
|
format!("device does not exist: {}", input.display()),
|
|
));
|
|
}
|
|
|
|
// Resolve symlink (canonicalize) but check escape
|
|
let canonical = std::fs::canonicalize(input).map_err(|e| {
|
|
WipeError::with_source(
|
|
exit_code::TARGET_NOT_FOUND,
|
|
format!("failed to resolve device path: {}", input.display()),
|
|
e,
|
|
)
|
|
})?;
|
|
|
|
let canonical_str = canonical.to_string_lossy();
|
|
if !canonical_str.starts_with("/dev/") {
|
|
return Err(WipeError::new(
|
|
exit_code::INVALID_ARGS,
|
|
format!(
|
|
"symlink escapes /dev: {} -> {}",
|
|
input.display(),
|
|
canonical.display()
|
|
),
|
|
));
|
|
}
|
|
|
|
// Check block device
|
|
let metadata = std::fs::metadata(&canonical).map_err(|e| {
|
|
WipeError::with_source(
|
|
exit_code::TARGET_NOT_FOUND,
|
|
format!("cannot stat device: {}", canonical.display()),
|
|
e,
|
|
)
|
|
})?;
|
|
|
|
// Use nix to check file type is block device? Simpler: use std and check via libc
|
|
// Use std::os::unix::fs::FileTypeExt
|
|
use std::os::unix::fs::FileTypeExt;
|
|
if !metadata.file_type().is_block_device() {
|
|
return Err(WipeError::new(
|
|
exit_code::NOT_A_BLOCK_DEVICE,
|
|
format!("not a block device: {}", canonical.display()),
|
|
));
|
|
}
|
|
|
|
Ok(canonical)
|
|
}
|
|
|
|
/// Run lsblk --json with needed columns and parse output
|
|
pub fn run_lsblk() -> Result<LsblkOutput, WipeError> {
|
|
let output = Command::new("lsblk")
|
|
.args([
|
|
"--json",
|
|
"-o",
|
|
"NAME,KNAME,PATH,TYPE,SIZE,ROTA,MOUNTPOINTS,FSTYPE,MODEL,SERIAL,TRAN,MAJ:MIN",
|
|
])
|
|
.output()
|
|
.map_err(|e| {
|
|
WipeError::with_source(
|
|
exit_code::EXTERNAL_COMMAND_FAILED,
|
|
"failed to execute lsblk",
|
|
e,
|
|
)
|
|
})?;
|
|
|
|
if !output.status.success() {
|
|
return Err(WipeError::new(
|
|
exit_code::EXTERNAL_COMMAND_FAILED,
|
|
format!(
|
|
"lsblk failed: {}",
|
|
String::from_utf8_lossy(&output.stderr).trim()
|
|
),
|
|
));
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
serde_json::from_str::<LsblkOutput>(&stdout).map_err(|e| {
|
|
WipeError::with_source(
|
|
exit_code::EXTERNAL_COMMAND_FAILED,
|
|
format!("failed to parse lsblk output: {e}"),
|
|
e,
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Find device in lsblk tree by canonical path or kname
|
|
fn find_device<'a>(devices: &'a [LsblkDevice], target: &Path) -> Option<&'a LsblkDevice> {
|
|
let target_str = target.to_string_lossy();
|
|
let target_kname = target.file_name().map(|n| n.to_string_lossy().to_string());
|
|
for dev in devices {
|
|
if let Some(p) = &dev.path {
|
|
if Path::new(p) == target {
|
|
return Some(dev);
|
|
}
|
|
}
|
|
if let Some(kname) = &dev.kname {
|
|
if Some(kname.as_str()) == target_kname.as_deref() {
|
|
// Check also if path matches or kname matches
|
|
if dev.path.is_none() {
|
|
// fallback to /dev/<kname>
|
|
let kpath = format!("/dev/{kname}");
|
|
if kpath == target_str {
|
|
return Some(dev);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let Some(name) = dev.name.strip_prefix("/dev/") {
|
|
let full = format!("/dev/{name}");
|
|
if full == target_str {
|
|
return Some(dev);
|
|
}
|
|
}
|
|
// Also match by NAME field
|
|
if format!("/dev/{}", dev.name) == target_str {
|
|
return Some(dev);
|
|
}
|
|
if let Some(children) = &dev.children {
|
|
if let Some(found) = find_device(children, target) {
|
|
return Some(found);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Also search recursively and return top-level disk if needed
|
|
fn find_device_recursive<'a>(devices: &'a [LsblkDevice], target: &Path) -> Option<&'a LsblkDevice> {
|
|
find_device(devices, target)
|
|
}
|
|
|
|
pub fn inspect_device(resolved_path: &Path) -> Result<DeviceInfo, WipeError> {
|
|
let lsblk = run_lsblk()?;
|
|
let dev = find_device_recursive(&lsblk.blockdevices, resolved_path).ok_or_else(|| {
|
|
WipeError::new(
|
|
exit_code::TARGET_NOT_FOUND,
|
|
format!("device not found in lsblk: {}", resolved_path.display()),
|
|
)
|
|
})?;
|
|
|
|
let devtype = dev.devtype.clone().unwrap_or_else(|| "unknown".to_string());
|
|
let is_whole_disk = devtype == "disk" || devtype == "loop";
|
|
|
|
// Get mountpoints for this device
|
|
let mountpoints = collect_mountpoints(dev);
|
|
// Collect all mountpoints recursively (for whole disk, include children)
|
|
let all_mountpoints = collect_all_mountpoints(dev);
|
|
|
|
// Partitions: children where type == "part"
|
|
let mut partitions = Vec::new();
|
|
if let Some(children) = &dev.children {
|
|
for child in children {
|
|
let ctype = child.devtype.as_deref().unwrap_or("");
|
|
if ctype == "part" {
|
|
let cpath = child
|
|
.path
|
|
.clone()
|
|
.unwrap_or_else(|| format!("/dev/{}", child.name));
|
|
partitions.push(PartitionInfo {
|
|
path: PathBuf::from(cpath),
|
|
kname: child.kname.clone().unwrap_or_else(|| child.name.clone()),
|
|
size_bytes: child.size.as_deref().and_then(parse_lsblk_size),
|
|
fstype: child.fstype.clone(),
|
|
mountpoints: collect_mountpoints(child),
|
|
});
|
|
} else if child.children.is_some() {
|
|
// For nested, still collect part children
|
|
if let Some(sub) = &child.children {
|
|
for subchild in sub {
|
|
if subchild.devtype.as_deref() == Some("part") {
|
|
let cpath = subchild
|
|
.path
|
|
.clone()
|
|
.unwrap_or_else(|| format!("/dev/{}", subchild.name));
|
|
partitions.push(PartitionInfo {
|
|
path: PathBuf::from(cpath),
|
|
kname: subchild
|
|
.kname
|
|
.clone()
|
|
.unwrap_or_else(|| subchild.name.clone()),
|
|
size_bytes: None,
|
|
fstype: subchild.fstype.clone(),
|
|
mountpoints: collect_mountpoints(subchild),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// For partition devices, partitions is empty; but for whole-disk we have children
|
|
// Also if target is partition, we don't need to populate partitions.
|
|
|
|
// Try to get size_bytes via blockdev or sysfs
|
|
let size_bytes = get_block_device_size(resolved_path).ok();
|
|
|
|
let kname = dev
|
|
.kname
|
|
.clone()
|
|
.unwrap_or_else(|| dev.name.clone())
|
|
.trim()
|
|
.to_string();
|
|
|
|
Ok(DeviceInfo {
|
|
path: resolved_path.to_path_buf(),
|
|
resolved_path: resolved_path.to_path_buf(),
|
|
kname,
|
|
devtype,
|
|
size_bytes,
|
|
rota: dev.rota,
|
|
tran: dev.tran.clone(),
|
|
model: dev.model.clone().map(|s| s.trim().to_string()),
|
|
serial: dev.serial.clone().map(|s| s.trim().to_string()),
|
|
fstype: dev.fstype.clone(),
|
|
is_whole_disk,
|
|
partitions,
|
|
mountpoints: mountpoints.clone(),
|
|
all_mountpoints,
|
|
})
|
|
}
|
|
|
|
fn collect_mountpoints(dev: &LsblkDevice) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
if let Some(mps) = &dev.mountpoints {
|
|
for m in mps.iter().flatten() {
|
|
if !m.is_empty() && m != "null" {
|
|
// lsblk may return "[SWAP]" for swap
|
|
out.push(m.clone());
|
|
}
|
|
}
|
|
}
|
|
if let Some(mp) = &dev.mountpoint {
|
|
if !mp.is_empty() && !out.contains(mp) {
|
|
out.push(mp.clone());
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn collect_all_mountpoints(dev: &LsblkDevice) -> Vec<String> {
|
|
let mut out = collect_mountpoints(dev);
|
|
if let Some(children) = &dev.children {
|
|
for child in children {
|
|
out.extend(collect_all_mountpoints(child));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Parse lsblk SIZE string like "476.9G" or "513M" into bytes (approximate)
|
|
/// This is only fallback; primary size comes from blockdev ioctl
|
|
pub fn parse_lsblk_size(s: &str) -> Option<u64> {
|
|
let s = s.trim();
|
|
if s.is_empty() {
|
|
return None;
|
|
}
|
|
// If it's plain number, treat as bytes
|
|
if let Ok(n) = s.parse::<u64>() {
|
|
return Some(n);
|
|
}
|
|
// Handle human sizes: number + unit
|
|
let mut num_part = String::new();
|
|
let mut unit_part = String::new();
|
|
for c in s.chars() {
|
|
if c.is_ascii_digit() || c == '.' {
|
|
if unit_part.is_empty() {
|
|
num_part.push(c);
|
|
} else {
|
|
// invalid interleaving
|
|
return None;
|
|
}
|
|
} else {
|
|
unit_part.push(c);
|
|
}
|
|
}
|
|
let num: f64 = num_part.parse().ok()?;
|
|
let unit = unit_part.trim().to_ascii_uppercase();
|
|
let mult: f64 = match unit.as_str() {
|
|
"B" => 1.0,
|
|
"K" | "KB" | "KIB" => 1024.0,
|
|
"M" | "MB" | "MIB" => 1024.0 * 1024.0,
|
|
"G" | "GB" | "GIB" => 1024.0 * 1024.0 * 1024.0,
|
|
"T" | "TB" | "TIB" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
|
|
_ => return None,
|
|
};
|
|
Some((num * mult) as u64)
|
|
}
|
|
|
|
/// Get block device size via ioctl BLKGETSIZE64 or fallback to sysfs / sys/block
|
|
pub fn get_block_device_size(path: &Path) -> Result<u64, WipeError> {
|
|
// Try ioctl first
|
|
if let Ok(size) = get_size_via_ioctl(path) {
|
|
return Ok(size);
|
|
}
|
|
// Fallback to blockdev --getsize64
|
|
if let Ok(size) = get_size_via_blockdev(path) {
|
|
return Ok(size);
|
|
}
|
|
// Fallback to /sys/class/block/<kname>/size (sectors * 512)
|
|
let kname = path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or_default();
|
|
let sys_path = format!("/sys/class/block/{kname}/size");
|
|
if let Ok(content) = std::fs::read_to_string(&sys_path) {
|
|
if let Ok(sectors) = content.trim().parse::<u64>() {
|
|
return Ok(sectors * 512);
|
|
}
|
|
}
|
|
// Try /sys/block variant
|
|
let sys_path2 = format!("/sys/block/{kname}/size");
|
|
if let Ok(content) = std::fs::read_to_string(&sys_path2) {
|
|
if let Ok(sectors) = content.trim().parse::<u64>() {
|
|
return Ok(sectors * 512);
|
|
}
|
|
}
|
|
Err(WipeError::new(
|
|
exit_code::EXTERNAL_COMMAND_FAILED,
|
|
format!("cannot determine size of {}", path.display()),
|
|
))
|
|
}
|
|
|
|
fn get_size_via_ioctl(path: &Path) -> Result<u64, WipeError> {
|
|
use std::os::unix::io::AsRawFd;
|
|
let file = std::fs::OpenOptions::new()
|
|
.read(true)
|
|
.open(path)
|
|
.map_err(|e| WipeError::with_source(exit_code::GENERIC_ERROR, "open for ioctl", e))?;
|
|
let fd = file.as_raw_fd();
|
|
let mut size: u64 = 0;
|
|
// BLKGETSIZE64 = _IOR(0x12,114,size_t) => 0x80081272 on x86_64
|
|
const BLKGETSIZE64: libc::c_ulong = 0x80081272;
|
|
let ret = unsafe {
|
|
libc::ioctl(
|
|
fd,
|
|
BLKGETSIZE64 as _,
|
|
&mut size as *mut u64 as *mut libc::c_void,
|
|
)
|
|
};
|
|
if ret == 0 {
|
|
Ok(size)
|
|
} else {
|
|
Err(WipeError::new(
|
|
exit_code::EXTERNAL_COMMAND_FAILED,
|
|
format!(
|
|
"ioctl BLKGETSIZE64 failed: {}",
|
|
std::io::Error::last_os_error()
|
|
),
|
|
))
|
|
}
|
|
}
|
|
|
|
fn get_size_via_blockdev(path: &Path) -> Result<u64, WipeError> {
|
|
let output = Command::new("blockdev")
|
|
.arg("--getsize64")
|
|
.arg(path)
|
|
.output()
|
|
.map_err(|e| {
|
|
WipeError::with_source(exit_code::EXTERNAL_COMMAND_FAILED, "blockdev exec", e)
|
|
})?;
|
|
if !output.status.success() {
|
|
return Err(WipeError::new(
|
|
exit_code::EXTERNAL_COMMAND_FAILED,
|
|
String::from_utf8_lossy(&output.stderr).to_string(),
|
|
));
|
|
}
|
|
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
s.parse::<u64>().map_err(|e| {
|
|
WipeError::with_source(exit_code::EXTERNAL_COMMAND_FAILED, "parse blockdev size", e)
|
|
})
|
|
}
|
|
|
|
/// Human-readable size formatting
|
|
pub fn format_human_size(bytes: u64) -> String {
|
|
const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
|
|
let mut size = bytes as f64;
|
|
let mut unit = 0;
|
|
while size >= 1024.0 && unit + 1 < UNITS.len() {
|
|
size /= 1024.0;
|
|
unit += 1;
|
|
}
|
|
if unit == 0 {
|
|
format!("{} {}", bytes, UNITS[unit])
|
|
} else {
|
|
format!("{:.2} {}", size, UNITS[unit])
|
|
}
|
|
}
|
|
|
|
/// Format size for display in TiB as spec example
|
|
pub fn format_size_tib(bytes: u64) -> String {
|
|
let tib = bytes as f64 / (1024.0 * 1024.0 * 1024.0 * 1024.0);
|
|
format!("{:.2} TiB", tib)
|
|
}
|
|
|
|
// For JSON output: collect device info into hashmap-like struct
|
|
pub fn device_info_to_map(info: &DeviceInfo) -> HashMap<String, serde_json::Value> {
|
|
let mut m = HashMap::new();
|
|
m.insert(
|
|
"path".to_string(),
|
|
serde_json::Value::String(info.path.display().to_string()),
|
|
);
|
|
m.insert(
|
|
"kname".to_string(),
|
|
serde_json::Value::String(info.kname.clone()),
|
|
);
|
|
m.insert(
|
|
"type".to_string(),
|
|
serde_json::Value::String(info.devtype.clone()),
|
|
);
|
|
if let Some(s) = info.size_bytes {
|
|
m.insert("size".to_string(), serde_json::Value::Number(s.into()));
|
|
}
|
|
m
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_lsblk_size() {
|
|
assert_eq!(parse_lsblk_size("512M"), Some(512 * 1024 * 1024));
|
|
assert_eq!(
|
|
parse_lsblk_size("476.9G"),
|
|
Some((476.9 * 1024.0 * 1024.0 * 1024.0) as u64)
|
|
);
|
|
assert_eq!(parse_lsblk_size("1M"), Some(1024 * 1024));
|
|
assert!(parse_lsblk_size("3.64 TiB").is_some());
|
|
// Plain bytes
|
|
assert_eq!(parse_lsblk_size("1024"), Some(1024));
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_human_size() {
|
|
assert_eq!(format_human_size(1024), "1.00 KiB");
|
|
assert_eq!(format_human_size(64 * 1024 * 1024), "64.00 MiB");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_human_size_via_cli() {
|
|
// cross-check with cli parser
|
|
use crate::cli::parse_human_size as chs;
|
|
assert_eq!(chs("64M"), Some(64 * 1024 * 1024));
|
|
}
|
|
}
|