kovra_core/formatter.rs
1//! Removable-media formatter (KOV-40, USB offline-exchange epic §7.3) — the
2//! destructive piece that wipes a USB stick so kovra can build a bootstrap
3//! device (`kovra exchange init`, KOV-41). The OS lives behind a mockable
4//! [`Formatter`] trait; the macOS `diskutil` implementation is `[host]`
5//! (validated on hardware by the human, not by CI — CLAUDE.md rule 4).
6//!
7//! ## Non-negotiable safety rails
8//!
9//! Erasing the wrong disk is irreversible, so the rails are deliberately strict
10//! and live in the OS-independent core where they are fully tested:
11//!
12//! 1. **External + ejectable + non-boot only** — [`assert_eraseable_target`] is a
13//! *hard refusal with no prompt*. An internal/boot/non-ejectable disk never
14//! even reaches the broker; there is no override. (The check is *not*
15//! `RemovableMedia=Yes` — a USB SSD reports `Fixed` yet is a legitimate
16//! target; the safety predicate is internal/boot/ejectable, not media type.)
17//! 2. **Attended broker confirmation** — [`format_removable`] gates the wipe
18//! behind the [`Confirmer`] (Touch ID on `[host]`, file broker otherwise)
19//! with an I16 authoritative headline carrying the device node, name, size,
20//! and `ALL DATA WILL BE ERASED`.
21//! 3. **Content warning** — when the device is non-empty the headline surfaces
22//! that fact (used bytes / a mounted volume) before the human authorizes.
23//!
24//! [`Formatter::erase`] is destructive and must only be reached *through*
25//! [`format_removable`]; callers never invoke it directly.
26
27use std::time::Duration;
28
29use crate::confirm::{ConfirmOutcome, ConfirmRequest, Confirmer};
30use crate::error::CoreError;
31use crate::scope::Origin;
32
33/// What the OS reports about a candidate device, authored by [`Formatter::probe`]
34/// — never from user input. Carries no secret material (I12).
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct DeviceInfo {
37 /// The device node as the OS addresses it (e.g. `/dev/disk4`).
38 pub node: String,
39 /// Human label (volume / media name), best-effort; may be empty.
40 pub name: String,
41 /// Total capacity in bytes (`0` if the OS did not report it).
42 pub total_bytes: u64,
43 /// Bytes in use across mounted volumes, if the OS reported it.
44 pub used_bytes: Option<u64>,
45 /// The device's *media* is removable from its mechanism (SD card, optical),
46 /// as opposed to fixed flash/SSD. Informational only — it is NOT the erase
47 /// safety predicate (a USB SSD reports `Fixed` yet is a perfectly safe,
48 /// intended target). The rail keys on [`Self::ejectable`] + external instead.
49 pub removable: bool,
50 /// The device can be ejected from the running system (external bus). Internal
51 /// disks are not ejectable. This — together with not-internal and not-boot —
52 /// is the actual erase-safety predicate the rail enforces.
53 pub ejectable: bool,
54 /// The device is internal/onboard — the opposite of an external stick.
55 pub internal: bool,
56 /// The device backs the current boot/system volume.
57 pub boot: bool,
58 /// At least one volume on the device is currently mounted.
59 pub mounted: bool,
60}
61
62impl DeviceInfo {
63 /// A human-readable capacity for the I16 headline (never a value).
64 #[must_use]
65 pub fn human_size(&self) -> String {
66 human_bytes(self.total_bytes)
67 }
68
69 /// Whether the device appears to hold data — used to decide whether the
70 /// confirmation headline must carry a content warning.
71 #[must_use]
72 pub fn non_empty(&self) -> bool {
73 self.used_bytes.map(|u| u > 0).unwrap_or(self.mounted)
74 }
75}
76
77/// The OS-format capability, behind a trait so the core logic is tested with a
78/// deterministic mock and the native `diskutil` half is injected at the edge.
79pub trait Formatter {
80 /// Inspect a device *without modifying it*.
81 fn probe(&self, node: &str) -> Result<DeviceInfo, CoreError>;
82
83 /// Enumerate **whole physical** devices the user could pick to format — the
84 /// raw probe list (the CLI applies [`eligible_targets`] to offer only the
85 /// safe ones). Read-only.
86 fn list_devices(&self) -> Result<Vec<DeviceInfo>, CoreError>;
87
88 /// Erase the device and lay down a single empty volume named `label`.
89 /// **Destructive.** Never call this directly — go through
90 /// [`format_removable`], which enforces the safety rails and the broker gate.
91 fn erase(&self, node: &str, label: &str) -> Result<(), CoreError>;
92}
93
94/// Hard safety rail (**no prompt**): refuse the **boot disk** and any **internal,
95/// fixed, non-ejectable** disk; allow everything else (external, removable, or
96/// ejectable media). Anything refused never reaches the confirmation broker.
97/// Erasing the wrong disk is irreversible — this check has no override.
98///
99/// The principle: the catastrophe to prevent is erasing the **system / an
100/// internal fixed** disk. Neither `RemovableMedia` nor `Device Location` alone is
101/// the right predicate:
102/// - A **USB SSD** reports `Removable Media: Fixed` yet is `Internal: No` — a
103/// legitimate external target (caught by `!internal`).
104/// - A **built-in SD card reader** reports `Device Location: Internal` yet
105/// `Removable Media: Removable` — a legitimate removable target (caught by
106/// `removable`).
107/// - The **soldered system SSD** is `Internal` + `Fixed` + non-ejectable — the
108/// one thing we must never wipe (refused by the `internal && !removable &&
109/// !ejectable` clause, and by `boot`).
110///
111/// So a device is eraseable iff it is **not boot** and it carries **positive
112/// evidence** of being external media — its media is **removable** or it is
113/// **ejectable**. This rail proves safety rather than merely failing to prove
114/// danger: a probe that could not read a device's bus/ejectability (malformed,
115/// renamed, or localized `diskutil` output) yields an all-`false`
116/// [`DeviceInfo`], which is **refused** here (fail closed), not waved through.
117/// Whether it is the *right* device (and may hold data) is the next layer's job:
118/// the content warning + the attended broker confirmation (I16), not this rail.
119pub fn assert_eraseable_target(info: &DeviceInfo) -> Result<(), CoreError> {
120 if info.boot {
121 return Err(CoreError::Format(format!(
122 "{} backs the boot/system volume — refusing to format it",
123 info.node
124 )));
125 }
126 if info.internal && !info.removable && !info.ejectable {
127 return Err(CoreError::Format(format!(
128 "{} is an internal fixed disk — kovra only formats external, removable, or ejectable media",
129 info.node
130 )));
131 }
132 // Fail closed on ambiguity (the catastrophe-prevention clause): require a
133 // POSITIVE safety signal. `removable`/`ejectable` are the affirmative "this
134 // is external media" facts; `!internal` alone is NOT trusted, because an
135 // unreadable probe also reports `internal = false`. Every legitimate target
136 // (plain stick, external USB SSD, built-in SD reader) reports `ejectable` or
137 // `removable`; an internal/soldered disk and an unparseable probe report
138 // neither, and both must be refused. Without this, an all-`false`
139 // `DeviceInfo` would slip past the clause above and a system disk could be
140 // erased.
141 if !info.removable && !info.ejectable {
142 return Err(CoreError::Format(format!(
143 "{} could not be confirmed as external removable/ejectable media — refusing to format it",
144 info.node
145 )));
146 }
147 Ok(())
148}
149
150/// Whether two probes describe the **same physical device** — used by the TOCTOU
151/// re-check before an erase. `DeviceInfo` carries no media UUID, so we compare the
152/// stable identifying attributes (capacity + bus/ejectability + boot). A change in
153/// any of them means the `/dev/diskN` node now points at a different device, and
154/// the erase must be refused. Conservative by design: a false "different" only
155/// causes a safe refusal, never a wrong erase.
156#[must_use]
157fn same_device(a: &DeviceInfo, b: &DeviceInfo) -> bool {
158 a.total_bytes == b.total_bytes
159 && a.removable == b.removable
160 && a.ejectable == b.ejectable
161 && a.internal == b.internal
162 && a.boot == b.boot
163}
164
165/// Filter probed devices to those the rail accepts — the candidate list a UI/CLI
166/// offers the user to pick from (KOV-41 device picker). Pure helper over
167/// [`assert_eraseable_target`].
168#[must_use]
169pub fn eligible_targets(devices: Vec<DeviceInfo>) -> Vec<DeviceInfo> {
170 devices
171 .into_iter()
172 .filter(|d| assert_eraseable_target(d).is_ok())
173 .collect()
174}
175
176/// The authoritative confirmation headline for a wipe (I16, §8.3): device node,
177/// name, size, the irreversible-erase warning, and — when the device is
178/// non-empty — a content warning. No secret material.
179#[must_use]
180pub fn wipe_headline(info: &DeviceInfo) -> String {
181 let name = if info.name.trim().is_empty() {
182 "unnamed".to_string()
183 } else {
184 info.name.clone()
185 };
186 let mut headline = format!(
187 "ERASE {} (\"{}\", {}) — ALL DATA ON THIS DEVICE WILL BE ERASED",
188 info.node,
189 name,
190 info.human_size()
191 );
192 if info.non_empty() {
193 match info.used_bytes {
194 Some(u) if u > 0 => {
195 headline.push_str(&format!(" — it is NOT empty (~{} in use)", human_bytes(u)));
196 }
197 _ => headline.push_str(" — it has a mounted volume with existing data"),
198 }
199 }
200 headline
201}
202
203/// The single guarded entry point for a wipe: probe → safety rail (hard refusal)
204/// → attended broker confirmation (I16) → erase. Returns the probed
205/// [`DeviceInfo`] on success so the caller can report what was formatted.
206///
207/// The order is load-bearing: the rail runs *before* the prompt (an unsafe
208/// target is never offered for approval), and `erase` runs *only* on an explicit
209/// [`ConfirmOutcome::Approved`] (deny/timeout fail closed, §8).
210pub fn format_removable(
211 formatter: &dyn Formatter,
212 confirmer: &dyn Confirmer,
213 node: &str,
214 label: &str,
215 timeout: Duration,
216) -> Result<DeviceInfo, CoreError> {
217 let info = formatter.probe(node)?;
218 // Hard rail first — a dangerous device must not even reach the broker.
219 assert_eraseable_target(&info)?;
220
221 let req = ConfirmRequest::for_action(wipe_headline(&info), Origin::Human);
222 match confirmer.confirm(&req, timeout) {
223 ConfirmOutcome::Approved => {
224 // TOCTOU guard: re-probe immediately before the irreversible erase.
225 // A `/dev/diskN` node is reassignable, so the device backing `node`
226 // can change during the confirmation window (the human unplugs the
227 // approved stick and a different disk takes its node). Re-run the rail
228 // and require the device to still be the SAME one that was approved —
229 // any drift fails closed, erasing nothing.
230 let recheck = formatter.probe(node)?;
231 assert_eraseable_target(&recheck)?;
232 if !same_device(&info, &recheck) {
233 return Err(CoreError::Format(format!(
234 "{node} changed between confirmation and erase — refusing to format it"
235 )));
236 }
237 formatter.erase(node, label)?;
238 Ok(info)
239 }
240 ConfirmOutcome::Denied => Err(CoreError::Format(format!(
241 "denied — {node} was not formatted"
242 ))),
243 ConfirmOutcome::TimedOut => Err(CoreError::Format(format!(
244 "timed out — {node} was not formatted"
245 ))),
246 }
247}
248
249/// A deterministic in-memory [`Formatter`] for tests — no real device is ever
250/// touched. Mirrors [`MockSshAgent`](crate::MockSshAgent): construct it with a
251/// canned [`DeviceInfo`], then inspect what `erase` recorded.
252pub struct MockFormatter {
253 info: DeviceInfo,
254 devices: Vec<DeviceInfo>,
255 erased: std::sync::Mutex<Option<(String, String)>>,
256 erase_fails: bool,
257 /// If set, `probe` returns this from the **second** call onward — simulates a
258 /// device swap on the same `/dev/diskN` node between the confirmation re-probe
259 /// and the erase (the TOCTOU the guard refuses).
260 swap_to: Option<DeviceInfo>,
261 probes: std::sync::atomic::AtomicU32,
262}
263
264impl MockFormatter {
265 /// A formatter whose `probe` returns `info` (with the queried node overlaid)
266 /// and whose `erase` succeeds and records its arguments. `list_devices`
267 /// returns just `info`.
268 #[must_use]
269 pub fn new(info: DeviceInfo) -> Self {
270 Self {
271 devices: vec![info.clone()],
272 info,
273 erased: std::sync::Mutex::new(None),
274 erase_fails: false,
275 swap_to: None,
276 probes: std::sync::atomic::AtomicU32::new(0),
277 }
278 }
279
280 /// A formatter that probes as `first`, then (from the second probe onward)
281 /// reports `second` on the same node — to exercise the TOCTOU re-probe guard.
282 #[must_use]
283 pub fn swapping(first: DeviceInfo, second: DeviceInfo) -> Self {
284 Self {
285 devices: vec![first.clone()],
286 info: first,
287 erased: std::sync::Mutex::new(None),
288 erase_fails: false,
289 swap_to: Some(second),
290 probes: std::sync::atomic::AtomicU32::new(0),
291 }
292 }
293
294 /// A formatter whose `list_devices` returns `devices` (probe still returns
295 /// the first as the canned info) — to test the candidate-listing/filtering.
296 #[must_use]
297 pub fn with_devices(devices: Vec<DeviceInfo>) -> Self {
298 let info = devices.first().cloned().unwrap_or(DeviceInfo {
299 node: String::new(),
300 name: String::new(),
301 total_bytes: 0,
302 used_bytes: None,
303 removable: false,
304 ejectable: false,
305 internal: false,
306 boot: false,
307 mounted: false,
308 });
309 Self {
310 info,
311 devices,
312 erased: std::sync::Mutex::new(None),
313 erase_fails: false,
314 swap_to: None,
315 probes: std::sync::atomic::AtomicU32::new(0),
316 }
317 }
318
319 /// Like [`Self::new`], but `erase` fails — to test that a format error
320 /// propagates after an approval.
321 #[must_use]
322 pub fn failing(info: DeviceInfo) -> Self {
323 Self {
324 devices: vec![info.clone()],
325 info,
326 erased: std::sync::Mutex::new(None),
327 erase_fails: true,
328 swap_to: None,
329 probes: std::sync::atomic::AtomicU32::new(0),
330 }
331 }
332
333 /// The `(node, label)` the last successful `erase` recorded, if any.
334 #[must_use]
335 pub fn erased(&self) -> Option<(String, String)> {
336 self.erased
337 .lock()
338 .expect("mock formatter mutex poisoned")
339 .clone()
340 }
341}
342
343impl Formatter for MockFormatter {
344 fn probe(&self, node: &str) -> Result<DeviceInfo, CoreError> {
345 let n = self
346 .probes
347 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
348 let mut i = match (&self.swap_to, n) {
349 (Some(swapped), k) if k >= 1 => swapped.clone(),
350 _ => self.info.clone(),
351 };
352 i.node = node.to_string();
353 Ok(i)
354 }
355 fn list_devices(&self) -> Result<Vec<DeviceInfo>, CoreError> {
356 Ok(self.devices.clone())
357 }
358 fn erase(&self, node: &str, label: &str) -> Result<(), CoreError> {
359 if self.erase_fails {
360 return Err(CoreError::Format("mock erase failed".into()));
361 }
362 *self.erased.lock().expect("mock formatter mutex poisoned") =
363 Some((node.to_string(), label.to_string()));
364 Ok(())
365 }
366}
367
368/// Decimal (SI) human-readable byte size — matches `diskutil`'s GB convention.
369fn human_bytes(n: u64) -> String {
370 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
371 if n < 1000 {
372 return format!("{n} B");
373 }
374 let mut value = n as f64;
375 let mut unit = 0;
376 while value >= 1000.0 && unit < UNITS.len() - 1 {
377 value /= 1000.0;
378 unit += 1;
379 }
380 format!("{value:.1} {}", UNITS[unit])
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use std::sync::atomic::{AtomicU32, Ordering};
387
388 /// A valid eraseable target by default: external, ejectable, non-boot
389 /// (a plain removable stick). Tests tweak fields to exercise the rail.
390 fn eraseable(node: &str) -> DeviceInfo {
391 DeviceInfo {
392 node: node.to_string(),
393 name: "FIELDKIT".to_string(),
394 total_bytes: 30_752_000_000,
395 used_bytes: Some(0),
396 removable: true,
397 ejectable: true,
398 internal: false,
399 boot: false,
400 mounted: false,
401 }
402 }
403
404 /// A counting [`Confirmer`] so a test can assert the broker is (or is not)
405 /// even consulted, plus what outcome it returned.
406 struct CountingConfirmer {
407 outcome: ConfirmOutcome,
408 calls: AtomicU32,
409 }
410 impl CountingConfirmer {
411 fn new(outcome: ConfirmOutcome) -> Self {
412 Self {
413 outcome,
414 calls: AtomicU32::new(0),
415 }
416 }
417 fn calls(&self) -> u32 {
418 self.calls.load(Ordering::SeqCst)
419 }
420 }
421 impl Confirmer for CountingConfirmer {
422 fn confirm(&self, _req: &ConfirmRequest, _t: Duration) -> ConfirmOutcome {
423 self.calls.fetch_add(1, Ordering::SeqCst);
424 self.outcome
425 }
426 }
427
428 // The hard safety rail: refuse boot + internal-fixed-non-ejectable; allow
429 // external / removable / ejectable. Covers all three real device classes.
430 #[test]
431 fn rail_allows_external_removable_or_ejectable_refuses_boot_and_internal_fixed() {
432 // Plain external removable stick — accepted.
433 assert!(assert_eraseable_target(&eraseable("/dev/disk4")).is_ok());
434
435 // USB SSD: `Fixed` media but external + ejectable (the SL600) — accepted.
436 let mut usb_ssd = eraseable("/dev/disk4");
437 usb_ssd.removable = false;
438 usb_ssd.internal = false;
439 assert!(
440 assert_eraseable_target(&usb_ssd).is_ok(),
441 "external ejectable USB SSD must be eraseable even when Fixed"
442 );
443
444 // Built-in SD card reader: Device Location Internal but RemovableMedia
445 // Removable (the disk6 case) — accepted because it is removable.
446 let mut sd = eraseable("/dev/disk6");
447 sd.internal = true;
448 sd.removable = true;
449 sd.ejectable = false;
450 assert!(
451 assert_eraseable_target(&sd).is_ok(),
452 "an internal-location but removable SD card must be eraseable"
453 );
454
455 // Soldered internal system SSD: internal + fixed + non-ejectable — REFUSED.
456 let mut system = eraseable("/dev/disk0");
457 system.internal = true;
458 system.removable = false;
459 system.ejectable = false;
460 assert!(
461 assert_eraseable_target(&system).is_err(),
462 "an internal fixed non-ejectable disk must be refused"
463 );
464
465 // Boot — refused regardless.
466 let mut boot = eraseable("/dev/disk1");
467 boot.boot = true;
468 assert!(assert_eraseable_target(&boot).is_err());
469 }
470
471 // Fail closed: a probe that could not read the device's bus/ejectability
472 // (malformed/renamed/localized `diskutil` output) yields an all-`false`
473 // DeviceInfo. The rail must REFUSE it — `!removable && !ejectable` is no
474 // positive proof of external media, so erasing must not proceed. Regression
475 // for the fail-open hole where `internal && !removable && !ejectable` alone
476 // (all false) waved an unreadable system disk through.
477 #[test]
478 fn rail_refuses_unreadable_all_false_probe() {
479 let unreadable = DeviceInfo {
480 node: "/dev/disk0".to_string(),
481 name: String::new(),
482 total_bytes: 0,
483 used_bytes: None,
484 removable: false,
485 ejectable: false,
486 internal: false, // not provable as external — same as "unknown"
487 boot: false,
488 mounted: false,
489 };
490 assert!(
491 assert_eraseable_target(&unreadable).is_err(),
492 "an all-false (unreadable) probe must fail closed, not be erased"
493 );
494 // And it is excluded from the candidate picker.
495 assert!(
496 eligible_targets(vec![unreadable]).is_empty(),
497 "an unreadable device is never offered as a target"
498 );
499 }
500
501 // End-to-end: an unreadable device reaching format_removable is refused
502 // before the broker, and nothing is erased (fail closed through the guarded
503 // entry point, not just the bare rail).
504 #[test]
505 fn format_removable_refuses_unreadable_device_without_prompting() {
506 let unreadable = DeviceInfo {
507 node: "/dev/disk0".to_string(),
508 name: String::new(),
509 total_bytes: 0,
510 used_bytes: None,
511 removable: false,
512 ejectable: false,
513 internal: false,
514 boot: false,
515 mounted: false,
516 };
517 let fmt = MockFormatter::new(unreadable);
518 let confirmer = CountingConfirmer::new(ConfirmOutcome::Approved);
519 let err = format_removable(&fmt, &confirmer, "/dev/disk0", "KOVRA", Duration::ZERO);
520 assert!(err.is_err(), "unreadable probe must fail closed");
521 assert_eq!(confirmer.calls(), 0, "the broker is never consulted");
522 assert_eq!(fmt.erased(), None, "nothing is erased");
523 }
524
525 // The candidate picker offers only rail-eligible devices.
526 #[test]
527 fn eligible_targets_filters_to_safe_devices() {
528 let stick = eraseable("/dev/disk4");
529 let mut system = eraseable("/dev/disk0");
530 system.internal = true;
531 system.removable = false;
532 system.ejectable = false;
533 let mut boot = eraseable("/dev/disk1");
534 boot.boot = true;
535
536 let elig = eligible_targets(vec![stick.clone(), system, boot]);
537 assert_eq!(elig.len(), 1, "only the safe stick is eligible");
538 assert_eq!(elig[0].node, "/dev/disk4");
539 }
540
541 // Happy path: a removable device that is approved gets erased, and the probed
542 // info is returned.
543 #[test]
544 fn format_removable_approved_erases() {
545 let fmt = MockFormatter::new(eraseable("/dev/disk4"));
546 let confirmer = CountingConfirmer::new(ConfirmOutcome::Approved);
547 let info =
548 format_removable(&fmt, &confirmer, "/dev/disk4", "KOVRA", Duration::ZERO).unwrap();
549 assert_eq!(info.node, "/dev/disk4");
550 assert_eq!(confirmer.calls(), 1, "the broker is consulted exactly once");
551 assert_eq!(
552 fmt.erased(),
553 Some(("/dev/disk4".to_string(), "KOVRA".to_string()))
554 );
555 }
556
557 // TOCTOU — the device backing `/dev/diskN` changes between the confirmation
558 // and the erase (the approved stick is swapped for a different disk on the
559 // same node). The pre-erase re-probe detects the drift and refuses, even
560 // though the *new* device is itself rail-eligible. Nothing is erased.
561 #[test]
562 fn format_removable_refuses_if_device_swapped_after_confirmation() {
563 let approved = eraseable("/dev/disk4");
564 let mut swapped = eraseable("/dev/disk4"); // also eraseable, but…
565 swapped.total_bytes = 500_000_000_000; // …a different (larger) disk
566 let fmt = MockFormatter::swapping(approved, swapped);
567 let confirmer = CountingConfirmer::new(ConfirmOutcome::Approved);
568 let err = format_removable(&fmt, &confirmer, "/dev/disk4", "KOVRA", Duration::ZERO);
569 assert!(
570 err.is_err(),
571 "a device swap after confirmation must be refused"
572 );
573 assert_eq!(fmt.erased(), None, "nothing is erased on drift");
574 }
575
576 // Deny and timeout both fail closed — nothing is erased.
577 #[test]
578 fn format_removable_denied_or_timeout_fails_closed() {
579 for outcome in [ConfirmOutcome::Denied, ConfirmOutcome::TimedOut] {
580 let fmt = MockFormatter::new(eraseable("/dev/disk4"));
581 let confirmer = CountingConfirmer::new(outcome);
582 let err = format_removable(&fmt, &confirmer, "/dev/disk4", "KOVRA", Duration::ZERO);
583 assert!(err.is_err(), "{outcome:?} must fail closed");
584 assert_eq!(fmt.erased(), None, "{outcome:?} must not erase");
585 }
586 }
587
588 // An unsafe target is refused BEFORE the broker is ever consulted (no prompt
589 // for a dangerous disk) and is never erased.
590 #[test]
591 fn unsafe_target_refused_without_prompting() {
592 // An internal, fixed, non-ejectable disk (the soldered system SSD).
593 let mut system = eraseable("/dev/disk0");
594 system.internal = true;
595 system.removable = false;
596 system.ejectable = false;
597 let fmt = MockFormatter::new(system);
598 let confirmer = CountingConfirmer::new(ConfirmOutcome::Approved);
599 let err = format_removable(&fmt, &confirmer, "/dev/disk0", "KOVRA", Duration::ZERO);
600 assert!(err.is_err(), "internal fixed disk must be refused");
601 assert_eq!(
602 confirmer.calls(),
603 0,
604 "the broker must NOT be consulted for an unsafe target"
605 );
606 assert_eq!(fmt.erased(), None);
607 }
608
609 // The I16 headline names the device, size, the erase warning, and a content
610 // warning when the device is non-empty.
611 #[test]
612 fn headline_carries_authoritative_fields_and_content_warning() {
613 let mut info = eraseable("/dev/disk4");
614 info.used_bytes = Some(12_000_000_000);
615 let h = wipe_headline(&info);
616 assert!(h.contains("/dev/disk4"), "names the device: {h}");
617 assert!(h.contains("FIELDKIT"), "names the volume: {h}");
618 assert!(h.contains("GB"), "shows the size: {h}");
619 assert!(h.contains("ALL DATA"), "warns of erasure: {h}");
620 assert!(h.contains("NOT empty"), "warns about content: {h}");
621
622 let empty = eraseable("/dev/disk4"); // used_bytes Some(0), not mounted
623 let h2 = wipe_headline(&empty);
624 assert!(
625 !h2.contains("NOT empty"),
626 "no content warning when empty: {h2}"
627 );
628 }
629
630 #[test]
631 fn human_bytes_uses_si_units() {
632 assert_eq!(human_bytes(0), "0 B");
633 assert_eq!(human_bytes(512), "512 B");
634 assert_eq!(human_bytes(30_752_000_000), "30.8 GB");
635 }
636
637 // Property-based fuzzing of the rail (M3 fail-closed). For ANY combination of
638 // device booleans, an *accepted* device must carry positive evidence of being
639 // external media (`removable || ejectable`) and must not be boot — so an
640 // all-`false` / ambiguous probe (the unreadable-`diskutil` case) is always
641 // refused. This locks the fail-closed property against future edits to the
642 // predicate.
643 proptest::proptest! {
644 #[test]
645 fn rail_accepts_only_with_positive_external_evidence(
646 removable: bool,
647 ejectable: bool,
648 internal: bool,
649 boot: bool,
650 mounted: bool,
651 total_bytes: u64,
652 ) {
653 let info = DeviceInfo {
654 node: "/dev/diskN".to_string(),
655 name: String::new(),
656 total_bytes,
657 used_bytes: None,
658 removable,
659 ejectable,
660 internal,
661 boot,
662 mounted,
663 };
664 let accepted = assert_eraseable_target(&info).is_ok();
665 if accepted {
666 // Fail-closed core property: never erase without positive proof,
667 // and never the boot disk.
668 proptest::prop_assert!(removable || ejectable, "accepted with no external evidence");
669 proptest::prop_assert!(!boot, "accepted the boot disk");
670 // And never an internal fixed non-ejectable disk.
671 proptest::prop_assert!(!(internal && !removable && !ejectable));
672 }
673 // eligible_targets must agree with the rail for every input.
674 let eligible = !eligible_targets(vec![info.clone()]).is_empty();
675 proptest::prop_assert_eq!(accepted, eligible);
676 }
677 }
678}