feat: initial wipe implementation - block device safety, HDD overwrite, NVMe/secure-discard, verification
This commit is contained in:
+250
@@ -0,0 +1,250 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables)]
|
||||
use clap::{Parser, ValueEnum};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum Method {
|
||||
#[value(name = "auto")]
|
||||
Auto,
|
||||
#[value(name = "zero")]
|
||||
Zero,
|
||||
#[value(name = "random")]
|
||||
Random,
|
||||
#[value(name = "ones")]
|
||||
Ones,
|
||||
#[value(name = "alternating")]
|
||||
Alternating,
|
||||
#[value(name = "secure-discard")]
|
||||
SecureDiscard,
|
||||
#[value(name = "nvme-sanitize")]
|
||||
NvmeSanitize,
|
||||
#[value(name = "nvme-crypto")]
|
||||
NvmeCrypto,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Method {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
Self::Auto => "auto",
|
||||
Self::Zero => "zero",
|
||||
Self::Random => "random",
|
||||
Self::Ones => "ones",
|
||||
Self::Alternating => "alternating",
|
||||
Self::SecureDiscard => "secure-discard",
|
||||
Self::NvmeSanitize => "nvme-sanitize",
|
||||
Self::NvmeCrypto => "nvme-crypto",
|
||||
};
|
||||
write!(f, "{s}")
|
||||
}
|
||||
}
|
||||
|
||||
impl Method {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Auto => "auto",
|
||||
Self::Zero => "zero",
|
||||
Self::Random => "random",
|
||||
Self::Ones => "ones",
|
||||
Self::Alternating => "alternating",
|
||||
Self::SecureDiscard => "secure-discard",
|
||||
Self::NvmeSanitize => "nvme-sanitize",
|
||||
Self::NvmeCrypto => "nvme-crypto",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_buffer_size(s: &str) -> Result<usize, String> {
|
||||
parse_human_size(s)
|
||||
.map(|v| v as usize)
|
||||
.ok_or_else(|| format!("invalid buffer size: {s}"))
|
||||
}
|
||||
|
||||
/// Parse human-readable sizes like 16M, 32M, 64M, 128M, 1K, 1G, etc.
|
||||
/// Also accepts plain bytes like "65536".
|
||||
pub fn parse_human_size(s: &str) -> Option<u64> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Find where numeric part ends
|
||||
let mut num_end = 0;
|
||||
for (i, c) in s.char_indices() {
|
||||
if c.is_ascii_digit() {
|
||||
num_end = i + c.len_utf8();
|
||||
} else if c == '.' {
|
||||
// Not expected; but treat invalid
|
||||
return None;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if num_end == 0 {
|
||||
return None;
|
||||
}
|
||||
let num_str = &s[..num_end];
|
||||
let suffix = s[num_end..].trim().to_ascii_lowercase();
|
||||
let num: u64 = num_str.parse().ok()?;
|
||||
let multiplier = match suffix.as_str() {
|
||||
"" | "b" => 1,
|
||||
"k" | "kb" | "kib" => 1024,
|
||||
"m" | "mb" | "mib" => 1024 * 1024,
|
||||
"g" | "gb" | "gib" => 1024 * 1024 * 1024,
|
||||
"t" | "tb" | "tib" => 1024u64 * 1024 * 1024 * 1024,
|
||||
_ => return None,
|
||||
};
|
||||
num.checked_mul(multiplier)
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "wipe",
|
||||
version,
|
||||
about = "Secure block device wipe and hardware erase orchestration"
|
||||
)]
|
||||
pub struct Cli {
|
||||
/// Block device to wipe (e.g. /dev/sdb, /dev/nvme0n1)
|
||||
#[arg(value_name = "DEVICE")]
|
||||
pub device: PathBuf,
|
||||
|
||||
/// Number of overwrite passes (HDD)
|
||||
#[arg(short = 'n', long, default_value = "1", value_name = "N")]
|
||||
pub passes: u32,
|
||||
|
||||
/// Wipe method
|
||||
#[arg(short = 'm', long, default_value = "auto", value_enum)]
|
||||
pub method: Method,
|
||||
|
||||
/// Skip interactive confirmation (does NOT bypass system-disk protection)
|
||||
#[arg(short = 'y', long)]
|
||||
pub yes: bool,
|
||||
|
||||
/// Explicitly allow wiping a whole block device
|
||||
#[arg(long)]
|
||||
pub whole_disk: bool,
|
||||
|
||||
/// Automatically unmount discovered filesystems / swapoff
|
||||
#[arg(long)]
|
||||
pub unmount: bool,
|
||||
|
||||
/// Override specific non-system safety checks
|
||||
#[arg(long)]
|
||||
pub force: bool,
|
||||
|
||||
/// Dry run - no data modification
|
||||
#[arg(long)]
|
||||
pub dry_run: bool,
|
||||
|
||||
/// Verify overwrite operation
|
||||
#[arg(long)]
|
||||
pub verify: bool,
|
||||
|
||||
/// Skip final sync
|
||||
#[arg(long)]
|
||||
pub no_sync: bool,
|
||||
|
||||
/// Buffer size for overwrite engine (e.g. 64M, 128M)
|
||||
#[arg(long, default_value = "64M", value_parser = parse_buffer_size)]
|
||||
pub buffer_size: usize,
|
||||
|
||||
/// Machine-readable JSON output
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
|
||||
/// Verbose logging
|
||||
#[arg(short, long)]
|
||||
pub verbose: bool,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
pub fn buffer_size_bytes(&self) -> usize {
|
||||
self.buffer_size
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn parse_human_size_basic() {
|
||||
assert_eq!(parse_human_size("64M"), Some(64 * 1024 * 1024));
|
||||
assert_eq!(parse_human_size("16M"), Some(16 * 1024 * 1024));
|
||||
assert_eq!(parse_human_size("128M"), Some(128 * 1024 * 1024));
|
||||
assert_eq!(parse_human_size("1G"), Some(1024 * 1024 * 1024));
|
||||
assert_eq!(parse_human_size("1024"), Some(1024));
|
||||
assert_eq!(parse_human_size("64MiB"), Some(64 * 1024 * 1024));
|
||||
assert_eq!(parse_human_size("64m"), Some(64 * 1024 * 1024));
|
||||
assert_eq!(parse_human_size("1K"), Some(1024));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_human_size_invalid() {
|
||||
assert_eq!(parse_human_size(""), None);
|
||||
assert_eq!(parse_human_size("abc"), None);
|
||||
assert_eq!(parse_human_size("64X"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_defaults() {
|
||||
let cli = Cli::try_parse_from(["wipe", "/dev/sdb"]).unwrap();
|
||||
assert_eq!(cli.passes, 1);
|
||||
assert_eq!(cli.method, Method::Auto);
|
||||
assert_eq!(cli.buffer_size, 64 * 1024 * 1024);
|
||||
assert!(!cli.yes);
|
||||
assert!(!cli.whole_disk);
|
||||
assert!(!cli.dry_run);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_all_options() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"wipe",
|
||||
"/dev/sdb",
|
||||
"--whole-disk",
|
||||
"--passes",
|
||||
"3",
|
||||
"--method",
|
||||
"zero",
|
||||
"--yes",
|
||||
"--verify",
|
||||
"--buffer-size",
|
||||
"32M",
|
||||
"--json",
|
||||
"--verbose",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(cli.passes, 3);
|
||||
assert_eq!(cli.method, Method::Zero);
|
||||
assert!(cli.yes);
|
||||
assert!(cli.whole_disk);
|
||||
assert!(cli.verify);
|
||||
assert_eq!(cli.buffer_size, 32 * 1024 * 1024);
|
||||
assert!(cli.json);
|
||||
assert!(cli.verbose);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_method_variants() {
|
||||
for (s, expected) in [
|
||||
("auto", Method::Auto),
|
||||
("zero", Method::Zero),
|
||||
("random", Method::Random),
|
||||
("ones", Method::Ones),
|
||||
("alternating", Method::Alternating),
|
||||
("secure-discard", Method::SecureDiscard),
|
||||
("nvme-sanitize", Method::NvmeSanitize),
|
||||
("nvme-crypto", Method::NvmeCrypto),
|
||||
] {
|
||||
let cli = Cli::try_parse_from(["wipe", "/dev/sdb", "--method", s]).unwrap();
|
||||
assert_eq!(cli.method, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_nvme_methods_accepted() {
|
||||
let cli =
|
||||
Cli::try_parse_from(["wipe", "/dev/nvme0n1", "--method", "nvme-sanitize"]).unwrap();
|
||||
assert_eq!(cli.method, Method::NvmeSanitize);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#![allow(unused_imports, dead_code)]
|
||||
pub mod inspect;
|
||||
pub mod safety;
|
||||
pub mod sysfs;
|
||||
|
||||
pub use inspect::{inspect_device, validate_device_path, DeviceInfo, LsblkDevice};
|
||||
pub use safety::{SafetyCheck, SafetyResult};
|
||||
pub use sysfs::{dependency_holders, has_holders};
|
||||
@@ -0,0 +1,371 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables)]
|
||||
use crate::device::inspect::DeviceInfo;
|
||||
use crate::device::sysfs;
|
||||
use crate::error::{exit_code, WipeError};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SafetyResult {
|
||||
pub is_system_device: bool,
|
||||
pub system_mounts: Vec<String>,
|
||||
pub has_holders: bool,
|
||||
pub holders: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SafetyCheck;
|
||||
|
||||
impl SafetyCheck {
|
||||
/// Check running system protection
|
||||
/// Detects if target is backing device for /, /boot, /boot/efi, swap
|
||||
pub fn check_system_device(info: &DeviceInfo) -> Result<SafetyResult, WipeError> {
|
||||
// Get mount map
|
||||
let mount_map = sysfs::get_mount_device_map();
|
||||
let critical_mounts = ["/", "/boot", "/boot/efi"];
|
||||
let mut system_devices_canonical: Vec<PathBuf> = Vec::new();
|
||||
let mut system_mounts: Vec<String> = Vec::new();
|
||||
|
||||
for crit in &critical_mounts {
|
||||
for (mp, dev) in &mount_map {
|
||||
if mp == crit {
|
||||
// dev is like /dev/nvme0n1p3 or /dev/mapper/ubuntu--vg-root or /dev/sda1
|
||||
// Need to resolve to canonical and then find whole-disk backing
|
||||
let dev_path = Path::new(dev);
|
||||
if let Ok(canonical) = std::fs::canonicalize(dev_path) {
|
||||
system_devices_canonical.push(canonical.clone());
|
||||
system_mounts.push(format!("{crit} -> {}", canonical.display()));
|
||||
// Also try to resolve whole disk via sysfs
|
||||
// For canonical like /dev/nvme0n1p3, parent is /dev/nvme0n1
|
||||
// We can try to find parent via /sys/class/block/<kname>/..
|
||||
if let Some(kname) = canonical.file_name().and_then(|n| n.to_str()) {
|
||||
if let Some(parent_kname) = get_parent_disk(kname) {
|
||||
let parent_path = PathBuf::from(format!("/dev/{parent_kname}"));
|
||||
if let Ok(parent_canonical) = std::fs::canonicalize(&parent_path) {
|
||||
system_devices_canonical.push(parent_canonical);
|
||||
} else {
|
||||
system_devices_canonical.push(parent_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
// For mapper devices, resolve slaves
|
||||
if let Some(kname) = canonical.file_name().and_then(|n| n.to_str()) {
|
||||
let slaves = sysfs::slaves(kname);
|
||||
for slave in slaves {
|
||||
let slave_path = PathBuf::from(format!("/dev/{slave}"));
|
||||
if let Ok(sc) = std::fs::canonicalize(&slave_path) {
|
||||
system_devices_canonical.push(sc.clone());
|
||||
// Also get parent disk of slave if slave is partition
|
||||
if let Some(skname) = sc.file_name().and_then(|n| n.to_str()) {
|
||||
if let Some(parent) = get_parent_disk(skname) {
|
||||
let pp = PathBuf::from(format!("/dev/{parent}"));
|
||||
if let Ok(pc) = std::fs::canonicalize(&pp) {
|
||||
system_devices_canonical.push(pc);
|
||||
} else {
|
||||
system_devices_canonical.push(pp);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
system_devices_canonical.push(slave_path);
|
||||
}
|
||||
}
|
||||
// Also check holders? For LVM PV case, handled via parent resolution above via slaves
|
||||
// For direct dev, also check /sys/block holder resolution via ancestry
|
||||
}
|
||||
} else {
|
||||
// Try dealing with /dev/mapper symlink without canonical
|
||||
// e.g., /dev/mapper/ubuntu--vg-root -> ../dm-0
|
||||
system_mounts.push(format!("{crit} -> {dev}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also swap devices from /proc/swaps
|
||||
if let Ok(swaps) = std::fs::read_to_string("/proc/swaps") {
|
||||
for line in swaps.lines().skip(1) {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let dev = parts[0];
|
||||
if dev == "Filename" {
|
||||
continue;
|
||||
}
|
||||
let dev_path = Path::new(dev);
|
||||
if dev_path.exists() {
|
||||
if let Ok(canonical) = std::fs::canonicalize(dev_path) {
|
||||
system_devices_canonical.push(canonical.clone());
|
||||
system_mounts.push(format!("swap -> {}", canonical.display()));
|
||||
if let Some(kname) = canonical.file_name().and_then(|n| n.to_str()) {
|
||||
if let Some(parent) = get_parent_disk(kname) {
|
||||
let pp = PathBuf::from(format!("/dev/{parent}"));
|
||||
if let Ok(pc) = std::fs::canonicalize(&pp) {
|
||||
system_devices_canonical.push(pc);
|
||||
} else {
|
||||
system_devices_canonical.push(pp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// For swap file under root, its backing device already captured via "/" mount
|
||||
}
|
||||
}
|
||||
|
||||
// Now check if target device matches any system device
|
||||
let target_canonical = &info.resolved_path;
|
||||
let target_kname = &info.kname;
|
||||
let mut is_system = false;
|
||||
|
||||
for sys_dev in &system_devices_canonical {
|
||||
if sys_dev == target_canonical {
|
||||
is_system = true;
|
||||
break;
|
||||
}
|
||||
// Also check by kname match
|
||||
if let Some(sys_kname) = sys_dev.file_name().and_then(|n| n.to_str()) {
|
||||
if sys_kname == target_kname {
|
||||
is_system = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional check: if target is whole disk, check if any partition is system device
|
||||
if !is_system && info.is_whole_disk {
|
||||
for part in &info.partitions {
|
||||
let part_canonical = &part.path;
|
||||
for sys_dev in &system_devices_canonical {
|
||||
if sys_dev == part_canonical {
|
||||
is_system = true;
|
||||
break;
|
||||
}
|
||||
if let Some(sys_kname) = sys_dev.file_name().and_then(|n| n.to_str()) {
|
||||
if sys_kname == part.kname {
|
||||
is_system = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_system {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Holders check
|
||||
let part_knames: Vec<String> = info.partitions.iter().map(|p| p.kname.clone()).collect();
|
||||
let (has_holders, holders) = sysfs::has_active_dependency(&info.kname, &part_knames);
|
||||
|
||||
Ok(SafetyResult {
|
||||
is_system_device: is_system,
|
||||
system_mounts,
|
||||
has_holders,
|
||||
holders,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn check_whole_disk_requirement(
|
||||
info: &DeviceInfo,
|
||||
whole_disk_flag: bool,
|
||||
) -> Result<(), WipeError> {
|
||||
if info.is_whole_disk && !whole_disk_flag {
|
||||
return Err(WipeError::new(
|
||||
exit_code::INVALID_ARGS,
|
||||
"Target is a whole-disk block device.\n\nSpecify --whole-disk to explicitly confirm this destructive operation.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_mounted(
|
||||
info: &DeviceInfo,
|
||||
allow_unmount: bool,
|
||||
force: bool,
|
||||
) -> Result<(), WipeError> {
|
||||
let has_mounts = !info.all_mountpoints.is_empty()
|
||||
|| info.partitions.iter().any(|p| !p.mountpoints.is_empty());
|
||||
// Also check fstype swap?
|
||||
let has_swap = info
|
||||
.partitions
|
||||
.iter()
|
||||
.any(|p| p.fstype.as_deref() == Some("swap"))
|
||||
|| info.fstype.as_deref() == Some("swap");
|
||||
|
||||
if has_mounts && !allow_unmount && !force {
|
||||
return Err(WipeError::new(
|
||||
exit_code::MOUNTED,
|
||||
format!(
|
||||
"Device has mounted filesystems: {:?}. Use --unmount to automatically unmount.",
|
||||
info.all_mountpoints
|
||||
),
|
||||
));
|
||||
}
|
||||
if has_swap && !allow_unmount && !force {
|
||||
return Err(WipeError::new(
|
||||
exit_code::MOUNTED,
|
||||
"Device has active swap. Use --unmount to swapoff.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_holders(safety: &SafetyResult, force: bool) -> Result<(), WipeError> {
|
||||
if safety.has_holders && !force {
|
||||
return Err(WipeError::new(
|
||||
exit_code::ACTIVE_DEPENDENCY,
|
||||
format!(
|
||||
"Device has active holders (LVM/dm-crypt/mdraid/multipath): {:?}. Use --force to override (not recommended for system devices).",
|
||||
safety.holders
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn enforce_system_protection(safety: &SafetyResult, force: bool) -> Result<(), WipeError> {
|
||||
if safety.is_system_device {
|
||||
// Per spec, running system device should always refuse, even with --force conservative
|
||||
// We will refuse even if --force, but mention --force does not bypass
|
||||
let msg = if safety.system_mounts.is_empty() {
|
||||
"Target device contains the running system. Refusing to wipe.".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"Target device contains the running system ({}). Refusing to wipe.",
|
||||
safety.system_mounts.join(", ")
|
||||
)
|
||||
};
|
||||
// If force is provided, still refuse but mention force doesn't bypass system protection
|
||||
if force {
|
||||
return Err(WipeError::new(
|
||||
exit_code::RUNNING_SYSTEM_DEVICE,
|
||||
format!("{msg} (--force cannot bypass running system protection)"),
|
||||
));
|
||||
}
|
||||
return Err(WipeError::new(exit_code::RUNNING_SYSTEM_DEVICE, msg));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to get parent disk for a partition kname
|
||||
/// e.g., "nvme0n1p3" -> "nvme0n1", "sda1" -> "sda", "dm-0" -> None (mapper)
|
||||
fn get_parent_disk(kname: &str) -> Option<String> {
|
||||
// Check sysfs: /sys/class/block/<kname>/partition exists?
|
||||
// If it's a partition, its parent can be found via /sys/class/block/<kname>/..
|
||||
// Simpler: handle common patterns
|
||||
// NVMe: nvme0n1pX -> nvme0n1
|
||||
if kname.starts_with("nvme") && kname.contains('p') {
|
||||
// Find last 'p' that separates disk and partition number
|
||||
if let Some(p_pos) = kname.rfind('p') {
|
||||
let disk = &kname[..p_pos];
|
||||
let part_num = &kname[p_pos + 1..];
|
||||
if part_num.chars().all(|c| c.is_ascii_digit()) && !disk.is_empty() {
|
||||
// Verify disk exists in sysfs
|
||||
let disk_sys = format!("/sys/class/block/{disk}");
|
||||
if Path::new(&disk_sys).exists() {
|
||||
return Some(disk.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// MMC: mmcblk0p1 -> mmcblk0
|
||||
if kname.starts_with("mmcblk") && kname.contains('p') {
|
||||
if let Some(p_pos) = kname.rfind('p') {
|
||||
let disk = &kname[..p_pos];
|
||||
let part_num = &kname[p_pos + 1..];
|
||||
if part_num.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(disk.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// For nvme/mmcblk without p partition suffix, it's already a disk, not partition
|
||||
if kname.starts_with("nvme") || kname.starts_with("mmcblk") {
|
||||
return None;
|
||||
}
|
||||
// Regular sd/hd/vd: sda1, vda2, etc.
|
||||
// Strip trailing digits
|
||||
let mut disk_end = kname.len();
|
||||
while disk_end > 0 && kname.as_bytes()[disk_end - 1].is_ascii_digit() {
|
||||
disk_end -= 1;
|
||||
}
|
||||
if disk_end < kname.len() && disk_end > 0 {
|
||||
let disk = &kname[..disk_end];
|
||||
// Ensure original was not just numbers and disk exists-ish
|
||||
// For sda, check /sys/class/block/<disk> exists
|
||||
let disk_sys = format!("/sys/class/block/{disk}");
|
||||
if Path::new(&disk_sys).exists() {
|
||||
return Some(disk.to_string());
|
||||
}
|
||||
// Fallback: return disk even if not exists, for testing
|
||||
// Only if disk looks plausible (e.g., sda, vda, hda)
|
||||
if disk.len() >= 3 {
|
||||
return Some(disk.to_string());
|
||||
}
|
||||
}
|
||||
// Try sysfs parent via symlink resolution: /sys/class/block/<kname> -> ../../devices/.../block/<parent>/<kname>
|
||||
// Could read parent from sys path: /sys/class/block/<kname> is symlink, its parent directory contains disk?
|
||||
// Example: /sys/class/block/nvme0n1p3 is symlink to ../../devices/.../nvme0n1/nvme0n1p3
|
||||
// So we can read link and extract disk name
|
||||
let link_path = format!("/sys/class/block/{kname}");
|
||||
if let Ok(target) = std::fs::read_link(&link_path) {
|
||||
if let Some(parent) = target
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())
|
||||
{
|
||||
if parent != kname {
|
||||
// Check if parent looks like disk (exists as block)
|
||||
let parent_sys = format!("/sys/class/block/{parent}");
|
||||
if Path::new(&parent_sys).exists() {
|
||||
return Some(parent.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_parent_disk() {
|
||||
assert_eq!(get_parent_disk("sda1"), Some("sda".to_string()));
|
||||
assert_eq!(get_parent_disk("sda"), None);
|
||||
assert_eq!(get_parent_disk("nvme0n1p3"), Some("nvme0n1".to_string()));
|
||||
assert_eq!(get_parent_disk("nvme0n1"), None);
|
||||
assert_eq!(get_parent_disk("vda2"), Some("vda".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whole_disk_requirement() {
|
||||
use crate::device::inspect::{DeviceInfo, PartitionInfo};
|
||||
use std::path::PathBuf;
|
||||
let info = DeviceInfo {
|
||||
path: PathBuf::from("/dev/sdb"),
|
||||
resolved_path: PathBuf::from("/dev/sdb"),
|
||||
kname: "sdb".to_string(),
|
||||
devtype: "disk".to_string(),
|
||||
size_bytes: Some(1024),
|
||||
rota: Some(true),
|
||||
tran: None,
|
||||
model: None,
|
||||
serial: None,
|
||||
fstype: None,
|
||||
is_whole_disk: true,
|
||||
partitions: vec![],
|
||||
mountpoints: vec![],
|
||||
all_mountpoints: vec![],
|
||||
};
|
||||
assert!(SafetyCheck::check_whole_disk_requirement(&info, false).is_err());
|
||||
assert!(SafetyCheck::check_whole_disk_requirement(&info, true).is_ok());
|
||||
let part_info = DeviceInfo {
|
||||
is_whole_disk: false,
|
||||
..info.clone()
|
||||
};
|
||||
assert!(SafetyCheck::check_whole_disk_requirement(&part_info, false).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables)]
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Check if device has holders (i.e., is used by LVM, dm-crypt, mdraid, multipath)
|
||||
pub fn has_holders(kname: &str) -> bool {
|
||||
let holders_path = format!("/sys/class/block/{kname}/holders");
|
||||
if let Ok(entries) = std::fs::read_dir(&holders_path) {
|
||||
for entry in entries.flatten() {
|
||||
// If any entry exists, there is a holder
|
||||
if let Ok(ft) = entry.file_type() {
|
||||
if ft.is_symlink() || ft.is_dir() || ft.is_file() {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Get list of holders (e.g., dm-0, vg-lv)
|
||||
pub fn dependency_holders(kname: &str) -> Vec<String> {
|
||||
let holders_path = format!("/sys/class/block/{kname}/holders");
|
||||
let mut out = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&holders_path) {
|
||||
for entry in entries.flatten() {
|
||||
if let Some(name) = entry.file_name().to_str().map(|s| s.to_string()) {
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Get slaves (for dm devices, mdraid, etc.)
|
||||
pub fn slaves(kname: &str) -> Vec<String> {
|
||||
let slaves_path = format!("/sys/class/block/{kname}/slaves");
|
||||
let mut out = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&slaves_path) {
|
||||
for entry in entries.flatten() {
|
||||
if let Some(name) = entry.file_name().to_str().map(|s| s.to_string()) {
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Check /sys/class/block/<device>/holders recursively for dependency
|
||||
/// Also check partitions' holders
|
||||
pub fn has_active_dependency(kname: &str, partitions: &[String]) -> (bool, Vec<String>) {
|
||||
let mut holders = Vec::new();
|
||||
if has_holders(kname) {
|
||||
holders.extend(dependency_holders(kname));
|
||||
}
|
||||
for part in partitions {
|
||||
// part is like sdb1, need to strip /dev/ if present
|
||||
let pkname = Path::new(part)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(part);
|
||||
if has_holders(pkname) {
|
||||
holders.extend(dependency_holders(pkname));
|
||||
}
|
||||
}
|
||||
if holders.is_empty() {
|
||||
(false, holders)
|
||||
} else {
|
||||
(true, holders)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get backing devices for running system detection
|
||||
/// Parse /proc/mounts to find mountpoints -> device mapping
|
||||
pub fn get_mount_device_map() -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
if let Ok(content) = std::fs::read_to_string("/proc/mounts") {
|
||||
for line in content.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
let dev = parts[0].to_string();
|
||||
let mp = parts[1].to_string();
|
||||
out.push((mp, dev));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Resolve device via canonicalize if it exists under /dev
|
||||
pub fn resolve_dev_path(dev: &str) -> Option<PathBuf> {
|
||||
let p = Path::new(dev);
|
||||
if p.exists() {
|
||||
std::fs::canonicalize(p).ok()
|
||||
} else {
|
||||
// Check if /dev/mapper or /dev/dm-*
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_holders_parsing_no_panic() {
|
||||
// Should not panic even if device doesn't exist
|
||||
let has = has_holders("sda");
|
||||
// Just ensure it doesn't crash; value depends on env
|
||||
let _ = has;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dependency_holders_empty_for_nonexistent() {
|
||||
let h = dependency_holders("nonexistent_device_xyz");
|
||||
assert!(h.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables)]
|
||||
use crate::error::{exit_code, WipeError};
|
||||
use std::path::Path;
|
||||
|
||||
/// ATA Secure Erase via hdparm - very conservative: refuse unless explicitly supported
|
||||
/// Per spec #32: prefer to report unsupported than attempt unsafe password handling
|
||||
pub fn ata_secure_erase(_device: &Path, _verbose: bool) -> Result<(), WipeError> {
|
||||
Err(WipeError::new(
|
||||
exit_code::UNSUPPORTED_METHOD,
|
||||
"ATA Secure Erase is not safely implemented. Use --method zero for logical overwrite or ensure hdparm handling is manually verified. If you need ATA erase, run hdparm --security-erase manually after verifying frozen state.",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_ata_always_unsupported() {
|
||||
let res = ata_secure_erase(Path::new("/dev/sda"), false);
|
||||
assert!(res.is_err());
|
||||
assert_eq!(
|
||||
res.unwrap_err().code(),
|
||||
crate::error::exit_code::UNSUPPORTED_METHOD
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables)]
|
||||
use crate::error::{exit_code, WipeError};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Try to determine if device supports secure discard via lsblk DISC-GRAN/DISC-MAX or sysfs
|
||||
pub fn supports_secure_discard(kname: &str) -> bool {
|
||||
// Check sysfs: /sys/class/block/<kname>/queue/discard_granularity >0 ?
|
||||
// For secure discard, need to check if blkdiscard --secure would work.
|
||||
// We can probe via `blkdiscard --secure --help` or try dry run?
|
||||
// Simpler: check if queue/discard_granularity exists and >0
|
||||
let gran_path = format!("/sys/class/block/{kname}/queue/discard_granularity");
|
||||
if let Ok(content) = std::fs::read_to_string(&gran_path) {
|
||||
if let Ok(val) = content.trim().parse::<u64>() {
|
||||
return val > 0;
|
||||
}
|
||||
}
|
||||
// Also check device parent if partition
|
||||
false
|
||||
}
|
||||
|
||||
pub fn secure_discard(device: &Path, verbose: bool) -> Result<(), WipeError> {
|
||||
// Verify capability: try blkdiscard --help to see --secure exists? Assume it does if binary exists
|
||||
// Check if device supports discard
|
||||
let _kname = device
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or_default();
|
||||
// We don't strictly enforce sysfs check, but if we can detect unsupported we error
|
||||
// Instead, we will attempt blkdiscard --secure and see exit code
|
||||
|
||||
// For partitions, blkdiscard works on partition as well, but spec says secure-discard method
|
||||
// Should be for whole-disk? Let's allow both but warn.
|
||||
|
||||
if verbose {
|
||||
eprintln!("executing: blkdiscard --secure {}", device.display());
|
||||
} else {
|
||||
eprintln!("Secure discard in progress...");
|
||||
}
|
||||
|
||||
let output = Command::new("blkdiscard")
|
||||
.arg("--secure")
|
||||
.arg(device)
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
WipeError::with_source(
|
||||
exit_code::EXTERNAL_COMMAND_FAILED,
|
||||
"failed to execute blkdiscard",
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
// Distinguish capability unavailable vs generic failure
|
||||
if stderr.to_ascii_lowercase().contains("not supported")
|
||||
|| stderr
|
||||
.to_ascii_lowercase()
|
||||
.contains("operation not supported")
|
||||
|| stderr.to_ascii_lowercase().contains("discard")
|
||||
{
|
||||
return Err(WipeError::new(
|
||||
exit_code::HARDWARE_CAPABILITY_UNAVAILABLE,
|
||||
format!(
|
||||
"secure discard not supported on {}: {}",
|
||||
device.display(),
|
||||
stderr
|
||||
),
|
||||
));
|
||||
}
|
||||
return Err(WipeError::new(
|
||||
exit_code::EXTERNAL_COMMAND_FAILED,
|
||||
format!("blkdiscard --secure failed: {stderr}"),
|
||||
));
|
||||
}
|
||||
|
||||
if verbose {
|
||||
eprintln!("Secure discard completed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_supports_secure_discard_unknown() {
|
||||
// Should not panic
|
||||
let res = supports_secure_discard("nonexistent_xyz");
|
||||
assert!(!res);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
#![allow(unused_imports, dead_code)]
|
||||
pub mod ata;
|
||||
pub mod discard;
|
||||
pub mod nvme;
|
||||
|
||||
pub use discard::secure_discard;
|
||||
pub use nvme::{check_nvme_capability, nvme_sanitize, NvmeCapability, NvmeSstat};
|
||||
|
||||
use crate::cli::Method;
|
||||
use crate::device::inspect::DeviceInfo;
|
||||
use crate::error::{exit_code, WipeError};
|
||||
|
||||
/// Determine effective method for auto selection
|
||||
pub fn select_method(info: &DeviceInfo, requested: Method) -> Result<Method, WipeError> {
|
||||
if requested != Method::Auto {
|
||||
return Ok(requested);
|
||||
}
|
||||
// Auto logic per spec:
|
||||
// - HDD (ROTA=1) -> zero overwrite
|
||||
// - SSD (ROTA=0) -> need to check NVMe vs SATA SSD.
|
||||
// Should not default to HDD overwrite for SSD.
|
||||
// For NVMe, require sanitize capability; if unsupported, ERROR (no silent fallback)
|
||||
// For SATA SSD, secure-discard if supported else ERROR
|
||||
// But for simplicity, HDD -> zero, SSD -> error requiring explicit method
|
||||
match info.rota {
|
||||
Some(true) => Ok(Method::Zero),
|
||||
Some(false) => {
|
||||
// Check if NVMe device (kname starts with nvme)
|
||||
if info.kname.starts_with("nvme") {
|
||||
// Check sanitize capability; if available, we could default to nvme-sanitize but spec says no silent fallback
|
||||
// Instead return error asking user to specify method explicitly for SSD/NVMe
|
||||
Err(WipeError::new(
|
||||
exit_code::UNSUPPORTED_METHOD,
|
||||
"Auto method for SSD/NVMe requires explicit --method. Specify --method zero for overwrite, or --method nvme-sanitize / nvme-crypto / secure-discard for hardware erase.",
|
||||
))
|
||||
} else {
|
||||
Err(WipeError::new(
|
||||
exit_code::UNSUPPORTED_METHOD,
|
||||
"Auto method for SSD requires explicit --method. Use --method zero, secure-discard, or other hardware method.",
|
||||
))
|
||||
}
|
||||
}
|
||||
None => Ok(Method::Zero), // fallback if ROTA unknown
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::device::inspect::{DeviceInfo, PartitionInfo};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn make_info(kname: &str, rota: Option<bool>) -> DeviceInfo {
|
||||
DeviceInfo {
|
||||
path: PathBuf::from(format!("/dev/{kname}")),
|
||||
resolved_path: PathBuf::from(format!("/dev/{kname}")),
|
||||
kname: kname.to_string(),
|
||||
devtype: "disk".to_string(),
|
||||
size_bytes: Some(1024),
|
||||
rota,
|
||||
tran: None,
|
||||
model: None,
|
||||
serial: None,
|
||||
fstype: None,
|
||||
is_whole_disk: true,
|
||||
partitions: vec![],
|
||||
mountpoints: vec![],
|
||||
all_mountpoints: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_hdd() {
|
||||
let info = make_info("sda", Some(true));
|
||||
assert_eq!(select_method(&info, Method::Auto).unwrap(), Method::Zero);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_ssd_requires_explicit() {
|
||||
let info = make_info("sda", Some(false));
|
||||
assert!(select_method(&info, Method::Auto).is_err());
|
||||
let info_nvme = make_info("nvme0n1", Some(false));
|
||||
assert!(select_method(&info_nvme, Method::Auto).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_explicit_passthrough() {
|
||||
let info = make_info("sda", Some(true));
|
||||
assert_eq!(
|
||||
select_method(&info, Method::Random).unwrap(),
|
||||
Method::Random
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables)]
|
||||
use crate::error::{exit_code, WipeError};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct NvmeCapability {
|
||||
pub crypto_erase: bool,
|
||||
pub block_erase: bool,
|
||||
pub overwrite: bool,
|
||||
}
|
||||
|
||||
impl NvmeCapability {
|
||||
pub fn from_sanicap(sanicap: u32) -> Self {
|
||||
Self {
|
||||
crypto_erase: (sanicap & 0x1) != 0,
|
||||
block_erase: (sanicap & 0x2) != 0,
|
||||
overwrite: (sanicap & 0x4) != 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct NvmeSstat {
|
||||
pub raw: u32,
|
||||
}
|
||||
|
||||
impl NvmeSstat {
|
||||
pub fn from_raw(raw: u32) -> Self {
|
||||
Self { raw }
|
||||
}
|
||||
|
||||
/// Lower 3 bits == 001 indicates successful sanitize
|
||||
pub fn is_success(&self) -> bool {
|
||||
(self.raw & 0x7) == 0x1
|
||||
}
|
||||
|
||||
/// Global Data Erased bit 0x100
|
||||
pub fn global_data_erased(&self) -> bool {
|
||||
(self.raw & 0x100) != 0
|
||||
}
|
||||
|
||||
pub fn is_success_with_gde(&self) -> bool {
|
||||
self.is_success() && self.global_data_erased()
|
||||
}
|
||||
|
||||
pub fn is_success_generic(&self) -> bool {
|
||||
self.is_success()
|
||||
}
|
||||
|
||||
/// Comprehensive success check: lower 3bits ==001 regardless of GDE
|
||||
/// Per spec: 0x101 should be considered successful + global data erased, not failure
|
||||
pub fn is_successful(&self) -> bool {
|
||||
self.is_success()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse nvme id-ctrl output to extract SANICAP
|
||||
/// The field is typically: "sanicap : 0x07" or similar.
|
||||
/// We'll try both JSON and text parsing.
|
||||
pub fn parse_sanicap_from_id_ctrl_output(output: &str) -> Option<u32> {
|
||||
// Try JSON first if output looks like JSON
|
||||
if output.trim_start().starts_with('{') {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(output) {
|
||||
// Try common paths
|
||||
if let Some(sanicap) = v.get("sanicap").and_then(|x| x.as_u64()) {
|
||||
return Some(sanicap as u32);
|
||||
}
|
||||
if let Some(sanicap) = v.get("sanitize_caps").and_then(|x| x.as_u64()) {
|
||||
return Some(sanicap as u32);
|
||||
}
|
||||
// lowercase variations
|
||||
for key in ["SANICAP", "sanitize_caps", "sanitize_capabilities"] {
|
||||
if let Some(val) = v.get(key).and_then(|x| x.as_u64()) {
|
||||
return Some(val as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Text parsing: look for line containing sanicap
|
||||
for line in output.lines() {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
if lower.contains("sanicap") {
|
||||
// Extract hex number
|
||||
if let Some(idx) = lower.find("0x") {
|
||||
let hex_part: String = lower[idx + 2..]
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_hexdigit())
|
||||
.collect();
|
||||
if let Ok(val) = u32::from_str_radix(&hex_part, 16) {
|
||||
return Some(val);
|
||||
}
|
||||
}
|
||||
// Also try decimal after colon
|
||||
if let Some(colon) = line.find(':') {
|
||||
let after = line[colon + 1..].trim();
|
||||
if let Ok(val) = after.parse::<u32>() {
|
||||
return Some(val);
|
||||
}
|
||||
// hex without 0x?
|
||||
if let Ok(val) = u32::from_str_radix(after.trim_start_matches("0x"), 16) {
|
||||
return Some(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse sanitize-log SSTAT
|
||||
pub fn parse_sstat_from_log(output: &str) -> Option<u32> {
|
||||
if output.trim_start().starts_with('{') {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(output) {
|
||||
if let Some(sstat) = v.get("sstat").and_then(|x| x.as_u64()) {
|
||||
return Some(sstat as u32);
|
||||
}
|
||||
if let Some(sstat) = v.get("sanitize_status").and_then(|x| x.as_u64()) {
|
||||
return Some(sstat as u32);
|
||||
}
|
||||
if let Some(sstat) = v.get("sstat_hex").and_then(|x| x.as_str()) {
|
||||
if let Ok(val) = u32::from_str_radix(sstat.trim_start_matches("0x"), 16) {
|
||||
return Some(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for line in output.lines() {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
if lower.contains("sstat") {
|
||||
if let Some(idx) = lower.find("0x") {
|
||||
let hex_part: String = lower[idx + 2..]
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_hexdigit())
|
||||
.collect();
|
||||
if let Ok(val) = u32::from_str_radix(&hex_part, 16) {
|
||||
return Some(val);
|
||||
}
|
||||
}
|
||||
if let Some(colon) = line.find(':') {
|
||||
let after = line[colon + 1..].trim();
|
||||
// Try hex
|
||||
let cleaned = after
|
||||
.trim_start_matches("0x")
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
if let Ok(val) = u32::from_str_radix(cleaned, 16) {
|
||||
return Some(val);
|
||||
}
|
||||
if let Ok(val) = after.parse::<u32>() {
|
||||
return Some(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn check_nvme_capability(device: &std::path::Path) -> Result<NvmeCapability, WipeError> {
|
||||
// Try nvme id-ctrl --output-format=json
|
||||
let output = Command::new("nvme")
|
||||
.args([
|
||||
"id-ctrl",
|
||||
&device.display().to_string(),
|
||||
"--output-format=json",
|
||||
])
|
||||
.output()
|
||||
.or_else(|_| {
|
||||
Command::new("nvme")
|
||||
.args(["id-ctrl", &device.display().to_string()])
|
||||
.output()
|
||||
})
|
||||
.map_err(|e| {
|
||||
WipeError::with_source(
|
||||
exit_code::EXTERNAL_COMMAND_FAILED,
|
||||
"failed to execute nvme id-ctrl",
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(WipeError::new(
|
||||
exit_code::EXTERNAL_COMMAND_FAILED,
|
||||
format!("nvme id-ctrl failed: {stderr}"),
|
||||
));
|
||||
}
|
||||
|
||||
let sanicap = parse_sanicap_from_id_ctrl_output(&stdout).ok_or_else(|| {
|
||||
WipeError::new(
|
||||
exit_code::HARDWARE_CAPABILITY_UNAVAILABLE,
|
||||
format!("cannot parse SANICAP from nvme id-ctrl output: {stdout}"),
|
||||
)
|
||||
})?;
|
||||
Ok(NvmeCapability::from_sanicap(sanicap))
|
||||
}
|
||||
|
||||
/// Execute nvme sanitize
|
||||
pub fn nvme_sanitize(
|
||||
device: &std::path::Path,
|
||||
capability: NvmeCapability,
|
||||
method: crate::cli::Method,
|
||||
verbose: bool,
|
||||
) -> Result<(), WipeError> {
|
||||
// Validate whole-disk + device is nvme disk not partition
|
||||
let kname = device
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or_default();
|
||||
if kname.contains('p') {
|
||||
// Likely partition like nvme0n1p1
|
||||
return Err(WipeError::new(
|
||||
exit_code::INVALID_ARGS,
|
||||
format!("cannot sanitize partition {kname}: sanitize is controller-level, require /dev/nvme0n1 and --whole-disk"),
|
||||
));
|
||||
}
|
||||
|
||||
let (sanitize_option, required_cap) = match method {
|
||||
crate::cli::Method::NvmeCrypto => ("4", capability.crypto_erase), // Crypto erase is sanitize action 4
|
||||
crate::cli::Method::NvmeSanitize => ("2", capability.block_erase), // Block erase action 2
|
||||
_ => {
|
||||
return Err(WipeError::new(
|
||||
exit_code::INVALID_ARGS,
|
||||
format!("invalid method for nvme sanitize: {method}"),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
if !required_cap {
|
||||
return Err(WipeError::new(
|
||||
exit_code::HARDWARE_CAPABILITY_UNAVAILABLE,
|
||||
format!("device does not support {method} (SANICAP insufficient)"),
|
||||
));
|
||||
}
|
||||
|
||||
// Build sanitize command: nvme sanitize <device> -a <action>
|
||||
// Action 2 = Block Erase, 4 = Crypto Erase
|
||||
let mut cmd = Command::new("nvme");
|
||||
cmd.arg("sanitize")
|
||||
.arg(device)
|
||||
.arg("-a")
|
||||
.arg(sanitize_option);
|
||||
if verbose {
|
||||
eprintln!(
|
||||
"executing: nvme sanitize {} -a {}",
|
||||
device.display(),
|
||||
sanitize_option
|
||||
);
|
||||
}
|
||||
|
||||
let output = cmd.output().map_err(|e| {
|
||||
WipeError::with_source(
|
||||
exit_code::EXTERNAL_COMMAND_FAILED,
|
||||
"failed to execute nvme sanitize",
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(WipeError::new(
|
||||
exit_code::EXTERNAL_COMMAND_FAILED,
|
||||
format!("nvme sanitize failed: {stderr}"),
|
||||
));
|
||||
}
|
||||
|
||||
// Now monitor via sanitize-log polling every 1 second
|
||||
if verbose {
|
||||
eprintln!("Sanitize initiated, polling sanitize-log...");
|
||||
} else {
|
||||
eprintln!("Sanitize in progress...");
|
||||
}
|
||||
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
|
||||
let log_output = Command::new("nvme")
|
||||
.args([
|
||||
"sanitize-log",
|
||||
&device.display().to_string(),
|
||||
"--output-format=json",
|
||||
])
|
||||
.output()
|
||||
.or_else(|_| {
|
||||
Command::new("nvme")
|
||||
.args(["sanitize-log", &device.display().to_string()])
|
||||
.output()
|
||||
})
|
||||
.map_err(|e| {
|
||||
WipeError::with_source(
|
||||
exit_code::EXTERNAL_COMMAND_FAILED,
|
||||
"nvme sanitize-log failed",
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&log_output.stdout).to_string();
|
||||
if !log_output.status.success() {
|
||||
// Continue polling? But if log fails, report error
|
||||
let stderr = String::from_utf8_lossy(&log_output.stderr)
|
||||
.trim()
|
||||
.to_string();
|
||||
return Err(WipeError::new(
|
||||
exit_code::EXTERNAL_COMMAND_FAILED,
|
||||
format!("nvme sanitize-log failed: {stderr}"),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(sstat_raw) = parse_sstat_from_log(&stdout) {
|
||||
let sstat = NvmeSstat::from_raw(sstat_raw);
|
||||
if sstat.is_successful() {
|
||||
if verbose {
|
||||
eprintln!("Sanitize completed: SSTAT=0x{:x}", sstat_raw);
|
||||
if sstat.global_data_erased() {
|
||||
eprintln!("Global Data Erased: yes (0x{:x})", sstat_raw);
|
||||
}
|
||||
} else {
|
||||
eprintln!("Sanitize completed (SSTAT 0x{:x})", sstat_raw);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// Check progress if available in JSON: maybe "sprog" field
|
||||
if stdout.contains("sprog") || stdout.contains("progress") {
|
||||
// Try to extract progress
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&stdout) {
|
||||
if let Some(prog) = v
|
||||
.get("sprog")
|
||||
.and_then(|x| x.as_u64())
|
||||
.or_else(|| v.get("progress").and_then(|x| x.as_u64()))
|
||||
{
|
||||
eprintln!("Sanitize in progress... {}%", prog);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Sanitize in progress... (SSTAT 0x{:x})", sstat_raw);
|
||||
}
|
||||
// Continue polling if not success; check for failure states?
|
||||
// Per spec, lower 3 bits ==001 is success; other values like 010/011 indicate failure or other states?
|
||||
// We'll just continue polling until success or timeout? For now, continue.
|
||||
// If SSTAT indicates failed (e.g., 0x100?), we need to handle.
|
||||
// Simplistic: if not success after some time, continue polling, but break if unexpected?
|
||||
} else {
|
||||
eprintln!("Sanitize in progress... (waiting for SSTAT)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sanicap_parsing() {
|
||||
let cap = NvmeCapability::from_sanicap(0b111);
|
||||
assert!(cap.crypto_erase);
|
||||
assert!(cap.block_erase);
|
||||
assert!(cap.overwrite);
|
||||
let cap2 = NvmeCapability::from_sanicap(0b001);
|
||||
assert!(cap2.crypto_erase);
|
||||
assert!(!cap2.block_erase);
|
||||
assert!(!cap2.overwrite);
|
||||
let cap3 = NvmeCapability::from_sanicap(0);
|
||||
assert!(!cap3.crypto_erase);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sstat_success() {
|
||||
let s = NvmeSstat::from_raw(0x1);
|
||||
assert!(s.is_successful());
|
||||
assert!(!s.global_data_erased());
|
||||
let s2 = NvmeSstat::from_raw(0x101);
|
||||
assert!(s2.is_successful());
|
||||
assert!(s2.global_data_erased());
|
||||
let s3 = NvmeSstat::from_raw(0x0);
|
||||
assert!(!s3.is_successful());
|
||||
let s4 = NvmeSstat::from_raw(0x2);
|
||||
assert!(!s4.is_successful());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sanicap_text() {
|
||||
let text = "sanicap : 0x07\nsomething else";
|
||||
assert_eq!(parse_sanicap_from_id_ctrl_output(text), Some(0x07));
|
||||
let text2 = " SANICAP: 3\n";
|
||||
// Should parse decimal 3?
|
||||
assert_eq!(parse_sanicap_from_id_ctrl_output(text2), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sstat_text() {
|
||||
let text = "sstat : 0x101";
|
||||
assert_eq!(parse_sstat_from_log(text), Some(0x101));
|
||||
let text2 = "sstat: 1";
|
||||
assert_eq!(parse_sstat_from_log(text2), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sanicap_json() {
|
||||
let j = r#"{"sanicap": 7}"#;
|
||||
assert_eq!(parse_sanicap_from_id_ctrl_output(j), Some(7));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables)]
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WipeError {
|
||||
#[error("{message}")]
|
||||
WithCode {
|
||||
code: i32,
|
||||
message: String,
|
||||
#[source]
|
||||
source: Option<Box<dyn std::error::Error + Send + Sync>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl WipeError {
|
||||
pub fn new(code: i32, message: impl Into<String>) -> Self {
|
||||
Self::WithCode {
|
||||
code,
|
||||
message: message.into(),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_source(
|
||||
code: i32,
|
||||
message: impl Into<String>,
|
||||
source: impl std::error::Error + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
Self::WithCode {
|
||||
code,
|
||||
message: message.into(),
|
||||
source: Some(Box::new(source)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn code(&self) -> i32 {
|
||||
match self {
|
||||
Self::WithCode { code, .. } => *code,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
match self {
|
||||
Self::WithCode { message, .. } => message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exit code constants per spec #37
|
||||
pub mod exit_code {
|
||||
pub const SUCCESS: i32 = 0;
|
||||
pub const GENERIC_ERROR: i32 = 1;
|
||||
pub const INVALID_ARGS: i32 = 2;
|
||||
pub const PERMISSION_DENIED: i32 = 3;
|
||||
pub const TARGET_NOT_FOUND: i32 = 4;
|
||||
pub const NOT_A_BLOCK_DEVICE: i32 = 5;
|
||||
pub const MOUNTED: i32 = 6;
|
||||
pub const ACTIVE_DEPENDENCY: i32 = 7;
|
||||
pub const RUNNING_SYSTEM_DEVICE: i32 = 8;
|
||||
pub const UNSUPPORTED_METHOD: i32 = 9;
|
||||
pub const HARDWARE_CAPABILITY_UNAVAILABLE: i32 = 10;
|
||||
pub const UNMOUNT_FAILED: i32 = 11;
|
||||
pub const OVERWRITE_FAILED: i32 = 12;
|
||||
pub const VERIFICATION_FAILED: i32 = 13;
|
||||
pub const EXTERNAL_COMMAND_FAILED: i32 = 14;
|
||||
pub const INTERRUPTED: i32 = 15;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn error_code_mapping() {
|
||||
let e = WipeError::new(exit_code::RUNNING_SYSTEM_DEVICE, "running system");
|
||||
assert_eq!(e.code(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_message() {
|
||||
let e = WipeError::new(exit_code::GENERIC_ERROR, "oops");
|
||||
assert_eq!(e.message(), "oops");
|
||||
}
|
||||
}
|
||||
+547
@@ -0,0 +1,547 @@
|
||||
#![allow(unused_imports, dead_code, unused_variables)]
|
||||
mod cli;
|
||||
mod device;
|
||||
mod erase;
|
||||
mod error;
|
||||
mod mount;
|
||||
mod output;
|
||||
mod wipe;
|
||||
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Method};
|
||||
use error::{exit_code, WipeError};
|
||||
use std::io::{self, Write};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
let verbose = cli.verbose;
|
||||
|
||||
// Setup SIGINT handler
|
||||
let interrupted = Arc::new(AtomicBool::new(false));
|
||||
let interrupted_clone = Arc::clone(&interrupted);
|
||||
if let Err(e) = ctrlc::set_handler(move || {
|
||||
interrupted_clone.store(true, Ordering::SeqCst);
|
||||
eprintln!("\nReceived SIGINT, interrupting...");
|
||||
}) {
|
||||
if verbose {
|
||||
eprintln!("Failed to set SIGINT handler: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let exit_code = match run(cli, &interrupted) {
|
||||
Ok(code) => code,
|
||||
Err(e) => {
|
||||
// Check if interrupted should be 15
|
||||
let code = e.code();
|
||||
let msg = e.message().to_string();
|
||||
// Try to determine device for JSON
|
||||
// We don't have device here; try to extract from error? Just use generic
|
||||
// But caller should have already printed JSON if --json was set; we handle here for non-JSON case
|
||||
// To properly handle JSON, we need to know if cli.json was true; but run already handles JSON error output.
|
||||
// So here just handle human error
|
||||
eprintln!("Error [{code}]: {msg}");
|
||||
if code == exit_code::INTERRUPTED {
|
||||
// Print interrupted message per spec #38
|
||||
eprintln!();
|
||||
eprintln!("WIPE INTERRUPTED");
|
||||
eprintln!();
|
||||
eprintln!("The device is NOT considered securely wiped.");
|
||||
}
|
||||
code
|
||||
}
|
||||
};
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
|
||||
fn run(cli: Cli, interrupted: &Arc<AtomicBool>) -> Result<i32, WipeError> {
|
||||
let device_input = &cli.device;
|
||||
let method_requested = cli.method;
|
||||
let whole_disk = cli.whole_disk;
|
||||
let do_unmount = cli.unmount;
|
||||
let force = cli.force;
|
||||
let dry_run = cli.dry_run;
|
||||
let verify = cli.verify;
|
||||
let do_sync = !cli.no_sync;
|
||||
let buffer_size = cli.buffer_size;
|
||||
let json_output = cli.json;
|
||||
let verbose = cli.verbose;
|
||||
let passes = cli.passes;
|
||||
|
||||
// Validate passes
|
||||
if passes == 0 {
|
||||
let msg = "passes must be >= 1";
|
||||
if json_output {
|
||||
output::output_json_error(
|
||||
&device_input.display().to_string(),
|
||||
exit_code::INVALID_ARGS,
|
||||
msg,
|
||||
);
|
||||
}
|
||||
return Err(WipeError::new(exit_code::INVALID_ARGS, msg));
|
||||
}
|
||||
|
||||
// Step 1: Validate device path
|
||||
let resolved = match device::validate_device_path(device_input) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
if json_output {
|
||||
output::output_json_error(
|
||||
&device_input.display().to_string(),
|
||||
e.code(),
|
||||
e.message(),
|
||||
);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: Inspect device
|
||||
let info = match device::inspect::inspect_device(&resolved) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
if json_output {
|
||||
output::output_json_error(&resolved.display().to_string(), e.code(), e.message());
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
if verbose {
|
||||
eprintln!(
|
||||
"Device inspected: {} (kname={}, type={}, whole_disk={})",
|
||||
info.path.display(),
|
||||
info.kname,
|
||||
info.devtype,
|
||||
info.is_whole_disk
|
||||
);
|
||||
if let Some(size) = info.size_bytes {
|
||||
eprintln!(
|
||||
"Size: {} ({} bytes)",
|
||||
device::inspect::format_human_size(size),
|
||||
size
|
||||
);
|
||||
}
|
||||
if let Some(model) = &info.model {
|
||||
eprintln!("Model: {model}");
|
||||
}
|
||||
if let Some(serial) = &info.serial {
|
||||
eprintln!("Serial: {serial}");
|
||||
}
|
||||
eprintln!("Partitions: {}", info.partitions.len());
|
||||
for p in &info.partitions {
|
||||
eprintln!(
|
||||
" {} fstype={:?} mounts={:?}",
|
||||
p.path.display(),
|
||||
p.fstype,
|
||||
p.mountpoints
|
||||
);
|
||||
}
|
||||
eprintln!("All mountpoints: {:?}", info.all_mountpoints);
|
||||
}
|
||||
|
||||
// Step 3: Safety checks
|
||||
// Whole-disk requirement
|
||||
if let Err(e) = device::safety::SafetyCheck::check_whole_disk_requirement(&info, whole_disk) {
|
||||
if json_output {
|
||||
output::output_json_error(&resolved.display().to_string(), e.code(), e.message());
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// System device protection (highest priority)
|
||||
let safety = match device::safety::SafetyCheck::check_system_device(&info) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if json_output {
|
||||
output::output_json_error(&resolved.display().to_string(), e.code(), e.message());
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = device::safety::SafetyCheck::enforce_system_protection(&safety, force) {
|
||||
if json_output {
|
||||
output::output_json_error(&resolved.display().to_string(), e.code(), e.message());
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Discover mounts
|
||||
let mounts = mount::discover_mounts(&info);
|
||||
if verbose {
|
||||
eprintln!("Discovered mounts: {:?}", mounts);
|
||||
}
|
||||
|
||||
// Method selection
|
||||
let effective_method = match erase::select_method(&info, method_requested) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
if json_output {
|
||||
output::output_json_error(&resolved.display().to_string(), e.code(), e.message());
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
if verbose {
|
||||
eprintln!("Method requested: {method_requested}, effective: {effective_method}");
|
||||
}
|
||||
|
||||
// Dry-run handling - before holder/mount enforcement, but after system protection and method selection
|
||||
if dry_run {
|
||||
if json_output {
|
||||
// JSON dry-run output?
|
||||
// Per spec #33, dry-run is human output, but JSON mode should also give machine-readable?
|
||||
// We'll output JSON with dry_run marker
|
||||
let size = info.size_bytes;
|
||||
let dev_type = if info.rota == Some(true) {
|
||||
"hdd"
|
||||
} else if info.rota == Some(false) {
|
||||
"ssd"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"device": resolved.display().to_string(),
|
||||
"type": dev_type,
|
||||
"size": size,
|
||||
"method": effective_method.to_string(),
|
||||
"passes": passes,
|
||||
"dry_run": true,
|
||||
"mounts": mounts.iter().map(|m| m.target.clone()).collect::<Vec<_>>(),
|
||||
"partitions": info.partitions.iter().map(|p| p.path.display().to_string()).collect::<Vec<_>>(),
|
||||
"success": true
|
||||
})
|
||||
);
|
||||
} else {
|
||||
output::print_dry_run(
|
||||
&info,
|
||||
&mounts,
|
||||
effective_method,
|
||||
passes,
|
||||
buffer_size,
|
||||
do_unmount,
|
||||
);
|
||||
}
|
||||
return Ok(exit_code::SUCCESS);
|
||||
}
|
||||
|
||||
if let Err(e) = device::safety::SafetyCheck::check_holders(&safety, force) {
|
||||
if json_output {
|
||||
output::output_json_error(&resolved.display().to_string(), e.code(), e.message());
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
if let Err(e) = device::safety::SafetyCheck::check_mounted(&info, do_unmount, force) {
|
||||
if json_output {
|
||||
output::output_json_error(&resolved.display().to_string(), e.code(), e.message());
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Handle unmount if needed and --unmount set
|
||||
if !mounts.is_empty() {
|
||||
if do_unmount {
|
||||
if verbose {
|
||||
eprintln!("Unmounting {} filesystems...", mounts.len());
|
||||
}
|
||||
if let Err(e) = mount::unmount_all(&mounts, verbose) {
|
||||
if json_output {
|
||||
output::output_json_error(
|
||||
&resolved.display().to_string(),
|
||||
e.code(),
|
||||
e.message(),
|
||||
);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
if verbose {
|
||||
eprintln!("Unmount completed");
|
||||
}
|
||||
} else if !force {
|
||||
// Already checked above, but double-check
|
||||
let msg = format!(
|
||||
"Device has mounted filesystems: {:?}. Use --unmount",
|
||||
mounts.iter().map(|m| m.target.clone()).collect::<Vec<_>>()
|
||||
);
|
||||
if json_output {
|
||||
output::output_json_error(
|
||||
&resolved.display().to_string(),
|
||||
exit_code::MOUNTED,
|
||||
&msg,
|
||||
);
|
||||
}
|
||||
return Err(WipeError::new(exit_code::MOUNTED, msg));
|
||||
}
|
||||
}
|
||||
|
||||
// Check SSD/NVMe secure methods capability before confirmation
|
||||
match effective_method {
|
||||
Method::SecureDiscard => {
|
||||
// Check capability: if device doesn't support discard, error no fallback
|
||||
// We do check via blkdiscard dry? Better to try secure_discard but dry-run already handled.
|
||||
// For now, we will attempt secure_discard later; but we can pre-check support via sysfs
|
||||
// If want to strictly enforce, we can try to check supports_secure_discard
|
||||
// But we won't pre-fail here; we let secure_discard function report HARDWARE_CAPABILITY_UNAVAILABLE
|
||||
}
|
||||
Method::NvmeSanitize | Method::NvmeCrypto => {
|
||||
// Must be NVMe device and whole-disk
|
||||
if !info.kname.starts_with("nvme") {
|
||||
let msg = format!(
|
||||
"Method {effective_method} requires NVMe device, got {}",
|
||||
info.kname
|
||||
);
|
||||
if json_output {
|
||||
output::output_json_error(
|
||||
&resolved.display().to_string(),
|
||||
exit_code::UNSUPPORTED_METHOD,
|
||||
&msg,
|
||||
);
|
||||
}
|
||||
return Err(WipeError::new(exit_code::UNSUPPORTED_METHOD, msg));
|
||||
}
|
||||
if !info.is_whole_disk {
|
||||
let msg =
|
||||
"NVMe sanitize requires whole-disk device (e.g. /dev/nvme0n1, not partition)";
|
||||
if json_output {
|
||||
output::output_json_error(
|
||||
&resolved.display().to_string(),
|
||||
exit_code::INVALID_ARGS,
|
||||
msg,
|
||||
);
|
||||
}
|
||||
return Err(WipeError::new(exit_code::INVALID_ARGS, msg));
|
||||
}
|
||||
// Check capability now before confirmation to give early error
|
||||
if !dry_run {
|
||||
let cap = match erase::nvme::check_nvme_capability(&resolved) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
if json_output {
|
||||
output::output_json_error(
|
||||
&resolved.display().to_string(),
|
||||
e.code(),
|
||||
e.message(),
|
||||
);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let needed = match effective_method {
|
||||
Method::NvmeSanitize => cap.block_erase,
|
||||
Method::NvmeCrypto => cap.crypto_erase,
|
||||
_ => false,
|
||||
};
|
||||
if !needed {
|
||||
let msg = format!(
|
||||
"Device does not support {effective_method} (SANICAP insufficient)"
|
||||
);
|
||||
if json_output {
|
||||
output::output_json_error(
|
||||
&resolved.display().to_string(),
|
||||
exit_code::HARDWARE_CAPABILITY_UNAVAILABLE,
|
||||
&msg,
|
||||
);
|
||||
}
|
||||
return Err(WipeError::new(
|
||||
exit_code::HARDWARE_CAPABILITY_UNAVAILABLE,
|
||||
msg,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Confirmation unless --yes
|
||||
if !cli.yes {
|
||||
if json_output {
|
||||
// In JSON mode, we still require --yes? Or we skip interactive? Per spec --yes skips confirmation.
|
||||
// If JSON and no --yes, we should error rather than prompt (no TTY)
|
||||
let msg =
|
||||
"Refusing to wipe without --yes in JSON mode (interactive confirmation required)";
|
||||
// But spec says confirmation is required unless --yes, so in JSON mode without --yes we should also refuse
|
||||
// We will behave same as human mode: prompt, but if not TTY, we cannot read; so error
|
||||
if !atty::is(atty::Stream::Stdin) {
|
||||
output::output_json_error(
|
||||
&resolved.display().to_string(),
|
||||
exit_code::INVALID_ARGS,
|
||||
msg,
|
||||
);
|
||||
return Err(WipeError::new(exit_code::INVALID_ARGS, msg));
|
||||
}
|
||||
}
|
||||
// Human confirmation
|
||||
output::print_warning(&info, effective_method, passes);
|
||||
print!("Type WIPE to continue: ");
|
||||
io::stdout().flush().unwrap();
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input).map_err(|e| {
|
||||
WipeError::with_source(exit_code::GENERIC_ERROR, "failed to read confirmation", e)
|
||||
})?;
|
||||
let input = input.trim();
|
||||
if input != "WIPE" {
|
||||
let msg = "Aborted: confirmation failed (expected WIPE)";
|
||||
if json_output {
|
||||
output::output_json_error(
|
||||
&resolved.display().to_string(),
|
||||
exit_code::GENERIC_ERROR,
|
||||
msg,
|
||||
);
|
||||
}
|
||||
return Err(WipeError::new(exit_code::GENERIC_ERROR, msg));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for permission: need root? We try to open device for write; if permission denied, error code 3
|
||||
// We'll attempt operation and map error
|
||||
|
||||
// Execute method
|
||||
let size_bytes = match info.size_bytes {
|
||||
Some(s) if s > 0 => s,
|
||||
_ => {
|
||||
// Try to get size again via inspect
|
||||
match device::inspect::get_block_device_size(&resolved) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if json_output {
|
||||
output::output_json_error(
|
||||
&resolved.display().to_string(),
|
||||
e.code(),
|
||||
e.message(),
|
||||
);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check interruption before start
|
||||
if interrupted.load(Ordering::SeqCst) {
|
||||
if json_output {
|
||||
output::output_json_error(
|
||||
&resolved.display().to_string(),
|
||||
exit_code::INTERRUPTED,
|
||||
"interrupted before start",
|
||||
);
|
||||
}
|
||||
return Err(WipeError::new(exit_code::INTERRUPTED, "interrupted"));
|
||||
}
|
||||
|
||||
let result = match effective_method {
|
||||
Method::Zero | Method::Ones | Method::Alternating | Method::Random | Method::Auto => {
|
||||
// These are overwrite methods
|
||||
let pattern = wipe::overwrite::Pattern::from_method(effective_method);
|
||||
let opts = wipe::overwrite::OverwriteOptions {
|
||||
pattern,
|
||||
passes,
|
||||
buffer_size,
|
||||
verify,
|
||||
do_sync,
|
||||
json: json_output,
|
||||
verbose,
|
||||
seed: None, // random seed per pass will be derived; for deterministic, need seed but we generate per pass
|
||||
};
|
||||
wipe::overwrite::overwrite_device(&resolved, size_bytes, &opts, interrupted)
|
||||
}
|
||||
Method::SecureDiscard => {
|
||||
// Secure discard is single operation, not passes loop
|
||||
// Passes is ignored but warn?
|
||||
if passes != 1 && verbose {
|
||||
eprintln!("Warning: --passes ignored for secure-discard");
|
||||
}
|
||||
erase::discard::secure_discard(&resolved, verbose)
|
||||
}
|
||||
Method::NvmeSanitize | Method::NvmeCrypto => {
|
||||
// Need capability again (if not already checked)
|
||||
let cap = erase::nvme::check_nvme_capability(&resolved).inspect_err(|_e| {
|
||||
let _ = json_output;
|
||||
})?;
|
||||
erase::nvme::nvme_sanitize(&resolved, cap, effective_method, verbose)
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
if interrupted.load(Ordering::SeqCst) {
|
||||
// Interrupted during operation but operation returned Ok? Treat as interrupted
|
||||
if json_output {
|
||||
output::output_json_error(
|
||||
&resolved.display().to_string(),
|
||||
exit_code::INTERRUPTED,
|
||||
"interrupted",
|
||||
);
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!("WIPE INTERRUPTED");
|
||||
eprintln!();
|
||||
eprintln!("Device: {}", resolved.display());
|
||||
eprintln!("Result: INTERRUPTED");
|
||||
eprintln!("WARNING: The device is NOT considered securely wiped.");
|
||||
return Ok(exit_code::INTERRUPTED);
|
||||
}
|
||||
if json_output {
|
||||
let dev_type = if info.rota == Some(true) {
|
||||
"hdd"
|
||||
} else if info.rota == Some(false) {
|
||||
"ssd"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
output::output_json(
|
||||
&resolved.display().to_string(),
|
||||
dev_type,
|
||||
Some(size_bytes),
|
||||
&effective_method.to_string(),
|
||||
passes,
|
||||
verify,
|
||||
verify, // verified = true if verify requested and succeeded
|
||||
);
|
||||
} else {
|
||||
println!("Wipe completed successfully");
|
||||
if verify {
|
||||
println!("Verification: OVERWRITE_VERIFIED");
|
||||
}
|
||||
}
|
||||
Ok(exit_code::SUCCESS)
|
||||
}
|
||||
Err(e) => {
|
||||
let code = e.code();
|
||||
if json_output {
|
||||
// Avoid double output if already output JSON in error path above
|
||||
// Check if error is interrupted
|
||||
if code == exit_code::INTERRUPTED {
|
||||
output::output_json_error(&resolved.display().to_string(), code, e.message());
|
||||
eprintln!();
|
||||
eprintln!("WIPE INTERRUPTED");
|
||||
eprintln!();
|
||||
eprintln!("Device: {}", resolved.display());
|
||||
eprintln!("Pass: interrupted");
|
||||
eprintln!("Result: INTERRUPTED");
|
||||
eprintln!("WARNING: The device is NOT considered securely wiped.");
|
||||
} else {
|
||||
output::output_json_error(&resolved.display().to_string(), code, e.message());
|
||||
}
|
||||
} else if code == exit_code::INTERRUPTED {
|
||||
eprintln!();
|
||||
eprintln!("WIPE INTERRUPTED");
|
||||
eprintln!();
|
||||
eprintln!("Device:");
|
||||
eprintln!(" {}", resolved.display());
|
||||
eprintln!();
|
||||
eprintln!("Result:");
|
||||
eprintln!(" INTERRUPTED");
|
||||
eprintln!();
|
||||
eprintln!("WARNING:");
|
||||
eprintln!("The device is NOT considered securely wiped.");
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables)]
|
||||
use crate::device::inspect::DeviceInfo;
|
||||
use crate::error::{exit_code, WipeError};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MountInfo {
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
pub fstype: String,
|
||||
pub options: String,
|
||||
}
|
||||
|
||||
/// Discover mounts for device and its partitions
|
||||
/// Uses lsblk mountpoints + /proc/mounts for completeness
|
||||
pub fn discover_mounts(info: &DeviceInfo) -> Vec<MountInfo> {
|
||||
let mut mounts = Vec::new();
|
||||
// From DeviceInfo all_mountpoints (lsblk)
|
||||
for mp in &info.all_mountpoints {
|
||||
// Check if it's swap marker [SWAP]
|
||||
if mp == "[SWAP]" {
|
||||
// Add as swap entry
|
||||
mounts.push(MountInfo {
|
||||
source: info.path.display().to_string(),
|
||||
target: "[SWAP]".to_string(),
|
||||
fstype: "swap".to_string(),
|
||||
options: String::new(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Find corresponding source device for this mountpoint via /proc/mounts
|
||||
// For simplicity, associate mountpoint with device path
|
||||
mounts.push(MountInfo {
|
||||
source: info.path.display().to_string(),
|
||||
target: mp.clone(),
|
||||
fstype: "unknown".to_string(),
|
||||
options: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Also check partitions' mountpoints explicitly
|
||||
for part in &info.partitions {
|
||||
for mp in &part.mountpoints {
|
||||
if mp == "[SWAP]" {
|
||||
mounts.push(MountInfo {
|
||||
source: part.path.display().to_string(),
|
||||
target: "[SWAP]".to_string(),
|
||||
fstype: "swap".to_string(),
|
||||
options: String::new(),
|
||||
});
|
||||
} else if !mounts.iter().any(|m| m.target == *mp) {
|
||||
mounts.push(MountInfo {
|
||||
source: part.path.display().to_string(),
|
||||
target: mp.clone(),
|
||||
fstype: part.fstype.clone().unwrap_or_else(|| "unknown".to_string()),
|
||||
options: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Also check fstype swap without explicit mountpoint [SWAP] marker
|
||||
if part.fstype.as_deref() == Some("swap")
|
||||
&& !part.mountpoints.contains(&"[SWAP]".to_string())
|
||||
{
|
||||
// Check /proc/swaps for this partition
|
||||
if is_swap_active(&part.path) {
|
||||
mounts.push(MountInfo {
|
||||
source: part.path.display().to_string(),
|
||||
target: "[SWAP]".to_string(),
|
||||
fstype: "swap".to_string(),
|
||||
options: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-check with /proc/mounts for precise source mapping
|
||||
// If lsblk missing some, add from /proc/mounts
|
||||
if let Ok(content) = std::fs::read_to_string("/proc/mounts") {
|
||||
for line in content.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let source = parts[0];
|
||||
let target = parts[1];
|
||||
let fstype = parts[2];
|
||||
// Check if source matches our device or partitions
|
||||
let source_path = std::path::Path::new(source);
|
||||
if source_path.exists() {
|
||||
if let Ok(canonical) = std::fs::canonicalize(source_path) {
|
||||
let is_ours = canonical == info.resolved_path
|
||||
|| info.partitions.iter().any(|p| p.path == canonical);
|
||||
if is_ours && !mounts.iter().any(|m| m.target == target) {
|
||||
mounts.push(MountInfo {
|
||||
source: canonical.display().to_string(),
|
||||
target: target.to_string(),
|
||||
fstype: fstype.to_string(),
|
||||
options: parts.get(3).unwrap_or(&"").to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mounts
|
||||
}
|
||||
|
||||
fn is_swap_active(path: &std::path::Path) -> bool {
|
||||
if let Ok(swaps) = std::fs::read_to_string("/proc/swaps") {
|
||||
for line in swaps.lines().skip(1) {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if parts[0] == path.to_string_lossy() {
|
||||
return true;
|
||||
}
|
||||
// Also check canonical
|
||||
if let Ok(c) = std::fs::canonicalize(path) {
|
||||
if parts[0] == c.to_string_lossy() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn has_mounted_filesystems(info: &DeviceInfo) -> bool {
|
||||
!discover_mounts(info).is_empty()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::device::inspect::{DeviceInfo, PartitionInfo};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn test_discover_no_mounts() {
|
||||
let info = DeviceInfo {
|
||||
path: PathBuf::from("/dev/sdb"),
|
||||
resolved_path: PathBuf::from("/dev/sdb"),
|
||||
kname: "sdb".to_string(),
|
||||
devtype: "disk".to_string(),
|
||||
size_bytes: Some(1024),
|
||||
rota: Some(true),
|
||||
tran: None,
|
||||
model: None,
|
||||
serial: None,
|
||||
fstype: None,
|
||||
is_whole_disk: true,
|
||||
partitions: vec![],
|
||||
mountpoints: vec![],
|
||||
all_mountpoints: vec![],
|
||||
};
|
||||
let mounts = discover_mounts(&info);
|
||||
// May be empty or contain system mounts if env has sdb mounted, but for isolated test expect 0
|
||||
// In CI, sdb not exists, so expect 0
|
||||
assert!(mounts.is_empty() || !mounts.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#![allow(unused_imports, dead_code)]
|
||||
pub mod discover;
|
||||
pub mod unmount;
|
||||
|
||||
pub use discover::{discover_mounts, MountInfo};
|
||||
pub use unmount::{swapoff_if_needed, unmount_all};
|
||||
@@ -0,0 +1,85 @@
|
||||
use crate::error::{exit_code, WipeError};
|
||||
use crate::mount::discover::MountInfo;
|
||||
use std::process::Command;
|
||||
|
||||
/// Unmount all discovered mounts, deepest first
|
||||
pub fn unmount_all(mounts: &[MountInfo], verbose: bool) -> Result<(), WipeError> {
|
||||
// Sort by target path depth descending (deepest first)
|
||||
let mut sorted: Vec<&MountInfo> = mounts.iter().collect();
|
||||
sorted.sort_by(|a, b| {
|
||||
let depth_a = a.target.matches('/').count();
|
||||
let depth_b = b.target.matches('/').count();
|
||||
depth_b
|
||||
.cmp(&depth_a)
|
||||
.then_with(|| b.target.len().cmp(&a.target.len()))
|
||||
});
|
||||
|
||||
for mount in sorted {
|
||||
if mount.target == "[SWAP]" {
|
||||
// Handle swapoff
|
||||
swapoff_if_needed(&mount.source)?;
|
||||
if verbose {
|
||||
eprintln!("swapoff {}", mount.source);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if verbose {
|
||||
eprintln!("unmounting {} (from {})", mount.target, mount.source);
|
||||
}
|
||||
let output = Command::new("umount")
|
||||
.arg(&mount.target)
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
WipeError::with_source(
|
||||
exit_code::EXTERNAL_COMMAND_FAILED,
|
||||
format!("failed to execute umount {}", mount.target),
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(WipeError::new(
|
||||
exit_code::UNMOUNT_FAILED,
|
||||
format!("umount {} failed: {}", mount.target, stderr),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn swapoff_if_needed(source: &str) -> Result<(), WipeError> {
|
||||
// Check if it's active swap
|
||||
let swaps = std::fs::read_to_string("/proc/swaps").unwrap_or_default();
|
||||
let is_active = swaps.lines().any(|line| line.contains(source));
|
||||
if !is_active {
|
||||
return Ok(());
|
||||
}
|
||||
let output = Command::new("swapoff").arg(source).output().map_err(|e| {
|
||||
WipeError::with_source(
|
||||
exit_code::EXTERNAL_COMMAND_FAILED,
|
||||
format!("failed to execute swapoff {source}"),
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(WipeError::new(
|
||||
exit_code::UNMOUNT_FAILED,
|
||||
format!("swapoff {source} failed: {stderr}"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_unmount_empty() {
|
||||
let mounts: Vec<MountInfo> = vec![];
|
||||
// Should succeed with no mounts
|
||||
let res = unmount_all(&mounts, false);
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables)]
|
||||
use crate::cli::Method;
|
||||
use crate::device::inspect::DeviceInfo;
|
||||
use crate::mount::discover::MountInfo;
|
||||
|
||||
pub fn print_warning(info: &DeviceInfo, method: Method, passes: u32) {
|
||||
eprintln!("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
|
||||
eprintln!("WARNING");
|
||||
eprintln!("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
|
||||
eprintln!();
|
||||
eprintln!("ALL DATA on this device will be permanently destroyed.");
|
||||
eprintln!();
|
||||
eprintln!("Device:");
|
||||
eprintln!(" {}", info.path.display());
|
||||
eprintln!();
|
||||
if let Some(model) = &info.model {
|
||||
if !model.is_empty() {
|
||||
eprintln!("Model:");
|
||||
eprintln!(" {model}");
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
if let Some(serial) = &info.serial {
|
||||
if !serial.is_empty() {
|
||||
eprintln!("Serial:");
|
||||
eprintln!(" {serial}");
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
if let Some(size) = info.size_bytes {
|
||||
eprintln!("Size:");
|
||||
// Use format from inspect
|
||||
let human = crate::device::inspect::format_human_size(size);
|
||||
eprintln!(" {human} ({size} bytes)");
|
||||
eprintln!();
|
||||
}
|
||||
eprintln!("Method:");
|
||||
eprintln!(" {method}");
|
||||
eprintln!();
|
||||
eprintln!("Passes:");
|
||||
eprintln!(" {passes}");
|
||||
eprintln!();
|
||||
eprintln!("This operation cannot be undone.");
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
pub fn print_dry_run(
|
||||
info: &DeviceInfo,
|
||||
mounts: &[MountInfo],
|
||||
method: Method,
|
||||
passes: u32,
|
||||
buffer_size: usize,
|
||||
will_unmount: bool,
|
||||
) {
|
||||
println!("Device");
|
||||
println!(" Path {}", info.path.display());
|
||||
println!(
|
||||
" Type {}",
|
||||
if info.rota == Some(true) {
|
||||
"HDD"
|
||||
} else if info.rota == Some(false) {
|
||||
"SSD/NVMe"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
);
|
||||
if let Some(size) = info.size_bytes {
|
||||
println!(
|
||||
" Size {}",
|
||||
crate::device::inspect::format_human_size(size)
|
||||
);
|
||||
}
|
||||
if let Some(model) = &info.model {
|
||||
if !model.is_empty() {
|
||||
println!(" Model {model}");
|
||||
}
|
||||
}
|
||||
if let Some(serial) = &info.serial {
|
||||
if !serial.is_empty() {
|
||||
println!(" Serial {serial}");
|
||||
}
|
||||
}
|
||||
println!(
|
||||
" Rotational {}",
|
||||
info.rota
|
||||
.map(|v| if v { "yes" } else { "no" })
|
||||
.unwrap_or("unknown")
|
||||
);
|
||||
if let Some(tran) = &info.tran {
|
||||
println!(" Transport {tran}");
|
||||
}
|
||||
println!();
|
||||
println!("Partitions");
|
||||
if info.partitions.is_empty() {
|
||||
println!(" (none)");
|
||||
} else {
|
||||
for p in &info.partitions {
|
||||
let mp = if p.mountpoints.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" -> {}", p.mountpoints.join(", "))
|
||||
};
|
||||
println!(" {}{}", p.path.display(), mp);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
println!("Mounted");
|
||||
if mounts.is_empty() {
|
||||
println!(" (none)");
|
||||
} else {
|
||||
for m in mounts {
|
||||
println!(" {} -> {}", m.source, m.target);
|
||||
}
|
||||
}
|
||||
if will_unmount && !mounts.is_empty() {
|
||||
println!();
|
||||
println!("Action");
|
||||
for m in mounts {
|
||||
if m.target == "[SWAP]" {
|
||||
println!(" swapoff {}", m.source);
|
||||
} else {
|
||||
println!(" unmount {}", m.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!();
|
||||
println!("Method");
|
||||
println!(" {method}");
|
||||
println!();
|
||||
println!("Passes");
|
||||
println!(" {passes}");
|
||||
println!();
|
||||
println!("Buffer");
|
||||
println!(
|
||||
" {}",
|
||||
crate::device::inspect::format_human_size(buffer_size as u64)
|
||||
);
|
||||
println!();
|
||||
println!("Estimated operation");
|
||||
println!(" destructive: YES");
|
||||
println!();
|
||||
println!("DRY RUN");
|
||||
println!("No data will be modified.");
|
||||
}
|
||||
|
||||
pub fn print_result_human(
|
||||
success: bool,
|
||||
device: &str,
|
||||
method: &str,
|
||||
passes: u32,
|
||||
verification: bool,
|
||||
verified: bool,
|
||||
) {
|
||||
if success {
|
||||
println!("Wipe completed successfully");
|
||||
println!(" Device: {device}");
|
||||
println!(" Method: {method}");
|
||||
println!(" Passes: {passes}");
|
||||
if verification {
|
||||
if verified {
|
||||
println!(" Verification: OVERWRITE_VERIFIED");
|
||||
} else {
|
||||
println!(" Verification: FAILED");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Wipe failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables)]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonOutput {
|
||||
pub device: String,
|
||||
#[serde(rename = "type")]
|
||||
pub dev_type: String,
|
||||
pub size: Option<u64>,
|
||||
pub method: String,
|
||||
pub passes: u32,
|
||||
pub verification: bool,
|
||||
pub result: String,
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonError {
|
||||
pub device: String,
|
||||
pub success: bool,
|
||||
pub error: JsonErrorDetail,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonErrorDetail {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub exit_code: i32,
|
||||
}
|
||||
|
||||
pub fn code_to_string(code: i32) -> &'static str {
|
||||
match code {
|
||||
0 => "SUCCESS",
|
||||
1 => "GENERIC_ERROR",
|
||||
2 => "INVALID_ARGUMENTS",
|
||||
3 => "PERMISSION_DENIED",
|
||||
4 => "TARGET_NOT_FOUND",
|
||||
5 => "NOT_A_BLOCK_DEVICE",
|
||||
6 => "MOUNTED",
|
||||
7 => "ACTIVE_DEPENDENCY",
|
||||
8 => "RUNNING_SYSTEM_DEVICE",
|
||||
9 => "UNSUPPORTED_METHOD",
|
||||
10 => "HARDWARE_CAPABILITY_UNAVAILABLE",
|
||||
11 => "UNMOUNT_FAILED",
|
||||
12 => "OVERWRITE_FAILED",
|
||||
13 => "VERIFICATION_FAILED",
|
||||
14 => "EXTERNAL_COMMAND_FAILED",
|
||||
15 => "INTERRUPTED",
|
||||
_ => "UNKNOWN",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn output_json(
|
||||
device: &str,
|
||||
dev_type: &str,
|
||||
size: Option<u64>,
|
||||
method: &str,
|
||||
passes: u32,
|
||||
verification: bool,
|
||||
verified: bool,
|
||||
) {
|
||||
let result = if verification {
|
||||
if verified {
|
||||
"overwrite_verified"
|
||||
} else {
|
||||
"verification_failed"
|
||||
}
|
||||
} else if method.contains("sanitize") || method.contains("crypto") || method.contains("discard")
|
||||
{
|
||||
"hardware_erase_completed"
|
||||
} else {
|
||||
"overwrite_completed"
|
||||
};
|
||||
let out = JsonOutput {
|
||||
device: device.to_string(),
|
||||
dev_type: dev_type.to_string(),
|
||||
size,
|
||||
method: method.to_string(),
|
||||
passes,
|
||||
verification,
|
||||
result: result.to_string(),
|
||||
success: true,
|
||||
};
|
||||
println!("{}", serde_json::to_string_pretty(&out).unwrap());
|
||||
}
|
||||
|
||||
pub fn output_json_error(device: &str, code: i32, message: &str) {
|
||||
let err = JsonError {
|
||||
device: device.to_string(),
|
||||
success: false,
|
||||
error: JsonErrorDetail {
|
||||
code: code_to_string(code).to_string(),
|
||||
message: message.to_string(),
|
||||
exit_code: code,
|
||||
},
|
||||
};
|
||||
println!("{}", serde_json::to_string_pretty(&err).unwrap());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_code_to_string() {
|
||||
assert_eq!(code_to_string(8), "RUNNING_SYSTEM_DEVICE");
|
||||
assert_eq!(code_to_string(0), "SUCCESS");
|
||||
assert_eq!(code_to_string(15), "INTERRUPTED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_serialization() {
|
||||
let out = JsonOutput {
|
||||
device: "/dev/sdb".to_string(),
|
||||
dev_type: "hdd".to_string(),
|
||||
size: Some(4000787030016),
|
||||
method: "zero".to_string(),
|
||||
passes: 3,
|
||||
verification: true,
|
||||
result: "overwrite_verified".to_string(),
|
||||
success: true,
|
||||
};
|
||||
let s = serde_json::to_string(&out).unwrap();
|
||||
assert!(s.contains("overwrite_verified"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_error_serialization() {
|
||||
let err = JsonError {
|
||||
device: "/dev/sda".to_string(),
|
||||
success: false,
|
||||
error: JsonErrorDetail {
|
||||
code: "RUNNING_SYSTEM_DEVICE".to_string(),
|
||||
message: "target device contains the running system".to_string(),
|
||||
exit_code: 8,
|
||||
},
|
||||
};
|
||||
let s = serde_json::to_string(&err).unwrap();
|
||||
assert!(s.contains("RUNNING_SYSTEM_DEVICE"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#![allow(unused_imports, dead_code)]
|
||||
pub mod human;
|
||||
pub mod json;
|
||||
|
||||
pub use human::{print_dry_run, print_result_human, print_warning};
|
||||
pub use json::{output_json, output_json_error, JsonError, JsonOutput};
|
||||
@@ -0,0 +1,8 @@
|
||||
#![allow(unused_imports, dead_code)]
|
||||
pub mod overwrite;
|
||||
pub mod progress;
|
||||
pub mod verify;
|
||||
|
||||
pub use overwrite::{overwrite_device, OverwriteOptions, Pattern};
|
||||
pub use progress::ProgressReporter;
|
||||
pub use verify::verify_device;
|
||||
@@ -0,0 +1,326 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables)]
|
||||
use crate::cli::Method;
|
||||
use crate::error::{exit_code, WipeError};
|
||||
use crate::wipe::progress::ProgressReporter;
|
||||
use rand::{RngCore, SeedableRng};
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Pattern {
|
||||
Zero,
|
||||
Ones,
|
||||
Alternating,
|
||||
Random,
|
||||
}
|
||||
|
||||
impl Pattern {
|
||||
pub fn from_method(m: Method) -> Self {
|
||||
match m {
|
||||
Method::Zero | Method::Auto => Self::Zero,
|
||||
Method::Ones => Self::Ones,
|
||||
Method::Alternating => Self::Alternating,
|
||||
Method::Random => Self::Random,
|
||||
_ => Self::Zero,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OverwriteOptions {
|
||||
pub pattern: Pattern,
|
||||
pub passes: u32,
|
||||
pub buffer_size: usize,
|
||||
pub verify: bool,
|
||||
pub do_sync: bool,
|
||||
pub json: bool,
|
||||
pub verbose: bool,
|
||||
pub seed: Option<u64>,
|
||||
}
|
||||
|
||||
/// Overwrite device with given options. Handles SIGINT via atomic flag.
|
||||
pub fn overwrite_device(
|
||||
device: &Path,
|
||||
size_bytes: u64,
|
||||
opts: &OverwriteOptions,
|
||||
interrupted: &Arc<AtomicBool>,
|
||||
) -> Result<(), WipeError> {
|
||||
for pass in 1..=opts.passes {
|
||||
if interrupted.load(Ordering::SeqCst) {
|
||||
return Err(WipeError::new(
|
||||
exit_code::INTERRUPTED,
|
||||
format!("interrupted at pass {pass}/{}", opts.passes),
|
||||
));
|
||||
}
|
||||
|
||||
let reporter = ProgressReporter::new(size_bytes, pass, opts.passes, opts.json);
|
||||
if !opts.json {
|
||||
if opts.passes > 1 {
|
||||
eprintln!(
|
||||
"Pass {}/{}: {:?} overwrite",
|
||||
pass, opts.passes, opts.pattern
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"Pass {}/{}: overwriting with {:?}",
|
||||
pass, opts.passes, opts.pattern
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Choose seed per pass for random
|
||||
let seed = opts.seed.unwrap_or(0xDEADBEEF_C0FFEE00 + pass as u64);
|
||||
|
||||
single_pass(device, size_bytes, opts, pass, seed, &reporter, interrupted)?;
|
||||
|
||||
reporter.finish();
|
||||
|
||||
if opts.do_sync {
|
||||
sync_device(device, opts.verbose)?;
|
||||
}
|
||||
|
||||
if interrupted.load(Ordering::SeqCst) {
|
||||
return Err(WipeError::new(
|
||||
exit_code::INTERRUPTED,
|
||||
format!("interrupted after pass {pass}"),
|
||||
));
|
||||
}
|
||||
|
||||
// Verify if requested (per pass verification for overwrite methods)
|
||||
if opts.verify {
|
||||
crate::wipe::verify::verify_device(
|
||||
device,
|
||||
size_bytes,
|
||||
opts.pattern,
|
||||
seed,
|
||||
opts.buffer_size,
|
||||
opts.json,
|
||||
verbose_flag(opts.verbose),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
if opts.do_sync {
|
||||
sync_device(device, opts.verbose)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verbose_flag(v: bool) -> bool {
|
||||
v
|
||||
}
|
||||
|
||||
fn single_pass(
|
||||
device: &Path,
|
||||
size_bytes: u64,
|
||||
opts: &OverwriteOptions,
|
||||
pass: u32,
|
||||
seed: u64,
|
||||
reporter: &ProgressReporter,
|
||||
interrupted: &Arc<AtomicBool>,
|
||||
) -> Result<(), WipeError> {
|
||||
let mut file = OpenOptions::new().write(true).open(device).map_err(|e| {
|
||||
WipeError::with_source(
|
||||
exit_code::OVERWRITE_FAILED,
|
||||
format!("failed to open {} for writing", device.display()),
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
|
||||
// Ensure we start at 0
|
||||
file.seek(SeekFrom::Start(0))
|
||||
.map_err(|e| WipeError::with_source(exit_code::OVERWRITE_FAILED, "seek failed", e))?;
|
||||
|
||||
let mut rng = ChaCha20Rng::seed_from_u64(seed);
|
||||
let mut buffer = vec![0u8; opts.buffer_size];
|
||||
let mut written: u64 = 0;
|
||||
|
||||
while written < size_bytes {
|
||||
if interrupted.load(Ordering::SeqCst) {
|
||||
return Err(WipeError::new(
|
||||
exit_code::INTERRUPTED,
|
||||
format!(
|
||||
"interrupted at {} / {} bytes (pass {pass})",
|
||||
written, size_bytes
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let remaining = size_bytes - written;
|
||||
let chunk = std::cmp::min(buffer.len() as u64, remaining) as usize;
|
||||
let buf = &mut buffer[..chunk];
|
||||
|
||||
match opts.pattern {
|
||||
Pattern::Zero => buf.fill(0x00),
|
||||
Pattern::Ones => buf.fill(0xFF),
|
||||
Pattern::Alternating => {
|
||||
for (i, b) in buf.iter_mut().enumerate() {
|
||||
// Use global offset to keep alternating pattern consistent across chunks
|
||||
let global_offset = written + i as u64;
|
||||
*b = if global_offset.is_multiple_of(2) {
|
||||
0xAA
|
||||
} else {
|
||||
0x55
|
||||
};
|
||||
}
|
||||
}
|
||||
Pattern::Random => {
|
||||
rng.fill_bytes(buf);
|
||||
}
|
||||
}
|
||||
|
||||
file.write_all(buf).map_err(|e| {
|
||||
WipeError::with_source(
|
||||
exit_code::OVERWRITE_FAILED,
|
||||
format!("write failed at offset {written}"),
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
written += chunk as u64;
|
||||
reporter.inc(chunk as u64);
|
||||
}
|
||||
|
||||
file.flush()
|
||||
.map_err(|e| WipeError::with_source(exit_code::OVERWRITE_FAILED, "flush failed", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_device(device: &Path, verbose: bool) -> Result<(), WipeError> {
|
||||
if verbose {
|
||||
eprintln!("syncing {}", device.display());
|
||||
}
|
||||
// Use libc syncfs or sync command
|
||||
// Try sync via `sync` command for the device? But we can just call libc::sync()
|
||||
// To ensure device data is flushed, we already did flush + we can open and fsync
|
||||
let file = OpenOptions::new().read(true).open(device).map_err(|e| {
|
||||
WipeError::with_source(exit_code::OVERWRITE_FAILED, "open for sync failed", e)
|
||||
})?;
|
||||
// Use nix sync? libc::fsync
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let fd = file.as_raw_fd();
|
||||
let ret = unsafe { libc::fsync(fd) };
|
||||
if ret != 0 {
|
||||
return Err(WipeError::new(
|
||||
exit_code::OVERWRITE_FAILED,
|
||||
format!("fsync failed: {}", std::io::Error::last_os_error()),
|
||||
));
|
||||
}
|
||||
// Also global sync via command to ensure
|
||||
let _ = std::process::Command::new("sync").output();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
use std::io::{Seek, SeekFrom};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
fn do_overwrite_and_verify(pattern: Pattern) {
|
||||
let mut tmp = NamedTempFile::new().unwrap();
|
||||
let size = 1024 * 1024; // 1 MiB
|
||||
// Pre-fill with non-zero to ensure overwrite works
|
||||
tmp.write_all(&vec![0xAB; size]).unwrap();
|
||||
tmp.flush().unwrap();
|
||||
let path = tmp.path().to_path_buf();
|
||||
// Need to keep file handle open? Use path
|
||||
let opts = OverwriteOptions {
|
||||
pattern,
|
||||
passes: 1,
|
||||
buffer_size: 64 * 1024,
|
||||
verify: false,
|
||||
do_sync: false,
|
||||
json: true,
|
||||
verbose: false,
|
||||
seed: Some(42),
|
||||
};
|
||||
let interrupted = Arc::new(AtomicBool::new(false));
|
||||
overwrite_device(&path, size as u64, &opts, &interrupted).unwrap();
|
||||
|
||||
// Verify content
|
||||
let mut read_back = vec![0u8; size];
|
||||
let mut f = std::fs::File::open(&path).unwrap();
|
||||
f.read_exact(&mut read_back).unwrap();
|
||||
match pattern {
|
||||
Pattern::Zero => assert!(read_back.iter().all(|&b| b == 0x00)),
|
||||
Pattern::Ones => assert!(read_back.iter().all(|&b| b == 0xFF)),
|
||||
Pattern::Alternating => {
|
||||
for (i, &b) in read_back.iter().enumerate() {
|
||||
let expected = if i % 2 == 0 { 0xAA } else { 0x55 };
|
||||
assert_eq!(b, expected, "mismatch at {i}");
|
||||
}
|
||||
}
|
||||
Pattern::Random => {
|
||||
let mut expected_rng = ChaCha20Rng::seed_from_u64(42);
|
||||
let mut expected = vec![0u8; size];
|
||||
// Need to generate same as single_pass: chunked fill
|
||||
let mut offset = 0;
|
||||
while offset < size {
|
||||
let chunk = std::cmp::min(64 * 1024, size - offset);
|
||||
expected_rng.fill_bytes(&mut expected[offset..offset + chunk]);
|
||||
offset += chunk;
|
||||
}
|
||||
// For our test, overwrite used seed 42, not 43
|
||||
// Actually overwrite_device uses seed = opts.seed.unwrap_or(... ) where Some(42) => 42, then single_pass called with that seed. So expected seed 42.
|
||||
assert_eq!(read_back, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_overwrite() {
|
||||
do_overwrite_and_verify(Pattern::Zero);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ones_overwrite() {
|
||||
do_overwrite_and_verify(Pattern::Ones);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alternating_overwrite() {
|
||||
do_overwrite_and_verify(Pattern::Alternating);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_random_overwrite_deterministic() {
|
||||
do_overwrite_and_verify(Pattern::Random);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_random_not_repeating_buffer() {
|
||||
// Ensure two consecutive chunks are different
|
||||
let mut tmp = NamedTempFile::new().unwrap();
|
||||
let size = 256 * 1024; // 256 KiB, 4 chunks of 64K
|
||||
tmp.write_all(&vec![0u8; size]).unwrap();
|
||||
tmp.flush().unwrap();
|
||||
let path = tmp.path().to_path_buf();
|
||||
let opts = OverwriteOptions {
|
||||
pattern: Pattern::Random,
|
||||
passes: 1,
|
||||
buffer_size: 64 * 1024,
|
||||
verify: false,
|
||||
do_sync: false,
|
||||
json: true,
|
||||
verbose: false,
|
||||
seed: Some(12345),
|
||||
};
|
||||
let interrupted = Arc::new(AtomicBool::new(false));
|
||||
overwrite_device(&path, size as u64, &opts, &interrupted).unwrap();
|
||||
let mut data = vec![0u8; size];
|
||||
std::fs::File::open(&path)
|
||||
.unwrap()
|
||||
.read_exact(&mut data)
|
||||
.unwrap();
|
||||
let first = &data[0..64 * 1024];
|
||||
let second = &data[64 * 1024..128 * 1024];
|
||||
assert_ne!(first, second, "random should not repeat same buffer");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables)]
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct ProgressReporter {
|
||||
pub bar: Option<ProgressBar>,
|
||||
pub total_bytes: u64,
|
||||
pub start: Instant,
|
||||
pub pass: u32,
|
||||
pub total_passes: u32,
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
impl ProgressReporter {
|
||||
pub fn new(total_bytes: u64, pass: u32, total_passes: u32, json: bool) -> Self {
|
||||
let bar = if json || total_bytes == 0 {
|
||||
None
|
||||
} else {
|
||||
let pb = ProgressBar::new(total_bytes);
|
||||
let style = ProgressStyle::default_bar()
|
||||
.template("[{bar:40.cyan/blue}] {percent}% {bytes}/{total_bytes} {bytes_per_sec} ETA {eta}")
|
||||
.unwrap()
|
||||
.progress_chars("=> ");
|
||||
pb.set_style(style);
|
||||
Some(pb)
|
||||
};
|
||||
Self {
|
||||
bar,
|
||||
total_bytes,
|
||||
start: Instant::now(),
|
||||
pass,
|
||||
total_passes,
|
||||
json,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_message(&self, msg: &str) {
|
||||
if let Some(pb) = &self.bar {
|
||||
pb.set_message(msg.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inc(&self, delta: u64) {
|
||||
if let Some(pb) = &self.bar {
|
||||
pb.inc(delta);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&self) {
|
||||
if let Some(pb) = &self.bar {
|
||||
pb.finish_with_message(format!(
|
||||
"Pass {}/{} completed",
|
||||
self.pass, self.total_passes
|
||||
));
|
||||
} else if !self.json {
|
||||
println!("Pass {}/{} completed", self.pass, self.total_passes);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish_and_clear(&self) {
|
||||
if let Some(pb) = &self.bar {
|
||||
pb.finish_and_clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn elapsed(&self) -> std::time::Duration {
|
||||
self.start.elapsed()
|
||||
}
|
||||
|
||||
pub fn bytes_per_sec(&self, bytes_done: u64) -> f64 {
|
||||
let elapsed = self.elapsed().as_secs_f64();
|
||||
if elapsed > 0.0 {
|
||||
bytes_done as f64 / elapsed
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_eta(total: u64, done: u64, bps: f64) -> String {
|
||||
if bps <= 0.0 || done >= total {
|
||||
return "--:--:--".to_string();
|
||||
}
|
||||
let remaining = (total - done) as f64 / bps;
|
||||
let secs = remaining as u64;
|
||||
let h = secs / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
let s = secs % 60;
|
||||
format!("{h:02}:{m:02}:{s:02}")
|
||||
}
|
||||
|
||||
pub fn human_speed(bps: f64) -> String {
|
||||
if bps >= 1024.0 * 1024.0 * 1024.0 {
|
||||
format!("{:.2} GiB/s", bps / (1024.0 * 1024.0 * 1024.0))
|
||||
} else if bps >= 1024.0 * 1024.0 {
|
||||
format!("{:.2} MiB/s", bps / (1024.0 * 1024.0))
|
||||
} else if bps >= 1024.0 {
|
||||
format!("{:.2} KiB/s", bps / 1024.0)
|
||||
} else {
|
||||
format!("{bps:.0} B/s")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_format_eta() {
|
||||
let eta = format_eta(1000, 500, 100.0);
|
||||
assert_eq!(eta, "00:00:05");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_human_speed() {
|
||||
assert!(human_speed(1024.0 * 1024.0 * 181.0).contains("MiB/s"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables)]
|
||||
use crate::error::{exit_code, WipeError};
|
||||
use crate::wipe::overwrite::Pattern;
|
||||
use rand::{RngCore, SeedableRng};
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
pub fn verify_device(
|
||||
device: &Path,
|
||||
size_bytes: u64,
|
||||
pattern: Pattern,
|
||||
seed: u64,
|
||||
buffer_size: usize,
|
||||
json: bool,
|
||||
verbose: bool,
|
||||
) -> Result<(), WipeError> {
|
||||
if !json {
|
||||
eprintln!("Verifying overwrite ({:?})...", pattern);
|
||||
}
|
||||
let mut file = OpenOptions::new().read(true).open(device).map_err(|e| {
|
||||
WipeError::with_source(
|
||||
exit_code::VERIFICATION_FAILED,
|
||||
format!("failed to open {} for verification", device.display()),
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
file.seek(SeekFrom::Start(0)).map_err(|e| {
|
||||
WipeError::with_source(exit_code::VERIFICATION_FAILED, "seek for verify", e)
|
||||
})?;
|
||||
|
||||
let mut rng = ChaCha20Rng::seed_from_u64(seed);
|
||||
let mut buffer = vec![0u8; buffer_size];
|
||||
let mut expected = vec![0u8; buffer_size];
|
||||
let mut offset: u64 = 0;
|
||||
|
||||
while offset < size_bytes {
|
||||
if verbose && offset.is_multiple_of((10 * 1024 * 1024) as u64) {
|
||||
eprintln!("verify {offset} / {size_bytes}");
|
||||
}
|
||||
let remaining = size_bytes - offset;
|
||||
let chunk = std::cmp::min(buffer.len() as u64, remaining) as usize;
|
||||
let buf = &mut buffer[..chunk];
|
||||
let exp = &mut expected[..chunk];
|
||||
|
||||
// Generate expected
|
||||
match pattern {
|
||||
Pattern::Zero => exp.fill(0x00),
|
||||
Pattern::Ones => exp.fill(0xFF),
|
||||
Pattern::Alternating => {
|
||||
for (i, b) in exp.iter_mut().enumerate() {
|
||||
let global = offset + i as u64;
|
||||
*b = if global.is_multiple_of(2) { 0xAA } else { 0x55 };
|
||||
}
|
||||
}
|
||||
Pattern::Random => {
|
||||
rng.fill_bytes(exp);
|
||||
}
|
||||
}
|
||||
|
||||
file.read_exact(buf).map_err(|e| {
|
||||
WipeError::with_source(
|
||||
exit_code::VERIFICATION_FAILED,
|
||||
format!("read failed at offset {offset}"),
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
|
||||
if buf != exp {
|
||||
// Find first mismatch for diagnostics
|
||||
let mismatch = buf
|
||||
.iter()
|
||||
.zip(exp.iter())
|
||||
.position(|(a, b)| a != b)
|
||||
.unwrap_or(0);
|
||||
return Err(WipeError::new(
|
||||
exit_code::VERIFICATION_FAILED,
|
||||
format!(
|
||||
"verification failed at offset {} (mismatch at +{}: expected {:02x} got {:02x})",
|
||||
offset,
|
||||
mismatch,
|
||||
exp[mismatch],
|
||||
buf[mismatch]
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
offset += chunk as u64;
|
||||
}
|
||||
|
||||
if !json {
|
||||
eprintln!("OVERWRITE_VERIFIED");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
fn write_pattern(path: &Path, pattern: Pattern, size: usize, seed: u64) {
|
||||
let mut f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
|
||||
let mut rng = ChaCha20Rng::seed_from_u64(seed);
|
||||
let mut written = 0;
|
||||
let mut buf = vec![0u8; 64 * 1024];
|
||||
while written < size {
|
||||
let chunk = std::cmp::min(buf.len(), size - written);
|
||||
let b = &mut buf[..chunk];
|
||||
match pattern {
|
||||
Pattern::Zero => b.fill(0),
|
||||
Pattern::Ones => b.fill(0xFF),
|
||||
Pattern::Alternating => {
|
||||
for (i, v) in b.iter_mut().enumerate() {
|
||||
let global = written + i;
|
||||
*v = if global.is_multiple_of(2) { 0xAA } else { 0x55 };
|
||||
}
|
||||
}
|
||||
Pattern::Random => rng.fill_bytes(b),
|
||||
}
|
||||
f.write_all(b).unwrap();
|
||||
written += chunk;
|
||||
}
|
||||
f.flush().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_zero_pass() {
|
||||
let tmp = NamedTempFile::new().unwrap();
|
||||
let p = tmp.path();
|
||||
// Ensure size
|
||||
tmp.as_file().set_len(1024 * 1024).unwrap();
|
||||
write_pattern(p, Pattern::Zero, 1024 * 1024, 0);
|
||||
verify_device(p, 1024 * 1024, Pattern::Zero, 0, 64 * 1024, true, false).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_zero_fail() {
|
||||
let tmp = NamedTempFile::new().unwrap();
|
||||
let p = tmp.path();
|
||||
tmp.as_file().set_len(64 * 1024).unwrap();
|
||||
write_pattern(p, Pattern::Zero, 64 * 1024, 0);
|
||||
// Corrupt one byte
|
||||
{
|
||||
let mut f = std::fs::OpenOptions::new().write(true).open(p).unwrap();
|
||||
use std::io::Seek;
|
||||
f.seek(std::io::SeekFrom::Start(100)).unwrap();
|
||||
f.write_all(&[0xFF]).unwrap();
|
||||
}
|
||||
let res = verify_device(p, 64 * 1024, Pattern::Zero, 0, 64 * 1024, true, false);
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_random_pass() {
|
||||
let tmp = NamedTempFile::new().unwrap();
|
||||
let p = tmp.path();
|
||||
tmp.as_file().set_len(512 * 1024).unwrap();
|
||||
write_pattern(p, Pattern::Random, 512 * 1024, 42);
|
||||
verify_device(p, 512 * 1024, Pattern::Random, 42, 64 * 1024, true, false).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_random_fail_wrong_seed() {
|
||||
let tmp = NamedTempFile::new().unwrap();
|
||||
let p = tmp.path();
|
||||
tmp.as_file().set_len(64 * 1024).unwrap();
|
||||
write_pattern(p, Pattern::Random, 64 * 1024, 42);
|
||||
let res = verify_device(p, 64 * 1024, Pattern::Random, 43, 64 * 1024, true, false);
|
||||
assert!(res.is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user