use crate::{DeviceInfo, DiskError, Result};
use serde::Deserialize;
use std::process::Command;
pub fn enumerate() -> Result<Vec<DeviceInfo>> {
let bsd_names = list_all_disks()?;
let boot_disk_bsd = boot_disk_whole()?;
let mut out = Vec::with_capacity(bsd_names.len());
for name in bsd_names {
match info_for_internal(&name, Some(&boot_disk_bsd)) {
Ok(Some(info)) => out.push(info),
Ok(None) => {}
Err(e) => {
tracing::warn!(disk = %name, error = %e, "skipping disk during enumeration");
}
}
}
Ok(out)
}
pub fn info_for(path_or_name: &str) -> Result<Option<DeviceInfo>> {
let bsd = normalize_bsd_name(path_or_name);
let boot_disk_bsd = boot_disk_whole().ok();
info_for_internal(&bsd, boot_disk_bsd.as_deref())
}
pub fn normalize_bsd_name(path_or_name: &str) -> String {
let s = path_or_name.trim_start_matches("/dev/");
let s = s.strip_prefix('r').unwrap_or(s);
let mut out = String::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
out.push(c);
if out == "disk" {
while let Some(&c) = chars.peek() {
if c.is_ascii_digit() {
out.push(c);
chars.next();
} else {
return out;
}
}
return out;
}
}
out
}
fn info_for_internal(bsd: &str, boot_disk_bsd: Option<&str>) -> Result<Option<DeviceInfo>> {
let plist_bytes = match run_diskutil_info(bsd) {
Ok(b) => b,
Err(DiskError::External { .. }) => return Ok(None), Err(e) => return Err(e),
};
let info: DiskutilInfo = plist::from_bytes(&plist_bytes).map_err(|e| DiskError::DaError(
format!("parsing diskutil info plist for {bsd}: {e}"),
))?;
if !info.whole_disk {
return Ok(None);
}
let path = format!("/dev/r{bsd}");
let model = info
.io_registry_entry_name
.as_deref()
.filter(|s| !s.trim().is_empty())
.or(info.media_name.as_deref().filter(|s| !s.trim().is_empty()))
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "(unknown model)".to_string());
Ok(Some(DeviceInfo {
path,
size_bytes: info.size,
model,
internal: info.internal,
is_boot_disk: boot_disk_bsd.map_or(false, |b| b == bsd),
removable: info.removable_media,
}))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct DiskutilInfo {
#[serde(default)]
whole_disk: bool,
#[serde(default, rename = "Size")]
size: u64,
#[serde(default)]
internal: bool,
#[serde(default, rename = "RemovableMedia")]
removable_media: bool,
#[serde(default, rename = "IORegistryEntryName")]
io_registry_entry_name: Option<String>,
#[serde(default, rename = "MediaName")]
media_name: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct DiskutilList {
all_disks: Vec<String>,
}
fn list_all_disks() -> Result<Vec<String>> {
let bytes = run_command("diskutil", &["list", "-plist"])?;
let parsed: DiskutilList = plist::from_bytes(&bytes).map_err(|e| {
DiskError::DaError(format!("parsing diskutil list plist: {e}"))
})?;
Ok(parsed
.all_disks
.into_iter()
.filter(|d| is_whole_disk_name(d))
.collect())
}
fn is_whole_disk_name(s: &str) -> bool {
let Some(rest) = s.strip_prefix("disk") else { return false };
!rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())
}
fn run_diskutil_info(bsd: &str) -> Result<Vec<u8>> {
run_command("diskutil", &["info", "-plist", bsd])
}
fn run_command(cmd: &str, args: &[&str]) -> Result<Vec<u8>> {
let output = Command::new(cmd).args(args).output().map_err(DiskError::Io)?;
if !output.status.success() {
return Err(DiskError::External {
cmd: format!("{cmd} {}", args.join(" ")),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
});
}
Ok(output.stdout)
}
fn boot_disk_whole() -> Result<String> {
use nix::libc;
use std::ffi::{CStr, CString};
use std::mem::MaybeUninit;
let path = CString::new("/").unwrap();
let mut buf: MaybeUninit<libc::statfs> = MaybeUninit::uninit();
let rc = unsafe { libc::statfs(path.as_ptr(), buf.as_mut_ptr()) };
if rc != 0 {
let err = std::io::Error::last_os_error();
return Err(DiskError::DaError(format!("statfs(\"/\") failed: {err}")));
}
let stat = unsafe { buf.assume_init() };
let mnt = unsafe { CStr::from_ptr(stat.f_mntfromname.as_ptr()) };
Ok(normalize_bsd_name(&mnt.to_string_lossy()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_bsd_name_handles_common_inputs() {
assert_eq!(normalize_bsd_name("disk8"), "disk8");
assert_eq!(normalize_bsd_name("rdisk8"), "disk8");
assert_eq!(normalize_bsd_name("/dev/disk8"), "disk8");
assert_eq!(normalize_bsd_name("/dev/rdisk8"), "disk8");
assert_eq!(normalize_bsd_name("/dev/disk3s1"), "disk3");
assert_eq!(normalize_bsd_name("/dev/disk3s1s1"), "disk3");
assert_eq!(normalize_bsd_name("/dev/rdisk12s2"), "disk12");
}
#[test]
fn normalize_bsd_name_preserves_multidigit() {
assert_eq!(normalize_bsd_name("/dev/disk100"), "disk100");
}
}