Skip to main content

forensicnomicon_core/
volume_serial.rs

1//! Volume serial-number extraction from a volume boot record.
2//!
3//! The volume serial is a forensic join key: a FAT `BS_VolID` matches the serial that
4//! `EMDMgmt` (ReadyBoost) and Windows Shell Links record for the same volume; an NTFS volume
5//! serial appears in its own artifacts. Like [`crate::volume_encryption`], this reads the
6//! boot record, so it is independent of the bus and of the MBR/GPT/APM partition table.
7//!
8//! Field offsets are from the Microsoft FAT specification (`fatgen103`), the Microsoft exFAT
9//! specification, and the NTFS boot-sector layout.
10
11/// A volume serial number, tagged by width so a 4-byte serial cannot be confused with an
12/// 8-byte one (they render differently and join different artifacts).
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize))]
15pub enum VolumeSerial {
16    /// A 4-byte serial — FAT `BS_VolID` or exFAT `VolumeSerialNumber`. Rendered `XXXX-XXXX`,
17    /// the form Shell Links and `EMDMgmt` record.
18    Short(u32),
19    /// The 8-byte NTFS volume serial. Rendered `XXXXXXXX-XXXXXXXX`; by width it cannot collide
20    /// with a 4-byte serial.
21    Long(u64),
22}
23
24impl VolumeSerial {
25    /// The canonical hex rendering: `XXXX-XXXX` for a 4-byte serial, `XXXXXXXX-XXXXXXXX` for
26    /// the 8-byte NTFS serial.
27    #[must_use]
28    pub fn display(self) -> String {
29        match self {
30            Self::Short(v) => format!("{:04X}-{:04X}", v >> 16, v & 0xFFFF),
31            Self::Long(v) => format!("{:08X}-{:08X}", (v >> 32) as u32, (v & 0xFFFF_FFFF) as u32),
32        }
33    }
34}
35
36/// Extract the volume serial from a volume's boot record, keyed off the filesystem signature:
37///
38/// - NTFS (`"NTFS    "` at offset 3) → 8-byte serial at offset `0x48`.
39/// - exFAT (`"EXFAT   "` at offset 3) → 4-byte `VolumeSerialNumber` at offset `0x64`.
40/// - FAT32 (`"FAT32   "` at offset `0x52`) → 4-byte `BS_VolID` at offset `0x43`.
41/// - FAT12/16 (`"FAT"` at offset `0x36`) → 4-byte `BS_VolID` at offset `0x27`.
42///
43/// `None` for any other volume, or a slice too short to reach the field. Panic-free.
44#[must_use]
45pub fn volume_serial(volume: &[u8]) -> Option<VolumeSerial> {
46    if volume.get(3..11) == Some(b"NTFS    ") {
47        return u64_le(volume, 0x48).map(VolumeSerial::Long);
48    }
49    if volume.get(3..11) == Some(b"EXFAT   ") {
50        return u32_le(volume, 0x64).map(VolumeSerial::Short);
51    }
52    if volume.get(0x52..0x5A) == Some(b"FAT32   ") {
53        return u32_le(volume, 0x43).map(VolumeSerial::Short);
54    }
55    if volume.get(0x36..0x39) == Some(b"FAT") {
56        return u32_le(volume, 0x27).map(VolumeSerial::Short);
57    }
58    None
59}
60
61/// Read a little-endian `u32` at `off`; `None` if the slice is too short. Panic-free.
62fn u32_le(b: &[u8], off: usize) -> Option<u32> {
63    let end = off.checked_add(4)?;
64    let bytes: [u8; 4] = b.get(off..end)?.try_into().ok()?;
65    Some(u32::from_le_bytes(bytes))
66}
67
68/// Read a little-endian `u64` at `off`; `None` if the slice is too short. Panic-free.
69fn u64_le(b: &[u8], off: usize) -> Option<u64> {
70    let end = off.checked_add(8)?;
71    let bytes: [u8; 8] = b.get(off..end)?.try_into().ok()?;
72    Some(u64::from_le_bytes(bytes))
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn fat32_reads_bs_volid_at_0x43() {
81        let mut vbr = vec![0u8; 512];
82        vbr[0x52..0x5A].copy_from_slice(b"FAT32   ");
83        vbr[0x43..0x47].copy_from_slice(&0xB4D8_5399u32.to_le_bytes());
84        assert_eq!(volume_serial(&vbr), Some(VolumeSerial::Short(0xB4D8_5399)));
85        assert_eq!(VolumeSerial::Short(0xB4D8_5399).display(), "B4D8-5399");
86    }
87
88    #[test]
89    fn fat16_reads_bs_volid_at_0x27() {
90        let mut vbr = vec![0u8; 512];
91        vbr[0x36..0x39].copy_from_slice(b"FAT");
92        vbr[0x27..0x2B].copy_from_slice(&0x1234_5678u32.to_le_bytes());
93        assert_eq!(volume_serial(&vbr), Some(VolumeSerial::Short(0x1234_5678)));
94    }
95
96    #[test]
97    fn ntfs_reads_the_8_byte_serial_at_0x48() {
98        let mut vbr = vec![0u8; 512];
99        vbr[3..11].copy_from_slice(b"NTFS    ");
100        vbr[0x48..0x50].copy_from_slice(&0x0011_2233_4455_6677u64.to_le_bytes());
101        assert_eq!(
102            volume_serial(&vbr),
103            Some(VolumeSerial::Long(0x0011_2233_4455_6677))
104        );
105        assert_eq!(
106            VolumeSerial::Long(0x0011_2233_4455_6677).display(),
107            "00112233-44556677"
108        );
109    }
110
111    #[test]
112    fn exfat_reads_the_serial_at_0x64() {
113        let mut vbr = vec![0u8; 512];
114        vbr[3..11].copy_from_slice(b"EXFAT   ");
115        vbr[0x64..0x68].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes());
116        assert_eq!(volume_serial(&vbr), Some(VolumeSerial::Short(0xDEAD_BEEF)));
117    }
118
119    #[test]
120    fn an_unrecognized_or_short_volume_has_no_serial() {
121        assert_eq!(volume_serial(&[0xABu8; 512]), None);
122        assert_eq!(volume_serial(&[]), None);
123        // NTFS OEM present but the slice ends before the serial field → None, not a panic.
124        let mut short = vec![0u8; 0x48];
125        short[3..11].copy_from_slice(b"NTFS    ");
126        assert_eq!(volume_serial(&short), None);
127    }
128}