Skip to main content

ntfs_core/logfile/
mod.rs

1//! $LogFile parser for gap detection and LSN correlation.
2//!
3//! The NTFS $LogFile records transaction log entries. By analyzing restart
4//! areas and record pages, we can detect gaps that indicate journal clearing
5//! or corruption.
6
7pub mod usn_extractor;
8
9pub use usn_extractor::{extract_usn_from_logfile, LogFileRecordSource, LogFileUsnRecord};
10
11use crate::error::Result;
12
13// ─── Constants ───────────────────────────────────────────────────────────────
14
15/// NTFS $LogFile restart area signature "RSTR".
16const RSTR_SIGNATURE: &[u8; 4] = b"RSTR";
17
18/// NTFS $LogFile record page signature "RCRD".
19const RCRD_SIGNATURE: &[u8; 4] = b"RCRD";
20
21/// Default NTFS $LogFile page size.
22const LOG_PAGE_SIZE: usize = 0x1000; // 4096 bytes
23
24// ─── Parsed structures ──────────────────────────────────────────────────────
25
26/// Parsed NTFS $LogFile restart area.
27#[derive(Debug, Clone)]
28pub struct RestartArea {
29    pub offset: usize,
30    pub current_lsn: u64,
31    pub log_clients: u16,
32    pub system_page_size: u32,
33    pub log_page_size: u32,
34}
35
36/// Summary of $LogFile analysis.
37#[derive(Debug, Clone)]
38pub struct LogFileSummary {
39    pub restart_areas: Vec<RestartArea>,
40    pub record_page_count: usize,
41    pub has_gaps: bool,
42    pub highest_lsn: u64,
43}
44
45/// Parse NTFS $LogFile data.
46///
47/// Scans for restart areas (RSTR) and record pages (RCRD) to build
48/// a summary. Detects gaps in the log sequence.
49pub fn parse_logfile(data: &[u8]) -> Result<LogFileSummary> {
50    let mut restart_areas = Vec::new();
51    let mut record_page_count = 0;
52    let mut highest_lsn: u64 = 0;
53    let mut has_gaps = false;
54    let mut last_page_had_rcrd = false;
55
56    let page_count = data.len() / LOG_PAGE_SIZE;
57
58    for page_idx in 0..page_count {
59        let page_offset = page_idx * LOG_PAGE_SIZE;
60
61        // page_count = data.len() / LOG_PAGE_SIZE guarantees a full page fits here.
62        let sig = &data[page_offset..page_offset + 4];
63
64        if sig == RSTR_SIGNATURE {
65            if page_offset + 0x28 <= data.len() {
66                let current_lsn = u64::from_le_bytes(
67                    data[page_offset + 0x08..page_offset + 0x10]
68                        .try_into()
69                        .unwrap_or([0; 8]),
70                );
71                let log_clients = u16::from_le_bytes(
72                    data[page_offset + 0x10..page_offset + 0x12]
73                        .try_into()
74                        .unwrap_or([0; 2]),
75                );
76                let system_page_size = u32::from_le_bytes(
77                    data[page_offset + 0x20..page_offset + 0x24]
78                        .try_into()
79                        .unwrap_or([0; 4]),
80                );
81                let log_page_size = u32::from_le_bytes(
82                    data[page_offset + 0x24..page_offset + 0x28]
83                        .try_into()
84                        .unwrap_or([0; 4]),
85                );
86
87                if current_lsn > highest_lsn {
88                    highest_lsn = current_lsn;
89                }
90
91                restart_areas.push(RestartArea {
92                    offset: page_offset,
93                    current_lsn,
94                    log_clients,
95                    system_page_size,
96                    log_page_size,
97                });
98            } // cov:unreachable: page_count = data.len() / LOG_PAGE_SIZE (0x1000) ⇒ each page is a full 4096 bytes, so page_offset + 0x28 always fits; the false-branch is unreachable
99            last_page_had_rcrd = false;
100        } else if sig == RCRD_SIGNATURE {
101            record_page_count += 1;
102
103            // Extract last_end_lsn from RCRD header (offset 0x18)
104            if page_offset + 0x20 <= data.len() {
105                let page_lsn = u64::from_le_bytes(
106                    data[page_offset + 0x18..page_offset + 0x20]
107                        .try_into()
108                        .unwrap_or([0; 8]),
109                );
110                if page_lsn > highest_lsn {
111                    highest_lsn = page_lsn;
112                }
113            } // cov:unreachable: page_count = data.len() / LOG_PAGE_SIZE (0x1000) ⇒ each page is a full 4096 bytes, so page_offset + 0x20 always fits; the false-branch is unreachable
114
115            last_page_had_rcrd = true;
116        } else {
117            // Neither RSTR nor RCRD - could be a gap
118            if last_page_had_rcrd && page_idx > 2 {
119                // If we had RCRD pages and now see something else, that's a gap
120                let is_zeroed = data[page_offset..page_offset + 4] == [0, 0, 0, 0];
121                if !is_zeroed {
122                    has_gaps = true;
123                }
124            }
125            last_page_had_rcrd = false;
126        }
127    }
128
129    Ok(LogFileSummary {
130        restart_areas,
131        record_page_count,
132        has_gaps,
133        highest_lsn,
134    })
135}
136
137/// Correlate $LogFile LSN with USN Journal entries.
138///
139/// The USN (Update Sequence Number) in journal records corresponds to
140/// byte offsets in the journal. $LogFile LSNs are separate but can help
141/// detect if the journal was cleared (LSN continuity break).
142pub fn detect_journal_clearing(logfile_summary: &LogFileSummary) -> bool {
143    // Journal clearing indicators:
144    // 1. Gaps in $LogFile record pages
145    // 2. Very few restart areas (should have exactly 2 normally)
146    // 3. LSN discontinuities
147
148    if logfile_summary.has_gaps {
149        return true;
150    }
151
152    if logfile_summary.restart_areas.len() != 2 {
153        return logfile_summary.restart_areas.is_empty();
154    }
155
156    false
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn make_rstr_page(lsn: u64) -> Vec<u8> {
164        let mut page = vec![0u8; LOG_PAGE_SIZE];
165        page[0..4].copy_from_slice(RSTR_SIGNATURE);
166        page[0x08..0x10].copy_from_slice(&lsn.to_le_bytes());
167        page[0x10..0x12].copy_from_slice(&1u16.to_le_bytes()); // 1 client
168        page[0x20..0x24].copy_from_slice(&4096u32.to_le_bytes());
169        page[0x24..0x28].copy_from_slice(&4096u32.to_le_bytes());
170        page
171    }
172
173    fn make_rcrd_page(lsn: u64) -> Vec<u8> {
174        let mut page = vec![0u8; LOG_PAGE_SIZE];
175        page[0..4].copy_from_slice(RCRD_SIGNATURE);
176        page[0x18..0x20].copy_from_slice(&lsn.to_le_bytes());
177        page
178    }
179
180    #[test]
181    fn test_parse_logfile_with_restart_areas() {
182        let mut data = Vec::new();
183        data.extend_from_slice(&make_rstr_page(1000));
184        data.extend_from_slice(&make_rstr_page(2000));
185        data.extend_from_slice(&make_rcrd_page(3000));
186
187        let summary = parse_logfile(&data).unwrap();
188        assert_eq!(summary.restart_areas.len(), 2);
189        assert_eq!(summary.record_page_count, 1);
190        assert_eq!(summary.highest_lsn, 3000);
191        assert!(!summary.has_gaps);
192    }
193
194    #[test]
195    fn test_detect_journal_clearing_with_gaps() {
196        let summary = LogFileSummary {
197            restart_areas: vec![],
198            record_page_count: 0,
199            has_gaps: true,
200            highest_lsn: 0,
201        };
202        assert!(detect_journal_clearing(&summary));
203    }
204
205    #[test]
206    fn test_normal_logfile_no_clearing() {
207        let summary = LogFileSummary {
208            restart_areas: vec![
209                RestartArea {
210                    offset: 0,
211                    current_lsn: 1000,
212                    log_clients: 1,
213                    system_page_size: 4096,
214                    log_page_size: 4096,
215                },
216                RestartArea {
217                    offset: 4096,
218                    current_lsn: 2000,
219                    log_clients: 1,
220                    system_page_size: 4096,
221                    log_page_size: 4096,
222                },
223            ],
224            record_page_count: 100,
225            has_gaps: false,
226            highest_lsn: 5000,
227        };
228        assert!(!detect_journal_clearing(&summary));
229    }
230
231    #[test]
232    fn test_detect_journal_clearing_empty_restart_areas() {
233        let summary = LogFileSummary {
234            restart_areas: vec![],
235            record_page_count: 0,
236            has_gaps: false,
237            highest_lsn: 0,
238        };
239        assert!(detect_journal_clearing(&summary));
240    }
241
242    #[test]
243    fn test_detect_journal_clearing_one_restart_area() {
244        // 1 restart area (not 2) but no gaps - not detected as clearing
245        let summary = LogFileSummary {
246            restart_areas: vec![RestartArea {
247                offset: 0,
248                current_lsn: 1000,
249                log_clients: 1,
250                system_page_size: 4096,
251                log_page_size: 4096,
252            }],
253            record_page_count: 50,
254            has_gaps: false,
255            highest_lsn: 5000,
256        };
257        assert!(!detect_journal_clearing(&summary));
258    }
259
260    #[test]
261    fn test_detect_journal_clearing_three_restart_areas() {
262        // 3 restart areas (not 2) but no gaps
263        let summary = LogFileSummary {
264            restart_areas: vec![
265                RestartArea {
266                    offset: 0,
267                    current_lsn: 1000,
268                    log_clients: 1,
269                    system_page_size: 4096,
270                    log_page_size: 4096,
271                },
272                RestartArea {
273                    offset: 4096,
274                    current_lsn: 2000,
275                    log_clients: 1,
276                    system_page_size: 4096,
277                    log_page_size: 4096,
278                },
279                RestartArea {
280                    offset: 8192,
281                    current_lsn: 3000,
282                    log_clients: 1,
283                    system_page_size: 4096,
284                    log_page_size: 4096,
285                },
286            ],
287            record_page_count: 50,
288            has_gaps: false,
289            highest_lsn: 5000,
290        };
291        assert!(!detect_journal_clearing(&summary));
292    }
293
294    #[test]
295    fn test_parse_logfile_empty() {
296        let summary = parse_logfile(&[]).unwrap();
297        assert_eq!(summary.restart_areas.len(), 0);
298        assert_eq!(summary.record_page_count, 0);
299        assert!(!summary.has_gaps);
300        assert_eq!(summary.highest_lsn, 0);
301    }
302
303    #[test]
304    fn test_parse_logfile_only_rcrd_pages() {
305        let mut data = Vec::new();
306        data.extend_from_slice(&make_rcrd_page(1000));
307        data.extend_from_slice(&make_rcrd_page(2000));
308        data.extend_from_slice(&make_rcrd_page(3000));
309
310        let summary = parse_logfile(&data).unwrap();
311        assert_eq!(summary.restart_areas.len(), 0);
312        assert_eq!(summary.record_page_count, 3);
313        assert_eq!(summary.highest_lsn, 3000);
314    }
315
316    #[test]
317    fn test_parse_logfile_gap_detection() {
318        // RSTR, RSTR, RCRD, RCRD, non-RCRD/non-zero page, RCRD
319        // Gap should be detected at the non-RCRD page
320        let mut data = Vec::new();
321        data.extend_from_slice(&make_rstr_page(1000));
322        data.extend_from_slice(&make_rstr_page(2000));
323        data.extend_from_slice(&make_rcrd_page(3000));
324
325        // Create a non-zero, non-RCRD, non-RSTR page (looks like corruption)
326        let mut garbage_page = vec![0xDEu8; LOG_PAGE_SIZE];
327        garbage_page[0..4].copy_from_slice(b"JUNK");
328        data.extend_from_slice(&garbage_page);
329
330        data.extend_from_slice(&make_rcrd_page(5000));
331
332        let summary = parse_logfile(&data).unwrap();
333        assert!(summary.has_gaps);
334    }
335
336    #[test]
337    fn test_parse_logfile_no_gap_for_zeroed_page() {
338        // Zeroed pages after RCRD pages should NOT be treated as gaps
339        let mut data = Vec::new();
340        data.extend_from_slice(&make_rstr_page(1000));
341        data.extend_from_slice(&make_rstr_page(2000));
342        data.extend_from_slice(&make_rcrd_page(3000));
343        data.extend_from_slice(&vec![0u8; LOG_PAGE_SIZE]); // zeroed page
344
345        let summary = parse_logfile(&data).unwrap();
346        assert!(!summary.has_gaps);
347    }
348
349    #[test]
350    fn test_parse_logfile_restart_area_lsn_tracking() {
351        let mut data = Vec::new();
352        data.extend_from_slice(&make_rstr_page(5000));
353        data.extend_from_slice(&make_rstr_page(3000));
354        data.extend_from_slice(&make_rcrd_page(4000));
355
356        let summary = parse_logfile(&data).unwrap();
357        assert_eq!(summary.highest_lsn, 5000);
358        assert_eq!(summary.restart_areas.len(), 2);
359        assert_eq!(summary.restart_areas[0].current_lsn, 5000);
360        assert_eq!(summary.restart_areas[1].current_lsn, 3000);
361    }
362
363    #[test]
364    fn test_parse_logfile_short_rstr_page() {
365        // A page with RSTR signature but too small for full header
366        let mut data = vec![0u8; LOG_PAGE_SIZE];
367        data[0..4].copy_from_slice(RSTR_SIGNATURE);
368        // Only write signature, not enough data for header fields at 0x08..0x28
369        // But we set the full page so offset + 0x28 <= data.len() is true
370        // The actual data at those offsets will be zeros, which is still valid
371
372        let summary = parse_logfile(&data).unwrap();
373        assert_eq!(summary.restart_areas.len(), 1);
374        assert_eq!(summary.restart_areas[0].current_lsn, 0);
375    }
376
377    #[test]
378    fn test_parse_logfile_page_offset_boundary() {
379        // Line 61: page_offset + 4 > data.len() break condition
380        // This is tricky because page_count = data.len() / LOG_PAGE_SIZE,
381        // so page_offset = page_idx * LOG_PAGE_SIZE is always <= data.len() - LOG_PAGE_SIZE.
382        // For page_offset + 4 > data.len(), we'd need data.len() < page_offset + 4.
383        // Since page_offset < data.len() (because page_idx < page_count and
384        // page_count = data.len() / LOG_PAGE_SIZE), page_offset is at most
385        // data.len() - LOG_PAGE_SIZE. And LOG_PAGE_SIZE (4096) >> 4.
386        // So line 61 is effectively unreachable with the current loop bounds.
387        // Still, let's add a test for the edge case of exactly one page.
388        let data = make_rcrd_page(5000);
389        assert_eq!(data.len(), LOG_PAGE_SIZE);
390        let summary = parse_logfile(&data).unwrap();
391        assert_eq!(summary.record_page_count, 1);
392        assert_eq!(summary.highest_lsn, 5000);
393    }
394
395    #[test]
396    fn test_parse_logfile_data_smaller_than_page() {
397        // Data that's not a full page
398        let data = vec![0xAAu8; 100];
399        let summary = parse_logfile(&data).unwrap();
400        assert_eq!(summary.restart_areas.len(), 0);
401        assert_eq!(summary.record_page_count, 0);
402    }
403
404    #[test]
405    fn test_parse_logfile_boundary_check_line_61() {
406        // Line 61: page_offset + 4 > data.len() break
407        // This line is unreachable with current loop bounds because:
408        //   page_count = data.len() / LOG_PAGE_SIZE
409        //   page_offset = page_idx * LOG_PAGE_SIZE (max = (page_count-1) * LOG_PAGE_SIZE)
410        //   So page_offset <= data.len() - LOG_PAGE_SIZE, and LOG_PAGE_SIZE (4096) >> 4.
411        // Exercise the closest boundary: data.len() exactly equals one page.
412        let data = vec![0u8; LOG_PAGE_SIZE];
413        let summary = parse_logfile(&data).unwrap();
414        // All zeros -> no RSTR or RCRD signatures
415        assert_eq!(summary.restart_areas.len(), 0);
416        assert_eq!(summary.record_page_count, 0);
417        assert!(!summary.has_gaps);
418    }
419
420    #[test]
421    fn test_parse_logfile_gap_not_flagged_early_pages() {
422        // Covers line 120: the condition page_idx > 2 prevents false gap detection
423        // for the very first pages. Build data: RCRD page 0, then garbage page 1.
424        // Since page_idx=1 which is <= 2, no gap should be flagged.
425        let mut data = Vec::new();
426        data.extend_from_slice(&make_rcrd_page(1000)); // page 0
427        let mut garbage = vec![0xDEu8; LOG_PAGE_SIZE];
428        garbage[0..4].copy_from_slice(b"JUNK");
429        data.extend_from_slice(&garbage); // page 1
430
431        let summary = parse_logfile(&data).unwrap();
432        assert!(!summary.has_gaps);
433    }
434
435    #[test]
436    fn test_parse_logfile_rstr_too_short_for_header() {
437        // Test RSTR page where page_offset + 0x28 > data.len() is false
438        // but then we need the opposite: page_offset + 0x28 > data.len()
439        // This can't happen with full pages since LOG_PAGE_SIZE (4096) >> 0x28.
440        // Exercise: a full RSTR page that has a zero LSN.
441        let mut data = make_rstr_page(0);
442        // Override the LSN to zero - should track as highest_lsn = 0
443        data[0x08..0x10].copy_from_slice(&0u64.to_le_bytes());
444
445        let summary = parse_logfile(&data).unwrap();
446        assert_eq!(summary.restart_areas.len(), 1);
447        assert_eq!(summary.restart_areas[0].current_lsn, 0);
448        assert_eq!(summary.highest_lsn, 0);
449    }
450}