ntfs_core/logfile/semantics.rs
1//! Semantic interpretation of `$LogFile` LFS records as file operations.
2//!
3//! [`parse_log_records`](super::parse_log_records) decodes the raw redo/undo
4//! [`LogOp`] vocabulary; this layer maps each record's
5//! `(redo, undo)` operation pair to the higher-level **file operation** it
6//! effects — file creation, deletion, rename, data write, attribute change,
7//! index change, or transaction control.
8//!
9//! ## Why the mapping is per-opcode, not fixture-keyed
10//!
11//! In the NTFS Log File Service every redo opcode names a *structural* mutation
12//! of an on-disk object (an FRS, an attribute, an index entry, a bitmap), and
13//! the undo opcode names its inverse. The operation class is therefore intrinsic
14//! to the opcode pair, independent of which file or image produced it. The
15//! groupings below are transcribed from two independent references:
16//!
17//! - **msuhanov, `dfir_ntfs/LogFile.py`** — the maintained NTFS journal parser
18//! used in DFIR. Its `LOGGED_RESIDENT_UPDATES` / `LOGGED_NONRESIDENT_UPDATES`
19//! lists and `NTFSOperations` table classify each opcode by what it mutates.
20//! <https://github.com/msuhanov/dfir_ntfs/blob/master/dfir_ntfs/LogFile.py>
21//! - **jschicht, `LogFileParser`** — `_SanityTest1` enumerates the *valid*
22//! `(redo, undo)` pairings (e.g. `CreateAttribute`↔`DeleteAttribute`,
23//! `AddIndexEntryAllocation`↔`DeleteIndexEntryAllocation`,
24//! `UpdateFileNameAllocation`↔self), which is the authority for how the redo
25//! and undo opcodes compose.
26//! <https://github.com/jschicht/LogFileParser/blob/master/LogFileParser.au3>
27//! - **Brian Carrier, *File System Forensic Analysis* (2005), ch. 13 "NTFS
28//! Application Category" / the `$LogFile` redo/undo discussion** — the primary
29//! forensic reference for `InitializeFileRecordSegment` ⇒ file creation and
30//! `DeallocateFileRecordSegment` ⇒ file deletion.
31//!
32//! A record whose redo *and* undo opcodes are both documented but whose pair is
33//! not a recognised file operation surfaces the raw `(redo_code, undo_code)`
34//! verbatim via [`FileOperation::Unknown`] — the bytes are never dropped.
35
36use super::{LogOp, LogRecord};
37
38/// A higher-level file operation reconstructed from a `$LogFile` LFS record's
39/// `(redo, undo)` operation pair.
40///
41/// The taxonomy follows the structural mutation each redo opcode performs (see
42/// the module docs for the per-opcode source citations). It is deliberately
43/// coarse: one variant per class of on-disk effect a forensic timeline cares
44/// about, not one per raw opcode (that vocabulary is [`LogOp`]).
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum FileOperation {
47 /// A file record segment was initialized — a new MFT entry, i.e. file/dir
48 /// **creation** (`InitializeFileRecordSegment`, redo 0x02).
49 Create,
50 /// A file record segment was freed — file/dir **deletion**
51 /// (`DeallocateFileRecordSegment`, redo 0x03).
52 Delete,
53 /// A `$FILE_NAME` in a directory index was updated in place — a **rename /
54 /// move** (or an in-index timestamp/size update). `UpdateFileNameRoot`
55 /// (0x13) / `UpdateFileNameAllocation` (0x14), each self-paired.
56 Rename,
57 /// A name was **inserted** into a directory index — the create side of a
58 /// link/rename. `AddIndexEntryRoot` (0x0C) / `AddIndexEntryAllocation`
59 /// (0x0E).
60 IndexInsert,
61 /// A name was **removed** from a directory index — the delete side of an
62 /// unlink/rename. `DeleteIndexEntryRoot` (0x0D) /
63 /// `DeleteIndexEntryAllocation` (0x0F).
64 IndexDelete,
65 /// A new attribute was added to a file record (`CreateAttribute`, 0x05).
66 AttributeCreate,
67 /// An attribute was removed from a file record (`DeleteAttribute`, 0x06).
68 AttributeDelete,
69 /// An attribute's logical/allocated/initialized sizes changed — a file
70 /// **resize** (`SetNewAttributeSizes`, 0x0B).
71 Resize,
72 /// Attribute **data** (resident or non-resident) was written: a content
73 /// update, run-list / VCN mapping change, or index-buffer write.
74 /// `UpdateResidentValue` (0x07), `UpdateNonResidentValue` (0x08),
75 /// `UpdateMappingPairs` (0x09), `WriteEndOfFileRecordSegment` (0x04),
76 /// `WriteEndOfIndexBuffer` (0x10), `SetIndexEntryVcnRoot` (0x11),
77 /// `SetIndexEntryVcnAllocation` (0x12), `UpdateRecordDataRoot` (0x21),
78 /// `UpdateRecordDataAllocation` (0x22).
79 DataWrite,
80 /// A cluster/MFT-allocation **bitmap** bit was set, cleared, or clusters
81 /// marked dirty — space (de)allocation. `SetBitsInNonResidentBitMap`
82 /// (0x15), `ClearBitsInNonResidentBitMap` (0x16), `DeleteDirtyClusters`
83 /// (0x0A).
84 BitmapAllocation,
85 /// Transaction-boundary control, not an on-disk file mutation:
86 /// `EndTopLevelAction` (0x18), `PrepareTransaction` (0x19),
87 /// `CommitTransaction` (0x1A), `ForgetTransaction` (0x1B),
88 /// `CompensationLogRecord` (0x01).
89 TransactionControl,
90 /// A restart-area / open-attribute-table / dirty-page / transaction-table
91 /// **dump** or hot-fix — log housekeeping that records no file change.
92 /// `HotFix` (0x17), `OpenNonResidentAttribute` (0x1C),
93 /// `OpenAttributeTableDump` (0x1D), `AttributeNamesDump` (0x1E),
94 /// `DirtyPageTableDump` (0x1F), `TransactionTableDump` (0x20).
95 TableDump,
96 /// A no-op log record (`Noop`, redo 0x00 with no undo effect).
97 Noop,
98 /// The `(redo, undo)` opcode pair is not a recognised file operation. The
99 /// raw codes are surfaced verbatim — `(redo_code, undo_code)` — so an
100 /// investigator can identify the operation themselves (never dropped).
101 Unknown(u16, u16),
102}
103
104impl FileOperation {
105 /// Classify a single decoded LFS [`LogRecord`] into its file operation.
106 ///
107 /// The redo opcode names the operation; the undo opcode is the inverse the
108 /// recovery pass would apply. A `Noop` redo paired with a substantive undo
109 /// (the form NTFS uses to log a pure deallocation) is classified by the
110 /// undo. Any pair outside the documented map yields
111 /// [`FileOperation::Unknown`] carrying both raw codes.
112 #[must_use]
113 pub fn classify(record: &LogRecord) -> Self {
114 classify(record.redo_op, record.undo_op)
115 }
116}
117
118/// Core `(redo, undo)` → [`FileOperation`] map. Separated from
119/// [`FileOperation::classify`] so it can be unit-tested on bare opcode pairs
120/// without constructing a full [`LogRecord`].
121#[must_use]
122pub fn classify(redo: LogOp, undo: LogOp) -> FileOperation {
123 use FileOperation as F;
124 use LogOp as O;
125
126 match redo {
127 O::InitializeFileRecordSegment => F::Create,
128 O::DeallocateFileRecordSegment => F::Delete,
129
130 O::UpdateFileNameRoot | O::UpdateFileNameAllocation => F::Rename,
131
132 O::AddIndexEntryRoot | O::AddIndexEntryAllocation => F::IndexInsert,
133 O::DeleteIndexEntryRoot | O::DeleteIndexEntryAllocation => F::IndexDelete,
134
135 O::CreateAttribute => F::AttributeCreate,
136 O::DeleteAttribute => F::AttributeDelete,
137 O::SetNewAttributeSizes => F::Resize,
138
139 O::UpdateResidentValue
140 | O::UpdateNonResidentValue
141 | O::UpdateMappingPairs
142 | O::WriteEndOfFileRecordSegment
143 | O::WriteEndOfIndexBuffer
144 | O::SetIndexEntryVcnRoot
145 | O::SetIndexEntryVcnAllocation
146 | O::UpdateRecordDataRoot
147 | O::UpdateRecordDataAllocation => F::DataWrite,
148
149 O::SetBitsInNonResidentBitMap
150 | O::ClearBitsInNonResidentBitMap
151 | O::DeleteDirtyClusters => F::BitmapAllocation,
152
153 O::EndTopLevelAction
154 | O::PrepareTransaction
155 | O::CommitTransaction
156 | O::ForgetTransaction
157 | O::CompensationLogRecord => F::TransactionControl,
158
159 O::HotFix
160 | O::OpenNonResidentAttribute
161 | O::OpenAttributeTableDump
162 | O::AttributeNamesDump
163 | O::DirtyPageTableDump
164 | O::TransactionTableDump => F::TableDump,
165
166 // A bare `Noop` redo with a substantive undo is how NTFS logs a pure
167 // deallocation (LogFileParser `_SanityTest1`: `Undo =
168 // DeallocateFileRecordSegment` requires `Redo = Noop`). Classify by the
169 // undo so the deletion is not lost as a Noop.
170 O::Noop => match undo {
171 O::Noop => F::Noop,
172 O::DeallocateFileRecordSegment => F::Delete,
173 O::DeleteIndexEntryRoot | O::DeleteIndexEntryAllocation => F::IndexDelete,
174 O::DeleteAttribute => F::AttributeDelete,
175 O::Unknown(u) => F::Unknown(0x00, u),
176 other => F::Unknown(0x00, other.code()),
177 },
178
179 O::Unknown(r) => F::Unknown(r, undo.code()),
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::logfile::RecordPage;
187
188 // Each test cites the authoritative source for the (redo, undo) → operation
189 // mapping it asserts. Sources: msuhanov dfir_ntfs/LogFile.py (LOGGED_*_UPDATES,
190 // NTFSOperations); jschicht LogFileParser _SanityTest1 (valid redo/undo pairs);
191 // Carrier, File System Forensic Analysis ch. 13 (Init/Dealloc ⇒ create/delete).
192
193 // Carrier ch.13: InitializeFileRecordSegment allocates and initializes a
194 // new FRS => file creation. dfir_ntfs lists it in LOGGED_RESIDENT_UPDATES.
195 #[test]
196 fn initialize_file_record_segment_is_create() {
197 assert_eq!(
198 classify(LogOp::InitializeFileRecordSegment, LogOp::Noop),
199 FileOperation::Create
200 );
201 }
202
203 // Carrier ch.13: DeallocateFileRecordSegment frees an FRS => file deletion.
204 #[test]
205 fn deallocate_file_record_segment_is_delete() {
206 assert_eq!(
207 classify(LogOp::DeallocateFileRecordSegment, LogOp::Noop),
208 FileOperation::Delete
209 );
210 }
211
212 // LogFileParser _SanityTest1: a pure deallocation is logged as
213 // Redo=Noop, Undo=DeallocateFileRecordSegment -- classified by undo.
214 #[test]
215 fn noop_redo_dealloc_undo_is_delete() {
216 assert_eq!(
217 classify(LogOp::Noop, LogOp::DeallocateFileRecordSegment),
218 FileOperation::Delete
219 );
220 }
221
222 // A Noop redo with a DeleteIndexEntry{Root,Allocation} undo is the
223 // inverse-side of an index removal (the redo already happened in a prior
224 // record); classify by the undo so the index delete is not lost as a Noop.
225 #[test]
226 fn noop_redo_delete_index_undo_is_index_delete() {
227 assert_eq!(
228 classify(LogOp::Noop, LogOp::DeleteIndexEntryRoot),
229 FileOperation::IndexDelete
230 );
231 assert_eq!(
232 classify(LogOp::Noop, LogOp::DeleteIndexEntryAllocation),
233 FileOperation::IndexDelete
234 );
235 }
236
237 // A Noop redo with a DeleteAttribute undo is the inverse-side of an
238 // attribute removal; classify by the undo.
239 #[test]
240 fn noop_redo_delete_attribute_undo_is_attribute_delete() {
241 assert_eq!(
242 classify(LogOp::Noop, LogOp::DeleteAttribute),
243 FileOperation::AttributeDelete
244 );
245 }
246
247 // LogFileParser _SanityTest1 (#13/#14): UpdateFileName{Root,Allocation}
248 // are self-paired and update the $FILE_NAME held in a directory index =>
249 // rename / move.
250 #[test]
251 fn update_file_name_is_rename() {
252 assert_eq!(
253 classify(LogOp::UpdateFileNameRoot, LogOp::UpdateFileNameRoot),
254 FileOperation::Rename
255 );
256 assert_eq!(
257 classify(
258 LogOp::UpdateFileNameAllocation,
259 LogOp::UpdateFileNameAllocation
260 ),
261 FileOperation::Rename
262 );
263 }
264
265 // LogFileParser _SanityTest1 (#4/#5): AddIndexEntry{Root,Allocation} pair
266 // with the corresponding Delete; the redo inserts a name into a directory
267 // index.
268 #[test]
269 fn add_index_entry_is_index_insert() {
270 assert_eq!(
271 classify(LogOp::AddIndexEntryRoot, LogOp::DeleteIndexEntryRoot),
272 FileOperation::IndexInsert
273 );
274 assert_eq!(
275 classify(
276 LogOp::AddIndexEntryAllocation,
277 LogOp::DeleteIndexEntryAllocation
278 ),
279 FileOperation::IndexInsert
280 );
281 }
282
283 // LogFileParser _SanityTest1 (#4/#5): DeleteIndexEntry{Root,Allocation}
284 // remove a name from a directory index.
285 #[test]
286 fn delete_index_entry_is_index_delete() {
287 assert_eq!(
288 classify(LogOp::DeleteIndexEntryRoot, LogOp::AddIndexEntryRoot),
289 FileOperation::IndexDelete
290 );
291 assert_eq!(
292 classify(
293 LogOp::DeleteIndexEntryAllocation,
294 LogOp::AddIndexEntryAllocation
295 ),
296 FileOperation::IndexDelete
297 );
298 }
299
300 // LogFileParser _SanityTest1 (#6): CreateAttribute <-> DeleteAttribute.
301 #[test]
302 fn create_and_delete_attribute() {
303 assert_eq!(
304 classify(LogOp::CreateAttribute, LogOp::DeleteAttribute),
305 FileOperation::AttributeCreate
306 );
307 assert_eq!(
308 classify(LogOp::DeleteAttribute, LogOp::CreateAttribute),
309 FileOperation::AttributeDelete
310 );
311 }
312
313 // dfir_ntfs _Decode_SetNewAttributeSize: SetNewAttributeSizes carries the
314 // new alloc/real/initialized sizes => a resize.
315 #[test]
316 fn set_new_attribute_sizes_is_resize() {
317 assert_eq!(
318 classify(LogOp::SetNewAttributeSizes, LogOp::SetNewAttributeSizes),
319 FileOperation::Resize
320 );
321 }
322
323 // dfir_ntfs LOGGED_RESIDENT_UPDATES / LOGGED_NONRESIDENT_UPDATES: these
324 // opcodes write attribute data (resident $MFT bytes or nonresident clusters)
325 // or index-buffer content => data write.
326 #[test]
327 fn value_and_mapping_updates_are_data_write() {
328 for redo in [
329 LogOp::UpdateResidentValue,
330 LogOp::UpdateNonResidentValue,
331 LogOp::UpdateMappingPairs,
332 LogOp::WriteEndOfFileRecordSegment,
333 LogOp::WriteEndOfIndexBuffer,
334 LogOp::SetIndexEntryVcnRoot,
335 LogOp::SetIndexEntryVcnAllocation,
336 LogOp::UpdateRecordDataRoot,
337 LogOp::UpdateRecordDataAllocation,
338 ] {
339 assert_eq!(classify(redo, redo), FileOperation::DataWrite, "{redo:?}");
340 }
341 }
342
343 // LogFileParser _SanityTest1 (#7): SetBits <-> ClearBits in the nonresident
344 // bitmap; DeleteDirtyClusters marks clusters dirty. All are space
345 // (de)allocation, not a file-content change.
346 #[test]
347 fn bitmap_and_dirty_clusters_are_bitmap_allocation() {
348 assert_eq!(
349 classify(
350 LogOp::SetBitsInNonResidentBitMap,
351 LogOp::ClearBitsInNonResidentBitMap
352 ),
353 FileOperation::BitmapAllocation
354 );
355 assert_eq!(
356 classify(
357 LogOp::ClearBitsInNonResidentBitMap,
358 LogOp::SetBitsInNonResidentBitMap
359 ),
360 FileOperation::BitmapAllocation
361 );
362 assert_eq!(
363 classify(LogOp::DeleteDirtyClusters, LogOp::Noop),
364 FileOperation::BitmapAllocation
365 );
366 }
367
368 // flatcap NTFS recovery doc + LogFileParser: these mark transaction
369 // boundaries (prepare/commit/forget/end-top-level) or an undo
370 // (CompensationLogRecord), not an on-disk file mutation.
371 #[test]
372 fn transaction_boundaries_are_transaction_control() {
373 for redo in [
374 LogOp::EndTopLevelAction,
375 LogOp::PrepareTransaction,
376 LogOp::CommitTransaction,
377 LogOp::ForgetTransaction,
378 LogOp::CompensationLogRecord,
379 ] {
380 assert_eq!(
381 classify(redo, LogOp::Noop),
382 FileOperation::TransactionControl,
383 "{redo:?}"
384 );
385 }
386 }
387
388 // dfir_ntfs NTFSOperations: the *Dump and HotFix opcodes are log
389 // housekeeping (restart-area / table snapshots) recording no file change.
390 #[test]
391 fn dumps_and_hotfix_are_table_dump() {
392 for redo in [
393 LogOp::HotFix,
394 LogOp::OpenNonResidentAttribute,
395 LogOp::OpenAttributeTableDump,
396 LogOp::AttributeNamesDump,
397 LogOp::DirtyPageTableDump,
398 LogOp::TransactionTableDump,
399 ] {
400 assert_eq!(
401 classify(redo, LogOp::Noop),
402 FileOperation::TableDump,
403 "{redo:?}"
404 );
405 }
406 }
407
408 // A Noop/Noop record is a genuine no-op.
409 #[test]
410 fn noop_pair_is_noop() {
411 assert_eq!(classify(LogOp::Noop, LogOp::Noop), FileOperation::Noop);
412 }
413
414 // Show-the-unrecognized-value: an undocumented redo opcode surfaces both raw
415 // codes verbatim, never silently dropped.
416 #[test]
417 fn unknown_redo_surfaces_both_raw_codes() {
418 assert_eq!(
419 classify(LogOp::Unknown(0x40), LogOp::CommitTransaction),
420 FileOperation::Unknown(0x40, 0x1A)
421 );
422 }
423
424 // A Noop redo paired with an undocumented undo also surfaces both codes.
425 #[test]
426 fn noop_redo_unknown_undo_surfaces_codes() {
427 assert_eq!(
428 classify(LogOp::Noop, LogOp::Unknown(0x55)),
429 FileOperation::Unknown(0x00, 0x55)
430 );
431 }
432
433 // A Noop redo paired with a documented-but-unmapped undo (e.g. an
434 // UpdateResidentValue undo with a Noop redo, which NTFS does not emit)
435 // surfaces both real codes rather than misclassifying.
436 #[test]
437 fn noop_redo_unmapped_known_undo_surfaces_codes() {
438 assert_eq!(
439 classify(LogOp::Noop, LogOp::UpdateResidentValue),
440 FileOperation::Unknown(0x00, 0x07)
441 );
442 }
443
444 // The FileOperation::classify convenience wraps a full LogRecord.
445 #[test]
446 fn classify_over_log_record() {
447 let rec = LogRecord {
448 page_offset: 0x40,
449 this_lsn: 1,
450 client_previous_lsn: 0,
451 client_undo_next_lsn: 0,
452 record_type: 1,
453 transaction_id: 0,
454 redo_op: LogOp::InitializeFileRecordSegment,
455 undo_op: LogOp::Noop,
456 target_attribute: 0,
457 mft_cluster_index: 0,
458 target_vcn: 0,
459 };
460 assert_eq!(FileOperation::classify(&rec), FileOperation::Create);
461 // The record is unused beyond its op pair; touch a field so the borrow is
462 // meaningful and the helper is exercised end-to-end.
463 let _ = RecordPage {
464 offset: 0,
465 last_lsn: 1,
466 data: vec![],
467 };
468 }
469}