livedisk-core 0.1.0

Cross-platform live block-device enumeration — list physical disks and partitions on the running host (macOS IOKit, Linux sysfs, Windows DeviceIoControl) in one unified model, with proportional partition-layout rendering
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
//! # livedisk
//!
//! Cross-platform enumeration of the **live system's** physical disks and
//! partitions — `diskutil list` / `lsblk` / `diskpart`, but as a library with
//! one unified model across macOS, Linux, and Windows.
//!
//! ```no_run
//! for disk in livedisk::enumerate()? {
//!     println!("{} — {}", disk.name, livedisk::human_size(disk.size_bytes));
//!     for part in &disk.partitions {
//!         println!("  {} {}", part.name, livedisk::human_size(part.size_bytes));
//!     }
//! }
//! # Ok::<(), livedisk::Error>(())
//! ```
//!
//! Discovery is the only OS-specific part. Each backend (sysfs on Linux, the
//! `IOKit` `IOMedia` registry on macOS, `DeviceIoControl` on Windows) fills the
//! same [`PhysicalDisk`]/[`Partition`] structs; everything downstream — the
//! [`render_overview`] bar chart, the per-disk [`render_disk_bar`], the
//! [`render_listing`] view, and the JSON form — is platform-agnostic.
//!
//! [`open_device`] opens a chosen device node as a sized `Read + Seek` so a
//! partition/filesystem analyzer can run on the live disk exactly as it would on
//! an image file.
//!
//! Listing layout/metadata works **unprivileged** on all three platforms (it
//! reads the kernel's device registry, not raw sectors); only *reading a device*
//! needs root/Administrator. Backends therefore never silently return an empty
//! list on a permission problem — they surface [`Error`].

use core::fmt::Write as _;
use std::fs::File;
use std::io::{Seek, SeekFrom};
use std::path::Path;

mod bar;
pub use bar::{render_disk_bar, render_overview};

// Pure sysfs parsing for the Linux backend lives in its own module compiled on
// every target, so its tests run regardless of host; only the file/dir I/O in
// `linux` is Linux-gated. `dead_code` is expected when not building for Linux.
#[cfg(target_os = "linux")]
mod linux;
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
mod sysfs;

// Pure DRIVE_LAYOUT_INFORMATION_EX byte parsing for the Windows backend, on the
// same always-compiled / Windows-gated-I/O split as `sysfs`/`linux`.
#[cfg_attr(not(windows), allow(dead_code))]
mod drive_layout;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(windows)]
mod windows;

/// A whole physical (or, on macOS, synthesized) disk on the live system.
///
/// `size_bytes` and the sector sizes come from the OS/driver layer, not from the
/// on-disk partition table — only the kernel knows the device's true geometry.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct PhysicalDisk {
    /// OS path to open for raw access (`/dev/disk0`, `/dev/sda`,
    /// `\\.\PhysicalDrive0`).
    pub device_path: String,
    /// Short kernel identifier (`disk0`, `sda`, `PhysicalDrive0`).
    pub name: String,
    /// Total device size in bytes, as reported by the driver.
    pub size_bytes: u64,
    /// Smallest addressable I/O unit (logical sector), in bytes.
    pub logical_sector_size: u32,
    /// Physical sector size in bytes (4096 on 4Kn/512e media; may exceed
    /// `logical_sector_size`).
    pub physical_sector_size: u32,
    /// Device model string, when the driver exposes one.
    pub model: Option<String>,
    /// Device serial number, when the driver exposes one.
    pub serial: Option<String>,
    /// Removable media (USB stick, SD card, optical).
    pub removable: bool,
    /// Device is write-protected / read-only at the driver level.
    pub read_only: bool,
    /// Not a backing physical device but a kernel-synthesized one (macOS APFS
    /// container, Linux device-mapper/LVM). Real evidence imaging targets the
    /// backing physical disk; synthesized disks are shown for completeness.
    pub synthesized: bool,
    /// Partitions/slices carved out of this disk, in on-disk order.
    pub partitions: Vec<Partition>,
}

/// A partition (slice/volume) within a [`PhysicalDisk`].
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Partition {
    /// OS path to open for raw access to just this partition.
    pub device_path: String,
    /// Short kernel identifier (`disk0s1`, `sda1`, `nvme0n1p1`).
    pub name: String,
    /// Byte offset of the partition's first sector from the start of the disk.
    pub start_offset: u64,
    /// Partition length in bytes.
    pub size_bytes: u64,
    /// Partition type as the OS names it (GPT type GUID/name, MBR type byte, or
    /// platform content hint), when known.
    pub partition_type: Option<String>,
    /// Current mount point, when the partition is mounted.
    pub mount_point: Option<String>,
    /// Mounted filesystem type, when known.
    pub filesystem: Option<String>,
    /// Volume label, when known.
    pub label: Option<String>,
}

/// Failure enumerating live devices.
#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// Live enumeration has no backend for this target OS.
    #[error("live device enumeration is not supported on this platform")]
    Unsupported,
    /// An I/O error while reading the OS device registry.
    #[error("I/O error enumerating devices: {0}")]
    Io(#[from] std::io::Error),
    /// The platform enumeration API returned an error.
    #[error("device enumeration failed: {0}")]
    Os(String),
}

/// Enumerate every physical disk on the live system, each with its partitions.
///
/// Dispatches to the platform backend. The list is best-effort complete: a disk
/// whose details cannot be read is still listed with whatever the OS provided.
///
/// # Errors
/// [`Error::Unsupported`] on a target without a backend, [`Error::Io`] /
/// [`Error::Os`] when the OS device registry cannot be read.
pub fn enumerate() -> Result<Vec<PhysicalDisk>, Error> {
    #[cfg(target_os = "linux")]
    {
        linux::enumerate()
    }
    #[cfg(target_os = "macos")]
    {
        macos::enumerate()
    }
    #[cfg(windows)]
    {
        windows::enumerate()
    }
    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
    {
        Err(Error::Unsupported)
    }
}

/// Open a live block device for reading and return it with its size in bytes.
///
/// Block devices report `metadata().len() == 0`, so the size is obtained by
/// seeking to the end; the handle is rewound to the start before returning, so
/// the caller gets a fresh `Read + Seek` view ready for partition/filesystem
/// analysis. Reading a raw device typically requires root/Administrator — the
/// returned [`std::io::Error`] surfaces a permission failure rather than masking
/// it.
///
/// # Errors
/// Propagates any I/O error from opening or seeking the device.
pub fn open_device(path: &Path) -> std::io::Result<(File, u64)> {
    let mut file = File::open(path)?;
    let size = file.seek(SeekFrom::End(0))?;
    file.seek(SeekFrom::Start(0))?;
    Ok((file, size))
}

/// Format a byte count the way disk utilities do — decimal (SI) units with one
/// fractional digit (`4.0 TB`, `524.3 MB`, `24.6 KB`), matching `diskutil`/
/// `lsblk` so output is recognisable. Bytes under 1000 render as `N B`.
#[must_use]
pub fn human_size(bytes: u64) -> String {
    const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
    if bytes < 1000 {
        return format!("{bytes} B");
    }
    let mut value = bytes as f64;
    let mut unit = 0;
    while value >= 1000.0 && unit < UNITS.len() - 1 {
        value /= 1000.0;
        unit += 1;
    }
    format!("{value:.1} {}", UNITS[unit])
}

/// Render the enumerated disks as a unified, indented text table — the
/// `disk4n6 list` human view. Whole disks are flush-left; their partitions are
/// indented beneath them, so the layout reads the same on every platform.
#[must_use]
pub fn render_disks(disks: &[PhysicalDisk]) -> String {
    let mut s = String::new();
    if disks.is_empty() {
        s.push_str("No disks found.\n");
        return s;
    }
    let _ = writeln!(s, "{:<14} {:>10}  {:<6} INFO", "NAME", "SIZE", "TYPE");
    for d in disks {
        let kind = if d.synthesized { "synth" } else { "disk" };
        let mut info = d.model.clone().unwrap_or_default();
        if d.removable {
            info = if info.is_empty() {
                "removable".to_string()
            } else {
                format!("{info} (removable)")
            };
        }
        let _ = writeln!(
            s,
            "{:<14} {:>10}  {:<6} {}",
            d.name,
            human_size(d.size_bytes),
            kind,
            info.trim()
        );
        for p in &d.partitions {
            let indented = format!("  {}", p.name);
            let _ = writeln!(
                s,
                "{:<14} {:>10}  {:<6} {}",
                indented,
                human_size(p.size_bytes),
                "part",
                partition_info(p)
            );
        }
    }
    s
}

/// The trailing description column for a partition row: type, then mount point
/// and label when present (`Apple_APFS  /Volumes/Data [DATA]`).
fn partition_info(p: &Partition) -> String {
    let mut parts: Vec<String> = Vec::new();
    if let Some(t) = &p.partition_type {
        parts.push(t.clone());
    }
    if let Some(m) = &p.mount_point {
        parts.push(m.clone());
    }
    if let Some(l) = &p.label {
        parts.push(format!("[{l}]"));
    }
    parts.join("  ")
}

/// Render the full `disk4n6 list` view: each disk as a header line followed by
/// its proportional partition bar (see [`render_disk_bar`]). Synthesized disks
/// (macOS APFS containers, Linux device-mapper) whose volumes share space rather
/// than occupy fixed extents get a plain volume list instead of a — misleading —
/// proportional bar. `color` selects ANSI vs ASCII (the caller passes whether
/// stdout is a TTY).
#[must_use]
pub fn render_listing(disks: &[PhysicalDisk], width: usize, color: bool) -> String {
    if disks.is_empty() {
        return "No disks found.\n".to_string();
    }
    let mut s = String::new();
    // At-a-glance comparison of the physical disks' capacities, then per-disk
    // detail. Empty (and skipped) when there are fewer than two physical disks.
    let overview = render_overview(disks, width, color);
    if !overview.is_empty() {
        s.push_str(&overview);
        s.push('\n');
    }
    for d in disks {
        let kind = if d.synthesized { " (synthesized)" } else { "" };
        let model = d
            .model
            .as_deref()
            .map(|m| format!("  {m}"))
            .unwrap_or_default();
        let _ = writeln!(
            s,
            "{}  {}{kind}{model}",
            d.device_path,
            human_size(d.size_bytes)
        );
        if d.partitions.is_empty() {
            s.push_str("  (no partitions)\n");
        } else if d.synthesized {
            for p in &d.partitions {
                let _ = writeln!(
                    s,
                    "  {:<16} {:>10}  {}",
                    p.name,
                    human_size(p.size_bytes),
                    partition_info(p)
                );
            }
            s.push_str("  (volumes share container space)\n");
        } else {
            s.push_str(&render_disk_bar(d, width, color));
        }
        s.push('\n');
    }
    s
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample_disk() -> PhysicalDisk {
        PhysicalDisk {
            device_path: "/dev/disk0".into(),
            name: "disk0".into(),
            size_bytes: 4_000_000_000_000,
            logical_sector_size: 512,
            physical_sector_size: 4096,
            model: Some("APPLE SSD AP4096".into()),
            serial: None,
            removable: false,
            read_only: false,
            synthesized: false,
            partitions: vec![Partition {
                device_path: "/dev/disk0s1".into(),
                name: "disk0s1".into(),
                start_offset: 20480,
                size_bytes: 524_300_000,
                partition_type: Some("Apple_APFS_ISC".into()),
                mount_point: None,
                filesystem: None,
                label: None,
            }],
        }
    }

    #[test]
    fn human_size_matches_decimal_units() {
        assert_eq!(human_size(512), "512 B");
        assert_eq!(human_size(999), "999 B");
        assert_eq!(human_size(1000), "1.0 KB");
        assert_eq!(human_size(24_576), "24.6 KB");
        assert_eq!(human_size(524_300_000), "524.3 MB");
        assert_eq!(human_size(5_400_000_000), "5.4 GB");
        assert_eq!(human_size(4_000_000_000_000), "4.0 TB");
    }

    #[test]
    fn render_disks_shows_disk_then_indented_partitions() {
        let out = render_disks(&[sample_disk()]);
        assert!(out.contains("NAME"));
        assert!(out.contains("disk0"));
        assert!(out.contains("4.0 TB"));
        assert!(out.contains("APPLE SSD AP4096"));
        // The partition is indented and tagged `part` with its type.
        assert!(out.contains("  disk0s1"));
        assert!(out.contains("Apple_APFS_ISC"));
        let disk_line = out.lines().find(|l| l.contains("disk0 ")).unwrap();
        assert!(disk_line.contains("disk"));
    }

    #[test]
    fn render_disks_empty_is_explicit() {
        assert_eq!(render_disks(&[]), "No disks found.\n");
    }

    #[test]
    fn partition_info_joins_type_mount_label() {
        let p = Partition {
            device_path: "/dev/disk0s2".into(),
            name: "disk0s2".into(),
            start_offset: 0,
            size_bytes: 1,
            partition_type: Some("Apple_APFS".into()),
            mount_point: Some("/Volumes/Data".into()),
            label: Some("DATA".into()),
            filesystem: None,
        };
        assert_eq!(partition_info(&p), "Apple_APFS  /Volumes/Data  [DATA]");
    }

    #[test]
    fn removable_flag_annotates_info() {
        let mut d = sample_disk();
        d.model = None;
        d.removable = true;
        let out = render_disks(&[d]);
        assert!(out.contains("removable"));
    }

    #[test]
    fn render_listing_draws_bar_for_physical_disk() {
        let out = render_listing(&[sample_disk()], 40, false);
        assert!(out.contains("/dev/disk0"));
        assert!(out.contains("4.0 TB"));
        assert!(out.contains("APPLE SSD AP4096"));
        assert!(out.contains('['), "physical disk gets a proportional bar");
    }

    #[test]
    fn render_listing_lists_volumes_for_synthesized_disk() {
        let mut d = sample_disk();
        d.synthesized = true;
        d.model = None;
        let out = render_listing(&[d], 40, false);
        assert!(out.contains("(synthesized)"));
        assert!(out.contains("share container space"));
        // No proportional bar for shared-space volumes.
        assert!(!out.contains('['));
    }

    #[test]
    fn render_listing_empty_is_explicit() {
        assert_eq!(render_listing(&[], 40, false), "No disks found.\n");
    }

    // Smoke tests exercising the OS-facing entry points against the real host
    // (drives the platform backend + open_device end-to-end; on CI this covers
    // the sysfs/IOKit/DeviceIoControl dispatch). Output is host-dependent, so
    // they assert only that the calls run, not a specific device list.
    #[test]
    fn enumerate_runs_on_host() {
        // Lists the machine's disks, or fails loud where raw access needs
        // privileges — never panics.
        let _ = enumerate();
    }

    #[cfg(unix)]
    #[test]
    fn open_device_sizes_dev_null_to_zero() {
        let (_file, size) = open_device(Path::new("/dev/null")).unwrap();
        assert_eq!(size, 0);
    }
}