Skip to main content

livedisk_forensic/
lib.rs

1//! # livedisk-forensic
2//!
3//! Acquisition-integrity analysis of a live block device enumerated by
4//! [`livedisk`]. Given a [`PhysicalDisk`], [`analyse`] returns graded
5//! [`forensicnomicon`] findings flagging conditions that bear on a *forensically
6//! sound acquisition* of the running system — never a verdict, always an
7//! observation:
8//!
9//! - `LIVE-MOUNTED` — a volume is mounted during acquisition (live writes may
10//!   alter the image).
11//! - `LIVE-WRITABLE` — the device being **acquired** is writable (no hardware
12//!   write-blocker engaged). Emitted only by [`analyse_target`], never by the
13//!   host overview [`analyse`] — on a live host every disk is writable, so it
14//!   would fire on every device.
15//! - `LIVE-REMOVABLE` — removable media.
16//! - `LIVE-SECTOR-4KN` — logical/physical sector sizes differ (512e/4Kn).
17//! - `LIVE-SYNTHESIZED` — a synthesized container overlay, not a backing
18//!   physical store.
19//!
20//! ```no_run
21//! for disk in livedisk::enumerate()? {
22//!     for finding in livedisk_forensic::analyse(&disk) {
23//!         println!("{}: {}", finding.code, finding.note);
24//!     }
25//! }
26//! # Ok::<(), livedisk::Error>(())
27//! ```
28
29use forensicnomicon::report::{Category, Finding, Severity, Source};
30use livedisk::PhysicalDisk;
31
32/// Analyzer name recorded on every finding's [`Source`].
33const ANALYZER: &str = "livedisk-forensic";
34
35fn source(disk: &PhysicalDisk) -> Source {
36    Source {
37        analyzer: ANALYZER.to_string(),
38        scope: disk.name.clone(),
39        version: Some(env!("CARGO_PKG_VERSION").to_string()),
40    }
41}
42
43/// Analyse a live disk for acquisition-integrity conditions, returning graded
44/// findings (empty for a write-protected, unmounted, fixed, non-synthesized
45/// disk with matching sector sizes — the ideal acquisition target).
46#[must_use]
47pub fn analyse(disk: &PhysicalDisk) -> Vec<Finding> {
48    let mut findings = Vec::new();
49
50    // LIVE-MOUNTED — mounted volumes during acquisition risk altering the image.
51    let mounted = disk
52        .partitions
53        .iter()
54        .filter(|p| p.mount_point.is_some())
55        .count();
56    if mounted > 0 {
57        let mut builder = Finding::observation(Severity::High, Category::Integrity, "LIVE-MOUNTED")
58            .source(source(disk))
59            .note(
60                "device has mounted volume(s) during acquisition; live writes may alter the \
61                 image — consistent with imaging a running system",
62            );
63        for p in &disk.partitions {
64            if let Some(mount) = &p.mount_point {
65                builder = builder.evidence(p.name.clone(), mount.clone());
66            }
67        }
68        findings.push(builder.build());
69    }
70
71    // LIVE-WRITABLE is intentionally NOT emitted here: on a live host every
72    // internal disk is writable, so flagging it per-device is noise that buries
73    // the discriminating findings. It is signal only for the specific device
74    // being acquired — see `analyse_target`.
75
76    // LIVE-REMOVABLE — removable media (provenance/chain-of-custody context).
77    if disk.removable {
78        findings.push(
79            Finding::observation(Severity::Info, Category::Provenance, "LIVE-REMOVABLE")
80                .source(source(disk))
81                .note("removable media")
82                .build(),
83        );
84    }
85
86    // LIVE-SECTOR-4KN — 512e/4Kn mismatch; image aligned to the physical sector.
87    if disk.logical_sector_size > 0 && disk.physical_sector_size != disk.logical_sector_size {
88        findings.push(
89            Finding::observation(Severity::Info, Category::Structure, "LIVE-SECTOR-4KN")
90                .source(source(disk))
91                .note(
92                    "logical and physical sector sizes differ (512e/4Kn); align imaging to the \
93                     physical sector size",
94                )
95                .evidence("logical_sector_size", disk.logical_sector_size.to_string())
96                .evidence(
97                    "physical_sector_size",
98                    disk.physical_sector_size.to_string(),
99                )
100                .build(),
101        );
102    }
103
104    // LIVE-SYNTHESIZED — overlay (APFS container, device-mapper), not a store.
105    if disk.synthesized {
106        findings.push(
107            Finding::observation(Severity::Info, Category::Provenance, "LIVE-SYNTHESIZED")
108                .source(source(disk))
109                .note(
110                    "synthesized device — a container overlay (e.g. APFS container, \
111                     device-mapper/LVM) over one or more physical stores, not itself a backing \
112                     physical disk",
113                )
114                .build(),
115        );
116    }
117
118    findings
119}
120
121/// Analyse a disk you intend to **acquire** (image). Returns everything
122/// [`analyse`] reports for the host overview, plus the acquisition-target-only
123/// `LIVE-WRITABLE` warning when the device is writable — i.e. no hardware
124/// write-blocker is engaged, so imaging could alter the evidence. On a live
125/// host every internal disk is writable, so that condition is omitted from the
126/// overview [`analyse`] (it would fire on every device); it is signal only for
127/// the specific device under acquisition.
128#[must_use]
129pub fn analyse_target(disk: &PhysicalDisk) -> Vec<Finding> {
130    let mut findings = analyse(disk);
131    if !disk.read_only {
132        findings.push(
133            Finding::observation(Severity::High, Category::Integrity, "LIVE-WRITABLE")
134                .source(source(disk))
135                .note(
136                    "acquisition target is writable — no hardware write-blocker is engaged; \
137                     imaging can alter the evidence",
138                )
139                .build(),
140        );
141    }
142    findings
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use livedisk::Partition;
149
150    /// A pristine acquisition target: write-protected, unmounted, fixed,
151    /// physical, matching sector sizes.
152    fn clean_disk() -> PhysicalDisk {
153        PhysicalDisk {
154            device_path: "/dev/disk0".into(),
155            name: "disk0".into(),
156            size_bytes: 1_000_000_000_000,
157            logical_sector_size: 512,
158            physical_sector_size: 512,
159            model: Some("WRITE BLOCKED".into()),
160            serial: None,
161            removable: false,
162            read_only: true,
163            synthesized: false,
164            partitions: vec![],
165        }
166    }
167
168    fn codes(findings: &[Finding]) -> Vec<&str> {
169        findings.iter().map(|f| f.code.as_ref()).collect()
170    }
171
172    #[test]
173    fn clean_write_protected_disk_has_no_findings() {
174        assert!(analyse(&clean_disk()).is_empty());
175    }
176
177    #[test]
178    fn overview_analyse_omits_live_writable() {
179        // Writable is the baseline on a live host — flagging it on every disk is
180        // noise, so the overview analyser must not emit LIVE-WRITABLE.
181        let mut d = clean_disk();
182        d.read_only = false;
183        assert!(!codes(&analyse(&d)).contains(&"LIVE-WRITABLE"));
184    }
185
186    #[test]
187    fn target_analyse_flags_writable_high() {
188        // Imaging a writable target means no write-blocker is engaged — a real,
189        // high-severity acquisition risk.
190        let mut d = clean_disk();
191        d.read_only = false;
192        let findings = analyse_target(&d);
193        let f = findings.iter().find(|f| f.code == "LIVE-WRITABLE").unwrap();
194        assert_eq!(f.severity, Some(Severity::High));
195        assert_eq!(f.source.analyzer, "livedisk-forensic");
196        assert_eq!(f.source.scope, "disk0");
197    }
198
199    #[test]
200    fn target_analyse_write_blocked_has_no_writable() {
201        // A read-only target = write-blocker engaged → reassuring silence.
202        assert!(!codes(&analyse_target(&clean_disk())).contains(&"LIVE-WRITABLE"));
203    }
204
205    #[test]
206    fn mounted_disk_flags_live_mounted_high_with_evidence() {
207        let mut d = clean_disk();
208        d.partitions = vec![Partition {
209            device_path: "/dev/disk0s1".into(),
210            name: "disk0s1".into(),
211            start_offset: 0,
212            size_bytes: 1,
213            partition_type: None,
214            mount_point: Some("/Volumes/Data".into()),
215            filesystem: None,
216            label: None,
217        }];
218        let findings = analyse(&d);
219        let f = findings.iter().find(|f| f.code == "LIVE-MOUNTED").unwrap();
220        assert_eq!(f.severity, Some(Severity::High));
221        assert!(f.evidence.iter().any(|e| e.value == "/Volumes/Data"));
222    }
223
224    #[test]
225    fn removable_disk_flags_live_removable_info() {
226        let mut d = clean_disk();
227        d.removable = true;
228        assert!(codes(&analyse(&d)).contains(&"LIVE-REMOVABLE"));
229    }
230
231    #[test]
232    fn sector_mismatch_flags_4kn_with_both_sizes() {
233        let mut d = clean_disk();
234        d.logical_sector_size = 512;
235        d.physical_sector_size = 4096;
236        let findings = analyse(&d);
237        let f = findings
238            .iter()
239            .find(|f| f.code == "LIVE-SECTOR-4KN")
240            .unwrap();
241        assert!(f.evidence.iter().any(|e| e.value == "4096"));
242        assert!(f.evidence.iter().any(|e| e.value == "512"));
243    }
244
245    #[test]
246    fn synthesized_disk_flags_live_synthesized() {
247        let mut d = clean_disk();
248        d.synthesized = true;
249        assert!(codes(&analyse(&d)).contains(&"LIVE-SYNTHESIZED"));
250    }
251}