Skip to main content

ntfs_core/
record.rs

1//! MFT file-record-segment header parsing and update-sequence-array (fixup).
2//!
3//! Every `$MFT` entry is a fixed-size *file record segment* (typically 1024
4//! bytes) beginning with a `FILE` signature. To protect against torn writes,
5//! NTFS replaces the last two bytes of every sector with an incrementing
6//! **Update Sequence Number (USN)**; the displaced originals are stored in the
7//! **Update Sequence Array (USA)**. Reading a record means verifying each
8//! sector still carries the expected USN (a mismatch is a torn write or
9//! tampering) and restoring the originals before the bytes are interpreted.
10//!
11//! Layout facts (signatures, field offsets, flags) come from
12//! [`forensicnomicon::ntfs`].
13
14use forensicnomicon::ntfs::{mft_flags, mft_offsets as off, SIGNATURE_BAAD, SIGNATURE_FILE};
15
16use crate::bytes::{arr, le_u16, le_u32, le_u64};
17use crate::error::{NtfsError, Result};
18
19/// Bytes required to read the full record header (through the record number at 0x2C).
20const HEADER_LEN: usize = 0x30;
21
22/// Parsed MFT file-record-segment header.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct MftRecordHeader {
25    /// Record signature: `FILE` (normal) or `BAAD` (chkdsk marked it corrupt).
26    pub signature: [u8; 4],
27    /// Byte offset of the Update Sequence Array within the record.
28    pub usa_offset: u16,
29    /// Number of u16 entries in the USA (1 USN + one original per sector).
30    pub usa_count: u16,
31    /// `$LogFile` sequence number of the last change to this record.
32    pub lsn: u64,
33    /// Reuse counter — incremented each time this record number is reallocated.
34    pub sequence_number: u16,
35    /// Number of hard links (`$FILE_NAME` attributes) to this record.
36    pub hard_link_count: u16,
37    /// Byte offset of the first attribute.
38    pub first_attribute_offset: u16,
39    /// Record flags (see [`is_in_use`](Self::is_in_use) / [`is_directory`](Self::is_directory)).
40    pub flags: u16,
41    /// Bytes of the record actually used.
42    pub used_size: u32,
43    /// Bytes allocated to the record (the record size).
44    pub allocated_size: u32,
45    /// File reference to the base record (0 when this *is* the base record).
46    pub base_record: u64,
47    /// Id to assign to the next attribute added.
48    pub next_attr_id: u16,
49    /// This record's own number (Windows XP and later).
50    pub record_number: u32,
51}
52
53impl MftRecordHeader {
54    /// `true` if the record is currently allocated (in use).
55    #[must_use]
56    pub fn is_in_use(&self) -> bool {
57        self.flags & mft_flags::IN_USE != 0
58    }
59
60    /// `true` if the record describes a directory.
61    #[must_use]
62    pub fn is_directory(&self) -> bool {
63        self.flags & mft_flags::DIRECTORY != 0
64    }
65
66    /// `true` if this is a base record (not an extension/child record).
67    #[must_use]
68    pub fn is_base_record(&self) -> bool {
69        self.base_record == 0
70    }
71
72    /// `true` if chkdsk marked this record corrupt (`BAAD` signature).
73    #[must_use]
74    pub fn is_corrupt(&self) -> bool {
75        self.signature == SIGNATURE_BAAD
76    }
77
78    /// Parse a record header from the start of a record buffer.
79    ///
80    /// Does not apply the fixup (the header fields all precede the first
81    /// sector boundary). Validates the `FILE`/`BAAD` signature.
82    ///
83    /// # Errors
84    ///
85    /// [`NtfsError::TooShort`] if `buf` is smaller than the header, or
86    /// [`NtfsError::BadRecordSignature`] for an unrecognised signature.
87    pub fn parse(buf: &[u8]) -> Result<MftRecordHeader> {
88        if buf.len() < HEADER_LEN {
89            return Err(NtfsError::TooShort {
90                what: "MFT record header",
91                need: HEADER_LEN,
92                got: buf.len(),
93            });
94        }
95
96        let signature: [u8; 4] = arr(buf, off::SIGNATURE);
97        if signature != SIGNATURE_FILE && signature != SIGNATURE_BAAD {
98            return Err(NtfsError::BadRecordSignature(signature));
99        }
100
101        let u16at = |o: usize| le_u16(buf, o);
102        let u32at = |o: usize| le_u32(buf, o);
103        let u64at = |o: usize| le_u64(buf, o);
104
105        Ok(MftRecordHeader {
106            signature,
107            usa_offset: u16at(off::USA_OFFSET),
108            usa_count: u16at(off::USA_COUNT),
109            lsn: u64at(off::LSN),
110            sequence_number: u16at(off::SEQUENCE_NUMBER),
111            hard_link_count: u16at(off::HARD_LINK_COUNT),
112            first_attribute_offset: u16at(off::FIRST_ATTRIBUTE),
113            flags: u16at(off::FLAGS),
114            used_size: u32at(off::USED_SIZE),
115            allocated_size: u32at(off::ALLOCATED_SIZE),
116            base_record: u64at(off::BASE_RECORD),
117            next_attr_id: u16at(off::NEXT_ATTR_ID),
118            record_number: u32at(off::RECORD_NUMBER),
119        })
120    }
121}
122
123/// Apply the NTFS update-sequence-array fixup to a raw record buffer in place.
124///
125/// Verifies that each protected sector's last two bytes equal the USN, then
126/// restores the displaced original bytes from the USA.
127///
128/// # Errors
129///
130/// [`NtfsError::FixupMismatch`] when a sector tail does not match the USN (torn
131/// write / tampering), or [`NtfsError::BadUpdateSequence`] when the USA is
132/// malformed.
133pub fn apply_fixup(buf: &mut [u8], sector_size: usize) -> Result<()> {
134    if buf.len() < HEADER_LEN {
135        return Err(NtfsError::TooShort {
136            what: "MFT record",
137            need: HEADER_LEN,
138            got: buf.len(),
139        });
140    }
141    if sector_size < 2 {
142        return Err(NtfsError::BadUpdateSequence(
143            "sector size smaller than 2 bytes",
144        ));
145    }
146
147    let usa_offset = le_u16(buf, off::USA_OFFSET) as usize;
148    let usa_count = le_u16(buf, off::USA_COUNT) as usize;
149    if usa_count == 0 {
150        return Err(NtfsError::BadUpdateSequence("usa_count is zero"));
151    }
152
153    // The USA holds `usa_count` u16 entries (1 USN + one original per sector).
154    let usa_end = usa_offset
155        .checked_add(usa_count * 2)
156        .ok_or(NtfsError::BadUpdateSequence("usa offset/count overflow"))?;
157    if usa_end > buf.len() {
158        return Err(NtfsError::BadUpdateSequence("usa extends past record"));
159    }
160
161    let fixup_sectors = usa_count - 1;
162    let span = fixup_sectors
163        .checked_mul(sector_size)
164        .ok_or(NtfsError::BadUpdateSequence("sector span overflow"))?;
165    if span > buf.len() {
166        return Err(NtfsError::BadUpdateSequence(
167            "fixup sectors exceed record size",
168        ));
169    }
170
171    let usn = le_u16(buf, usa_offset);
172
173    for i in 0..fixup_sectors {
174        let tail = (i + 1) * sector_size - 2;
175        let found = u16::from_le_bytes([buf[tail], buf[tail + 1]]);
176        if found != usn {
177            return Err(NtfsError::FixupMismatch {
178                sector: i,
179                expected: usn,
180                found,
181            });
182        }
183        let original = usa_offset + 2 + i * 2;
184        buf[tail] = buf[original];
185        buf[tail + 1] = buf[original + 1];
186    }
187
188    Ok(())
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    /// Build a `FILE` record with a valid USA: `usn` written to each sector
196    /// tail, the `originals` stored in the USA. One original per sector.
197    fn make_record(size: usize, sector_size: usize, usn: u16, originals: &[u16]) -> Vec<u8> {
198        assert_eq!(size / sector_size, originals.len());
199        let mut b = vec![0u8; size];
200        b[0..4].copy_from_slice(b"FILE");
201        let usa_offset: u16 = 0x30;
202        let usa_count = (originals.len() + 1) as u16;
203        b[0x04..0x06].copy_from_slice(&usa_offset.to_le_bytes());
204        b[0x06..0x08].copy_from_slice(&usa_count.to_le_bytes());
205        // first attribute offset: just past the USA.
206        let first_attr = usa_offset + usa_count * 2;
207        b[0x14..0x16].copy_from_slice(&first_attr.to_le_bytes());
208        b[0x16..0x18].copy_from_slice(&mft_flags::IN_USE.to_le_bytes());
209
210        let uo = usa_offset as usize;
211        b[uo..uo + 2].copy_from_slice(&usn.to_le_bytes());
212        for (i, orig) in originals.iter().enumerate() {
213            let p = uo + 2 + i * 2;
214            b[p..p + 2].copy_from_slice(&orig.to_le_bytes());
215            // On disk, each sector tail holds the USN sentinel.
216            let tail = (i + 1) * sector_size - 2;
217            b[tail..tail + 2].copy_from_slice(&usn.to_le_bytes());
218        }
219        b
220    }
221
222    // ── MftRecordHeader::parse ────────────────────────────────────────────────
223
224    #[test]
225    fn parses_file_record_header() {
226        let mut b = make_record(1024, 512, 0xABCD, &[0x1111, 0x2222]);
227        // Set a few more header fields directly.
228        b[0x08..0x10].copy_from_slice(&0x0000_0000_DEAD_BEEFu64.to_le_bytes()); // LSN
229        b[0x10..0x12].copy_from_slice(&7u16.to_le_bytes()); // sequence number
230        b[0x12..0x14].copy_from_slice(&1u16.to_le_bytes()); // hard link count
231        b[0x18..0x1C].copy_from_slice(&0x0000_0188u32.to_le_bytes()); // used size
232        b[0x1C..0x20].copy_from_slice(&1024u32.to_le_bytes()); // allocated size
233        b[0x20..0x28].copy_from_slice(&0u64.to_le_bytes()); // base record (base)
234        b[0x28..0x2A].copy_from_slice(&3u16.to_le_bytes()); // next attr id
235        b[0x2C..0x30].copy_from_slice(&42u32.to_le_bytes()); // record number
236
237        let h = MftRecordHeader::parse(&b).expect("valid FILE record");
238        assert_eq!(&h.signature, b"FILE");
239        assert_eq!(h.usa_offset, 0x30);
240        assert_eq!(h.usa_count, 3);
241        assert_eq!(h.lsn, 0xDEAD_BEEF);
242        assert_eq!(h.sequence_number, 7);
243        assert_eq!(h.hard_link_count, 1);
244        assert_eq!(h.first_attribute_offset, 0x30 + 3 * 2);
245        assert_eq!(h.used_size, 0x188);
246        assert_eq!(h.allocated_size, 1024);
247        assert_eq!(h.next_attr_id, 3);
248        assert_eq!(h.record_number, 42);
249        assert!(h.is_in_use());
250        assert!(!h.is_directory());
251        assert!(h.is_base_record());
252        assert!(!h.is_corrupt());
253    }
254
255    #[test]
256    fn directory_and_extension_flags_decode() {
257        let mut b = make_record(1024, 512, 1, &[0, 0]);
258        b[0x16..0x18].copy_from_slice(&(mft_flags::IN_USE | mft_flags::DIRECTORY).to_le_bytes());
259        b[0x20..0x28].copy_from_slice(&0x0001_0000_0000_0005u64.to_le_bytes()); // base ref (extension)
260        let h = MftRecordHeader::parse(&b).unwrap();
261        assert!(h.is_in_use());
262        assert!(h.is_directory());
263        assert!(!h.is_base_record());
264    }
265
266    #[test]
267    fn baad_signature_parses_as_corrupt() {
268        let mut b = make_record(1024, 512, 1, &[0, 0]);
269        b[0..4].copy_from_slice(b"BAAD");
270        let h = MftRecordHeader::parse(&b).expect("BAAD is a valid (corrupt) record");
271        assert!(h.is_corrupt());
272    }
273
274    #[test]
275    fn rejects_unknown_signature() {
276        let mut b = make_record(1024, 512, 1, &[0, 0]);
277        b[0..4].copy_from_slice(b"XXXX");
278        assert!(matches!(
279            MftRecordHeader::parse(&b),
280            Err(NtfsError::BadRecordSignature(s)) if &s == b"XXXX"
281        ));
282    }
283
284    #[test]
285    fn header_too_short_returns_error() {
286        let b = vec![b'F', b'I', b'L', b'E', 0, 0];
287        assert!(matches!(
288            MftRecordHeader::parse(&b),
289            Err(NtfsError::TooShort { .. })
290        ));
291    }
292
293    // ── apply_fixup ───────────────────────────────────────────────────────────
294
295    #[test]
296    fn fixup_restores_sector_tails() {
297        let mut b = make_record(1024, 512, 0xABCD, &[0x1111, 0x2222]);
298        // Before fixup the tails hold the USN sentinel.
299        assert_eq!(&b[510..512], &0xABCDu16.to_le_bytes());
300        assert_eq!(&b[1022..1024], &0xABCDu16.to_le_bytes());
301
302        apply_fixup(&mut b, 512).expect("valid fixup");
303
304        // After fixup the tails hold the original values from the USA.
305        assert_eq!(u16::from_le_bytes([b[510], b[511]]), 0x1111);
306        assert_eq!(u16::from_le_bytes([b[1022], b[1023]]), 0x2222);
307    }
308
309    #[test]
310    fn fixup_detects_torn_write() {
311        let mut b = make_record(1024, 512, 0xABCD, &[0x1111, 0x2222]);
312        // Corrupt the second sector's tail so it no longer matches the USN.
313        b[1022..1024].copy_from_slice(&0xDEADu16.to_le_bytes());
314        assert!(matches!(
315            apply_fixup(&mut b, 512),
316            Err(NtfsError::FixupMismatch {
317                sector: 1,
318                expected: 0xABCD,
319                found: 0xDEAD
320            })
321        ));
322    }
323
324    #[test]
325    fn fixup_rejects_zero_usa_count() {
326        let mut b = make_record(1024, 512, 1, &[0, 0]);
327        b[0x06..0x08].copy_from_slice(&0u16.to_le_bytes()); // usa_count = 0
328        assert!(matches!(
329            apply_fixup(&mut b, 512),
330            Err(NtfsError::BadUpdateSequence(_))
331        ));
332    }
333
334    #[test]
335    fn fixup_rejects_usa_out_of_bounds() {
336        let mut b = make_record(1024, 512, 1, &[0, 0]);
337        b[0x04..0x06].copy_from_slice(&0x0FFEu16.to_le_bytes()); // usa_offset near end
338        b[0x06..0x08].copy_from_slice(&8u16.to_le_bytes()); // count overruns buffer
339        assert!(matches!(
340            apply_fixup(&mut b, 512),
341            Err(NtfsError::BadUpdateSequence(_))
342        ));
343    }
344
345    #[test]
346    fn fixup_rejects_buffer_shorter_than_header() {
347        let mut b = vec![0u8; HEADER_LEN - 1];
348        assert!(matches!(
349            apply_fixup(&mut b, 512),
350            Err(NtfsError::TooShort { .. })
351        ));
352    }
353
354    #[test]
355    fn fixup_rejects_sector_size_below_two() {
356        let mut b = make_record(1024, 512, 1, &[0, 0]);
357        assert!(matches!(
358            apply_fixup(&mut b, 1),
359            Err(NtfsError::BadUpdateSequence(_))
360        ));
361    }
362
363    #[test]
364    fn fixup_rejects_sectors_exceeding_record() {
365        // USA fits, but (usa_count - 1) sectors span more than the buffer holds.
366        let mut b = make_record(1024, 512, 1, &[0, 0]);
367        b[0x06..0x08].copy_from_slice(&10u16.to_le_bytes()); // 9 sectors × 512 > 1024
368        assert!(matches!(
369            apply_fixup(&mut b, 512),
370            Err(NtfsError::BadUpdateSequence(detail)) if detail.contains("exceed")
371        ));
372    }
373}