use std::time::Duration;
use crate::confirm::{ConfirmOutcome, ConfirmRequest, Confirmer};
use crate::error::CoreError;
use crate::scope::Origin;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeviceInfo {
pub node: String,
pub name: String,
pub total_bytes: u64,
pub used_bytes: Option<u64>,
pub removable: bool,
pub ejectable: bool,
pub internal: bool,
pub boot: bool,
pub mounted: bool,
}
impl DeviceInfo {
#[must_use]
pub fn human_size(&self) -> String {
human_bytes(self.total_bytes)
}
#[must_use]
pub fn non_empty(&self) -> bool {
self.used_bytes.map(|u| u > 0).unwrap_or(self.mounted)
}
}
pub trait Formatter {
fn probe(&self, node: &str) -> Result<DeviceInfo, CoreError>;
fn list_devices(&self) -> Result<Vec<DeviceInfo>, CoreError>;
fn erase(&self, node: &str, label: &str) -> Result<(), CoreError>;
}
pub fn assert_eraseable_target(info: &DeviceInfo) -> Result<(), CoreError> {
if info.boot {
return Err(CoreError::Format(format!(
"{} backs the boot/system volume — refusing to format it",
info.node
)));
}
if info.internal && !info.removable && !info.ejectable {
return Err(CoreError::Format(format!(
"{} is an internal fixed disk — kovra only formats external, removable, or ejectable media",
info.node
)));
}
if !info.removable && !info.ejectable {
return Err(CoreError::Format(format!(
"{} could not be confirmed as external removable/ejectable media — refusing to format it",
info.node
)));
}
Ok(())
}
#[must_use]
fn same_device(a: &DeviceInfo, b: &DeviceInfo) -> bool {
a.total_bytes == b.total_bytes
&& a.removable == b.removable
&& a.ejectable == b.ejectable
&& a.internal == b.internal
&& a.boot == b.boot
}
#[must_use]
pub fn eligible_targets(devices: Vec<DeviceInfo>) -> Vec<DeviceInfo> {
devices
.into_iter()
.filter(|d| assert_eraseable_target(d).is_ok())
.collect()
}
#[must_use]
pub fn wipe_headline(info: &DeviceInfo) -> String {
let name = if info.name.trim().is_empty() {
"unnamed".to_string()
} else {
info.name.clone()
};
let mut headline = format!(
"ERASE {} (\"{}\", {}) — ALL DATA ON THIS DEVICE WILL BE ERASED",
info.node,
name,
info.human_size()
);
if info.non_empty() {
match info.used_bytes {
Some(u) if u > 0 => {
headline.push_str(&format!(" — it is NOT empty (~{} in use)", human_bytes(u)));
}
_ => headline.push_str(" — it has a mounted volume with existing data"),
}
}
headline
}
pub fn format_removable(
formatter: &dyn Formatter,
confirmer: &dyn Confirmer,
node: &str,
label: &str,
timeout: Duration,
) -> Result<DeviceInfo, CoreError> {
let info = formatter.probe(node)?;
assert_eraseable_target(&info)?;
let req = ConfirmRequest::for_action(wipe_headline(&info), Origin::Human);
match confirmer.confirm(&req, timeout) {
ConfirmOutcome::Approved => {
let recheck = formatter.probe(node)?;
assert_eraseable_target(&recheck)?;
if !same_device(&info, &recheck) {
return Err(CoreError::Format(format!(
"{node} changed between confirmation and erase — refusing to format it"
)));
}
formatter.erase(node, label)?;
Ok(info)
}
ConfirmOutcome::Denied => Err(CoreError::Format(format!(
"denied — {node} was not formatted"
))),
ConfirmOutcome::TimedOut => Err(CoreError::Format(format!(
"timed out — {node} was not formatted"
))),
}
}
pub struct MockFormatter {
info: DeviceInfo,
devices: Vec<DeviceInfo>,
erased: std::sync::Mutex<Option<(String, String)>>,
erase_fails: bool,
swap_to: Option<DeviceInfo>,
probes: std::sync::atomic::AtomicU32,
}
impl MockFormatter {
#[must_use]
pub fn new(info: DeviceInfo) -> Self {
Self {
devices: vec![info.clone()],
info,
erased: std::sync::Mutex::new(None),
erase_fails: false,
swap_to: None,
probes: std::sync::atomic::AtomicU32::new(0),
}
}
#[must_use]
pub fn swapping(first: DeviceInfo, second: DeviceInfo) -> Self {
Self {
devices: vec![first.clone()],
info: first,
erased: std::sync::Mutex::new(None),
erase_fails: false,
swap_to: Some(second),
probes: std::sync::atomic::AtomicU32::new(0),
}
}
#[must_use]
pub fn with_devices(devices: Vec<DeviceInfo>) -> Self {
let info = devices.first().cloned().unwrap_or(DeviceInfo {
node: String::new(),
name: String::new(),
total_bytes: 0,
used_bytes: None,
removable: false,
ejectable: false,
internal: false,
boot: false,
mounted: false,
});
Self {
info,
devices,
erased: std::sync::Mutex::new(None),
erase_fails: false,
swap_to: None,
probes: std::sync::atomic::AtomicU32::new(0),
}
}
#[must_use]
pub fn failing(info: DeviceInfo) -> Self {
Self {
devices: vec![info.clone()],
info,
erased: std::sync::Mutex::new(None),
erase_fails: true,
swap_to: None,
probes: std::sync::atomic::AtomicU32::new(0),
}
}
#[must_use]
pub fn erased(&self) -> Option<(String, String)> {
self.erased
.lock()
.expect("mock formatter mutex poisoned")
.clone()
}
}
impl Formatter for MockFormatter {
fn probe(&self, node: &str) -> Result<DeviceInfo, CoreError> {
let n = self
.probes
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let mut i = match (&self.swap_to, n) {
(Some(swapped), k) if k >= 1 => swapped.clone(),
_ => self.info.clone(),
};
i.node = node.to_string();
Ok(i)
}
fn list_devices(&self) -> Result<Vec<DeviceInfo>, CoreError> {
Ok(self.devices.clone())
}
fn erase(&self, node: &str, label: &str) -> Result<(), CoreError> {
if self.erase_fails {
return Err(CoreError::Format("mock erase failed".into()));
}
*self.erased.lock().expect("mock formatter mutex poisoned") =
Some((node.to_string(), label.to_string()));
Ok(())
}
}
fn human_bytes(n: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
if n < 1000 {
return format!("{n} B");
}
let mut value = n as f64;
let mut unit = 0;
while value >= 1000.0 && unit < UNITS.len() - 1 {
value /= 1000.0;
unit += 1;
}
format!("{value:.1} {}", UNITS[unit])
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
fn eraseable(node: &str) -> DeviceInfo {
DeviceInfo {
node: node.to_string(),
name: "FIELDKIT".to_string(),
total_bytes: 30_752_000_000,
used_bytes: Some(0),
removable: true,
ejectable: true,
internal: false,
boot: false,
mounted: false,
}
}
struct CountingConfirmer {
outcome: ConfirmOutcome,
calls: AtomicU32,
}
impl CountingConfirmer {
fn new(outcome: ConfirmOutcome) -> Self {
Self {
outcome,
calls: AtomicU32::new(0),
}
}
fn calls(&self) -> u32 {
self.calls.load(Ordering::SeqCst)
}
}
impl Confirmer for CountingConfirmer {
fn confirm(&self, _req: &ConfirmRequest, _t: Duration) -> ConfirmOutcome {
self.calls.fetch_add(1, Ordering::SeqCst);
self.outcome
}
}
#[test]
fn rail_allows_external_removable_or_ejectable_refuses_boot_and_internal_fixed() {
assert!(assert_eraseable_target(&eraseable("/dev/disk4")).is_ok());
let mut usb_ssd = eraseable("/dev/disk4");
usb_ssd.removable = false;
usb_ssd.internal = false;
assert!(
assert_eraseable_target(&usb_ssd).is_ok(),
"external ejectable USB SSD must be eraseable even when Fixed"
);
let mut sd = eraseable("/dev/disk6");
sd.internal = true;
sd.removable = true;
sd.ejectable = false;
assert!(
assert_eraseable_target(&sd).is_ok(),
"an internal-location but removable SD card must be eraseable"
);
let mut system = eraseable("/dev/disk0");
system.internal = true;
system.removable = false;
system.ejectable = false;
assert!(
assert_eraseable_target(&system).is_err(),
"an internal fixed non-ejectable disk must be refused"
);
let mut boot = eraseable("/dev/disk1");
boot.boot = true;
assert!(assert_eraseable_target(&boot).is_err());
}
#[test]
fn rail_refuses_unreadable_all_false_probe() {
let unreadable = DeviceInfo {
node: "/dev/disk0".to_string(),
name: String::new(),
total_bytes: 0,
used_bytes: None,
removable: false,
ejectable: false,
internal: false, boot: false,
mounted: false,
};
assert!(
assert_eraseable_target(&unreadable).is_err(),
"an all-false (unreadable) probe must fail closed, not be erased"
);
assert!(
eligible_targets(vec![unreadable]).is_empty(),
"an unreadable device is never offered as a target"
);
}
#[test]
fn format_removable_refuses_unreadable_device_without_prompting() {
let unreadable = DeviceInfo {
node: "/dev/disk0".to_string(),
name: String::new(),
total_bytes: 0,
used_bytes: None,
removable: false,
ejectable: false,
internal: false,
boot: false,
mounted: false,
};
let fmt = MockFormatter::new(unreadable);
let confirmer = CountingConfirmer::new(ConfirmOutcome::Approved);
let err = format_removable(&fmt, &confirmer, "/dev/disk0", "KOVRA", Duration::ZERO);
assert!(err.is_err(), "unreadable probe must fail closed");
assert_eq!(confirmer.calls(), 0, "the broker is never consulted");
assert_eq!(fmt.erased(), None, "nothing is erased");
}
#[test]
fn eligible_targets_filters_to_safe_devices() {
let stick = eraseable("/dev/disk4");
let mut system = eraseable("/dev/disk0");
system.internal = true;
system.removable = false;
system.ejectable = false;
let mut boot = eraseable("/dev/disk1");
boot.boot = true;
let elig = eligible_targets(vec![stick.clone(), system, boot]);
assert_eq!(elig.len(), 1, "only the safe stick is eligible");
assert_eq!(elig[0].node, "/dev/disk4");
}
#[test]
fn format_removable_approved_erases() {
let fmt = MockFormatter::new(eraseable("/dev/disk4"));
let confirmer = CountingConfirmer::new(ConfirmOutcome::Approved);
let info =
format_removable(&fmt, &confirmer, "/dev/disk4", "KOVRA", Duration::ZERO).unwrap();
assert_eq!(info.node, "/dev/disk4");
assert_eq!(confirmer.calls(), 1, "the broker is consulted exactly once");
assert_eq!(
fmt.erased(),
Some(("/dev/disk4".to_string(), "KOVRA".to_string()))
);
}
#[test]
fn format_removable_refuses_if_device_swapped_after_confirmation() {
let approved = eraseable("/dev/disk4");
let mut swapped = eraseable("/dev/disk4"); swapped.total_bytes = 500_000_000_000; let fmt = MockFormatter::swapping(approved, swapped);
let confirmer = CountingConfirmer::new(ConfirmOutcome::Approved);
let err = format_removable(&fmt, &confirmer, "/dev/disk4", "KOVRA", Duration::ZERO);
assert!(
err.is_err(),
"a device swap after confirmation must be refused"
);
assert_eq!(fmt.erased(), None, "nothing is erased on drift");
}
#[test]
fn format_removable_denied_or_timeout_fails_closed() {
for outcome in [ConfirmOutcome::Denied, ConfirmOutcome::TimedOut] {
let fmt = MockFormatter::new(eraseable("/dev/disk4"));
let confirmer = CountingConfirmer::new(outcome);
let err = format_removable(&fmt, &confirmer, "/dev/disk4", "KOVRA", Duration::ZERO);
assert!(err.is_err(), "{outcome:?} must fail closed");
assert_eq!(fmt.erased(), None, "{outcome:?} must not erase");
}
}
#[test]
fn unsafe_target_refused_without_prompting() {
let mut system = eraseable("/dev/disk0");
system.internal = true;
system.removable = false;
system.ejectable = false;
let fmt = MockFormatter::new(system);
let confirmer = CountingConfirmer::new(ConfirmOutcome::Approved);
let err = format_removable(&fmt, &confirmer, "/dev/disk0", "KOVRA", Duration::ZERO);
assert!(err.is_err(), "internal fixed disk must be refused");
assert_eq!(
confirmer.calls(),
0,
"the broker must NOT be consulted for an unsafe target"
);
assert_eq!(fmt.erased(), None);
}
#[test]
fn headline_carries_authoritative_fields_and_content_warning() {
let mut info = eraseable("/dev/disk4");
info.used_bytes = Some(12_000_000_000);
let h = wipe_headline(&info);
assert!(h.contains("/dev/disk4"), "names the device: {h}");
assert!(h.contains("FIELDKIT"), "names the volume: {h}");
assert!(h.contains("GB"), "shows the size: {h}");
assert!(h.contains("ALL DATA"), "warns of erasure: {h}");
assert!(h.contains("NOT empty"), "warns about content: {h}");
let empty = eraseable("/dev/disk4"); let h2 = wipe_headline(&empty);
assert!(
!h2.contains("NOT empty"),
"no content warning when empty: {h2}"
);
}
#[test]
fn human_bytes_uses_si_units() {
assert_eq!(human_bytes(0), "0 B");
assert_eq!(human_bytes(512), "512 B");
assert_eq!(human_bytes(30_752_000_000), "30.8 GB");
}
proptest::proptest! {
#[test]
fn rail_accepts_only_with_positive_external_evidence(
removable: bool,
ejectable: bool,
internal: bool,
boot: bool,
mounted: bool,
total_bytes: u64,
) {
let info = DeviceInfo {
node: "/dev/diskN".to_string(),
name: String::new(),
total_bytes,
used_bytes: None,
removable,
ejectable,
internal,
boot,
mounted,
};
let accepted = assert_eraseable_target(&info).is_ok();
if accepted {
proptest::prop_assert!(removable || ejectable, "accepted with no external evidence");
proptest::prop_assert!(!boot, "accepted the boot disk");
proptest::prop_assert!(!(internal && !removable && !ejectable));
}
let eligible = !eligible_targets(vec![info.clone()]).is_empty();
proptest::prop_assert_eq!(accepted, eligible);
}
}
}