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