Skip to main content

ntfs_core/
boot.rs

1//! NTFS Volume Boot Record ($Boot / VBR).
2//!
3//! The first sector of an NTFS volume holds the BIOS Parameter Block (BPB) and
4//! NTFS's extended BPB. It tells us the geometry needed to locate everything
5//! else: sector/cluster size, the total volume size, and the cluster numbers of
6//! `$MFT` and `$MFTMirr`.
7//!
8//! ## Layout (little-endian; offsets in hex)
9//!
10//! ```text
11//! 0x03  u8[8]  OEM ID                 = "NTFS    "
12//! 0x0B  u16    bytes per sector
13//! 0x0D  u8     sectors per cluster
14//! 0x28  u64    total sectors
15//! 0x30  u64    $MFT  cluster number (LCN)
16//! 0x38  u64    $MFTMirr cluster number (LCN)
17//! 0x40  i8     clusters per file-record segment   (signed: <0 ⇒ 2^|v| bytes)
18//! 0x44  i8     clusters per index buffer          (signed: same encoding)
19//! 0x48  u64    volume serial number
20//! ```
21
22use forensicnomicon::ntfs::{boot_offsets as off, OEM_ID};
23
24use crate::error::{NtfsError, Result};
25
26/// Highest offset we read (volume serial ends at 0x50); we require this many bytes.
27const MIN_LEN: usize = 0x50;
28
29/// Lower/upper sanity bounds for record and index buffer sizes (bytes).
30const MIN_RECORD_SIZE: u64 = 256;
31const MAX_RECORD_SIZE: u64 = 1 << 20; // 1 MiB — far above any real NTFS value
32
33/// Parsed NTFS boot sector geometry.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct BootSector {
36    /// Bytes per logical sector (power of two, 256..=4096; typically 512).
37    pub bytes_per_sector: u16,
38    /// Logical sectors per cluster (power of two; typically 8).
39    pub sectors_per_cluster: u8,
40    /// Total number of sectors in the volume.
41    pub total_sectors: u64,
42    /// Cluster number (LCN) of the `$MFT`.
43    pub mft_lcn: u64,
44    /// Cluster number (LCN) of the `$MFTMirr`.
45    pub mftmirr_lcn: u64,
46    /// Size of one MFT file-record segment, in bytes (typically 1024).
47    pub mft_record_size: u64,
48    /// Size of one index buffer, in bytes (typically 4096).
49    pub index_record_size: u64,
50    /// 64-bit volume serial number.
51    pub volume_serial: u64,
52}
53
54impl BootSector {
55    /// Cluster size in bytes (`bytes_per_sector × sectors_per_cluster`).
56    #[must_use]
57    pub fn cluster_size(&self) -> u64 {
58        u64::from(self.bytes_per_sector) * u64::from(self.sectors_per_cluster)
59    }
60
61    /// Absolute byte offset of the `$MFT` within the volume.
62    #[must_use]
63    pub fn mft_byte_offset(&self) -> u64 {
64        self.mft_lcn.saturating_mul(self.cluster_size())
65    }
66
67    /// Absolute byte offset of the `$MFTMirr` within the volume.
68    #[must_use]
69    pub fn mftmirr_byte_offset(&self) -> u64 {
70        self.mftmirr_lcn.saturating_mul(self.cluster_size())
71    }
72
73    /// Parse an NTFS boot sector from the start of a volume.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`NtfsError::TooShort`] if `sector` is smaller than the BPB,
78    /// [`NtfsError::BadOemId`] if it is not an NTFS volume, and the various
79    /// `Bad*` variants for out-of-range geometry fields.
80    pub fn parse(sector: &[u8]) -> Result<BootSector> {
81        if sector.len() < MIN_LEN {
82            return Err(NtfsError::TooShort {
83                what: "boot sector",
84                need: MIN_LEN,
85                got: sector.len(),
86            });
87        }
88
89        let oem: [u8; 8] = sector[off::OEM_ID..off::OEM_ID + 8].try_into().unwrap();
90        if oem != OEM_ID {
91            return Err(NtfsError::BadOemId(oem));
92        }
93
94        let bytes_per_sector = u16::from_le_bytes(
95            sector[off::BYTES_PER_SECTOR..off::BYTES_PER_SECTOR + 2]
96                .try_into()
97                .unwrap(),
98        );
99        if !(256..=4096).contains(&bytes_per_sector) || !bytes_per_sector.is_power_of_two() {
100            return Err(NtfsError::BadBytesPerSector(bytes_per_sector));
101        }
102
103        let sectors_per_cluster = sector[off::SECTORS_PER_CLUSTER];
104        if sectors_per_cluster == 0 || !sectors_per_cluster.is_power_of_two() {
105            return Err(NtfsError::BadSectorsPerCluster(sectors_per_cluster));
106        }
107
108        let cluster_size = u64::from(bytes_per_sector) * u64::from(sectors_per_cluster);
109        let total_sectors = u64::from_le_bytes(
110            sector[off::TOTAL_SECTORS..off::TOTAL_SECTORS + 8]
111                .try_into()
112                .unwrap(),
113        );
114        let mft_lcn =
115            u64::from_le_bytes(sector[off::MFT_LCN..off::MFT_LCN + 8].try_into().unwrap());
116        let mftmirr_lcn = u64::from_le_bytes(
117            sector[off::MFTMIRR_LCN..off::MFTMIRR_LCN + 8]
118                .try_into()
119                .unwrap(),
120        );
121
122        let cpr = sector[off::CLUSTERS_PER_RECORD];
123        let mft_record_size =
124            record_size(cpr, cluster_size).ok_or(NtfsError::BadRecordSize(cpr))?;
125        let cpi = sector[off::CLUSTERS_PER_INDEX];
126        let index_record_size =
127            record_size(cpi, cluster_size).ok_or(NtfsError::BadIndexRecordSize(cpi))?;
128
129        let volume_serial = u64::from_le_bytes(
130            sector[off::VOLUME_SERIAL..off::VOLUME_SERIAL + 8]
131                .try_into()
132                .unwrap(),
133        );
134
135        Ok(BootSector {
136            bytes_per_sector,
137            sectors_per_cluster,
138            total_sectors,
139            mft_lcn,
140            mftmirr_lcn,
141            mft_record_size,
142            index_record_size,
143            volume_serial,
144        })
145    }
146}
147
148/// Decode a "clusters per record/index" byte into a size in bytes.
149///
150/// NTFS overloads this signed field: a positive value `v` means `v` clusters;
151/// a non-positive value means `2^|v|` bytes. The shift can overflow for crafted
152/// images (e.g. `0x80` = -128 ⇒ `2^128`), so we use `checked_shl` and bound the
153/// result to a sane range — never panicking on adversarial input.
154fn record_size(raw: u8, cluster_size: u64) -> Option<u64> {
155    let v = raw as i8;
156    let size = if v > 0 {
157        u64::from(v.unsigned_abs()).checked_mul(cluster_size)?
158    } else {
159        1u64.checked_shl(u32::from(v.unsigned_abs()))?
160    };
161    (MIN_RECORD_SIZE..=MAX_RECORD_SIZE)
162        .contains(&size)
163        .then_some(size)
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    /// Build a synthetic 512-byte NTFS boot sector from field values.
171    #[allow(clippy::too_many_arguments)]
172    fn make_boot(
173        bytes_per_sector: u16,
174        sectors_per_cluster: u8,
175        total_sectors: u64,
176        mft_lcn: u64,
177        mftmirr_lcn: u64,
178        clusters_per_record: u8,
179        clusters_per_index: u8,
180        volume_serial: u64,
181    ) -> [u8; 512] {
182        let mut b = [0u8; 512];
183        b[0..3].copy_from_slice(&[0xEB, 0x52, 0x90]); // jump
184        b[3..11].copy_from_slice(b"NTFS    ");
185        b[0x0B..0x0D].copy_from_slice(&bytes_per_sector.to_le_bytes());
186        b[0x0D] = sectors_per_cluster;
187        b[0x15] = 0xF8; // media descriptor
188        b[0x28..0x30].copy_from_slice(&total_sectors.to_le_bytes());
189        b[0x30..0x38].copy_from_slice(&mft_lcn.to_le_bytes());
190        b[0x38..0x40].copy_from_slice(&mftmirr_lcn.to_le_bytes());
191        b[0x40] = clusters_per_record;
192        b[0x44] = clusters_per_index;
193        b[0x48..0x50].copy_from_slice(&volume_serial.to_le_bytes());
194        b[510] = 0x55;
195        b[511] = 0xAA;
196        b
197    }
198
199    #[test]
200    fn parses_standard_boot_sector() {
201        // 512 B/sector, 8 sectors/cluster ⇒ 4096-byte clusters.
202        // clusters_per_record = 0xF6 (-10) ⇒ 2^10 = 1024-byte MFT records.
203        // clusters_per_index  = 0x01       ⇒ 1 cluster = 4096-byte index buffers.
204        let b = make_boot(
205            512,
206            8,
207            0x0010_0000,
208            0x0004_0000,
209            0x02,
210            0xF6,
211            0x01,
212            0xDEAD_BEEF_CAFE_F00D,
213        );
214        let bs = BootSector::parse(&b).expect("valid NTFS boot sector");
215        assert_eq!(bs.bytes_per_sector, 512);
216        assert_eq!(bs.sectors_per_cluster, 8);
217        assert_eq!(bs.cluster_size(), 4096);
218        assert_eq!(bs.total_sectors, 0x0010_0000);
219        assert_eq!(bs.mft_lcn, 0x0004_0000);
220        assert_eq!(bs.mftmirr_lcn, 0x02);
221        assert_eq!(bs.mft_record_size, 1024);
222        assert_eq!(bs.index_record_size, 4096);
223        assert_eq!(bs.volume_serial, 0xDEAD_BEEF_CAFE_F00D);
224        assert_eq!(bs.mft_byte_offset(), 0x0004_0000 * 4096);
225        assert_eq!(bs.mftmirr_byte_offset(), 0x02 * 4096);
226    }
227
228    #[test]
229    fn positive_clusters_per_record_multiplies_cluster_size() {
230        // clusters_per_record = 1 (positive) ⇒ 1 × 4096 = 4096-byte records.
231        let b = make_boot(512, 8, 1000, 100, 2, 0x01, 0x01, 0);
232        let bs = BootSector::parse(&b).unwrap();
233        assert_eq!(bs.mft_record_size, 4096);
234    }
235
236    #[test]
237    fn rejects_non_ntfs_oem_id() {
238        let mut b = make_boot(512, 8, 1000, 100, 2, 0xF6, 0x01, 0);
239        b[3..11].copy_from_slice(b"MSDOS5.0");
240        assert!(matches!(BootSector::parse(&b), Err(NtfsError::BadOemId(_))));
241    }
242
243    #[test]
244    fn too_short_returns_error() {
245        let short = [0u8; 16];
246        assert!(matches!(
247            BootSector::parse(&short),
248            Err(NtfsError::TooShort { .. })
249        ));
250    }
251
252    #[test]
253    fn rejects_bad_bytes_per_sector() {
254        // 513 is neither a power of two nor in range.
255        let b = make_boot(513, 8, 1000, 100, 2, 0xF6, 0x01, 0);
256        assert!(matches!(
257            BootSector::parse(&b),
258            Err(NtfsError::BadBytesPerSector(513))
259        ));
260    }
261
262    #[test]
263    fn rejects_zero_sectors_per_cluster() {
264        let b = make_boot(512, 0, 1000, 100, 2, 0xF6, 0x01, 0);
265        assert!(matches!(
266            BootSector::parse(&b),
267            Err(NtfsError::BadSectorsPerCluster(0))
268        ));
269    }
270
271    #[test]
272    fn record_size_encoding_min_i8_does_not_panic() {
273        // clusters_per_record = 0x80 (-128) ⇒ 2^128, which overflows — must be
274        // rejected cleanly, never panic. (This is the isomage/NTFS cpfrs bug.)
275        let b = make_boot(512, 8, 1000, 100, 2, 0x80, 0x01, 0);
276        assert!(matches!(
277            BootSector::parse(&b),
278            Err(NtfsError::BadRecordSize(0x80))
279        ));
280    }
281
282    #[test]
283    fn rejects_bad_index_record_size() {
284        let b = make_boot(512, 8, 1000, 100, 2, 0xF6, 0x80, 0);
285        assert!(matches!(
286            BootSector::parse(&b),
287            Err(NtfsError::BadIndexRecordSize(0x80))
288        ));
289    }
290}