livedisk/lib.rs
1//! # livedisk
2//!
3//! Cross-platform enumeration of the **live system's** physical disks and
4//! partitions — `diskutil list` / `lsblk` / `diskpart`, but as a library with
5//! one unified model across macOS, Linux, and Windows.
6//!
7//! ```no_run
8//! for disk in livedisk::enumerate()? {
9//! println!("{} — {}", disk.name, livedisk::human_size(disk.size_bytes));
10//! for part in &disk.partitions {
11//! println!(" {} {}", part.name, livedisk::human_size(part.size_bytes));
12//! }
13//! }
14//! # Ok::<(), livedisk::Error>(())
15//! ```
16//!
17//! Discovery is the only OS-specific part. Each backend (sysfs on Linux, the
18//! `IOKit` `IOMedia` registry on macOS, `DeviceIoControl` on Windows) fills the
19//! same [`PhysicalDisk`]/[`Partition`] structs; everything downstream — the
20//! [`render_overview`] bar chart, the per-disk [`render_disk_bar`], the
21//! [`render_listing`] view, and the JSON form — is platform-agnostic.
22//!
23//! [`open_device`] opens a chosen device node as a sized `Read + Seek` so a
24//! partition/filesystem analyzer can run on the live disk exactly as it would on
25//! an image file.
26//!
27//! Listing layout/metadata works **unprivileged** on all three platforms (it
28//! reads the kernel's device registry, not raw sectors); only *reading a device*
29//! needs root/Administrator. Backends therefore never silently return an empty
30//! list on a permission problem — they surface [`Error`].
31
32// Tests assert on known-good fixtures, where a panic on an unexpected value is
33// the intended failure mode. Production code stays under the workspace's
34// `unwrap_used`/`expect_used` denies.
35#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
36
37use core::fmt::Write as _;
38use std::fs::File;
39use std::io::{Seek, SeekFrom};
40use std::path::Path;
41
42mod bar;
43pub use bar::{render_disk_bar, render_overview};
44
45// Pure sysfs parsing for the Linux backend lives in its own module compiled on
46// every target, so its tests run regardless of host; only the file/dir I/O in
47// `linux` is Linux-gated. `dead_code` is expected when not building for Linux.
48#[cfg(target_os = "linux")]
49mod linux;
50#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
51mod sysfs;
52
53// Pure DRIVE_LAYOUT_INFORMATION_EX byte parsing for the Windows backend, on the
54// same always-compiled / Windows-gated-I/O split as `sysfs`/`linux`.
55#[cfg_attr(not(windows), allow(dead_code))]
56mod drive_layout;
57#[cfg(target_os = "macos")]
58mod macos;
59#[cfg(windows)]
60mod windows;
61
62/// Internal parsers of externally-supplied data, exposed **only** under the
63/// `fuzzing` feature for the fuzz harness — not part of the public API. The
64/// wrappers discard results; fuzzing asserts these never panic on malformed
65/// input.
66#[cfg(feature = "fuzzing")]
67#[doc(hidden)]
68pub mod fuzz_api {
69 /// Drive the Windows `DRIVE_LAYOUT_INFORMATION_EX` byte parser.
70 pub fn parse_drive_layout(buf: &[u8]) {
71 let _ = crate::drive_layout::parse_drive_layout(buf);
72 }
73
74 /// Drive the `/proc/mounts` text parser.
75 pub fn parse_mounts(s: &str) {
76 let _ = crate::sysfs::parse_mounts(s);
77 }
78}
79
80/// A whole physical (or, on macOS, synthesized) disk on the live system.
81///
82/// `size_bytes` and the sector sizes come from the OS/driver layer, not from the
83/// on-disk partition table — only the kernel knows the device's true geometry.
84#[derive(Debug, Clone, PartialEq, Eq)]
85#[cfg_attr(feature = "serde", derive(serde::Serialize))]
86pub struct PhysicalDisk {
87 /// OS path to open for raw access (`/dev/disk0`, `/dev/sda`,
88 /// `\\.\PhysicalDrive0`).
89 pub device_path: String,
90 /// Short kernel identifier (`disk0`, `sda`, `PhysicalDrive0`).
91 pub name: String,
92 /// Total device size in bytes, as reported by the driver.
93 pub size_bytes: u64,
94 /// Smallest addressable I/O unit (logical sector), in bytes.
95 pub logical_sector_size: u32,
96 /// Physical sector size in bytes (4096 on 4Kn/512e media; may exceed
97 /// `logical_sector_size`).
98 pub physical_sector_size: u32,
99 /// Device model string, when the driver exposes one.
100 pub model: Option<String>,
101 /// Device serial number, when the driver exposes one.
102 pub serial: Option<String>,
103 /// Removable media (USB stick, SD card, optical).
104 pub removable: bool,
105 /// Device is write-protected / read-only at the driver level.
106 pub read_only: bool,
107 /// Not a backing physical device but a kernel-synthesized one (macOS APFS
108 /// container, Linux device-mapper/LVM). Real evidence imaging targets the
109 /// backing physical disk; synthesized disks are shown for completeness.
110 pub synthesized: bool,
111 /// Partitions/slices carved out of this disk, in on-disk order.
112 pub partitions: Vec<Partition>,
113}
114
115/// A partition (slice/volume) within a [`PhysicalDisk`].
116#[derive(Debug, Clone, PartialEq, Eq)]
117#[cfg_attr(feature = "serde", derive(serde::Serialize))]
118pub struct Partition {
119 /// OS path to open for raw access to just this partition.
120 pub device_path: String,
121 /// Short kernel identifier (`disk0s1`, `sda1`, `nvme0n1p1`).
122 pub name: String,
123 /// Byte offset of the partition's first sector from the start of the disk.
124 pub start_offset: u64,
125 /// Partition length in bytes.
126 pub size_bytes: u64,
127 /// Partition type as the OS names it (GPT type GUID/name, MBR type byte, or
128 /// platform content hint), when known.
129 pub partition_type: Option<String>,
130 /// Current mount point, when the partition is mounted.
131 pub mount_point: Option<String>,
132 /// Mounted filesystem type, when known.
133 pub filesystem: Option<String>,
134 /// Volume label, when known.
135 pub label: Option<String>,
136}
137
138/// Failure enumerating live devices.
139#[derive(Debug, thiserror::Error)]
140pub enum Error {
141 /// Live enumeration has no backend for this target OS.
142 #[error("live device enumeration is not supported on this platform")]
143 Unsupported,
144 /// An I/O error while reading the OS device registry.
145 #[error("I/O error enumerating devices: {0}")]
146 Io(#[from] std::io::Error),
147 /// The platform enumeration API returned an error.
148 #[error("device enumeration failed: {0}")]
149 Os(String),
150}
151
152/// Enumerate every physical disk on the live system, each with its partitions.
153///
154/// Dispatches to the platform backend. The list is best-effort complete: a disk
155/// whose details cannot be read is still listed with whatever the OS provided.
156///
157/// # Errors
158/// [`Error::Unsupported`] on a target without a backend, [`Error::Io`] /
159/// [`Error::Os`] when the OS device registry cannot be read.
160pub fn enumerate() -> Result<Vec<PhysicalDisk>, Error> {
161 #[cfg(target_os = "linux")]
162 {
163 linux::enumerate()
164 }
165 #[cfg(target_os = "macos")]
166 {
167 macos::enumerate()
168 }
169 #[cfg(windows)]
170 {
171 windows::enumerate()
172 }
173 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
174 {
175 Err(Error::Unsupported)
176 }
177}
178
179/// Open a live block device for reading and return it with its size in bytes.
180///
181/// Block devices report `metadata().len() == 0`, so the size is obtained by
182/// seeking to the end; the handle is rewound to the start before returning, so
183/// the caller gets a fresh `Read + Seek` view ready for partition/filesystem
184/// analysis. Reading a raw device typically requires root/Administrator — the
185/// returned [`std::io::Error`] surfaces a permission failure rather than masking
186/// it.
187///
188/// # Errors
189/// Propagates any I/O error from opening or seeking the device.
190pub fn open_device(path: &Path) -> std::io::Result<(File, u64)> {
191 let mut file = File::open(path)?;
192 let size = file.seek(SeekFrom::End(0))?;
193 file.seek(SeekFrom::Start(0))?;
194 Ok((file, size))
195}
196
197/// Format a byte count the way disk utilities do — decimal (SI) units with one
198/// fractional digit (`4.0 TB`, `524.3 MB`, `24.6 KB`), matching `diskutil`/
199/// `lsblk` so output is recognisable. Bytes under 1000 render as `N B`.
200#[must_use]
201pub fn human_size(bytes: u64) -> String {
202 const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
203 if bytes < 1000 {
204 return format!("{bytes} B");
205 }
206 let mut value = bytes as f64;
207 let mut unit = 0;
208 while value >= 1000.0 && unit < UNITS.len() - 1 {
209 value /= 1000.0;
210 unit += 1;
211 }
212 format!("{value:.1} {}", UNITS[unit])
213}
214
215/// Render the enumerated disks as a unified, indented text table — the
216/// `disk4n6 list` human view. Whole disks are flush-left; their partitions are
217/// indented beneath them, so the layout reads the same on every platform.
218#[must_use]
219pub fn render_disks(disks: &[PhysicalDisk]) -> String {
220 let mut s = String::new();
221 if disks.is_empty() {
222 s.push_str("No disks found.\n");
223 return s;
224 }
225 let _ = writeln!(s, "{:<14} {:>10} {:<6} INFO", "NAME", "SIZE", "TYPE");
226 for d in disks {
227 let kind = if d.synthesized { "synth" } else { "disk" };
228 let mut info = d.model.clone().unwrap_or_default();
229 if d.removable {
230 info = if info.is_empty() {
231 "removable".to_string()
232 } else {
233 format!("{info} (removable)")
234 };
235 }
236 let _ = writeln!(
237 s,
238 "{:<14} {:>10} {:<6} {}",
239 d.name,
240 human_size(d.size_bytes),
241 kind,
242 info.trim()
243 );
244 for p in &d.partitions {
245 let indented = format!(" {}", p.name);
246 let _ = writeln!(
247 s,
248 "{:<14} {:>10} {:<6} {}",
249 indented,
250 human_size(p.size_bytes),
251 "part",
252 partition_info(p)
253 );
254 }
255 }
256 s
257}
258
259/// The trailing description column for a partition row: type, then mount point
260/// and label when present (`Apple_APFS /Volumes/Data [DATA]`).
261fn partition_info(p: &Partition) -> String {
262 let mut parts: Vec<String> = Vec::new();
263 if let Some(t) = &p.partition_type {
264 parts.push(t.clone());
265 }
266 if let Some(m) = &p.mount_point {
267 parts.push(m.clone());
268 }
269 if let Some(l) = &p.label {
270 parts.push(format!("[{l}]"));
271 }
272 parts.join(" ")
273}
274
275/// Render the full `disk4n6 list` view: each disk as a header line followed by
276/// its proportional partition bar (see [`render_disk_bar`]). Synthesized disks
277/// (macOS APFS containers, Linux device-mapper) whose volumes share space rather
278/// than occupy fixed extents get a plain volume list instead of a — misleading —
279/// proportional bar. `color` selects ANSI vs ASCII (the caller passes whether
280/// stdout is a TTY).
281#[must_use]
282pub fn render_listing(disks: &[PhysicalDisk], width: usize, color: bool) -> String {
283 if disks.is_empty() {
284 return "No disks found.\n".to_string();
285 }
286 let mut s = String::new();
287 // At-a-glance comparison of the physical disks' capacities, then per-disk
288 // detail. Empty (and skipped) when there are fewer than two physical disks.
289 let overview = render_overview(disks, width, color);
290 if !overview.is_empty() {
291 s.push_str(&overview);
292 s.push('\n');
293 }
294 // Physical disks are colour-indexed in overview order; the per-disk bar
295 // reuses that index as its accent so a disk's largest partition matches the
296 // colour representing it in the overview.
297 let mut phys_idx = 0;
298 for d in disks {
299 let kind = if d.synthesized { " (synthesized)" } else { "" };
300 let model = d
301 .model
302 .as_deref()
303 .map(|m| format!(" {m}"))
304 .unwrap_or_default();
305 let _ = writeln!(
306 s,
307 "{} {}{kind}{model}",
308 d.device_path,
309 human_size(d.size_bytes)
310 );
311 if d.partitions.is_empty() {
312 s.push_str(" (no partitions)\n");
313 } else if d.synthesized {
314 for p in &d.partitions {
315 let _ = writeln!(
316 s,
317 " {:<16} {:>10} {}",
318 p.name,
319 human_size(p.size_bytes),
320 partition_info(p)
321 );
322 }
323 s.push_str(" (volumes share container space)\n");
324 } else {
325 s.push_str(&bar::disk_bar(d, width, color, phys_idx));
326 }
327 if !d.synthesized {
328 phys_idx += 1;
329 }
330 s.push('\n');
331 }
332 s
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 fn sample_disk() -> PhysicalDisk {
340 PhysicalDisk {
341 device_path: "/dev/disk0".into(),
342 name: "disk0".into(),
343 size_bytes: 4_000_000_000_000,
344 logical_sector_size: 512,
345 physical_sector_size: 4096,
346 model: Some("APPLE SSD AP4096".into()),
347 serial: None,
348 removable: false,
349 read_only: false,
350 synthesized: false,
351 partitions: vec![Partition {
352 device_path: "/dev/disk0s1".into(),
353 name: "disk0s1".into(),
354 start_offset: 20480,
355 size_bytes: 524_300_000,
356 partition_type: Some("Apple_APFS_ISC".into()),
357 mount_point: None,
358 filesystem: None,
359 label: None,
360 }],
361 }
362 }
363
364 #[test]
365 fn human_size_matches_decimal_units() {
366 assert_eq!(human_size(512), "512 B");
367 assert_eq!(human_size(999), "999 B");
368 assert_eq!(human_size(1000), "1.0 KB");
369 assert_eq!(human_size(24_576), "24.6 KB");
370 assert_eq!(human_size(524_300_000), "524.3 MB");
371 assert_eq!(human_size(5_400_000_000), "5.4 GB");
372 assert_eq!(human_size(4_000_000_000_000), "4.0 TB");
373 }
374
375 #[test]
376 fn render_disks_shows_disk_then_indented_partitions() {
377 let out = render_disks(&[sample_disk()]);
378 assert!(out.contains("NAME"));
379 assert!(out.contains("disk0"));
380 assert!(out.contains("4.0 TB"));
381 assert!(out.contains("APPLE SSD AP4096"));
382 // The partition is indented and tagged `part` with its type.
383 assert!(out.contains(" disk0s1"));
384 assert!(out.contains("Apple_APFS_ISC"));
385 let disk_line = out.lines().find(|l| l.contains("disk0 ")).unwrap();
386 assert!(disk_line.contains("disk"));
387 }
388
389 #[test]
390 fn render_disks_empty_is_explicit() {
391 assert_eq!(render_disks(&[]), "No disks found.\n");
392 }
393
394 #[test]
395 fn partition_info_joins_type_mount_label() {
396 let p = Partition {
397 device_path: "/dev/disk0s2".into(),
398 name: "disk0s2".into(),
399 start_offset: 0,
400 size_bytes: 1,
401 partition_type: Some("Apple_APFS".into()),
402 mount_point: Some("/Volumes/Data".into()),
403 label: Some("DATA".into()),
404 filesystem: None,
405 };
406 assert_eq!(partition_info(&p), "Apple_APFS /Volumes/Data [DATA]");
407 }
408
409 #[test]
410 fn removable_flag_annotates_info() {
411 let mut d = sample_disk();
412 d.model = None;
413 d.removable = true;
414 let out = render_disks(&[d]);
415 assert!(out.contains("removable"));
416 }
417
418 #[test]
419 fn render_listing_draws_bar_for_physical_disk() {
420 let out = render_listing(&[sample_disk()], 40, false);
421 assert!(out.contains("/dev/disk0"));
422 assert!(out.contains("4.0 TB"));
423 assert!(out.contains("APPLE SSD AP4096"));
424 assert!(out.contains('['), "physical disk gets a proportional bar");
425 }
426
427 #[test]
428 fn render_listing_lists_volumes_for_synthesized_disk() {
429 let mut d = sample_disk();
430 d.synthesized = true;
431 d.model = None;
432 let out = render_listing(&[d], 40, false);
433 assert!(out.contains("(synthesized)"));
434 assert!(out.contains("share container space"));
435 // No proportional bar for shared-space volumes.
436 assert!(!out.contains('['));
437 }
438
439 #[test]
440 fn render_listing_empty_is_explicit() {
441 assert_eq!(render_listing(&[], 40, false), "No disks found.\n");
442 }
443
444 // Smoke tests exercising the OS-facing entry points against the real host
445 // (drives the platform backend + open_device end-to-end; on CI this covers
446 // the sysfs/IOKit/DeviceIoControl dispatch). Output is host-dependent, so
447 // they assert only that the calls run, not a specific device list.
448 #[test]
449 fn enumerate_runs_on_host() {
450 // Lists the machine's disks, or fails loud where raw access needs
451 // privileges — never panics.
452 let _ = enumerate();
453 }
454
455 #[cfg(unix)]
456 #[test]
457 fn open_device_sizes_dev_null_to_zero() {
458 let (_file, size) = open_device(Path::new("/dev/null")).unwrap();
459 assert_eq!(size, 0);
460 }
461}