kovra_native_macos/
formatter.rs1use kovra_core::{CoreError, DeviceInfo, Formatter};
22
23pub struct DiskutilFormatter;
25
26impl DiskutilFormatter {
27 #[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 const DISKUTIL_TIMEOUT: Duration = Duration::from_secs(60);
95
96 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 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 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 fn part_of_whole(text: &str) -> Option<String> {
161 field(text, "Part of Whole:").map(str::to_string)
162 }
163
164 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 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 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 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 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 let Some(rest) = line.strip_prefix("/dev/") else {
238 continue;
239 };
240 let Some((id, descriptor)) = rest.split_once(' ') else {
241 continue;
242 };
243 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 let label = sanitize_label(label);
259 diskutil(&["eraseDisk", "ExFAT", &label, node])?;
260 Ok(())
261 }
262
263 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 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}