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}