feat: initial wipe implementation - block device safety, HDD overwrite, NVMe/secure-discard, verification
This commit is contained in:
@@ -0,0 +1,527 @@
|
||||
#![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 libc::c_ulong, &mut size as *mut u64) };
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user