Skip to main content

ntfs_core/
rewind.rs

1//! Journal Rewind engine for complete path reconstruction.
2//!
3//! Implements the `CyberCX` "Rewind" algorithm: processes USN journal entries
4//! in reverse chronological order to reconstruct full paths even when MFT
5//! entries have been reallocated multiple times.
6//!
7//! ## Algorithm
8//!
9//! Traditional tools resolve USN parent references against the *current* `$MFT`
10//! state. When an MFT entry has been reused (sequence number changed), the
11//! parent can't be found and the path is marked "UNKNOWN".
12//!
13//! The rewind approach:
14//! 1. Seed a lookup table from the current `$MFT` state (entry -> name + parent)
15//! 2. Process USN records from newest to oldest
16//! 3. For each record, track the (entry, sequence) -> (name, `parent_entry`, `parent_seq`) mapping
17//! 4. Handle renames by restoring old names when seeing `RENAME_OLD_NAME` events
18//! 5. Recursively resolve paths through the lookup table
19//!
20//! This guarantees complete path resolution for every journal entry.
21
22use std::collections::HashMap;
23
24use crate::carve::CarvedMftEntry;
25use crate::usn::{UsnReason, UsnRecord};
26
27/// Key for the rewind lookup table: (`mft_entry`, `mft_sequence`).
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub struct EntryKey {
30    pub entry: u64,
31    pub sequence: u16,
32}
33
34impl EntryKey {
35    pub fn new(entry: u64, sequence: u16) -> Self {
36        Self { entry, sequence }
37    }
38
39    /// The NTFS root directory is always entry 5, sequence 5.
40    pub fn root() -> Self {
41        Self {
42            entry: 5,
43            sequence: 5,
44        }
45    }
46
47    pub fn is_root(&self) -> bool {
48        self.entry == 5
49    }
50}
51
52/// Value stored in the rewind lookup table.
53#[derive(Debug, Clone)]
54pub struct EntryInfo {
55    /// Filename of this entry (not full path).
56    pub name: String,
57    /// Key to the parent directory entry.
58    pub parent: EntryKey,
59}
60
61/// Where a resolved record originated.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum RecordSource {
64    /// From the live (allocated) USN journal.
65    Allocated,
66    /// Carved from unallocated disk space.
67    Carved,
68    /// Ghost record inferred from MFT/journal correlation.
69    Ghost,
70}
71
72impl RecordSource {
73    /// Returns the lowercase label used in serialization and source filters.
74    pub fn as_str(&self) -> &'static str {
75        match self {
76            RecordSource::Allocated => "allocated",
77            RecordSource::Carved => "entry-carved",
78            RecordSource::Ghost => "ghost",
79        }
80    }
81}
82
83/// Result of path resolution for a single USN record.
84#[derive(Debug, Clone)]
85pub struct ResolvedRecord {
86    /// The original USN record.
87    pub record: UsnRecord,
88    /// The fully resolved path (e.g. ".\\Users\\admin\\temp\\malware.exe").
89    pub full_path: String,
90    /// The resolved parent path (e.g. ".\\Users\\admin\\temp").
91    pub parent_path: String,
92    /// Where this record originated (allocated journal, carved, or ghost).
93    pub source: RecordSource,
94}
95
96/// The Rewind engine for full path reconstruction.
97pub struct RewindEngine {
98    /// Lookup table: (entry, sequence) -> (name, `parent_key`).
99    lookup: HashMap<EntryKey, EntryInfo>,
100}
101
102impl RewindEngine {
103    /// Create a new empty `RewindEngine`.
104    pub fn new() -> Self {
105        Self {
106            lookup: HashMap::new(),
107        }
108    }
109
110    /// Create a `RewindEngine` seeded with MFT entries.
111    ///
112    /// The `mft_entries` iterator yields (`entry_number`, sequence, filename,
113    /// `parent_entry`, `parent_sequence`).
114    pub fn from_mft<I>(mft_entries: I) -> Self
115    where
116        I: IntoIterator<Item = (u64, u16, String, u64, u16)>,
117    {
118        let mut engine = Self::new();
119        for (entry, seq, name, parent_entry, parent_seq) in mft_entries {
120            engine.lookup.insert(
121                EntryKey::new(entry, seq),
122                EntryInfo {
123                    name,
124                    parent: EntryKey::new(parent_entry, parent_seq),
125                },
126            );
127        }
128        engine
129    }
130
131    /// Number of entries in the lookup table.
132    pub fn lookup_len(&self) -> usize {
133        self.lookup.len()
134    }
135
136    /// Seed the engine with carved MFT entries from unallocated space.
137    ///
138    /// Uses `or_insert` so carved entries never overwrite allocated MFT data
139    /// that was seeded via `from_mft`. Historical entries (different sequence
140    /// numbers from the same MFT entry) are added as new keys.
141    pub fn seed_from_carved(&mut self, entries: &[CarvedMftEntry]) {
142        for e in entries {
143            let key = EntryKey::new(e.entry_number, e.sequence_number);
144            self.lookup.entry(key).or_insert(EntryInfo {
145                name: e.filename.clone(),
146                parent: EntryKey::new(e.parent_entry, e.parent_sequence),
147            });
148        }
149    }
150
151    /// Insert or update an entry in the lookup table.
152    pub fn insert(&mut self, key: EntryKey, info: EntryInfo) {
153        self.lookup.insert(key, info);
154    }
155
156    /// Resolve the full path for a given entry key.
157    ///
158    /// Recursively follows parent references until reaching the root.
159    /// Returns "." as the root prefix (representing the volume root).
160    pub fn resolve_path(&self, key: &EntryKey) -> String {
161        self.resolve_path_inner(key, 0)
162    }
163
164    fn resolve_path_inner(&self, key: &EntryKey, depth: usize) -> String {
165        // Prevent infinite loops from circular references
166        if depth > 256 {
167            return format!("UNRESOLVED({}:{})", key.entry, key.sequence);
168        }
169
170        if key.is_root() {
171            return ".".to_string();
172        }
173
174        if let Some(info) = self.lookup.get(key) {
175            let parent_path = self.resolve_path_inner(&info.parent, depth + 1);
176            format!("{}\\{}", parent_path, info.name)
177        } else {
178            format!("UNKNOWN({}:{})", key.entry, key.sequence)
179        }
180    }
181
182    /// Process USN records using the Rewind algorithm.
183    ///
184    /// Records MUST be sorted by USN/timestamp in ascending order (oldest first).
185    /// This function processes them in reverse to build the lookup table, then
186    /// resolves paths for each record in forward order.
187    ///
188    /// Returns resolved records with full paths.
189    pub fn rewind(&mut self, records: &[UsnRecord]) -> Vec<ResolvedRecord> {
190        // Phase 1: Process records in reverse to build/update the lookup table.
191        // Going backwards from newest to oldest, we track how entries were
192        // named and where they were parented at each point in time.
193        for record in records.iter().rev() {
194            let key = EntryKey::new(record.mft_entry, record.mft_sequence);
195            let parent_key = EntryKey::new(record.parent_mft_entry, record.parent_mft_sequence);
196
197            // Always record what we learn about this entry's name and parent.
198            // Since we're going backwards, the first time we see an entry-sequence
199            // pair is its LATEST state. We want to capture earlier states too.
200            if record.reason.contains(UsnReason::RENAME_OLD_NAME) {
201                // This is the OLD name before a rename. Going backwards, this means
202                // the entry was renamed FROM this name TO something else.
203                // We want to record this as the name for this entry-sequence pair
204                // at this point in time.
205                self.lookup.insert(
206                    key,
207                    EntryInfo {
208                        name: record.filename.clone(),
209                        parent: parent_key,
210                    },
211                );
212            } else {
213                // First time seeing this entry-sequence going backwards = latest state
214                self.lookup.entry(key).or_insert(EntryInfo {
215                    name: record.filename.clone(),
216                    parent: parent_key,
217                });
218            }
219
220            // If this is a directory, also ensure the parent's chain is known
221            // (the parent entry-sequence -> its name mapping may come from other records)
222        }
223
224        // Phase 2: Resolve paths for each record in forward order.
225        // Now re-process records forward. For each record, we need to resolve
226        // the path as it existed at that point in time.
227        //
228        // We rebuild the lookup as we go forward, updating names on renames
229        // and tracking creates/deletes.
230        let mut forward_lookup = self.lookup.clone();
231        let mut results = Vec::with_capacity(records.len());
232
233        for record in records {
234            let key = EntryKey::new(record.mft_entry, record.mft_sequence);
235            let parent_key = EntryKey::new(record.parent_mft_entry, record.parent_mft_sequence);
236
237            // Update the forward lookup with what this record tells us
238            if record.reason.contains(UsnReason::RENAME_NEW_NAME) {
239                // Entry was renamed to this name
240                forward_lookup.insert(
241                    key,
242                    EntryInfo {
243                        name: record.filename.clone(),
244                        parent: parent_key,
245                    },
246                );
247            } else if record.reason.contains(UsnReason::FILE_CREATE) {
248                // New entry created
249                forward_lookup.insert(
250                    key,
251                    EntryInfo {
252                        name: record.filename.clone(),
253                        parent: parent_key,
254                    },
255                );
256            } else {
257                forward_lookup.entry(key).or_insert(EntryInfo {
258                    name: record.filename.clone(),
259                    parent: parent_key,
260                });
261            }
262
263            // Resolve the parent path using the forward lookup
264            let parent_path = resolve_path_from(&forward_lookup, &parent_key);
265            let full_path = format!("{}\\{}", parent_path, record.filename);
266
267            results.push(ResolvedRecord {
268                record: record.clone(),
269                full_path,
270                parent_path,
271                source: RecordSource::Allocated,
272            });
273        }
274
275        results
276    }
277}
278
279/// Resolve a path from a lookup table (standalone function for forward pass).
280fn resolve_path_from(lookup: &HashMap<EntryKey, EntryInfo>, key: &EntryKey) -> String {
281    resolve_path_from_inner(lookup, key, 0)
282}
283
284fn resolve_path_from_inner(
285    lookup: &HashMap<EntryKey, EntryInfo>,
286    key: &EntryKey,
287    depth: usize,
288) -> String {
289    if depth > 256 {
290        return format!("UNRESOLVED({}:{})", key.entry, key.sequence);
291    }
292    if key.is_root() {
293        return ".".to_string();
294    }
295    if let Some(info) = lookup.get(key) {
296        let parent_path = resolve_path_from_inner(lookup, &info.parent, depth + 1);
297        format!("{}\\{}", parent_path, info.name)
298    } else {
299        format!("UNKNOWN({}:{})", key.entry, key.sequence)
300    }
301}
302
303impl Default for RewindEngine {
304    fn default() -> Self {
305        Self::new()
306    }
307}
308
309// ─── Tests ───────────────────────────────────────────────────────────────────
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use crate::usn::{FileAttributes, UsnReason};
315    use chrono::DateTime;
316
317    fn make_record(
318        entry: u64,
319        seq: u16,
320        parent_entry: u64,
321        parent_seq: u16,
322        reason: UsnReason,
323        filename: &str,
324        usn: i64,
325    ) -> UsnRecord {
326        UsnRecord {
327            mft_entry: entry,
328            mft_sequence: seq,
329            parent_mft_entry: parent_entry,
330            parent_mft_sequence: parent_seq,
331            usn,
332            timestamp: DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
333            reason,
334            filename: filename.to_string(),
335            file_attributes: FileAttributes::ARCHIVE,
336            source_info: 0,
337            security_id: 0,
338            major_version: 2,
339        }
340    }
341
342    #[test]
343    fn test_entry_key_root() {
344        let root = EntryKey::root();
345        assert!(root.is_root());
346        assert_eq!(root.entry, 5);
347    }
348
349    #[test]
350    fn test_record_source_as_str() {
351        // Exercises every RecordSource::as_str match arm.
352        assert_eq!(RecordSource::Allocated.as_str(), "allocated");
353        assert_eq!(RecordSource::Carved.as_str(), "entry-carved");
354        assert_eq!(RecordSource::Ghost.as_str(), "ghost");
355    }
356
357    #[test]
358    fn test_resolve_path_simple() {
359        // MFT has: entry 100 "Users" -> root, entry 200 "admin" -> 100
360        let engine = RewindEngine::from_mft(vec![
361            (100, 1, "Users".into(), 5, 5),
362            (200, 1, "admin".into(), 100, 1),
363        ]);
364
365        let path = engine.resolve_path(&EntryKey::new(200, 1));
366        assert_eq!(path, ".\\Users\\admin");
367    }
368
369    #[test]
370    fn test_resolve_path_root() {
371        let engine = RewindEngine::new();
372        let path = engine.resolve_path(&EntryKey::root());
373        assert_eq!(path, ".");
374    }
375
376    #[test]
377    fn test_resolve_path_unknown_entry() {
378        let engine = RewindEngine::new();
379        let path = engine.resolve_path(&EntryKey::new(999, 1));
380        assert!(path.contains("UNKNOWN"));
381    }
382
383    #[test]
384    fn test_rewind_simple_create() {
385        // Scenario: file created at .\temp\malware.exe
386        let mut engine = RewindEngine::from_mft(vec![(50, 1, "temp".into(), 5, 5)]);
387
388        let records = vec![make_record(
389            100,
390            1,
391            50,
392            1,
393            UsnReason::FILE_CREATE,
394            "malware.exe",
395            100,
396        )];
397
398        let resolved = engine.rewind(&records);
399        assert_eq!(resolved.len(), 1);
400        assert_eq!(resolved[0].full_path, ".\\temp\\malware.exe");
401        assert_eq!(resolved[0].parent_path, ".\\temp");
402    }
403
404    #[test]
405    fn test_rewind_resolves_unknown_parent_via_journal() {
406        // Scenario from CyberCX blog:
407        // MFT entry 983 was reused. Current state has seq=6, but the journal
408        // record refers to parent 983 with seq=4 (the old allocation).
409        // The journal itself contains events that tell us entry 983 seq 4
410        // was a folder named "ip_scanner" under entry 500 "Drivers".
411        //
412        // Without rewind, parent 983:4 would be UNKNOWN.
413        // With rewind, we should resolve it to .\Intel\Drivers\ip_scanner
414
415        let mut engine = RewindEngine::from_mft(vec![(30, 1, "Intel".into(), 5, 5)]);
416
417        // Journal records (oldest to newest):
418        // 1. Folder "Drivers" created at entry 500, seq 1, parent 30:1 (Intel)
419        // 2. Folder "ip_scanner" created at entry 983, seq 4, parent 500:1 (Drivers)
420        // 3. File "data.txt" created at entry 1500, seq 1, parent 983:4 (ip_scanner)
421        // 4. File "data.txt" deleted
422        // 5. Folder "ip_scanner" deleted
423        // 6. Entry 983 reused as seq 6 for something else
424        let records = vec![
425            make_record(500, 1, 30, 1, UsnReason::FILE_CREATE, "Drivers", 10),
426            make_record(983, 4, 500, 1, UsnReason::FILE_CREATE, "ip_scanner", 20),
427            make_record(1500, 1, 983, 4, UsnReason::FILE_CREATE, "data.txt", 30),
428            make_record(1500, 1, 983, 4, UsnReason::FILE_DELETE, "data.txt", 40),
429            make_record(983, 4, 500, 1, UsnReason::FILE_DELETE, "ip_scanner", 50),
430            make_record(983, 6, 5, 5, UsnReason::FILE_CREATE, "NewFolder", 60),
431        ];
432
433        let resolved = engine.rewind(&records);
434
435        // The data.txt create should resolve to .\Intel\Drivers\ip_scanner\data.txt
436        assert_eq!(
437            resolved[2].full_path,
438            ".\\Intel\\Drivers\\ip_scanner\\data.txt"
439        );
440        // The new folder at entry 983 seq 6 should be .\NewFolder
441        assert_eq!(resolved[5].full_path, ".\\NewFolder");
442    }
443
444    #[test]
445    fn test_rewind_handles_rename() {
446        // Scenario: folder renamed from "old_name" to "new_name"
447        // Files created under the folder should show correct name at each point.
448
449        let mut engine = RewindEngine::from_mft(vec![]);
450
451        let records = vec![
452            // Folder created as "old_name"
453            make_record(100, 1, 5, 5, UsnReason::FILE_CREATE, "old_name", 10),
454            // File created under old_name
455            make_record(200, 1, 100, 1, UsnReason::FILE_CREATE, "before.txt", 20),
456            // Folder renamed: old_name -> new_name
457            make_record(100, 1, 5, 5, UsnReason::RENAME_OLD_NAME, "old_name", 30),
458            make_record(100, 1, 5, 5, UsnReason::RENAME_NEW_NAME, "new_name", 31),
459            // File created under new_name
460            make_record(300, 1, 100, 1, UsnReason::FILE_CREATE, "after.txt", 40),
461        ];
462
463        let resolved = engine.rewind(&records);
464
465        // before.txt should be under old_name at the time it was created
466        assert_eq!(resolved[1].full_path, ".\\old_name\\before.txt");
467        // after.txt should be under new_name
468        assert_eq!(resolved[4].full_path, ".\\new_name\\after.txt");
469    }
470
471    #[test]
472    fn test_rewind_multiple_reuse() {
473        // Entry 50 is reused 3 times with different sequence numbers
474        let mut engine = RewindEngine::from_mft(vec![]);
475
476        let records = vec![
477            make_record(50, 2, 5, 5, UsnReason::FILE_CREATE, "first_life", 10),
478            make_record(50, 2, 5, 5, UsnReason::FILE_DELETE, "first_life", 20),
479            make_record(50, 4, 5, 5, UsnReason::FILE_CREATE, "second_life", 30),
480            make_record(50, 4, 5, 5, UsnReason::FILE_DELETE, "second_life", 40),
481            make_record(50, 6, 5, 5, UsnReason::FILE_CREATE, "third_life", 50),
482        ];
483
484        let resolved = engine.rewind(&records);
485        assert_eq!(resolved[0].full_path, ".\\first_life");
486        assert_eq!(resolved[2].full_path, ".\\second_life");
487        assert_eq!(resolved[4].full_path, ".\\third_life");
488    }
489
490    #[test]
491    fn test_rewind_deep_path_reconstruction() {
492        // Deep path: .\A\B\C\D\file.txt where all are created in journal
493        let mut engine = RewindEngine::from_mft(vec![]);
494
495        let records = vec![
496            make_record(10, 1, 5, 5, UsnReason::FILE_CREATE, "A", 10),
497            make_record(20, 1, 10, 1, UsnReason::FILE_CREATE, "B", 20),
498            make_record(30, 1, 20, 1, UsnReason::FILE_CREATE, "C", 30),
499            make_record(40, 1, 30, 1, UsnReason::FILE_CREATE, "D", 40),
500            make_record(50, 1, 40, 1, UsnReason::FILE_CREATE, "file.txt", 50),
501        ];
502
503        let resolved = engine.rewind(&records);
504        assert_eq!(resolved[4].full_path, ".\\A\\B\\C\\D\\file.txt");
505    }
506
507    #[test]
508    fn test_from_mft_seeding() {
509        let engine = RewindEngine::from_mft(vec![
510            (100, 1, "Users".into(), 5, 5),
511            (200, 1, "admin".into(), 100, 1),
512            (300, 1, "Desktop".into(), 200, 1),
513        ]);
514        assert_eq!(engine.lookup_len(), 3);
515        let path = engine.resolve_path(&EntryKey::new(300, 1));
516        assert_eq!(path, ".\\Users\\admin\\Desktop");
517    }
518
519    #[test]
520    fn test_rewind_engine_default() {
521        let engine = RewindEngine::default();
522        assert_eq!(engine.lookup_len(), 0);
523    }
524
525    #[test]
526    fn test_rewind_engine_insert() {
527        let mut engine = RewindEngine::new();
528        engine.insert(
529            EntryKey::new(100, 1),
530            EntryInfo {
531                name: "inserted.txt".to_string(),
532                parent: EntryKey::root(),
533            },
534        );
535        assert_eq!(engine.lookup_len(), 1);
536        let path = engine.resolve_path(&EntryKey::new(100, 1));
537        assert_eq!(path, ".\\inserted.txt");
538    }
539
540    #[test]
541    fn test_resolve_path_circular_reference() {
542        // Create circular parent references: A -> B -> A
543        let mut engine = RewindEngine::new();
544        engine.insert(
545            EntryKey::new(100, 1),
546            EntryInfo {
547                name: "A".to_string(),
548                parent: EntryKey::new(200, 1),
549            },
550        );
551        engine.insert(
552            EntryKey::new(200, 1),
553            EntryInfo {
554                name: "B".to_string(),
555                parent: EntryKey::new(100, 1),
556            },
557        );
558
559        let path = engine.resolve_path(&EntryKey::new(100, 1));
560        // Should hit depth limit and return UNRESOLVED
561        assert!(path.contains("UNRESOLVED"));
562    }
563
564    #[test]
565    fn test_entry_key_not_root() {
566        let key = EntryKey::new(100, 1);
567        assert!(!key.is_root());
568    }
569
570    #[test]
571    fn test_rewind_empty_records() {
572        let mut engine = RewindEngine::new();
573        let resolved = engine.rewind(&[]);
574        assert!(resolved.is_empty());
575    }
576
577    #[test]
578    fn test_rewind_data_extend_and_truncation() {
579        let mut engine = RewindEngine::from_mft(vec![(50, 1, "data".into(), 5, 5)]);
580
581        let records = vec![
582            make_record(100, 1, 50, 1, UsnReason::FILE_CREATE, "log.txt", 10),
583            make_record(100, 1, 50, 1, UsnReason::DATA_EXTEND, "log.txt", 20),
584            make_record(100, 1, 50, 1, UsnReason::DATA_TRUNCATION, "log.txt", 30),
585        ];
586
587        let resolved = engine.rewind(&records);
588        assert_eq!(resolved.len(), 3);
589        assert_eq!(resolved[0].full_path, ".\\data\\log.txt");
590        assert_eq!(resolved[1].full_path, ".\\data\\log.txt");
591        assert_eq!(resolved[2].full_path, ".\\data\\log.txt");
592    }
593
594    #[test]
595    fn test_resolve_path_hits_depth_limit_linear_chain() {
596        // Create a chain of 258 entries (> 256 depth limit) that is NOT circular
597        // but exceeds the depth guard. Entry 1000 -> 1001 -> 1002 -> ... -> 1257
598        // None of them point to root, so path resolution will recurse 257+ times.
599        let mut engine = RewindEngine::new();
600        let chain_length = 258;
601        for i in 0..chain_length {
602            let entry_num = 1000 + i as u64;
603            let parent_num = 1001 + i as u64; // Points to next in chain
604            engine.insert(
605                EntryKey::new(entry_num, 1),
606                EntryInfo {
607                    name: format!("dir_{i}"),
608                    parent: EntryKey::new(parent_num, 1),
609                },
610            );
611        }
612
613        // Resolving from the start of the chain should hit depth limit
614        let path = engine.resolve_path(&EntryKey::new(1000, 1));
615        assert!(path.contains("UNRESOLVED") || path.contains("UNKNOWN"));
616    }
617
618    #[test]
619    fn test_resolve_path_from_hits_depth_limit_in_rewind() {
620        // Create a circular reference that will hit the depth limit in the
621        // resolve_path_from function (used in the forward pass of rewind).
622        // Build entries: A -> B -> C -> A (cycle)
623        let mut engine = RewindEngine::new();
624        engine.insert(
625            EntryKey::new(100, 1),
626            EntryInfo {
627                name: "A".to_string(),
628                parent: EntryKey::new(200, 1),
629            },
630        );
631        engine.insert(
632            EntryKey::new(200, 1),
633            EntryInfo {
634                name: "B".to_string(),
635                parent: EntryKey::new(300, 1),
636            },
637        );
638        engine.insert(
639            EntryKey::new(300, 1),
640            EntryInfo {
641                name: "C".to_string(),
642                parent: EntryKey::new(100, 1),
643            },
644        );
645
646        // Process a record whose parent is in the cycle
647        let records = vec![make_record(
648            400,
649            1,
650            100,
651            1,
652            UsnReason::FILE_CREATE,
653            "trapped.txt",
654            10,
655        )];
656
657        let resolved = engine.rewind(&records);
658        assert_eq!(resolved.len(), 1);
659        // The parent path should contain UNRESOLVED due to the circular chain
660        assert!(resolved[0].parent_path.contains("UNRESOLVED"));
661    }
662
663    #[test]
664    fn test_rewind_forward_pass_unseen_entry() {
665        // Test lines 204-206: forward_lookup doesn't have the key yet,
666        // and the record is not a RENAME_NEW_NAME or FILE_CREATE.
667        // This exercises the else branch in the forward pass.
668        let mut engine = RewindEngine::from_mft(vec![(50, 1, "data".into(), 5, 5)]);
669
670        let records = vec![
671            // DATA_EXTEND is not RENAME_NEW_NAME nor FILE_CREATE
672            // And entry 100:1 is not in the lookup initially
673            make_record(100, 1, 50, 1, UsnReason::DATA_EXTEND, "log.txt", 10),
674        ];
675
676        let resolved = engine.rewind(&records);
677        assert_eq!(resolved.len(), 1);
678        assert_eq!(resolved[0].full_path, ".\\data\\log.txt");
679    }
680
681    #[test]
682    fn test_resolve_path_from_unknown_parent_in_forward() {
683        // Test line 245: resolve_path_from returns UNKNOWN for unknown key
684        // This happens during rewind's forward pass when the parent key
685        // is not in the forward_lookup and is not root.
686        let mut engine = RewindEngine::new();
687
688        let records = vec![
689            // Parent 999:1 is not in any lookup and is not root
690            make_record(100, 1, 999, 1, UsnReason::FILE_CREATE, "orphan.txt", 10),
691        ];
692
693        let resolved = engine.rewind(&records);
694        assert_eq!(resolved.len(), 1);
695        assert!(resolved[0].parent_path.contains("UNKNOWN(999:1)"));
696    }
697
698    #[test]
699    fn test_seed_from_carved_adds_entries() {
700        use crate::carve::CarvedMftEntry;
701
702        let mut engine = RewindEngine::from_mft(vec![
703            (5, 5, ".".into(), 5, 5), // root
704            (10, 1, "Users".into(), 5, 5),
705        ]);
706        assert_eq!(engine.lookup_len(), 2);
707
708        let carved = vec![
709            CarvedMftEntry {
710                offset: 0,
711                entry_number: 20,
712                sequence_number: 1,
713                filename: "admin".to_string(),
714                parent_entry: 10,
715                parent_sequence: 1,
716                is_directory: true,
717                is_in_use: false, // deleted — carved from unallocated
718            },
719            CarvedMftEntry {
720                offset: 1024,
721                entry_number: 30,
722                sequence_number: 1,
723                filename: "Desktop".to_string(),
724                parent_entry: 20,
725                parent_sequence: 1,
726                is_directory: true,
727                is_in_use: false,
728            },
729        ];
730
731        engine.seed_from_carved(&carved);
732
733        assert_eq!(engine.lookup_len(), 4);
734        let path = engine.resolve_path(&EntryKey::new(30, 1));
735        assert_eq!(path, ".\\Users\\admin\\Desktop");
736    }
737
738    #[test]
739    fn test_seed_from_carved_does_not_overwrite_allocated() {
740        use crate::carve::CarvedMftEntry;
741
742        // Allocated MFT says entry 100 seq 1 = "current.txt"
743        let mut engine = RewindEngine::from_mft(vec![(100, 1, "current.txt".into(), 5, 5)]);
744
745        // Carved data also has entry 100 seq 1 but with old name
746        let carved = vec![CarvedMftEntry {
747            offset: 0,
748            entry_number: 100,
749            sequence_number: 1,
750            filename: "old_name.txt".to_string(),
751            parent_entry: 5,
752            parent_sequence: 5,
753            is_directory: false,
754            is_in_use: false,
755        }];
756
757        engine.seed_from_carved(&carved);
758
759        // Allocated entry should win — carved should not overwrite
760        assert_eq!(engine.lookup_len(), 1);
761        let path = engine.resolve_path(&EntryKey::new(100, 1));
762        assert_eq!(path, ".\\current.txt");
763    }
764
765    #[test]
766    fn test_seed_from_carved_adds_historical_sequence() {
767        use crate::carve::CarvedMftEntry;
768
769        // Allocated MFT has entry 100 seq 3 (current)
770        let mut engine = RewindEngine::from_mft(vec![(100, 3, "new_file.txt".into(), 5, 5)]);
771
772        // Carved: entry 100 seq 1 (historical, different sequence)
773        let carved = vec![CarvedMftEntry {
774            offset: 0,
775            entry_number: 100,
776            sequence_number: 1,
777            filename: "old_file.txt".to_string(),
778            parent_entry: 5,
779            parent_sequence: 5,
780            is_directory: false,
781            is_in_use: false,
782        }];
783
784        engine.seed_from_carved(&carved);
785
786        // Both should exist — different sequence numbers
787        assert_eq!(engine.lookup_len(), 2);
788        assert_eq!(
789            engine.resolve_path(&EntryKey::new(100, 3)),
790            ".\\new_file.txt"
791        );
792        assert_eq!(
793            engine.resolve_path(&EntryKey::new(100, 1)),
794            ".\\old_file.txt"
795        );
796    }
797
798    #[test]
799    fn test_resolve_path_from_standalone() {
800        // Test the resolve_path_from function directly via rewind behavior
801        let mut engine = RewindEngine::new();
802        let records = vec![make_record(
803            10,
804            1,
805            5,
806            5,
807            UsnReason::FILE_CREATE,
808            "root_file.txt",
809            10,
810        )];
811
812        let resolved = engine.rewind(&records);
813        assert_eq!(resolved.len(), 1);
814        assert_eq!(resolved[0].parent_path, ".");
815        assert_eq!(resolved[0].full_path, ".\\root_file.txt");
816    }
817
818    // ─── Carving pipeline integration tests ──────────────────────────────────
819
820    #[test]
821    fn test_carved_records_resolve_paths_via_carved_mft() {
822        use crate::carve::CarvedMftEntry;
823        use crate::usn::CarvedRecord;
824
825        // Simulate: allocated MFT only has root (entry 5).
826        // Carved MFT recovers a deleted directory tree: Users/admin/Temp
827        // Carved USN records reference files under that deleted tree.
828
829        let mut engine = RewindEngine::from_mft(vec![]);
830
831        let carved_mft = vec![
832            CarvedMftEntry {
833                offset: 0,
834                entry_number: 10,
835                sequence_number: 1,
836                filename: "Users".to_string(),
837                parent_entry: 5,
838                parent_sequence: 5,
839                is_directory: true,
840                is_in_use: false,
841            },
842            CarvedMftEntry {
843                offset: 1024,
844                entry_number: 20,
845                sequence_number: 1,
846                filename: "admin".to_string(),
847                parent_entry: 10,
848                parent_sequence: 1,
849                is_directory: true,
850                is_in_use: false,
851            },
852            CarvedMftEntry {
853                offset: 2048,
854                entry_number: 30,
855                sequence_number: 1,
856                filename: "Temp".to_string(),
857                parent_entry: 20,
858                parent_sequence: 1,
859                is_directory: true,
860                is_in_use: false,
861            },
862        ];
863
864        engine.seed_from_carved(&carved_mft);
865
866        // Carved USN records: malware.exe created under the deleted Temp dir
867        let carved_usn = vec![CarvedRecord {
868            offset: 50000,
869            record: make_record(500, 1, 30, 1, UsnReason::FILE_CREATE, "malware.exe", 99999),
870        }];
871
872        // Merge carved USN records into the record list (simulating main.rs logic)
873        let mut all_records: Vec<UsnRecord> = Vec::new();
874        all_records.extend(carved_usn.into_iter().map(|c| c.record));
875
876        let resolved = engine.rewind(&all_records);
877        assert_eq!(resolved.len(), 1);
878        assert_eq!(resolved[0].full_path, ".\\Users\\admin\\Temp\\malware.exe");
879    }
880
881    #[test]
882    fn test_carved_and_allocated_records_merge_in_pipeline() {
883        use crate::carve::CarvedMftEntry;
884        use crate::usn::CarvedRecord;
885
886        // Allocated: MFT has current tree, USN has recent records
887        let mut engine = RewindEngine::from_mft(vec![
888            (10, 1, "Windows".into(), 5, 5),
889            (20, 1, "System32".into(), 10, 1),
890        ]);
891
892        // Carved: historical MFT directory that was deleted
893        let carved_mft = vec![CarvedMftEntry {
894            offset: 0,
895            entry_number: 50,
896            sequence_number: 2,
897            filename: "HackTools".to_string(),
898            parent_entry: 5,
899            parent_sequence: 5,
900            is_directory: true,
901            is_in_use: false,
902        }];
903        engine.seed_from_carved(&carved_mft);
904
905        // Allocated USN records
906        let allocated = vec![make_record(
907            100,
908            1,
909            20,
910            1,
911            UsnReason::FILE_CREATE,
912            "cmd.exe",
913            1000,
914        )];
915
916        // Carved USN records
917        let carved_usn = vec![CarvedRecord {
918            offset: 80000,
919            record: make_record(
920                200,
921                1,
922                50,
923                2,
924                UsnReason::FILE_CREATE,
925                "mimikatz.exe",
926                500, // older USN offset
927            ),
928        }];
929
930        // Merge: allocated + carved, sorted by USN
931        let mut all_records = allocated;
932        all_records.extend(carved_usn.into_iter().map(|c| c.record));
933        all_records.sort_by_key(|r| r.usn);
934
935        let resolved = engine.rewind(&all_records);
936        assert_eq!(resolved.len(), 2);
937
938        // Carved record (lower USN) comes first after sorting
939        assert_eq!(resolved[0].full_path, ".\\HackTools\\mimikatz.exe");
940        assert_eq!(resolved[1].full_path, ".\\Windows\\System32\\cmd.exe");
941    }
942}