Skip to main content

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}