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}