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 semantics;
8pub mod transactions;
9pub mod usn_extractor;
10
11pub use semantics::{classify, FileOperation};
12pub use transactions::{reconstruct_transactions, Transaction, TransactionState};
13pub use usn_extractor::{extract_usn_from_logfile, LogFileRecordSource, LogFileUsnRecord};
14
15use crate::error::Result;
16
17// ─── Constants ───────────────────────────────────────────────────────────────
18
19/// NTFS $LogFile restart area signature "RSTR".
20const RSTR_SIGNATURE: &[u8; 4] = b"RSTR";
21
22/// NTFS $LogFile record page signature "RCRD".
23const RCRD_SIGNATURE: &[u8; 4] = b"RCRD";
24
25/// Default NTFS $LogFile page size.
26const LOG_PAGE_SIZE: usize = 0x1000; // 4096 bytes
27
28// ─── Parsed structures ──────────────────────────────────────────────────────
29
30/// Parsed NTFS $LogFile restart area.
31#[derive(Debug, Clone)]
32pub struct RestartArea {
33    pub offset: usize,
34    pub current_lsn: u64,
35    pub log_clients: u16,
36    pub system_page_size: u32,
37    pub log_page_size: u32,
38}
39
40/// Summary of $LogFile analysis.
41#[derive(Debug, Clone)]
42pub struct LogFileSummary {
43    pub restart_areas: Vec<RestartArea>,
44    pub record_page_count: usize,
45    pub has_gaps: bool,
46    pub highest_lsn: u64,
47}
48
49/// An NTFS $LogFile (LFS) redo/undo operation code.
50///
51/// These are the NTFS log-file-service operations (Brian Carrier, *File System
52/// Forensic Analysis*). The code→operation mapping is transcribed verbatim from
53/// the `_SolveUndoRedoCodes` function in jschicht's `LogFileParser` — the exact
54/// lookup its GUI runs to label the `RedoOP`/`UndoOP` columns — so this enum's
55/// mapping is identical to that tool's by construction. Names use the canonical
56/// spelling (`LogFileParser` carries a few typos, e.g. "Segement"); the invariant
57/// shared with the tool is the numeric code, not the label. A code outside the
58/// documented `0x00..=0x22` range is surfaced verbatim via [`LogOp::Unknown`],
59/// never silently mapped.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum LogOp {
62    Noop,
63    CompensationLogRecord,
64    InitializeFileRecordSegment,
65    DeallocateFileRecordSegment,
66    WriteEndOfFileRecordSegment,
67    CreateAttribute,
68    DeleteAttribute,
69    UpdateResidentValue,
70    UpdateNonResidentValue,
71    UpdateMappingPairs,
72    DeleteDirtyClusters,
73    SetNewAttributeSizes,
74    AddIndexEntryRoot,
75    DeleteIndexEntryRoot,
76    AddIndexEntryAllocation,
77    DeleteIndexEntryAllocation,
78    WriteEndOfIndexBuffer,
79    SetIndexEntryVcnRoot,
80    SetIndexEntryVcnAllocation,
81    UpdateFileNameRoot,
82    UpdateFileNameAllocation,
83    SetBitsInNonResidentBitMap,
84    ClearBitsInNonResidentBitMap,
85    HotFix,
86    EndTopLevelAction,
87    PrepareTransaction,
88    CommitTransaction,
89    ForgetTransaction,
90    OpenNonResidentAttribute,
91    OpenAttributeTableDump,
92    AttributeNamesDump,
93    DirtyPageTableDump,
94    TransactionTableDump,
95    UpdateRecordDataRoot,
96    UpdateRecordDataAllocation,
97    /// A code outside the documented `0x00..=0x22` range, surfaced verbatim.
98    Unknown(u16),
99}
100
101impl LogOp {
102    /// Map a raw 16-bit redo/undo operation code to its operation.
103    #[must_use]
104    pub fn from_u16(code: u16) -> Self {
105        use LogOp::{
106            AddIndexEntryAllocation, AddIndexEntryRoot, AttributeNamesDump,
107            ClearBitsInNonResidentBitMap, CommitTransaction, CompensationLogRecord,
108            CreateAttribute, DeallocateFileRecordSegment, DeleteAttribute, DeleteDirtyClusters,
109            DeleteIndexEntryAllocation, DeleteIndexEntryRoot, DirtyPageTableDump,
110            EndTopLevelAction, ForgetTransaction, HotFix, InitializeFileRecordSegment, Noop,
111            OpenAttributeTableDump, OpenNonResidentAttribute, PrepareTransaction,
112            SetBitsInNonResidentBitMap, SetIndexEntryVcnAllocation, SetIndexEntryVcnRoot,
113            SetNewAttributeSizes, TransactionTableDump, Unknown, UpdateFileNameAllocation,
114            UpdateFileNameRoot, UpdateMappingPairs, UpdateNonResidentValue,
115            UpdateRecordDataAllocation, UpdateRecordDataRoot, UpdateResidentValue,
116            WriteEndOfFileRecordSegment, WriteEndOfIndexBuffer,
117        };
118        match code {
119            0x00 => Noop,
120            0x01 => CompensationLogRecord,
121            0x02 => InitializeFileRecordSegment,
122            0x03 => DeallocateFileRecordSegment,
123            0x04 => WriteEndOfFileRecordSegment,
124            0x05 => CreateAttribute,
125            0x06 => DeleteAttribute,
126            0x07 => UpdateResidentValue,
127            0x08 => UpdateNonResidentValue,
128            0x09 => UpdateMappingPairs,
129            0x0A => DeleteDirtyClusters,
130            0x0B => SetNewAttributeSizes,
131            0x0C => AddIndexEntryRoot,
132            0x0D => DeleteIndexEntryRoot,
133            0x0E => AddIndexEntryAllocation,
134            0x0F => DeleteIndexEntryAllocation,
135            0x10 => WriteEndOfIndexBuffer,
136            0x11 => SetIndexEntryVcnRoot,
137            0x12 => SetIndexEntryVcnAllocation,
138            0x13 => UpdateFileNameRoot,
139            0x14 => UpdateFileNameAllocation,
140            0x15 => SetBitsInNonResidentBitMap,
141            0x16 => ClearBitsInNonResidentBitMap,
142            0x17 => HotFix,
143            0x18 => EndTopLevelAction,
144            0x19 => PrepareTransaction,
145            0x1A => CommitTransaction,
146            0x1B => ForgetTransaction,
147            0x1C => OpenNonResidentAttribute,
148            0x1D => OpenAttributeTableDump,
149            0x1E => AttributeNamesDump,
150            0x1F => DirtyPageTableDump,
151            0x20 => TransactionTableDump,
152            0x21 => UpdateRecordDataRoot,
153            0x22 => UpdateRecordDataAllocation,
154            other => Unknown(other),
155        }
156    }
157
158    /// The raw 16-bit operation code (inverse of [`LogOp::from_u16`]).
159    #[must_use]
160    pub fn code(self) -> u16 {
161        use LogOp::{
162            AddIndexEntryAllocation, AddIndexEntryRoot, AttributeNamesDump,
163            ClearBitsInNonResidentBitMap, CommitTransaction, CompensationLogRecord,
164            CreateAttribute, DeallocateFileRecordSegment, DeleteAttribute, DeleteDirtyClusters,
165            DeleteIndexEntryAllocation, DeleteIndexEntryRoot, DirtyPageTableDump,
166            EndTopLevelAction, ForgetTransaction, HotFix, InitializeFileRecordSegment, Noop,
167            OpenAttributeTableDump, OpenNonResidentAttribute, PrepareTransaction,
168            SetBitsInNonResidentBitMap, SetIndexEntryVcnAllocation, SetIndexEntryVcnRoot,
169            SetNewAttributeSizes, TransactionTableDump, Unknown, UpdateFileNameAllocation,
170            UpdateFileNameRoot, UpdateMappingPairs, UpdateNonResidentValue,
171            UpdateRecordDataAllocation, UpdateRecordDataRoot, UpdateResidentValue,
172            WriteEndOfFileRecordSegment, WriteEndOfIndexBuffer,
173        };
174        match self {
175            Noop => 0x00,
176            CompensationLogRecord => 0x01,
177            InitializeFileRecordSegment => 0x02,
178            DeallocateFileRecordSegment => 0x03,
179            WriteEndOfFileRecordSegment => 0x04,
180            CreateAttribute => 0x05,
181            DeleteAttribute => 0x06,
182            UpdateResidentValue => 0x07,
183            UpdateNonResidentValue => 0x08,
184            UpdateMappingPairs => 0x09,
185            DeleteDirtyClusters => 0x0A,
186            SetNewAttributeSizes => 0x0B,
187            AddIndexEntryRoot => 0x0C,
188            DeleteIndexEntryRoot => 0x0D,
189            AddIndexEntryAllocation => 0x0E,
190            DeleteIndexEntryAllocation => 0x0F,
191            WriteEndOfIndexBuffer => 0x10,
192            SetIndexEntryVcnRoot => 0x11,
193            SetIndexEntryVcnAllocation => 0x12,
194            UpdateFileNameRoot => 0x13,
195            UpdateFileNameAllocation => 0x14,
196            SetBitsInNonResidentBitMap => 0x15,
197            ClearBitsInNonResidentBitMap => 0x16,
198            HotFix => 0x17,
199            EndTopLevelAction => 0x18,
200            PrepareTransaction => 0x19,
201            CommitTransaction => 0x1A,
202            ForgetTransaction => 0x1B,
203            OpenNonResidentAttribute => 0x1C,
204            OpenAttributeTableDump => 0x1D,
205            AttributeNamesDump => 0x1E,
206            DirtyPageTableDump => 0x1F,
207            TransactionTableDump => 0x20,
208            UpdateRecordDataRoot => 0x21,
209            UpdateRecordDataAllocation => 0x22,
210            Unknown(c) => c,
211        }
212    }
213}
214
215/// One RCRD record page from $LogFile with its multi-sector USA fixup applied.
216///
217/// `data` holds the page exactly as it was in memory before NTFS wrote the
218/// update sequence number (USN) into each 512-byte sector tail — i.e. the
219/// displaced original bytes have been restored from the update-sequence array,
220/// so the log-record stream within the page can be read directly. Pages whose
221/// USA integrity check fails are not represented here (see [`read_record_pages`]).
222#[derive(Debug, Clone)]
223pub struct RecordPage {
224    /// Byte offset of this page within the $LogFile stream.
225    pub offset: usize,
226    /// `last_lsn` from the RCRD header (offset 0x08): the LSN of the last log
227    /// record that ends on this page.
228    pub last_lsn: u64,
229    /// Page bytes with the USA fixup applied (sector tails restored).
230    pub data: Vec<u8>,
231}
232
233/// Read every RCRD record page from a $LogFile, applying the multi-sector USA
234/// fixup to each page in turn.
235///
236/// Only pages beginning with the `RCRD` signature are returned; RSTR restart
237/// pages and zeroed/garbage pages are skipped. A page whose USA integrity check
238/// fails — a sector tail on disk does not match the page's USN (torn write,
239/// corruption, or tampering) — is also skipped, because its record bytes cannot
240/// be trusted. The fixup reuses [`crate::record::apply_fixup`], which is
241/// signature-agnostic (it reads `usa_offset`/`usa_count` from the shared
242/// multi-sector header that RCRD pages and FILE records both carry).
243pub fn read_record_pages(data: &[u8]) -> Vec<RecordPage> {
244    let mut pages = Vec::new();
245    let page_count = data.len() / LOG_PAGE_SIZE;
246
247    for page_idx in 0..page_count {
248        let offset = page_idx * LOG_PAGE_SIZE;
249
250        // page_count = data.len() / LOG_PAGE_SIZE guarantees a full page here.
251        let page = &data[offset..offset + LOG_PAGE_SIZE];
252        if &page[0..4] != RCRD_SIGNATURE {
253            continue;
254        }
255
256        // Apply the multi-sector USA fixup on a private copy. The on-disk page
257        // has the USN written into each 512-byte sector tail; apply_fixup
258        // verifies every tail matches the USN, then restores the displaced
259        // originals from the update sequence array. A mismatch (torn write /
260        // corruption / tampering) means the record bytes cannot be trusted, so
261        // the page is skipped rather than returned with un-fixed bytes.
262        let mut buf = page.to_vec();
263        if crate::record::apply_fixup(&mut buf, 512).is_err() {
264            continue;
265        }
266
267        let last_lsn = u64::from_le_bytes(buf[0x08..0x10].try_into().unwrap_or([0; 8]));
268
269        pages.push(RecordPage {
270            offset,
271            last_lsn,
272            data: buf,
273        });
274    }
275
276    pages
277}
278
279/// One decoded LFS log record from a $LogFile RCRD page.
280///
281/// Combines the Log File Service record header (this/previous/undo LSNs, record
282/// type, transaction id) with the NTFS log-record operation descriptor that
283/// forms the record's client data: the redo/undo [`LogOp`], the target
284/// attribute (an index into the Open Attribute Table), and the target VCN.
285#[derive(Debug, Clone)]
286pub struct LogRecord {
287    /// Byte offset of this record within its [`RecordPage`]'s `data`.
288    pub page_offset: usize,
289    /// This record's log sequence number (LFS record header, offset 0x00).
290    pub this_lsn: u64,
291    /// The client's previous LSN (offset 0x08).
292    pub client_previous_lsn: u64,
293    /// The client's undo-next LSN (offset 0x10).
294    pub client_undo_next_lsn: u64,
295    /// LFS record type (offset 0x20): normal log record vs restart/checkpoint.
296    pub record_type: u32,
297    /// Transaction identifier (offset 0x24).
298    pub transaction_id: u32,
299    /// The redo operation (NTFS log-record offset 0x30 — the client data start).
300    pub redo_op: LogOp,
301    /// The undo operation (offset 0x32).
302    pub undo_op: LogOp,
303    /// Index into the Open Attribute Table identifying the target (offset 0x3C).
304    pub target_attribute: u16,
305    /// MFT cluster index of the target (offset 0x44).
306    pub mft_cluster_index: u16,
307    /// Target VCN (offset 0x48).
308    pub target_vcn: u64,
309}
310
311/// Walk and decode the LFS log records within a fixed-up [`RecordPage`].
312///
313/// Records run from the page's first-record offset to its `next_record_offset`,
314/// each advancing by the 0x30-byte LFS header plus its `client_data_length`,
315/// 8-byte aligned. Decodes the redo/undo operations via [`LogOp::from_u16`].
316pub fn parse_log_records(page: &RecordPage) -> Vec<LogRecord> {
317    use crate::bytes::{le_u16, le_u32, le_u64};
318
319    /// LFS record header length; the NTFS log-record (client data) starts here.
320    const LFS_HEADER_LEN: usize = 0x30;
321
322    let data = &page.data;
323    // The record stream ends at `next_record_offset` (the first free byte), in the
324    // RCRD page header at offset 0x18. Cap at the page length if it is out of range.
325    let end = (le_u16(data, 0x18) as usize).min(data.len());
326    // Every LSN in a page is <= the page's last_lsn (header offset 0x08); this lets
327    // us reject leading bytes (padding, or a record continued from the previous
328    // page) that coincidentally look like a record header.
329    let page_last_lsn = le_u64(data, 0x08);
330
331    let mut records = Vec::new();
332    let mut off = LFS_HEADER_LEN.max(0x40); // data area begins after the 0x40 page header
333    while off + LFS_HEADER_LEN <= end {
334        let this_lsn = le_u64(data, off);
335        let record_type = le_u32(data, off + 0x20);
336        let redo_raw = le_u16(data, off + 0x30);
337        let client_data_length = le_u32(data, off + 0x18) as usize;
338
339        // A genuine record start: a non-zero LSN within the page's range, a known
340        // LFS record type, a documented redo opcode, and a record that fits.
341        let plausible = this_lsn != 0
342            && this_lsn <= page_last_lsn
343            && (record_type == 1 || record_type == 2)
344            && redo_raw <= 0x22
345            && off + LFS_HEADER_LEN + client_data_length <= data.len();
346        if !plausible {
347            off += 8; // leading padding / cross-page continuation — skip, stay aligned
348            continue;
349        }
350
351        records.push(LogRecord {
352            page_offset: off,
353            this_lsn,
354            client_previous_lsn: le_u64(data, off + 0x08),
355            client_undo_next_lsn: le_u64(data, off + 0x10),
356            record_type,
357            transaction_id: le_u32(data, off + 0x24),
358            redo_op: LogOp::from_u16(redo_raw),
359            undo_op: LogOp::from_u16(le_u16(data, off + 0x32)),
360            target_attribute: le_u16(data, off + 0x3C),
361            mft_cluster_index: le_u16(data, off + 0x44),
362            target_vcn: le_u64(data, off + 0x48),
363        });
364
365        // Advance past the LFS header + client data, 8-byte aligned.
366        let advance = (LFS_HEADER_LEN + client_data_length + 7) & !7;
367        off += advance.max(8);
368    }
369
370    records
371}
372
373/// Parse NTFS $LogFile data.
374///
375/// Scans for restart areas (RSTR) and record pages (RCRD) to build
376/// a summary. Detects gaps in the log sequence.
377pub fn parse_logfile(data: &[u8]) -> Result<LogFileSummary> {
378    let mut restart_areas = Vec::new();
379    let mut record_page_count = 0;
380    let mut highest_lsn: u64 = 0;
381    let mut has_gaps = false;
382    let mut last_page_had_rcrd = false;
383
384    let page_count = data.len() / LOG_PAGE_SIZE;
385
386    for page_idx in 0..page_count {
387        let page_offset = page_idx * LOG_PAGE_SIZE;
388
389        // page_count = data.len() / LOG_PAGE_SIZE guarantees a full page fits here.
390        let sig = &data[page_offset..page_offset + 4];
391
392        if sig == RSTR_SIGNATURE {
393            if page_offset + 0x28 <= data.len() {
394                let current_lsn = u64::from_le_bytes(
395                    data[page_offset + 0x08..page_offset + 0x10]
396                        .try_into()
397                        .unwrap_or([0; 8]),
398                );
399                let log_clients = u16::from_le_bytes(
400                    data[page_offset + 0x10..page_offset + 0x12]
401                        .try_into()
402                        .unwrap_or([0; 2]),
403                );
404                let system_page_size = u32::from_le_bytes(
405                    data[page_offset + 0x20..page_offset + 0x24]
406                        .try_into()
407                        .unwrap_or([0; 4]),
408                );
409                let log_page_size = u32::from_le_bytes(
410                    data[page_offset + 0x24..page_offset + 0x28]
411                        .try_into()
412                        .unwrap_or([0; 4]),
413                );
414
415                if current_lsn > highest_lsn {
416                    highest_lsn = current_lsn;
417                }
418
419                restart_areas.push(RestartArea {
420                    offset: page_offset,
421                    current_lsn,
422                    log_clients,
423                    system_page_size,
424                    log_page_size,
425                });
426            } // 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
427            last_page_had_rcrd = false;
428        } else if sig == RCRD_SIGNATURE {
429            record_page_count += 1;
430
431            // Extract last_end_lsn from RCRD header (offset 0x18)
432            if page_offset + 0x20 <= data.len() {
433                let page_lsn = u64::from_le_bytes(
434                    data[page_offset + 0x18..page_offset + 0x20]
435                        .try_into()
436                        .unwrap_or([0; 8]),
437                );
438                if page_lsn > highest_lsn {
439                    highest_lsn = page_lsn;
440                }
441            } // 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
442
443            last_page_had_rcrd = true;
444        } else {
445            // Neither RSTR nor RCRD - could be a gap
446            if last_page_had_rcrd && page_idx > 2 {
447                // If we had RCRD pages and now see something else, that's a gap
448                let is_zeroed = data[page_offset..page_offset + 4] == [0, 0, 0, 0];
449                if !is_zeroed {
450                    has_gaps = true;
451                }
452            }
453            last_page_had_rcrd = false;
454        }
455    }
456
457    Ok(LogFileSummary {
458        restart_areas,
459        record_page_count,
460        has_gaps,
461        highest_lsn,
462    })
463}
464
465/// Correlate $LogFile LSN with USN Journal entries.
466///
467/// The USN (Update Sequence Number) in journal records corresponds to
468/// byte offsets in the journal. $LogFile LSNs are separate but can help
469/// detect if the journal was cleared (LSN continuity break).
470pub fn detect_journal_clearing(logfile_summary: &LogFileSummary) -> bool {
471    // Journal clearing indicators:
472    // 1. Gaps in $LogFile record pages
473    // 2. Very few restart areas (should have exactly 2 normally)
474    // 3. LSN discontinuities
475
476    if logfile_summary.has_gaps {
477        return true;
478    }
479
480    if logfile_summary.restart_areas.len() != 2 {
481        return logfile_summary.restart_areas.is_empty();
482    }
483
484    false
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    /// The complete code→operation mapping, transcribed verbatim from
492    /// `LogFileParser`'s `_SolveUndoRedoCodes` (the function its GUI runs). Index =
493    /// the raw opcode; this is the authoritative reference `LogOp::from_u16` must
494    /// reproduce exactly. Canonical spelling differs from `LogFileParser`'s typos
495    /// (e.g. its "Segement"); the shared invariant is the numeric code, asserted
496    /// here as the variant identity.
497    const LFP_OPS: [LogOp; 35] = [
498        LogOp::Noop,                         // 0x00
499        LogOp::CompensationLogRecord,        // 0x01
500        LogOp::InitializeFileRecordSegment,  // 0x02
501        LogOp::DeallocateFileRecordSegment,  // 0x03
502        LogOp::WriteEndOfFileRecordSegment,  // 0x04
503        LogOp::CreateAttribute,              // 0x05
504        LogOp::DeleteAttribute,              // 0x06
505        LogOp::UpdateResidentValue,          // 0x07
506        LogOp::UpdateNonResidentValue,       // 0x08
507        LogOp::UpdateMappingPairs,           // 0x09
508        LogOp::DeleteDirtyClusters,          // 0x0A
509        LogOp::SetNewAttributeSizes,         // 0x0B
510        LogOp::AddIndexEntryRoot,            // 0x0C
511        LogOp::DeleteIndexEntryRoot,         // 0x0D
512        LogOp::AddIndexEntryAllocation,      // 0x0E
513        LogOp::DeleteIndexEntryAllocation,   // 0x0F
514        LogOp::WriteEndOfIndexBuffer,        // 0x10
515        LogOp::SetIndexEntryVcnRoot,         // 0x11
516        LogOp::SetIndexEntryVcnAllocation,   // 0x12
517        LogOp::UpdateFileNameRoot,           // 0x13
518        LogOp::UpdateFileNameAllocation,     // 0x14
519        LogOp::SetBitsInNonResidentBitMap,   // 0x15
520        LogOp::ClearBitsInNonResidentBitMap, // 0x16
521        LogOp::HotFix,                       // 0x17
522        LogOp::EndTopLevelAction,            // 0x18
523        LogOp::PrepareTransaction,           // 0x19
524        LogOp::CommitTransaction,            // 0x1A
525        LogOp::ForgetTransaction,            // 0x1B
526        LogOp::OpenNonResidentAttribute,     // 0x1C
527        LogOp::OpenAttributeTableDump,       // 0x1D
528        LogOp::AttributeNamesDump,           // 0x1E
529        LogOp::DirtyPageTableDump,           // 0x1F
530        LogOp::TransactionTableDump,         // 0x20
531        LogOp::UpdateRecordDataRoot,         // 0x21
532        LogOp::UpdateRecordDataAllocation,   // 0x22
533    ];
534
535    #[test]
536    fn logop_from_u16_matches_logfileparser_table() {
537        for (code, &expected) in LFP_OPS.iter().enumerate() {
538            assert_eq!(
539                LogOp::from_u16(code as u16),
540                expected,
541                "opcode {code:#04x} must map to `LogFileParser`'s operation"
542            );
543        }
544    }
545
546    #[test]
547    fn logop_unknown_surfaces_the_raw_code() {
548        // 0x23 is `LogFileParser`'s internal "JS_NewEndOfRecord" marker, not a real
549        // NTFS operation; it and anything above the documented range are Unknown.
550        assert_eq!(LogOp::from_u16(0x23), LogOp::Unknown(0x23));
551        assert_eq!(LogOp::from_u16(0xFFFF), LogOp::Unknown(0xFFFF));
552    }
553
554    #[test]
555    fn logop_code_round_trips() {
556        for code in 0u16..=0x22 {
557            assert_eq!(LogOp::from_u16(code).code(), code, "round-trip {code:#04x}");
558        }
559        assert_eq!(LogOp::Unknown(0x99).code(), 0x99);
560    }
561
562    fn make_rstr_page(lsn: u64) -> Vec<u8> {
563        let mut page = vec![0u8; LOG_PAGE_SIZE];
564        page[0..4].copy_from_slice(RSTR_SIGNATURE);
565        page[0x08..0x10].copy_from_slice(&lsn.to_le_bytes());
566        page[0x10..0x12].copy_from_slice(&1u16.to_le_bytes()); // 1 client
567        page[0x20..0x24].copy_from_slice(&4096u32.to_le_bytes());
568        page[0x24..0x28].copy_from_slice(&4096u32.to_le_bytes());
569        page
570    }
571
572    fn make_rcrd_page(lsn: u64) -> Vec<u8> {
573        let mut page = vec![0u8; LOG_PAGE_SIZE];
574        page[0..4].copy_from_slice(RCRD_SIGNATURE);
575        page[0x18..0x20].copy_from_slice(&lsn.to_le_bytes());
576        page
577    }
578
579    /// Build an RCRD page with a well-formed update sequence array, in the
580    /// on-disk form `apply_fixup` accepts: `usa_offset` 0x28, `usa_count` 9 (1 USN +
581    /// 8 protected 512-byte sectors), the USN written into each sector tail, and
582    /// distinct original values held in usa[1..9]. (The real-data equivalent is
583    /// exercised by `core/tests/logfile_rcrd.rs`; this synthetic page is for
584    /// per-branch lib coverage.)
585    fn make_rcrd_page_with_usa(last_lsn: u64) -> Vec<u8> {
586        let mut page = vec![0u8; LOG_PAGE_SIZE];
587        page[0..4].copy_from_slice(RCRD_SIGNATURE);
588        page[0x04..0x06].copy_from_slice(&0x28u16.to_le_bytes()); // usa_offset
589        page[0x06..0x08].copy_from_slice(&9u16.to_le_bytes()); // usa_count
590        page[0x08..0x10].copy_from_slice(&last_lsn.to_le_bytes()); // last_lsn @0x08
591        let usn: u16 = 0x0007;
592        page[0x28..0x2a].copy_from_slice(&usn.to_le_bytes()); // usa[0] = USN
593        for i in 0..8usize {
594            let original: u16 = 0xAA00 | i as u16;
595            let usa_slot = 0x2a + i * 2;
596            page[usa_slot..usa_slot + 2].copy_from_slice(&original.to_le_bytes());
597            let tail = (i + 1) * 512 - 2;
598            page[tail..tail + 2].copy_from_slice(&usn.to_le_bytes()); // USN in tail
599        }
600        page
601    }
602
603    #[test]
604    fn read_record_pages_accepts_valid_usa_page() {
605        let pages = read_record_pages(&make_rcrd_page_with_usa(0x1234));
606        assert_eq!(pages.len(), 1);
607        assert_eq!(pages[0].offset, 0);
608        assert_eq!(pages[0].last_lsn, 0x1234);
609        // Sector-0 tail (0x1fe) restored from usa[1] = 0xAA00.
610        assert_eq!(&pages[0].data[0x1fe..0x200], &0xAA00u16.to_le_bytes());
611    }
612
613    #[test]
614    fn read_record_pages_skips_non_rcrd_pages() {
615        // RSTR and zeroed pages are not record pages → continue branch.
616        let mut data = make_rstr_page(1000);
617        data.extend_from_slice(&vec![0u8; LOG_PAGE_SIZE]);
618        assert!(read_record_pages(&data).is_empty());
619    }
620
621    #[test]
622    fn read_record_pages_skips_page_with_invalid_usa() {
623        // RCRD signature but usa_count is 0 → apply_fixup errors → page skipped.
624        let pages = read_record_pages(&make_rcrd_page(5000));
625        assert!(pages.is_empty());
626    }
627
628    #[test]
629    fn read_record_pages_empty_input() {
630        assert!(read_record_pages(&[]).is_empty());
631    }
632
633    #[test]
634    fn read_record_pages_returns_only_valid_pages_in_mixed_stream() {
635        // RSTR, valid-USA RCRD, invalid-USA RCRD, zeroed → exactly one recovered.
636        let mut data = make_rstr_page(1000);
637        data.extend_from_slice(&make_rcrd_page_with_usa(0xBEEF));
638        data.extend_from_slice(&make_rcrd_page(2000)); // usa_count 0 → rejected
639        data.extend_from_slice(&vec![0u8; LOG_PAGE_SIZE]);
640        let pages = read_record_pages(&data);
641        assert_eq!(pages.len(), 1);
642        assert_eq!(pages[0].offset, LOG_PAGE_SIZE);
643        assert_eq!(pages[0].last_lsn, 0xBEEF);
644    }
645
646    /// Build a `RecordPage` holding the given LFS records starting at `first_off`.
647    /// Each record = (`this_lsn`, `record_type`, `transaction_id`, redo, undo, cdl).
648    /// Sets the page `last_lsn` and the header's `next_record_offset` so the walk
649    /// bounds are correct. (The real-data equivalent is `core/tests/logfile_rcrd.rs`,
650    /// reconciled against `LogFileParser`; these synthetic pages drive lib coverage.)
651    fn page_with_records(
652        last_lsn: u64,
653        first_off: usize,
654        recs: &[(u64, u32, u32, u16, u16, u32)],
655    ) -> RecordPage {
656        let mut data = vec![0u8; LOG_PAGE_SIZE];
657        data[0..4].copy_from_slice(RCRD_SIGNATURE);
658        data[0x08..0x10].copy_from_slice(&last_lsn.to_le_bytes());
659        let mut off = first_off;
660        for &(lsn, rt, txid, redo, undo, cdl) in recs {
661            data[off..off + 8].copy_from_slice(&lsn.to_le_bytes());
662            data[off + 0x18..off + 0x1c].copy_from_slice(&cdl.to_le_bytes());
663            data[off + 0x20..off + 0x24].copy_from_slice(&rt.to_le_bytes());
664            data[off + 0x24..off + 0x28].copy_from_slice(&txid.to_le_bytes());
665            data[off + 0x30..off + 0x32].copy_from_slice(&redo.to_le_bytes());
666            data[off + 0x32..off + 0x34].copy_from_slice(&undo.to_le_bytes());
667            off += ((0x30 + cdl as usize + 7) & !7).max(8);
668        }
669        data[0x18..0x1a].copy_from_slice(&u16::try_from(off).unwrap_or(0).to_le_bytes());
670        RecordPage {
671            offset: 0,
672            last_lsn,
673            data,
674        }
675    }
676
677    #[test]
678    fn parse_log_records_decodes_record_fields() {
679        let page = page_with_records(5000, 0x40, &[(4200, 1, 7, 0x02, 0x00, 64)]);
680        let recs = parse_log_records(&page);
681        assert_eq!(recs.len(), 1);
682        let r = &recs[0];
683        assert_eq!(r.page_offset, 0x40);
684        assert_eq!(r.this_lsn, 4200);
685        assert_eq!(r.record_type, 1);
686        assert_eq!(r.transaction_id, 7);
687        assert_eq!(r.redo_op, LogOp::InitializeFileRecordSegment);
688        assert_eq!(r.undo_op, LogOp::Noop);
689    }
690
691    #[test]
692    fn parse_log_records_skips_leading_padding() {
693        // A record that does not begin at the 0x40 data area: 0x40..0x100 is zero
694        // padding (a cross-page continuation / unused), the record starts at 0x100.
695        let page = page_with_records(5000, 0x100, &[(4300, 2, 0, 0x01, 0x00, 80)]);
696        let recs = parse_log_records(&page);
697        assert_eq!(recs.len(), 1);
698        assert_eq!(recs[0].page_offset, 0x100);
699        assert_eq!(recs[0].redo_op, LogOp::CompensationLogRecord);
700    }
701
702    #[test]
703    fn parse_log_records_walks_multiple() {
704        let page = page_with_records(
705            5000,
706            0x40,
707            &[(4100, 1, 1, 0x02, 0x00, 32), (4150, 1, 1, 0x13, 0x00, 48)],
708        );
709        let recs = parse_log_records(&page);
710        assert_eq!(recs.len(), 2);
711        assert_eq!(recs[0].this_lsn, 4100);
712        assert_eq!(recs[1].this_lsn, 4150);
713        assert_eq!(recs[1].redo_op, LogOp::UpdateFileNameRoot);
714    }
715
716    #[test]
717    fn parse_log_records_empty_region() {
718        // next_record_offset == the data area → the record region is empty.
719        assert!(parse_log_records(&page_with_records(5000, 0x40, &[])).is_empty());
720    }
721
722    #[test]
723    fn test_parse_logfile_with_restart_areas() {
724        let mut data = Vec::new();
725        data.extend_from_slice(&make_rstr_page(1000));
726        data.extend_from_slice(&make_rstr_page(2000));
727        data.extend_from_slice(&make_rcrd_page(3000));
728
729        let summary = parse_logfile(&data).unwrap();
730        assert_eq!(summary.restart_areas.len(), 2);
731        assert_eq!(summary.record_page_count, 1);
732        assert_eq!(summary.highest_lsn, 3000);
733        assert!(!summary.has_gaps);
734    }
735
736    #[test]
737    fn test_detect_journal_clearing_with_gaps() {
738        let summary = LogFileSummary {
739            restart_areas: vec![],
740            record_page_count: 0,
741            has_gaps: true,
742            highest_lsn: 0,
743        };
744        assert!(detect_journal_clearing(&summary));
745    }
746
747    #[test]
748    fn test_normal_logfile_no_clearing() {
749        let summary = LogFileSummary {
750            restart_areas: vec![
751                RestartArea {
752                    offset: 0,
753                    current_lsn: 1000,
754                    log_clients: 1,
755                    system_page_size: 4096,
756                    log_page_size: 4096,
757                },
758                RestartArea {
759                    offset: 4096,
760                    current_lsn: 2000,
761                    log_clients: 1,
762                    system_page_size: 4096,
763                    log_page_size: 4096,
764                },
765            ],
766            record_page_count: 100,
767            has_gaps: false,
768            highest_lsn: 5000,
769        };
770        assert!(!detect_journal_clearing(&summary));
771    }
772
773    #[test]
774    fn test_detect_journal_clearing_empty_restart_areas() {
775        let summary = LogFileSummary {
776            restart_areas: vec![],
777            record_page_count: 0,
778            has_gaps: false,
779            highest_lsn: 0,
780        };
781        assert!(detect_journal_clearing(&summary));
782    }
783
784    #[test]
785    fn test_detect_journal_clearing_one_restart_area() {
786        // 1 restart area (not 2) but no gaps - not detected as clearing
787        let summary = LogFileSummary {
788            restart_areas: vec![RestartArea {
789                offset: 0,
790                current_lsn: 1000,
791                log_clients: 1,
792                system_page_size: 4096,
793                log_page_size: 4096,
794            }],
795            record_page_count: 50,
796            has_gaps: false,
797            highest_lsn: 5000,
798        };
799        assert!(!detect_journal_clearing(&summary));
800    }
801
802    #[test]
803    fn test_detect_journal_clearing_three_restart_areas() {
804        // 3 restart areas (not 2) but no gaps
805        let summary = LogFileSummary {
806            restart_areas: vec![
807                RestartArea {
808                    offset: 0,
809                    current_lsn: 1000,
810                    log_clients: 1,
811                    system_page_size: 4096,
812                    log_page_size: 4096,
813                },
814                RestartArea {
815                    offset: 4096,
816                    current_lsn: 2000,
817                    log_clients: 1,
818                    system_page_size: 4096,
819                    log_page_size: 4096,
820                },
821                RestartArea {
822                    offset: 8192,
823                    current_lsn: 3000,
824                    log_clients: 1,
825                    system_page_size: 4096,
826                    log_page_size: 4096,
827                },
828            ],
829            record_page_count: 50,
830            has_gaps: false,
831            highest_lsn: 5000,
832        };
833        assert!(!detect_journal_clearing(&summary));
834    }
835
836    #[test]
837    fn test_parse_logfile_empty() {
838        let summary = parse_logfile(&[]).unwrap();
839        assert_eq!(summary.restart_areas.len(), 0);
840        assert_eq!(summary.record_page_count, 0);
841        assert!(!summary.has_gaps);
842        assert_eq!(summary.highest_lsn, 0);
843    }
844
845    #[test]
846    fn test_parse_logfile_only_rcrd_pages() {
847        let mut data = Vec::new();
848        data.extend_from_slice(&make_rcrd_page(1000));
849        data.extend_from_slice(&make_rcrd_page(2000));
850        data.extend_from_slice(&make_rcrd_page(3000));
851
852        let summary = parse_logfile(&data).unwrap();
853        assert_eq!(summary.restart_areas.len(), 0);
854        assert_eq!(summary.record_page_count, 3);
855        assert_eq!(summary.highest_lsn, 3000);
856    }
857
858    #[test]
859    fn test_parse_logfile_gap_detection() {
860        // RSTR, RSTR, RCRD, RCRD, non-RCRD/non-zero page, RCRD
861        // Gap should be detected at the non-RCRD page
862        let mut data = Vec::new();
863        data.extend_from_slice(&make_rstr_page(1000));
864        data.extend_from_slice(&make_rstr_page(2000));
865        data.extend_from_slice(&make_rcrd_page(3000));
866
867        // Create a non-zero, non-RCRD, non-RSTR page (looks like corruption)
868        let mut garbage_page = vec![0xDEu8; LOG_PAGE_SIZE];
869        garbage_page[0..4].copy_from_slice(b"JUNK");
870        data.extend_from_slice(&garbage_page);
871
872        data.extend_from_slice(&make_rcrd_page(5000));
873
874        let summary = parse_logfile(&data).unwrap();
875        assert!(summary.has_gaps);
876    }
877
878    #[test]
879    fn test_parse_logfile_no_gap_for_zeroed_page() {
880        // Zeroed pages after RCRD pages should NOT be treated as gaps
881        let mut data = Vec::new();
882        data.extend_from_slice(&make_rstr_page(1000));
883        data.extend_from_slice(&make_rstr_page(2000));
884        data.extend_from_slice(&make_rcrd_page(3000));
885        data.extend_from_slice(&vec![0u8; LOG_PAGE_SIZE]); // zeroed page
886
887        let summary = parse_logfile(&data).unwrap();
888        assert!(!summary.has_gaps);
889    }
890
891    #[test]
892    fn test_parse_logfile_restart_area_lsn_tracking() {
893        let mut data = Vec::new();
894        data.extend_from_slice(&make_rstr_page(5000));
895        data.extend_from_slice(&make_rstr_page(3000));
896        data.extend_from_slice(&make_rcrd_page(4000));
897
898        let summary = parse_logfile(&data).unwrap();
899        assert_eq!(summary.highest_lsn, 5000);
900        assert_eq!(summary.restart_areas.len(), 2);
901        assert_eq!(summary.restart_areas[0].current_lsn, 5000);
902        assert_eq!(summary.restart_areas[1].current_lsn, 3000);
903    }
904
905    #[test]
906    fn test_parse_logfile_short_rstr_page() {
907        // A page with RSTR signature but too small for full header
908        let mut data = vec![0u8; LOG_PAGE_SIZE];
909        data[0..4].copy_from_slice(RSTR_SIGNATURE);
910        // Only write signature, not enough data for header fields at 0x08..0x28
911        // But we set the full page so offset + 0x28 <= data.len() is true
912        // The actual data at those offsets will be zeros, which is still valid
913
914        let summary = parse_logfile(&data).unwrap();
915        assert_eq!(summary.restart_areas.len(), 1);
916        assert_eq!(summary.restart_areas[0].current_lsn, 0);
917    }
918
919    #[test]
920    fn test_parse_logfile_page_offset_boundary() {
921        // Line 61: page_offset + 4 > data.len() break condition
922        // This is tricky because page_count = data.len() / LOG_PAGE_SIZE,
923        // so page_offset = page_idx * LOG_PAGE_SIZE is always <= data.len() - LOG_PAGE_SIZE.
924        // For page_offset + 4 > data.len(), we'd need data.len() < page_offset + 4.
925        // Since page_offset < data.len() (because page_idx < page_count and
926        // page_count = data.len() / LOG_PAGE_SIZE), page_offset is at most
927        // data.len() - LOG_PAGE_SIZE. And LOG_PAGE_SIZE (4096) >> 4.
928        // So line 61 is effectively unreachable with the current loop bounds.
929        // Still, let's add a test for the edge case of exactly one page.
930        let data = make_rcrd_page(5000);
931        assert_eq!(data.len(), LOG_PAGE_SIZE);
932        let summary = parse_logfile(&data).unwrap();
933        assert_eq!(summary.record_page_count, 1);
934        assert_eq!(summary.highest_lsn, 5000);
935    }
936
937    #[test]
938    fn test_parse_logfile_data_smaller_than_page() {
939        // Data that's not a full page
940        let data = vec![0xAAu8; 100];
941        let summary = parse_logfile(&data).unwrap();
942        assert_eq!(summary.restart_areas.len(), 0);
943        assert_eq!(summary.record_page_count, 0);
944    }
945
946    #[test]
947    fn test_parse_logfile_boundary_check_line_61() {
948        // Line 61: page_offset + 4 > data.len() break
949        // This line is unreachable with current loop bounds because:
950        //   page_count = data.len() / LOG_PAGE_SIZE
951        //   page_offset = page_idx * LOG_PAGE_SIZE (max = (page_count-1) * LOG_PAGE_SIZE)
952        //   So page_offset <= data.len() - LOG_PAGE_SIZE, and LOG_PAGE_SIZE (4096) >> 4.
953        // Exercise the closest boundary: data.len() exactly equals one page.
954        let data = vec![0u8; LOG_PAGE_SIZE];
955        let summary = parse_logfile(&data).unwrap();
956        // All zeros -> no RSTR or RCRD signatures
957        assert_eq!(summary.restart_areas.len(), 0);
958        assert_eq!(summary.record_page_count, 0);
959        assert!(!summary.has_gaps);
960    }
961
962    #[test]
963    fn test_parse_logfile_gap_not_flagged_early_pages() {
964        // Covers line 120: the condition page_idx > 2 prevents false gap detection
965        // for the very first pages. Build data: RCRD page 0, then garbage page 1.
966        // Since page_idx=1 which is <= 2, no gap should be flagged.
967        let mut data = Vec::new();
968        data.extend_from_slice(&make_rcrd_page(1000)); // page 0
969        let mut garbage = vec![0xDEu8; LOG_PAGE_SIZE];
970        garbage[0..4].copy_from_slice(b"JUNK");
971        data.extend_from_slice(&garbage); // page 1
972
973        let summary = parse_logfile(&data).unwrap();
974        assert!(!summary.has_gaps);
975    }
976
977    #[test]
978    fn test_parse_logfile_rstr_too_short_for_header() {
979        // Test RSTR page where page_offset + 0x28 > data.len() is false
980        // but then we need the opposite: page_offset + 0x28 > data.len()
981        // This can't happen with full pages since LOG_PAGE_SIZE (4096) >> 0x28.
982        // Exercise: a full RSTR page that has a zero LSN.
983        let mut data = make_rstr_page(0);
984        // Override the LSN to zero - should track as highest_lsn = 0
985        data[0x08..0x10].copy_from_slice(&0u64.to_le_bytes());
986
987        let summary = parse_logfile(&data).unwrap();
988        assert_eq!(summary.restart_areas.len(), 1);
989        assert_eq!(summary.restart_areas[0].current_lsn, 0);
990        assert_eq!(summary.highest_lsn, 0);
991    }
992}