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 (**not internal**, OR its
112/// media is **removable**, OR it is **ejectable**). Whether it is the *right*
113/// device (and may hold data) is the next layer's job: the content warning + the
114/// attended broker confirmation (I16), not this rail.
115pub fn assert_eraseable_target(info: &DeviceInfo) -> Result<(), CoreError> {
116    if info.boot {
117        return Err(CoreError::Format(format!(
118            "{} backs the boot/system volume — refusing to format it",
119            info.node
120        )));
121    }
122    if info.internal && !info.removable && !info.ejectable {
123        return Err(CoreError::Format(format!(
124            "{} is an internal fixed disk — kovra only formats external, removable, or ejectable media",
125            info.node
126        )));
127    }
128    Ok(())
129}
130
131/// Filter probed devices to those the rail accepts — the candidate list a UI/CLI
132/// offers the user to pick from (KOV-41 device picker). Pure helper over
133/// [`assert_eraseable_target`].
134#[must_use]
135pub fn eligible_targets(devices: Vec<DeviceInfo>) -> Vec<DeviceInfo> {
136    devices
137        .into_iter()
138        .filter(|d| assert_eraseable_target(d).is_ok())
139        .collect()
140}
141
142/// The authoritative confirmation headline for a wipe (I16, §8.3): device node,
143/// name, size, the irreversible-erase warning, and — when the device is
144/// non-empty — a content warning. No secret material.
145#[must_use]
146pub fn wipe_headline(info: &DeviceInfo) -> String {
147    let name = if info.name.trim().is_empty() {
148        "unnamed".to_string()
149    } else {
150        info.name.clone()
151    };
152    let mut headline = format!(
153        "ERASE {} (\"{}\", {}) — ALL DATA ON THIS DEVICE WILL BE ERASED",
154        info.node,
155        name,
156        info.human_size()
157    );
158    if info.non_empty() {
159        match info.used_bytes {
160            Some(u) if u > 0 => {
161                headline.push_str(&format!(" — it is NOT empty (~{} in use)", human_bytes(u)));
162            }
163            _ => headline.push_str(" — it has a mounted volume with existing data"),
164        }
165    }
166    headline
167}
168
169/// The single guarded entry point for a wipe: probe → safety rail (hard refusal)
170/// → attended broker confirmation (I16) → erase. Returns the probed
171/// [`DeviceInfo`] on success so the caller can report what was formatted.
172///
173/// The order is load-bearing: the rail runs *before* the prompt (an unsafe
174/// target is never offered for approval), and `erase` runs *only* on an explicit
175/// [`ConfirmOutcome::Approved`] (deny/timeout fail closed, §8).
176pub fn format_removable(
177    formatter: &dyn Formatter,
178    confirmer: &dyn Confirmer,
179    node: &str,
180    label: &str,
181    timeout: Duration,
182) -> Result<DeviceInfo, CoreError> {
183    let info = formatter.probe(node)?;
184    // Hard rail first — a dangerous device must not even reach the broker.
185    assert_eraseable_target(&info)?;
186
187    let req = ConfirmRequest::for_action(wipe_headline(&info), Origin::Human);
188    match confirmer.confirm(&req, timeout) {
189        ConfirmOutcome::Approved => {
190            formatter.erase(node, label)?;
191            Ok(info)
192        }
193        ConfirmOutcome::Denied => Err(CoreError::Format(format!(
194            "denied — {node} was not formatted"
195        ))),
196        ConfirmOutcome::TimedOut => Err(CoreError::Format(format!(
197            "timed out — {node} was not formatted"
198        ))),
199    }
200}
201
202/// A deterministic in-memory [`Formatter`] for tests — no real device is ever
203/// touched. Mirrors [`MockSshAgent`](crate::MockSshAgent): construct it with a
204/// canned [`DeviceInfo`], then inspect what `erase` recorded.
205pub struct MockFormatter {
206    info: DeviceInfo,
207    devices: Vec<DeviceInfo>,
208    erased: std::sync::Mutex<Option<(String, String)>>,
209    erase_fails: bool,
210}
211
212impl MockFormatter {
213    /// A formatter whose `probe` returns `info` (with the queried node overlaid)
214    /// and whose `erase` succeeds and records its arguments. `list_devices`
215    /// returns just `info`.
216    #[must_use]
217    pub fn new(info: DeviceInfo) -> Self {
218        Self {
219            devices: vec![info.clone()],
220            info,
221            erased: std::sync::Mutex::new(None),
222            erase_fails: false,
223        }
224    }
225
226    /// A formatter whose `list_devices` returns `devices` (probe still returns
227    /// the first as the canned info) — to test the candidate-listing/filtering.
228    #[must_use]
229    pub fn with_devices(devices: Vec<DeviceInfo>) -> Self {
230        let info = devices.first().cloned().unwrap_or(DeviceInfo {
231            node: String::new(),
232            name: String::new(),
233            total_bytes: 0,
234            used_bytes: None,
235            removable: false,
236            ejectable: false,
237            internal: false,
238            boot: false,
239            mounted: false,
240        });
241        Self {
242            info,
243            devices,
244            erased: std::sync::Mutex::new(None),
245            erase_fails: false,
246        }
247    }
248
249    /// Like [`Self::new`], but `erase` fails — to test that a format error
250    /// propagates after an approval.
251    #[must_use]
252    pub fn failing(info: DeviceInfo) -> Self {
253        Self {
254            devices: vec![info.clone()],
255            info,
256            erased: std::sync::Mutex::new(None),
257            erase_fails: true,
258        }
259    }
260
261    /// The `(node, label)` the last successful `erase` recorded, if any.
262    #[must_use]
263    pub fn erased(&self) -> Option<(String, String)> {
264        self.erased
265            .lock()
266            .expect("mock formatter mutex poisoned")
267            .clone()
268    }
269}
270
271impl Formatter for MockFormatter {
272    fn probe(&self, node: &str) -> Result<DeviceInfo, CoreError> {
273        let mut i = self.info.clone();
274        i.node = node.to_string();
275        Ok(i)
276    }
277    fn list_devices(&self) -> Result<Vec<DeviceInfo>, CoreError> {
278        Ok(self.devices.clone())
279    }
280    fn erase(&self, node: &str, label: &str) -> Result<(), CoreError> {
281        if self.erase_fails {
282            return Err(CoreError::Format("mock erase failed".into()));
283        }
284        *self.erased.lock().expect("mock formatter mutex poisoned") =
285            Some((node.to_string(), label.to_string()));
286        Ok(())
287    }
288}
289
290/// Decimal (SI) human-readable byte size — matches `diskutil`'s GB convention.
291fn human_bytes(n: u64) -> String {
292    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
293    if n < 1000 {
294        return format!("{n} B");
295    }
296    let mut value = n as f64;
297    let mut unit = 0;
298    while value >= 1000.0 && unit < UNITS.len() - 1 {
299        value /= 1000.0;
300        unit += 1;
301    }
302    format!("{value:.1} {}", UNITS[unit])
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use std::sync::atomic::{AtomicU32, Ordering};
309
310    /// A valid eraseable target by default: external, ejectable, non-boot
311    /// (a plain removable stick). Tests tweak fields to exercise the rail.
312    fn eraseable(node: &str) -> DeviceInfo {
313        DeviceInfo {
314            node: node.to_string(),
315            name: "FIELDKIT".to_string(),
316            total_bytes: 30_752_000_000,
317            used_bytes: Some(0),
318            removable: true,
319            ejectable: true,
320            internal: false,
321            boot: false,
322            mounted: false,
323        }
324    }
325
326    /// A counting [`Confirmer`] so a test can assert the broker is (or is not)
327    /// even consulted, plus what outcome it returned.
328    struct CountingConfirmer {
329        outcome: ConfirmOutcome,
330        calls: AtomicU32,
331    }
332    impl CountingConfirmer {
333        fn new(outcome: ConfirmOutcome) -> Self {
334            Self {
335                outcome,
336                calls: AtomicU32::new(0),
337            }
338        }
339        fn calls(&self) -> u32 {
340            self.calls.load(Ordering::SeqCst)
341        }
342    }
343    impl Confirmer for CountingConfirmer {
344        fn confirm(&self, _req: &ConfirmRequest, _t: Duration) -> ConfirmOutcome {
345            self.calls.fetch_add(1, Ordering::SeqCst);
346            self.outcome
347        }
348    }
349
350    // The hard safety rail: refuse boot + internal-fixed-non-ejectable; allow
351    // external / removable / ejectable. Covers all three real device classes.
352    #[test]
353    fn rail_allows_external_removable_or_ejectable_refuses_boot_and_internal_fixed() {
354        // Plain external removable stick — accepted.
355        assert!(assert_eraseable_target(&eraseable("/dev/disk4")).is_ok());
356
357        // USB SSD: `Fixed` media but external + ejectable (the SL600) — accepted.
358        let mut usb_ssd = eraseable("/dev/disk4");
359        usb_ssd.removable = false;
360        usb_ssd.internal = false;
361        assert!(
362            assert_eraseable_target(&usb_ssd).is_ok(),
363            "external ejectable USB SSD must be eraseable even when Fixed"
364        );
365
366        // Built-in SD card reader: Device Location Internal but RemovableMedia
367        // Removable (the disk6 case) — accepted because it is removable.
368        let mut sd = eraseable("/dev/disk6");
369        sd.internal = true;
370        sd.removable = true;
371        sd.ejectable = false;
372        assert!(
373            assert_eraseable_target(&sd).is_ok(),
374            "an internal-location but removable SD card must be eraseable"
375        );
376
377        // Soldered internal system SSD: internal + fixed + non-ejectable — REFUSED.
378        let mut system = eraseable("/dev/disk0");
379        system.internal = true;
380        system.removable = false;
381        system.ejectable = false;
382        assert!(
383            assert_eraseable_target(&system).is_err(),
384            "an internal fixed non-ejectable disk must be refused"
385        );
386
387        // Boot — refused regardless.
388        let mut boot = eraseable("/dev/disk1");
389        boot.boot = true;
390        assert!(assert_eraseable_target(&boot).is_err());
391    }
392
393    // The candidate picker offers only rail-eligible devices.
394    #[test]
395    fn eligible_targets_filters_to_safe_devices() {
396        let stick = eraseable("/dev/disk4");
397        let mut system = eraseable("/dev/disk0");
398        system.internal = true;
399        system.removable = false;
400        system.ejectable = false;
401        let mut boot = eraseable("/dev/disk1");
402        boot.boot = true;
403
404        let elig = eligible_targets(vec![stick.clone(), system, boot]);
405        assert_eq!(elig.len(), 1, "only the safe stick is eligible");
406        assert_eq!(elig[0].node, "/dev/disk4");
407    }
408
409    // Happy path: a removable device that is approved gets erased, and the probed
410    // info is returned.
411    #[test]
412    fn format_removable_approved_erases() {
413        let fmt = MockFormatter::new(eraseable("/dev/disk4"));
414        let confirmer = CountingConfirmer::new(ConfirmOutcome::Approved);
415        let info =
416            format_removable(&fmt, &confirmer, "/dev/disk4", "KOVRA", Duration::ZERO).unwrap();
417        assert_eq!(info.node, "/dev/disk4");
418        assert_eq!(confirmer.calls(), 1, "the broker is consulted exactly once");
419        assert_eq!(
420            fmt.erased(),
421            Some(("/dev/disk4".to_string(), "KOVRA".to_string()))
422        );
423    }
424
425    // Deny and timeout both fail closed — nothing is erased.
426    #[test]
427    fn format_removable_denied_or_timeout_fails_closed() {
428        for outcome in [ConfirmOutcome::Denied, ConfirmOutcome::TimedOut] {
429            let fmt = MockFormatter::new(eraseable("/dev/disk4"));
430            let confirmer = CountingConfirmer::new(outcome);
431            let err = format_removable(&fmt, &confirmer, "/dev/disk4", "KOVRA", Duration::ZERO);
432            assert!(err.is_err(), "{outcome:?} must fail closed");
433            assert_eq!(fmt.erased(), None, "{outcome:?} must not erase");
434        }
435    }
436
437    // An unsafe target is refused BEFORE the broker is ever consulted (no prompt
438    // for a dangerous disk) and is never erased.
439    #[test]
440    fn unsafe_target_refused_without_prompting() {
441        // An internal, fixed, non-ejectable disk (the soldered system SSD).
442        let mut system = eraseable("/dev/disk0");
443        system.internal = true;
444        system.removable = false;
445        system.ejectable = false;
446        let fmt = MockFormatter::new(system);
447        let confirmer = CountingConfirmer::new(ConfirmOutcome::Approved);
448        let err = format_removable(&fmt, &confirmer, "/dev/disk0", "KOVRA", Duration::ZERO);
449        assert!(err.is_err(), "internal fixed disk must be refused");
450        assert_eq!(
451            confirmer.calls(),
452            0,
453            "the broker must NOT be consulted for an unsafe target"
454        );
455        assert_eq!(fmt.erased(), None);
456    }
457
458    // The I16 headline names the device, size, the erase warning, and a content
459    // warning when the device is non-empty.
460    #[test]
461    fn headline_carries_authoritative_fields_and_content_warning() {
462        let mut info = eraseable("/dev/disk4");
463        info.used_bytes = Some(12_000_000_000);
464        let h = wipe_headline(&info);
465        assert!(h.contains("/dev/disk4"), "names the device: {h}");
466        assert!(h.contains("FIELDKIT"), "names the volume: {h}");
467        assert!(h.contains("GB"), "shows the size: {h}");
468        assert!(h.contains("ALL DATA"), "warns of erasure: {h}");
469        assert!(h.contains("NOT empty"), "warns about content: {h}");
470
471        let empty = eraseable("/dev/disk4"); // used_bytes Some(0), not mounted
472        let h2 = wipe_headline(&empty);
473        assert!(
474            !h2.contains("NOT empty"),
475            "no content warning when empty: {h2}"
476        );
477    }
478
479    #[test]
480    fn human_bytes_uses_si_units() {
481        assert_eq!(human_bytes(0), "0 B");
482        assert_eq!(human_bytes(512), "512 B");
483        assert_eq!(human_bytes(30_752_000_000), "30.8 GB");
484    }
485}