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::bytes::{arr, le_u32, le_u64};
13use crate::error::{NtfsError, Result};
14use crate::time::Filetime;
15
16/// Minimum `$FILE_NAME` content (through the namespace byte; name follows).
17const FN_MIN: usize = 0x42;
18
19/// A 64-bit NTFS file reference: a 48-bit MFT record number plus a 16-bit
20/// sequence (reuse) number. A stale sequence flags a dangling reference.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct FileReference {
23    /// MFT record number (low 48 bits).
24    pub record_number: u64,
25    /// Sequence number (high 16 bits).
26    pub sequence: u16,
27}
28
29impl FileReference {
30    /// Split a little-endian 64-bit reference into record number + sequence.
31    #[must_use]
32    pub fn from_u64(raw: u64) -> Self {
33        FileReference {
34            record_number: raw & 0x0000_FFFF_FFFF_FFFF,
35            sequence: (raw >> 48) as u16,
36        }
37    }
38}
39
40/// Parsed `$FILE_NAME` value.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct FileName {
43    /// Reference to the parent directory.
44    pub parent: FileReference,
45    /// Creation time.
46    pub created: Filetime,
47    /// File content modification time.
48    pub modified: Filetime,
49    /// MFT record modification time.
50    pub mft_modified: Filetime,
51    /// Last access time.
52    pub accessed: Filetime,
53    /// Allocated size of the file, in bytes.
54    pub allocated_size: u64,
55    /// Real (logical) size of the file, in bytes.
56    pub real_size: u64,
57    /// `FILE_ATTRIBUTE_*` flags.
58    pub flags: u32,
59    /// Namespace code (see [`forensicnomicon::ntfs::filename_namespace`]).
60    pub namespace: u8,
61    /// The decoded file name.
62    pub name: String,
63}
64
65impl FileName {
66    /// Human-readable namespace name, if recognised.
67    #[must_use]
68    pub fn namespace_name(&self) -> Option<&'static str> {
69        filename_namespace::name(self.namespace)
70    }
71
72    /// `true` if this is the short DOS (8.3) name — the one tools often omit
73    /// when listing, and which carries its own timestamps.
74    #[must_use]
75    pub fn is_dos_namespace(&self) -> bool {
76        self.namespace == filename_namespace::DOS
77    }
78
79    /// Parse a `$FILE_NAME` value from its resident content bytes.
80    ///
81    /// # Errors
82    ///
83    /// [`NtfsError::TooShort`] when smaller than the fixed header, or
84    /// [`NtfsError::BadAttribute`] when the name runs past the content.
85    pub fn parse(content: &[u8]) -> Result<FileName> {
86        if content.len() < FN_MIN {
87            return Err(NtfsError::TooShort {
88                what: "$FILE_NAME",
89                need: FN_MIN,
90                got: content.len(),
91            });
92        }
93
94        let parent = FileReference::from_u64(le_u64(content, 0x00));
95        let ft = |o: usize| Filetime::from_le(&arr(content, o));
96        let allocated_size = le_u64(content, 0x28);
97        let real_size = le_u64(content, 0x30);
98        let flags = le_u32(content, 0x38);
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}