Skip to main content

ntfs_core/logfile/
transactions.rs

1//! Transaction reconstruction over decoded `$LogFile` LFS records.
2//!
3//! [`parse_log_records`](super::parse_log_records) decodes individual redo/undo
4//! records; [`classify`](super::classify) labels each record's file operation.
5//! This layer groups those records into the unit a forensic analyst actually
6//! reasons about: a **transaction** — a single user/OS action whose redo/undo
7//! records are committed (and applied) or rolled back as one atom.
8//!
9//! ## `transaction_id` is a reused table slot, not a unique id
10//!
11//! Every LFS client record carries a 4-byte field at offset `0x24` of the record
12//! header that the Microsoft `LFS_RECORD` layout labels `TransactionId`. It is
13//! tempting to read this as a unique per-transaction identifier and group on it
14//! directly — but on real volumes it is **low-cardinality and reused**: it is the
15//! index of the transaction's entry in NTFS's in-memory *open transaction table*,
16//! and that slot is recycled as transactions commit and new ones begin. On the
17//! real CITADEL-DC01 `$LogFile` there are only **8 distinct values** across 78,765
18//! records (the busiest slot holds 26,274), so a single value spans thousands of
19//! distinct logical transactions. Grouping purely on `transaction_id` therefore
20//! yields a handful of giant *slot* groups, not transactions.
21//!
22//! The correct model is two-level:
23//!
24//! 1. **Slot** — partition records by `transaction_id` (the table slot). This is
25//!    exactly what `LogFileParser`'s `lf_transaction_id` column reports, and the
26//!    slot grouping is validated Tier-1 against it (per-slot LSN membership).
27//! 2. **Transaction** — *within* each slot, in LSN order, a transaction is the run
28//!    of records up to and including a **terminal**: `CommitTransaction` (0x1A) or
29//!    `ForgetTransaction` (0x1B). A new run begins after each terminal (the slot is
30//!    reused). A `CompensationLogRecord` (0x01) seen as a redo within a run marks
31//!    that transaction [`TransactionState::Aborted`]; a run sealed by a terminal is
32//!    [`TransactionState::Committed`] (a terminal wins over a compensation); a
33//!    trailing run with no terminal is [`TransactionState::Incomplete`].
34//!
35//! Records of concurrent transactions are interleaved in LSN order across slots,
36//! so contiguity in the global stream does not bound a transaction; the slot plus
37//! the terminal does. The `client_previous_lsn` / `client_undo_next_lsn`
38//! back-pointers are the redo/undo *replay* chain, not the grouping key.
39//!
40//! Sources for the model:
41//!
42//! - **Microsoft `LFS_RECORD` / flatcap `linux-ntfs` `$LogFile` docs** —
43//!   `TransactionId` at offset `0x24`; the open-transaction-table / restart model.
44//!   <https://flatcap.github.io/linux-ntfs/ntfs/files/logfile.html>
45//! - **`TZWorks` `mala` users guide** — records of concurrent transactions are
46//!   interleaved; the previous-record pointer chains a transaction but adjacency
47//!   does not bound it.
48//!   <https://tzworks.com/prototypes/mala/mala.users.guide.pdf>
49//! - **msuhanov `dfir_ntfs/LogFile.py`** — `get_transaction_id()` is the table
50//!   slot; the transaction table is implied from the log records, not a unique id.
51//!   <https://github.com/msuhanov/dfir_ntfs/blob/master/dfir_ntfs/LogFile.py>
52//! - **Brian Carrier, *File System Forensic Analysis* (2005), ch. 13** — the
53//!   redo/undo transaction model and commit/abort recovery passes.
54//!
55//! ## Why the terminal is read on the *redo* opcode
56//!
57//! NTFS writes a `ForgetTransaction` record with `undo = CompensationLogRecord`
58//! (its inverse). Terminal/abort detection therefore reads the **redo** opcode —
59//! the operation the record performs — never the undo field, or every
60//! Forget-sealed transaction would falsely look aborted.
61
62use super::{LogOp, LogRecord};
63
64/// The recovery disposition of a reconstructed [`Transaction`], derived from the
65/// terminal / compensation opcode that bounds its run within a slot.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum TransactionState {
68    /// A `CommitTransaction` (0x1A) or `ForgetTransaction` (0x1B) record sealed
69    /// the run — its redo records were applied (or are replayable). A terminal
70    /// seal wins even if the run also carried a compensation (sub-action undo).
71    Committed,
72    /// A `CompensationLogRecord` (0x01) redo appeared in the run with no
73    /// terminal — the transaction was rolled back, its effects undone.
74    Aborted,
75    /// The trailing run of a slot ended without a terminal: the records exist but
76    /// the commit/forget that would seal them was not recovered (e.g. the log
77    /// wrapped over it in the circular buffer). The final disposition is unknown.
78    Incomplete,
79}
80
81/// One terminal-bounded run of `$LogFile` LFS records within a transaction-table
82/// slot — a single logical transaction (one user/OS action) NTFS commits or rolls
83/// back as a whole.
84///
85/// `transaction_id` is the *slot* the run occupied (the reused
86/// open-transaction-table index, LFS header offset `0x24`); many [`Transaction`]s
87/// can share one `transaction_id`. `records` holds **indices into the slice passed
88/// to [`reconstruct_transactions`]**, ascending by LSN (replay order); `lsns`
89/// carries each record's `this_lsn` (the run's LSN set, comparable against an
90/// oracle without re-indexing); `operations` is the parallel
91/// [`FileOperation`](super::FileOperation) classification.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct Transaction {
94    /// The transaction-table **slot** this run occupied (LFS header offset
95    /// `0x24`). Reused across transactions — not a unique transaction id.
96    pub transaction_id: u32,
97    /// Indices into the source record slice, ascending by LSN.
98    pub records: Vec<usize>,
99    /// Each member record's `this_lsn`, ascending — the run's LSN set.
100    pub lsns: Vec<u64>,
101    /// Per-record file-operation classification, parallel to `records`.
102    pub operations: Vec<super::FileOperation>,
103    /// Recovery disposition derived from the terminal / compensation opcode.
104    pub state: TransactionState,
105}
106
107/// Reconstruct individual transactions from decoded LFS [`LogRecord`]s.
108///
109/// Partitions records by `transaction_id` (the transaction-table slot), then
110/// within each slot — in ascending `this_lsn` (replay) order — splits the stream
111/// into transactions at each terminal (`CommitTransaction` 0x1A /
112/// `ForgetTransaction` 0x1B). Each emitted [`Transaction`] is one terminal-bounded
113/// run (or the trailing un-terminated run), classified [`TransactionState`] from
114/// its terminal / `CompensationLogRecord` (0x01) redo.
115///
116/// Returned transactions are ordered by the lowest LSN they contain, so the
117/// output reads in log order. Every input record lands in exactly one
118/// transaction — none is dropped, including transaction-control records and
119/// records whose opcode is [`LogOp::Unknown`].
120#[must_use]
121pub fn reconstruct_transactions(records: &[LogRecord]) -> Vec<Transaction> {
122    use std::collections::HashMap;
123
124    // Level 1 — partition record indices by transaction-table slot.
125    let mut slots: HashMap<u32, Vec<usize>> = HashMap::new();
126    for (idx, rec) in records.iter().enumerate() {
127        slots.entry(rec.transaction_id).or_default().push(idx);
128    }
129
130    let mut txns: Vec<Transaction> = Vec::new();
131    for (transaction_id, mut indices) in slots {
132        // Replay order within the slot.
133        indices.sort_by_key(|&i| records[i].this_lsn);
134
135        // Level 2 — split the slot stream into terminal-bounded runs. A run ends
136        // *after* a terminal redo; a compensation redo within a run marks it
137        // aborted (unless a later terminal in the same run seals it).
138        let mut run: Vec<usize> = Vec::new();
139        let mut compensated = false;
140        for &i in &indices {
141            run.push(i);
142            // Read terminal/abort on the REDO opcode only — a Forget's undo field
143            // is CompensationLogRecord by construction and must not abort it.
144            match records[i].redo_op {
145                LogOp::CommitTransaction | LogOp::ForgetTransaction => {
146                    txns.push(build_transaction(
147                        transaction_id,
148                        std::mem::take(&mut run),
149                        records,
150                        TransactionState::Committed,
151                    ));
152                    compensated = false;
153                }
154                LogOp::CompensationLogRecord => compensated = true,
155                _ => {}
156            }
157        }
158        // Trailing records with no terminal: aborted if a compensation appeared,
159        // otherwise incomplete (the sealing terminal was not recovered).
160        if !run.is_empty() {
161            let state = if compensated {
162                TransactionState::Aborted
163            } else {
164                TransactionState::Incomplete
165            };
166            txns.push(build_transaction(transaction_id, run, records, state));
167        }
168    }
169
170    // Log order: by the lowest LSN each transaction owns. Every run is non-empty
171    // by construction, so `first` is always present.
172    txns.sort_by_key(|t| t.lsns.first().copied().unwrap_or(u64::MAX));
173    txns
174}
175
176/// Assemble a [`Transaction`] from an LSN-ordered run of record indices.
177fn build_transaction(
178    transaction_id: u32,
179    records_idx: Vec<usize>,
180    all: &[LogRecord],
181    state: TransactionState,
182) -> Transaction {
183    let mut lsns = Vec::with_capacity(records_idx.len());
184    let mut operations = Vec::with_capacity(records_idx.len());
185    for &i in &records_idx {
186        lsns.push(all[i].this_lsn);
187        operations.push(super::classify(all[i].redo_op, all[i].undo_op));
188    }
189    Transaction {
190        transaction_id,
191        records: records_idx,
192        lsns,
193        operations,
194        state,
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::logfile::FileOperation;
202
203    /// Build a minimal [`LogRecord`] carrying just the fields transaction
204    /// reconstruction reads: LSN, transaction-table slot, redo/undo opcodes.
205    fn rec(this_lsn: u64, slot: u32, redo: LogOp, undo: LogOp) -> LogRecord {
206        LogRecord {
207            page_offset: 0,
208            this_lsn,
209            client_previous_lsn: 0,
210            client_undo_next_lsn: 0,
211            record_type: 1,
212            transaction_id: slot,
213            redo_op: redo,
214            undo_op: undo,
215            target_attribute: 0,
216            mft_cluster_index: 0,
217            target_vcn: 0,
218        }
219    }
220
221    // ── terminal split (the corrected model) ─────────────────────────────────
222
223    // A slot reused across two committed transactions splits into TWO
224    // transactions at each ForgetTransaction terminal, NOT one slot-group.
225    #[test]
226    fn reused_slot_splits_into_two_transactions() {
227        let recs = vec![
228            rec(10, 0x40, LogOp::InitializeFileRecordSegment, LogOp::Noop),
229            rec(
230                11,
231                0x40,
232                LogOp::ForgetTransaction,
233                LogOp::CompensationLogRecord,
234            ),
235            rec(12, 0x40, LogOp::CreateAttribute, LogOp::DeleteAttribute),
236            rec(
237                13,
238                0x40,
239                LogOp::ForgetTransaction,
240                LogOp::CompensationLogRecord,
241            ),
242        ];
243        let txns = reconstruct_transactions(&recs);
244        assert_eq!(txns.len(), 2, "one slot, two terminals => two transactions");
245        assert_eq!(txns[0].lsns, vec![10, 11]);
246        assert_eq!(txns[0].state, TransactionState::Committed);
247        assert_eq!(txns[1].lsns, vec![12, 13]);
248        assert_eq!(txns[1].state, TransactionState::Committed);
249        // Both carry the same reused slot.
250        assert_eq!(txns[0].transaction_id, 0x40);
251        assert_eq!(txns[1].transaction_id, 0x40);
252    }
253
254    // CommitTransaction (0x1A) is also a terminal.
255    #[test]
256    fn commit_is_a_terminal() {
257        let recs = vec![
258            rec(1, 5, LogOp::InitializeFileRecordSegment, LogOp::Noop),
259            rec(2, 5, LogOp::CommitTransaction, LogOp::Noop),
260            rec(3, 5, LogOp::CreateAttribute, LogOp::DeleteAttribute),
261            rec(4, 5, LogOp::CommitTransaction, LogOp::Noop),
262        ];
263        let txns = reconstruct_transactions(&recs);
264        assert_eq!(txns.len(), 2);
265        assert!(txns.iter().all(|t| t.state == TransactionState::Committed));
266    }
267
268    // A Forget record's undo field is CompensationLogRecord by construction; it
269    // must be read as Committed, NOT Aborted (terminal read on redo only).
270    #[test]
271    fn forget_with_compensation_undo_is_committed_not_aborted() {
272        let recs = vec![rec(
273            1,
274            7,
275            LogOp::ForgetTransaction,
276            LogOp::CompensationLogRecord,
277        )];
278        let txns = reconstruct_transactions(&recs);
279        assert_eq!(txns.len(), 1);
280        assert_eq!(txns[0].state, TransactionState::Committed);
281    }
282
283    // A CompensationLogRecord redo with no terminal => Aborted.
284    #[test]
285    fn compensation_redo_without_terminal_is_aborted() {
286        let recs = vec![
287            rec(1, 9, LogOp::InitializeFileRecordSegment, LogOp::Noop),
288            rec(2, 9, LogOp::CompensationLogRecord, LogOp::Noop),
289        ];
290        let txns = reconstruct_transactions(&recs);
291        assert_eq!(txns.len(), 1);
292        assert_eq!(txns[0].state, TransactionState::Aborted);
293    }
294
295    // A run that compensates a sub-action then commits is Committed (terminal
296    // wins), and the compensation flag does not leak into the NEXT run.
297    #[test]
298    fn terminal_wins_over_compensation_and_flag_resets() {
299        let recs = vec![
300            rec(1, 3, LogOp::InitializeFileRecordSegment, LogOp::Noop),
301            rec(2, 3, LogOp::CompensationLogRecord, LogOp::Noop),
302            rec(3, 3, LogOp::CommitTransaction, LogOp::Noop),
303            // Next run in the same slot: a clean incomplete trailing run.
304            rec(4, 3, LogOp::CreateAttribute, LogOp::DeleteAttribute),
305        ];
306        let txns = reconstruct_transactions(&recs);
307        assert_eq!(txns.len(), 2);
308        assert_eq!(txns[0].lsns, vec![1, 2, 3]);
309        assert_eq!(txns[0].state, TransactionState::Committed);
310        // The trailing run must NOT inherit the previous run's compensation.
311        assert_eq!(txns[1].lsns, vec![4]);
312        assert_eq!(txns[1].state, TransactionState::Incomplete);
313    }
314
315    // Trailing records after the last terminal, with no compensation => Incomplete.
316    #[test]
317    fn trailing_run_without_terminal_is_incomplete() {
318        let recs = vec![
319            rec(1, 1, LogOp::InitializeFileRecordSegment, LogOp::Noop),
320            rec(2, 1, LogOp::ForgetTransaction, LogOp::CompensationLogRecord),
321            rec(3, 1, LogOp::CreateAttribute, LogOp::DeleteAttribute),
322        ];
323        let txns = reconstruct_transactions(&recs);
324        assert_eq!(txns.len(), 2);
325        assert_eq!(txns[1].lsns, vec![3]);
326        assert_eq!(txns[1].state, TransactionState::Incomplete);
327    }
328
329    // ── slot grouping + interleaving ─────────────────────────────────────────
330
331    // Records of two concurrent slots interleaved in LSN order split into the
332    // correct per-slot transactions (the mala example, slot-keyed).
333    #[test]
334    fn separates_interleaved_slots() {
335        let recs = vec![
336            rec(21, 1, LogOp::InitializeFileRecordSegment, LogOp::Noop),
337            rec(22, 1, LogOp::CreateAttribute, LogOp::DeleteAttribute),
338            rec(23, 2, LogOp::InitializeFileRecordSegment, LogOp::Noop),
339            rec(24, 1, LogOp::CommitTransaction, LogOp::Noop),
340            rec(25, 2, LogOp::CreateAttribute, LogOp::DeleteAttribute),
341            rec(26, 2, LogOp::CommitTransaction, LogOp::Noop),
342        ];
343        let txns = reconstruct_transactions(&recs);
344        assert_eq!(txns.len(), 2);
345        // Ordered by lowest LSN: slot 1's transaction first.
346        assert_eq!(txns[0].transaction_id, 1);
347        assert_eq!(txns[0].lsns, vec![21, 22, 24]);
348        assert_eq!(txns[1].transaction_id, 2);
349        assert_eq!(txns[1].lsns, vec![23, 25, 26]);
350    }
351
352    // Records must be held in ascending LSN order within a transaction even when
353    // the source slice presents the slot's records out of order.
354    #[test]
355    fn orders_records_by_lsn_within_slot() {
356        let recs = vec![
357            rec(30, 5, LogOp::CommitTransaction, LogOp::Noop),
358            rec(28, 5, LogOp::InitializeFileRecordSegment, LogOp::Noop),
359            rec(29, 5, LogOp::CreateAttribute, LogOp::DeleteAttribute),
360        ];
361        let txns = reconstruct_transactions(&recs);
362        assert_eq!(txns.len(), 1);
363        assert_eq!(txns[0].lsns, vec![28, 29, 30]);
364        assert_eq!(txns[0].records, vec![1, 2, 0]);
365    }
366
367    // Every input record lands in exactly one transaction — none dropped.
368    #[test]
369    fn assigns_every_record_exactly_once() {
370        let recs = vec![
371            rec(1, 1, LogOp::InitializeFileRecordSegment, LogOp::Noop),
372            rec(2, 2, LogOp::InitializeFileRecordSegment, LogOp::Noop),
373            rec(3, 1, LogOp::CommitTransaction, LogOp::Noop),
374            rec(4, 3, LogOp::InitializeFileRecordSegment, LogOp::Noop),
375        ];
376        let txns = reconstruct_transactions(&recs);
377        let total: usize = txns.iter().map(|t| t.records.len()).sum();
378        assert_eq!(total, recs.len(), "no record dropped");
379        let mut seen: Vec<usize> = txns
380            .iter()
381            .flat_map(|t| t.records.iter().copied())
382            .collect();
383        seen.sort_unstable();
384        assert_eq!(seen, vec![0, 1, 2, 3], "each index assigned exactly once");
385    }
386
387    // operations is parallel to records and carries the classification.
388    #[test]
389    fn operations_parallel_records() {
390        let recs = vec![
391            rec(1, 9, LogOp::InitializeFileRecordSegment, LogOp::Noop),
392            rec(2, 9, LogOp::CommitTransaction, LogOp::Noop),
393        ];
394        let txns = reconstruct_transactions(&recs);
395        assert_eq!(
396            txns[0].operations,
397            vec![FileOperation::Create, FileOperation::TransactionControl]
398        );
399    }
400
401    // An unrecognized opcode must not silently drop the record: a record with an
402    // Unknown opcode is still grouped and counted.
403    #[test]
404    fn unknown_opcode_record_is_retained() {
405        let recs = vec![
406            rec(1, 6, LogOp::Unknown(0x40), LogOp::Unknown(0x41)),
407            rec(2, 6, LogOp::CommitTransaction, LogOp::Noop),
408        ];
409        let txns = reconstruct_transactions(&recs);
410        assert_eq!(txns.len(), 1);
411        assert_eq!(txns[0].records.len(), 2);
412        assert_eq!(txns[0].operations[0], FileOperation::Unknown(0x40, 0x41));
413        assert_eq!(txns[0].state, TransactionState::Committed);
414    }
415
416    // EndTopLevelAction / PrepareTransaction are NOT terminals — a run carrying
417    // only those is the trailing Incomplete run (no commit/forget/compensation).
418    #[test]
419    fn prepare_and_end_top_level_are_not_terminals() {
420        let recs = vec![
421            rec(1, 4, LogOp::PrepareTransaction, LogOp::Noop),
422            rec(2, 4, LogOp::EndTopLevelAction, LogOp::Noop),
423        ];
424        let txns = reconstruct_transactions(&recs);
425        assert_eq!(txns.len(), 1, "no terminal => single trailing run");
426        assert_eq!(txns[0].state, TransactionState::Incomplete);
427        assert_eq!(txns[0].records.len(), 2);
428    }
429
430    #[test]
431    fn empty_input_yields_no_transactions() {
432        assert!(reconstruct_transactions(&[]).is_empty());
433    }
434}