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