Skip to main content

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}