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