Skip to main content

ntfs_core/
file_name.rs

1//! `$FILE_NAME` (type `0x30`) — a name link for a file: its parent directory
2//! reference, a *second* set of MACE timestamps, the file sizes, flags, and the
3//! name itself in one of four namespaces.
4//!
5//! A record may hold several `$FILE_NAME` attributes (one per hard link, plus a
6//! short DOS 8.3 name). The parent reference is what path reconstruction walks
7//! (increment 7); the `$FN` timestamps are the harder-to-forge counterpart used
8//! for timestomping detection.
9
10use forensicnomicon::ntfs::filename_namespace;
11
12use crate::error::{NtfsError, Result};
13use crate::time::Filetime;
14
15/// Minimum `$FILE_NAME` content (through the namespace byte; name follows).
16const FN_MIN: usize = 0x42;
17
18/// A 64-bit NTFS file reference: a 48-bit MFT record number plus a 16-bit
19/// sequence (reuse) number. A stale sequence flags a dangling reference.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct FileReference {
22    /// MFT record number (low 48 bits).
23    pub record_number: u64,
24    /// Sequence number (high 16 bits).
25    pub sequence: u16,
26}
27
28impl FileReference {
29    /// Split a little-endian 64-bit reference into record number + sequence.
30    #[must_use]
31    pub fn from_u64(raw: u64) -> Self {
32        FileReference {
33            record_number: raw & 0x0000_FFFF_FFFF_FFFF,
34            sequence: (raw >> 48) as u16,
35        }
36    }
37}
38
39/// Parsed `$FILE_NAME` value.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct FileName {
42    /// Reference to the parent directory.
43    pub parent: FileReference,
44    /// Creation time.
45    pub created: Filetime,
46    /// File content modification time.
47    pub modified: Filetime,
48    /// MFT record modification time.
49    pub mft_modified: Filetime,
50    /// Last access time.
51    pub accessed: Filetime,
52    /// Allocated size of the file, in bytes.
53    pub allocated_size: u64,
54    /// Real (logical) size of the file, in bytes.
55    pub real_size: u64,
56    /// `FILE_ATTRIBUTE_*` flags.
57    pub flags: u32,
58    /// Namespace code (see [`forensicnomicon::ntfs::filename_namespace`]).
59    pub namespace: u8,
60    /// The decoded file name.
61    pub name: String,
62}
63
64impl FileName {
65    /// Human-readable namespace name, if recognised.
66    #[must_use]
67    pub fn namespace_name(&self) -> Option<&'static str> {
68        filename_namespace::name(self.namespace)
69    }
70
71    /// `true` if this is the short DOS (8.3) name — the one tools often omit
72    /// when listing, and which carries its own timestamps.
73    #[must_use]
74    pub fn is_dos_namespace(&self) -> bool {
75        self.namespace == filename_namespace::DOS
76    }
77
78    /// Parse a `$FILE_NAME` value from its resident content bytes.
79    ///
80    /// # Errors
81    ///
82    /// [`NtfsError::TooShort`] when smaller than the fixed header, or
83    /// [`NtfsError::BadAttribute`] when the name runs past the content.
84    pub fn parse(content: &[u8]) -> Result<FileName> {
85        if content.len() < FN_MIN {
86            return Err(NtfsError::TooShort {
87                what: "$FILE_NAME",
88                need: FN_MIN,
89                got: content.len(),
90            });
91        }
92
93        let parent =
94            FileReference::from_u64(u64::from_le_bytes(content[0x00..0x08].try_into().unwrap()));
95        let ft = |o: usize| Filetime::from_le(content[o..o + 8].try_into().unwrap());
96        let allocated_size = u64::from_le_bytes(content[0x28..0x30].try_into().unwrap());
97        let real_size = u64::from_le_bytes(content[0x30..0x38].try_into().unwrap());
98        let flags = u32::from_le_bytes(content[0x38..0x3C].try_into().unwrap());
99
100        let name_length = content[0x40] as usize;
101        let namespace = content[0x41];
102        let name_bytes = name_length.checked_mul(2).ok_or(NtfsError::BadAttribute {
103            offset: 0,
104            detail: "$FILE_NAME name length overflow",
105        })?;
106        let name_end = FN_MIN
107            .checked_add(name_bytes)
108            .ok_or(NtfsError::BadAttribute {
109                offset: 0,
110                detail: "$FILE_NAME name overflow",
111            })?;
112        if name_end > content.len() {
113            return Err(NtfsError::BadAttribute {
114                offset: 0,
115                detail: "$FILE_NAME name extends past content",
116            });
117        }
118
119        let units: Vec<u16> = content[FN_MIN..name_end]
120            .chunks_exact(2)
121            .map(|c| u16::from_le_bytes([c[0], c[1]]))
122            .collect();
123        let name = char::decode_utf16(units)
124            .map(|r| r.unwrap_or('\u{FFFD}'))
125            .collect();
126
127        Ok(FileName {
128            parent,
129            created: ft(0x08),
130            modified: ft(0x10),
131            mft_modified: ft(0x18),
132            accessed: ft(0x20),
133            allocated_size,
134            real_size,
135            flags,
136            namespace,
137            name,
138        })
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[allow(clippy::too_many_arguments)]
147    fn make_fn(
148        parent: u64,
149        created: u64,
150        modified: u64,
151        mft_modified: u64,
152        accessed: u64,
153        allocated: u64,
154        real: u64,
155        flags: u32,
156        namespace: u8,
157        name: &str,
158    ) -> Vec<u8> {
159        let name_units: Vec<u16> = name.encode_utf16().collect();
160        let mut c = vec![0u8; FN_MIN + name_units.len() * 2];
161        c[0x00..0x08].copy_from_slice(&parent.to_le_bytes());
162        c[0x08..0x10].copy_from_slice(&created.to_le_bytes());
163        c[0x10..0x18].copy_from_slice(&modified.to_le_bytes());
164        c[0x18..0x20].copy_from_slice(&mft_modified.to_le_bytes());
165        c[0x20..0x28].copy_from_slice(&accessed.to_le_bytes());
166        c[0x28..0x30].copy_from_slice(&allocated.to_le_bytes());
167        c[0x30..0x38].copy_from_slice(&real.to_le_bytes());
168        c[0x38..0x3C].copy_from_slice(&flags.to_le_bytes());
169        c[0x40] = name_units.len() as u8;
170        c[0x41] = namespace;
171        for (i, u) in name_units.iter().enumerate() {
172            let p = 0x42 + i * 2;
173            c[p..p + 2].copy_from_slice(&u.to_le_bytes());
174        }
175        c
176    }
177
178    #[test]
179    fn file_reference_splits_record_and_sequence() {
180        // sequence 5 in the high 16 bits, record number 0x1234 in the low 48.
181        let raw = (5u64 << 48) | 0x1234;
182        let r = FileReference::from_u64(raw);
183        assert_eq!(r.record_number, 0x1234);
184        assert_eq!(r.sequence, 5);
185    }
186
187    #[test]
188    fn parses_file_name() {
189        let c = make_fn(
190            (3u64 << 48) | 5, // parent: record 5, sequence 3
191            0x10,
192            0x20,
193            0x30,
194            0x40,
195            0x1000,
196            0x0ABC,
197            super::super::standard_information::file_attr::ARCHIVE,
198            filename_namespace::WIN32,
199            "report.docx",
200        );
201        let f = FileName::parse(&c).unwrap();
202        assert_eq!(f.parent.record_number, 5);
203        assert_eq!(f.parent.sequence, 3);
204        assert_eq!(f.created, Filetime(0x10));
205        assert_eq!(f.accessed, Filetime(0x40));
206        assert_eq!(f.allocated_size, 0x1000);
207        assert_eq!(f.real_size, 0x0ABC);
208        assert_eq!(f.namespace, filename_namespace::WIN32);
209        assert_eq!(f.namespace_name(), Some("Win32"));
210        assert!(!f.is_dos_namespace());
211        assert_eq!(f.name, "report.docx");
212    }
213
214    #[test]
215    fn detects_dos_namespace() {
216        let c = make_fn(
217            0,
218            0,
219            0,
220            0,
221            0,
222            0,
223            0,
224            0,
225            filename_namespace::DOS,
226            "REPORT~1.DOC",
227        );
228        let f = FileName::parse(&c).unwrap();
229        assert!(f.is_dos_namespace());
230    }
231
232    #[test]
233    fn rejects_name_past_content() {
234        let mut c = make_fn(0, 0, 0, 0, 0, 0, 0, 0, filename_namespace::WIN32, "ab");
235        c[0x40] = 200; // claim a 200-char name that isn't there
236        assert!(matches!(
237            FileName::parse(&c),
238            Err(NtfsError::BadAttribute { .. })
239        ));
240    }
241
242    #[test]
243    fn rejects_too_short() {
244        let c = vec![0u8; 0x20];
245        assert!(matches!(
246            FileName::parse(&c),
247            Err(NtfsError::TooShort { .. })
248        ));
249    }
250}