Skip to main content

kovra_native_macos/
formatter.rs

1//! macOS removable-media [`Formatter`] (KOV-40, `[host]`). Shells out to
2//! `diskutil` to probe and erase a USB device. This is the *native half* — the
3//! security-load-bearing safety rails and the broker gate live in
4//! [`kovra_core::format_removable`]; this crate only reports what the OS sees and
5//! performs the erase once the core has authorized it.
6//!
7//! ## `[host]` validation
8//!
9//! The real `diskutil` path is **not** exercised by CI (it needs a real USB
10//! stick and would erase it). It is validated by a human on an M4 (the epic's
11//! `[host]` checklist). The OS-independent contract — the external/ejectable/
12//! non-boot rail, the I16 headline, deny/timeout fail-closed — is fully covered
13//! by the core unit tests against [`kovra_core::MockFormatter`].
14//!
15//! ## Cross-platform
16//!
17//! The real implementation is gated on `cfg(target_os = "macos")`. Off-macOS the
18//! type still exists but every method returns an explicit "macOS only" error, so
19//! the whole workspace builds on Linux CI (matching the biometric stub).
20
21use kovra_core::{CoreError, DeviceInfo, Formatter};
22
23/// The native macOS `Formatter`, backed by `diskutil`.
24pub struct DiskutilFormatter;
25
26impl DiskutilFormatter {
27    /// Construct the host formatter handle.
28    #[must_use]
29    pub fn new() -> Self {
30        Self
31    }
32}
33
34impl Default for DiskutilFormatter {
35    fn default() -> Self {
36        Self::new()
37    }
38}
39
40impl Formatter for DiskutilFormatter {
41    fn probe(&self, node: &str) -> Result<DeviceInfo, CoreError> {
42        #[cfg(target_os = "macos")]
43        {
44            macos::probe(node)
45        }
46        #[cfg(not(target_os = "macos"))]
47        {
48            let _ = node;
49            Err(CoreError::Format(
50                "removable-media formatting is only supported on macOS".into(),
51            ))
52        }
53    }
54
55    fn list_devices(&self) -> Result<Vec<DeviceInfo>, CoreError> {
56        #[cfg(target_os = "macos")]
57        {
58            macos::list_devices()
59        }
60        #[cfg(not(target_os = "macos"))]
61        {
62            Err(CoreError::Format(
63                "removable-media formatting is only supported on macOS".into(),
64            ))
65        }
66    }
67
68    fn erase(&self, node: &str, label: &str) -> Result<(), CoreError> {
69        #[cfg(target_os = "macos")]
70        {
71            macos::erase(node, label)
72        }
73        #[cfg(not(target_os = "macos"))]
74        {
75            let _ = (node, label);
76            Err(CoreError::Format(
77                "removable-media formatting is only supported on macOS".into(),
78            ))
79        }
80    }
81}
82
83#[cfg(target_os = "macos")]
84mod macos {
85    use std::io::Read;
86    use std::process::{Command, Stdio};
87    use std::time::Duration;
88
89    use kovra_core::{CoreError, DeviceInfo};
90    use wait_timeout::ChildExt;
91
92    /// `diskutil` is local and fast; a generous ceiling guards against a hung
93    /// device without ever blocking the broker prompt indefinitely.
94    const DISKUTIL_TIMEOUT: Duration = Duration::from_secs(60);
95
96    /// Run `diskutil <args>` with a timeout. Returns `(exit_code, stdout)`.
97    /// stderr is folded into the error message on failure (coarse, no secrets).
98    fn diskutil(args: &[&str]) -> Result<String, CoreError> {
99        let mut child = Command::new("diskutil")
100            .args(args)
101            .stdout(Stdio::piped())
102            .stderr(Stdio::piped())
103            .spawn()
104            .map_err(|e| CoreError::Format(format!("could not run `diskutil` ({e})")))?;
105
106        let status = match child
107            .wait_timeout(DISKUTIL_TIMEOUT)
108            .map_err(|e| CoreError::Format(format!("waiting on `diskutil` failed ({e})")))?
109        {
110            Some(status) => status,
111            None => {
112                let _ = child.kill();
113                let _ = child.wait();
114                return Err(CoreError::Format(format!(
115                    "`diskutil {}` timed out",
116                    args.first().copied().unwrap_or("")
117                )));
118            }
119        };
120
121        let mut stdout = String::new();
122        let mut stderr = String::new();
123        if let Some(mut o) = child.stdout.take() {
124            let _ = o.read_to_string(&mut stdout);
125        }
126        if let Some(mut e) = child.stderr.take() {
127            let _ = e.read_to_string(&mut stderr);
128        }
129        if !status.success() {
130            let detail = stderr.trim();
131            return Err(CoreError::Format(format!(
132                "`diskutil {}` failed{}",
133                args.first().copied().unwrap_or(""),
134                if detail.is_empty() {
135                    String::new()
136                } else {
137                    format!(": {detail}")
138                }
139            )));
140        }
141        Ok(stdout)
142    }
143
144    /// The trimmed value after the first `key` line in `diskutil info` output.
145    fn field<'a>(text: &'a str, key: &str) -> Option<&'a str> {
146        text.lines()
147            .find_map(|l| l.trim_start().strip_prefix(key))
148            .map(str::trim)
149    }
150
151    /// Extract the `(NNN Bytes)` count `diskutil` appends to size fields.
152    fn bytes_in_parens(value: &str) -> Option<u64> {
153        let open = value.find('(')?;
154        let rest = &value[open + 1..];
155        let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
156        digits.parse().ok()
157    }
158
159    /// `Part of Whole:` — the whole-disk identifier a node belongs to.
160    fn part_of_whole(text: &str) -> Option<String> {
161        field(text, "Part of Whole:").map(str::to_string)
162    }
163
164    /// The whole-disk identifier backing `/` (the boot/system volume).
165    fn boot_whole() -> Option<String> {
166        let info = diskutil(&["info", "/"]).ok()?;
167        part_of_whole(&info)
168    }
169
170    pub fn probe(node: &str) -> Result<DeviceInfo, CoreError> {
171        let text = diskutil(&["info", node])?;
172
173        // `Removable Media:` is "Removable"/"Fixed" on modern macOS (older builds
174        // print Yes/No). `Device Location:` is External/Internal. Treat the
175        // device as removable only when the OS clearly says so.
176        let removable = field(&text, "Removable Media:")
177            .map(|v| {
178                let v = v.to_ascii_lowercase();
179                v.contains("removable") || v.starts_with("yes")
180            })
181            .unwrap_or(false);
182        let internal = field(&text, "Internal:")
183            .map(|v| v.starts_with("Yes"))
184            .or_else(|| field(&text, "Device Location:").map(|v| v.contains("Internal")))
185            .unwrap_or(false);
186        // `Ejectable:` is the safety-relevant signal — Yes for external USB/TB
187        // media (sticks AND SSDs), No for internal disks. This, not
188        // `Removable Media:`, is what the core rail keys on.
189        let ejectable = field(&text, "Ejectable:")
190            .map(|v| v.starts_with("Yes"))
191            .unwrap_or(false);
192
193        let name = field(&text, "Volume Name:")
194            .filter(|v| !v.is_empty() && *v != "(no value)")
195            .or_else(|| field(&text, "Device / Media Name:"))
196            .unwrap_or("")
197            .to_string();
198
199        let total_bytes = field(&text, "Disk Size:")
200            .and_then(bytes_in_parens)
201            .or_else(|| field(&text, "Total Size:").and_then(bytes_in_parens))
202            .unwrap_or(0);
203        let used_bytes = field(&text, "Volume Used Space:").and_then(bytes_in_parens);
204        let mounted = field(&text, "Mounted:")
205            .map(|v| v.starts_with("Yes"))
206            .unwrap_or(false);
207
208        // Boot defense-in-depth: refuse if the target shares the whole disk that
209        // backs `/`. (A boot disk is also internal+fixed, so the rail catches it
210        // regardless; this is belt-and-suspenders.)
211        let boot = match (part_of_whole(&text), boot_whole()) {
212            (Some(target), Some(boot)) => target == boot,
213            _ => false,
214        };
215
216        Ok(DeviceInfo {
217            node: node.to_string(),
218            name,
219            total_bytes,
220            used_bytes,
221            removable,
222            ejectable,
223            internal,
224            boot,
225            mounted,
226        })
227    }
228
229    /// Enumerate whole **physical** disks (`/dev/diskN (... physical):` lines from
230    /// `diskutil list`) and probe each. Probe failures are skipped. The CLI
231    /// applies `eligible_targets` to offer only rail-safe devices.
232    pub fn list_devices() -> Result<Vec<DeviceInfo>, CoreError> {
233        let text = diskutil(&["list"])?;
234        let mut out = Vec::new();
235        for line in text.lines() {
236            // e.g. "/dev/disk6 (internal, physical):" / "/dev/disk4 (external, physical):"
237            let Some(rest) = line.strip_prefix("/dev/") else {
238                continue;
239            };
240            let Some((id, descriptor)) = rest.split_once(' ') else {
241                continue;
242            };
243            // Whole physical disks only — skip synthesized containers / images.
244            if !descriptor.contains("physical") {
245                continue;
246            }
247            if let Ok(info) = probe(&format!("/dev/{id}")) {
248                out.push(info);
249            }
250        }
251        Ok(out)
252    }
253
254    pub fn erase(node: &str, label: &str) -> Result<(), CoreError> {
255        // ExFAT: read/write on macOS and broadly portable for carrying the
256        // bootstrap files. The volume label is sanitized to a conservative,
257        // FAT-safe form.
258        let label = sanitize_label(label);
259        diskutil(&["eraseDisk", "ExFAT", &label, node])?;
260        Ok(())
261    }
262
263    /// FAT volume labels are short and uppercase-friendly: keep ASCII
264    /// alphanumerics, uppercase, cap at 11 chars, never empty.
265    fn sanitize_label(label: &str) -> String {
266        let cleaned: String = label
267            .chars()
268            .filter(|c| c.is_ascii_alphanumeric())
269            .take(11)
270            .collect::<String>()
271            .to_ascii_uppercase();
272        if cleaned.is_empty() {
273            "KOVRA".to_string()
274        } else {
275            cleaned
276        }
277    }
278
279    #[cfg(test)]
280    mod tests {
281        use super::*;
282
283        const SAMPLE: &str = "   Device Node:             /dev/disk4\n   \
284            Volume Name:              FIELDKIT\n   \
285            Removable Media:          Fixed\n   \
286            Internal:                 No\n   \
287            Ejectable:                Yes\n   \
288            Device Location:          External\n   \
289            Part of Whole:            disk4\n   \
290            Disk Size:                30.8 GB (30752000000 Bytes) (exactly ...)\n   \
291            Volume Used Space:        1.2 GB (1200000000 Bytes) (exactly ...)\n   \
292            Mounted:                  Yes\n";
293
294        #[test]
295        fn parses_diskutil_fields() {
296            assert_eq!(field(SAMPLE, "Volume Name:"), Some("FIELDKIT"));
297            // A USB SSD: Fixed media but ejectable — the case the refined rail accepts.
298            assert_eq!(field(SAMPLE, "Removable Media:"), Some("Fixed"));
299            assert_eq!(field(SAMPLE, "Internal:"), Some("No"));
300            assert_eq!(field(SAMPLE, "Ejectable:"), Some("Yes"));
301            assert_eq!(part_of_whole(SAMPLE).as_deref(), Some("disk4"));
302        }
303
304        #[test]
305        fn extracts_byte_counts() {
306            assert_eq!(
307                bytes_in_parens("30.8 GB (30752000000 Bytes) (exactly ...)"),
308                Some(30_752_000_000)
309            );
310            assert_eq!(bytes_in_parens("no parens"), None);
311        }
312
313        #[test]
314        fn sanitize_label_is_fat_safe() {
315            assert_eq!(sanitize_label("kovra exchange!"), "KOVRAEXCHAN");
316            assert_eq!(sanitize_label(""), "KOVRA");
317            assert_eq!(sanitize_label("---"), "KOVRA");
318        }
319    }
320}