Files
wipe/src/device/inspect.rs
T
changchichung 45fe412098
CI / check (push) Successful in 1m3s
fmt: cargo fmt
2026-09-02 10:08:21 +08:00

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));
}
}