Skip to main content

idb/innodb/
log.rs

1//! InnoDB redo log file parsing.
2//!
3//! Reads InnoDB redo log files (`ib_logfile0`/`ib_logfile1` for MySQL < 8.0.30,
4//! or `#ib_redo*` files for 8.0.30+). The file layout consists of a 2048-byte
5//! header (4 blocks of 512 bytes each) containing the log file header and two
6//! checkpoint records, followed by 512-byte data blocks.
7//!
8//! ## Log format versions
9//!
10//! The redo log format has evolved across MySQL versions:
11//!
12//! | Version | Introduced | Key changes |
13//! |---------|-----------|-------------|
14//! | 1 | MySQL 5.7.9 | Initial versioned format |
15//! | 2 | MySQL 8.0.1 | Removed MLOG_FILE_NAME, added MLOG_FILE_OPEN |
16//! | 3 | MySQL 8.0.3 | checkpoint_lsn can point to any byte |
17//! | 4 | MySQL 8.0.19 | Expanded compressed ulint form |
18//! | 5 | MySQL 8.0.28 | Row versioning header; new MLOG record types (67-76) |
19//! | 6 | MySQL 8.0.30 | innodb_redo_log_capacity; `#ib_redo*` file naming |
20//!
21//! MySQL 9.0 and 9.1 continue to use format version 6.
22//!
23//! Use [`LogFile::open`] to read and parse the header and checkpoint records,
24//! then [`LogFile::read_block`] to read individual data blocks. Each block's
25//! [`LogBlockHeader`] provides the block number, data length, first record
26//! group offset, epoch number, and CRC-32C checksum validation status.
27
28use byteorder::{BigEndian, ByteOrder};
29use serde::Serialize;
30use std::io::{Cursor, Read, Seek, SeekFrom};
31
32use crate::IdbError;
33
34/// Supertrait combining `Read + Seek` for type-erased readers.
35trait ReadSeek: Read + Seek {}
36impl<T: Read + Seek> ReadSeek for T {}
37
38/// Size of a redo log block in bytes (from MySQL `log0constants.h`).
39pub const LOG_BLOCK_SIZE: usize = 512;
40/// Maximum size of the log block header in bytes.
41///
42/// Prior to MySQL 8.0.30, the header was 14 bytes (with a 4-byte checkpoint
43/// number at offset 8). In 8.0.30+ (format version 6), the header is 12
44/// bytes with a 4-byte epoch number at offset 8. We use 14 as the universal
45/// threshold for `has_data()` to correctly handle both old and new formats.
46pub const LOG_BLOCK_HDR_SIZE: usize = 14;
47/// Size of the log block trailer in bytes.
48pub const LOG_BLOCK_TRL_SIZE: usize = 4;
49/// Bitmask for the flush flag in the block number field (bit 31).
50pub const LOG_BLOCK_FLUSH_BIT_MASK: u32 = 0x80000000;
51/// Byte offset of the CRC-32C checksum within a block (bytes 508-511).
52pub const LOG_BLOCK_CHECKSUM_OFFSET: usize = 508;
53/// Number of reserved header/checkpoint blocks at the start of the file.
54pub const LOG_FILE_HDR_BLOCKS: u64 = 4;
55
56/// Offset of the format version within the log file header (block 0).
57///
58/// This field was called "group ID" in MySQL < 8.0.30. In 8.0.30+ (format
59/// version 6), it stores the log format version number (e.g. 6 for MySQL 9.x).
60pub const LOG_HEADER_FORMAT: usize = 0;
61/// Offset of the group ID within the log file header (block 0).
62///
63/// Alias for [`LOG_HEADER_FORMAT`] for backward compatibility. In MySQL < 8.0.30,
64/// this field stored the log group ID. In 8.0.30+ it stores the format version.
65pub const LOG_HEADER_GROUP_ID: usize = 0;
66/// Offset of the log UUID within the log file header (bytes 4-7).
67///
68/// In MySQL 8.0.30+, this is a 4-byte UUID identifying the data directory.
69/// In older formats, this was part of the start LSN field.
70pub const LOG_HEADER_LOG_UUID: usize = 4;
71/// Offset of the start LSN within the log file header (bytes 8-15).
72pub const LOG_HEADER_START_LSN: usize = 8;
73/// Offset of the file number within the log file header (bytes 12-15).
74///
75/// Only meaningful for MySQL < 8.0.30 where the start LSN was at offset 4.
76/// Overlaps with the lower 4 bytes of `LOG_HEADER_START_LSN` in the 8.0.30+ layout.
77#[deprecated(note = "Use LOG_HEADER_START_LSN instead; file_no is not a separate field in 8.0.30+")]
78pub const LOG_HEADER_FILE_NO: usize = 12;
79/// Offset of the creator string within the log file header (bytes 16-47).
80pub const LOG_HEADER_CREATED_BY: usize = 16;
81/// Maximum length of the creator string.
82pub const LOG_HEADER_CREATED_BY_LEN: usize = 32;
83
84/// Offset of the checkpoint number within a checkpoint block.
85///
86/// Only meaningful for MySQL < 8.0.30. In 8.0.30+ the checkpoint block
87/// only contains the checkpoint LSN at offset 8.
88pub const LOG_CHECKPOINT_NO: usize = 0;
89/// Offset of the checkpoint LSN within a checkpoint block.
90pub const LOG_CHECKPOINT_LSN: usize = 8;
91/// Offset of the checkpoint byte-offset within a checkpoint block.
92///
93/// Only meaningful for MySQL < 8.0.30.
94pub const LOG_CHECKPOINT_OFFSET: usize = 16;
95/// Offset of the log buffer size within a checkpoint block.
96///
97/// Only meaningful for MySQL < 8.0.30.
98pub const LOG_CHECKPOINT_BUF_SIZE: usize = 20;
99/// Offset of the archived LSN within a checkpoint block.
100///
101/// Only meaningful for MySQL < 8.0.30.
102pub const LOG_CHECKPOINT_ARCHIVED_LSN: usize = 24;
103
104/// Log file header (block 0 of the redo log file).
105///
106/// The header layout changed in MySQL 8.0.30 (format version 6):
107///
108/// | Offset | Pre-8.0.30 | 8.0.30+ (incl. 9.x) |
109/// |--------|------------|---------------------|
110/// | 0 | group_id (u32) | format_version (u32) |
111/// | 4 | start_lsn (u64) | log_uuid (u32) |
112/// | 8 | (start_lsn cont.) | start_lsn (u64) |
113/// | 12 | file_no (u32) | (start_lsn cont.) |
114/// | 16 | created_by (32 bytes) | created_by (32 bytes) |
115///
116/// The `format_version` and `group_id` fields share offset 0; in practice,
117/// format version values 1-6 are distinguishable from typical group IDs.
118#[derive(Debug, Clone, Serialize)]
119pub struct LogFileHeader {
120    /// Log format version (offset 0).
121    ///
122    /// In MySQL < 8.0.30 this was the log group ID. In 8.0.30+ this is the
123    /// format version number (e.g. 6 for MySQL 8.0.30+, 9.0, 9.1).
124    /// Aliased as `group_id` for backward compatibility.
125    pub format_version: u32,
126    /// Start LSN of this log file.
127    pub start_lsn: u64,
128    /// Log UUID (MySQL 8.0.30+) or 0 for older formats.
129    ///
130    /// A 4-byte identifier for the data directory, stored at offset 4.
131    #[serde(skip_serializing_if = "is_zero_u32")]
132    pub log_uuid: u32,
133    /// MySQL version string that created this log file (e.g. "MySQL 9.0.1").
134    pub created_by: String,
135}
136
137fn is_zero_u32(v: &u32) -> bool {
138    *v == 0
139}
140
141impl LogFileHeader {
142    /// Backward-compatible accessor: returns `format_version` as the group ID.
143    ///
144    /// In MySQL < 8.0.30, offset 0 stored the log group ID. In 8.0.30+
145    /// it stores the format version. This accessor preserves API compatibility.
146    pub fn group_id(&self) -> u32 {
147        self.format_version
148    }
149
150    /// Parse a log file header from the first 512-byte block.
151    ///
152    /// Handles both pre-8.0.30 and 8.0.30+ header layouts. The format is
153    /// auto-detected: format version >= 6 indicates the 8.0.30+ layout
154    /// where offset 4 is the log UUID and offset 8 is the start LSN.
155    /// Versions 1-5 use the pre-8.0.30 layout where offset 4 is the
156    /// start LSN (u64) and offset 12 is the file number.
157    ///
158    /// # Examples
159    ///
160    /// ```
161    /// use idb::innodb::log::{LogFileHeader, LOG_BLOCK_SIZE,
162    ///     LOG_HEADER_FORMAT, LOG_HEADER_START_LSN,
163    ///     LOG_HEADER_LOG_UUID, LOG_HEADER_CREATED_BY};
164    /// use byteorder::{BigEndian, ByteOrder};
165    ///
166    /// // MySQL 8.0.30+ / 9.x format (format_version = 6)
167    /// let mut block = vec![0u8; LOG_BLOCK_SIZE];
168    /// BigEndian::write_u32(&mut block[LOG_HEADER_FORMAT..], 6);
169    /// BigEndian::write_u32(&mut block[LOG_HEADER_LOG_UUID..], 0x12345678);
170    /// BigEndian::write_u64(&mut block[LOG_HEADER_START_LSN..], 0x1000);
171    /// block[LOG_HEADER_CREATED_BY..LOG_HEADER_CREATED_BY + 12]
172    ///     .copy_from_slice(b"MySQL 9.0.1\0");
173    ///
174    /// let hdr = LogFileHeader::parse(&block).unwrap();
175    /// assert_eq!(hdr.format_version, 6);
176    /// assert_eq!(hdr.start_lsn, 0x1000);
177    /// assert_eq!(hdr.log_uuid, 0x12345678);
178    /// assert_eq!(hdr.created_by, "MySQL 9.0.1");
179    /// ```
180    pub fn parse(block: &[u8]) -> Option<Self> {
181        if block.len() < LOG_BLOCK_SIZE {
182            return None;
183        }
184
185        let format_version = BigEndian::read_u32(&block[LOG_HEADER_FORMAT..]);
186
187        let (log_uuid, start_lsn) = if format_version >= 6 {
188            // 8.0.30+ layout: log_uuid at offset 4, start_lsn at offset 8
189            (
190                BigEndian::read_u32(&block[LOG_HEADER_LOG_UUID..]),
191                BigEndian::read_u64(&block[LOG_HEADER_START_LSN..]),
192            )
193        } else {
194            // Pre-8.0.30 layout: start_lsn at offset 4 (u64), file_no at offset 12
195            (0u32, BigEndian::read_u64(&block[4..]))
196        };
197
198        let created_bytes =
199            &block[LOG_HEADER_CREATED_BY..LOG_HEADER_CREATED_BY + LOG_HEADER_CREATED_BY_LEN];
200        let created_by = created_bytes
201            .iter()
202            .take_while(|&&b| b != 0)
203            .map(|&b| b as char)
204            .collect::<String>();
205
206        Some(LogFileHeader {
207            format_version,
208            start_lsn,
209            log_uuid,
210            created_by,
211        })
212    }
213}
214
215/// Checkpoint record (blocks 1 and 3 of the redo log file).
216///
217/// In MySQL 8.0.30+ (format version 6, including MySQL 9.x), the checkpoint
218/// block only contains the checkpoint LSN at offset 8. The `number`, `offset`,
219/// `buf_size`, and `archived_lsn` fields are not written and will be zero.
220#[derive(Debug, Clone, Serialize)]
221pub struct LogCheckpoint {
222    /// Checkpoint sequence number (pre-8.0.30 only; zero in 8.0.30+).
223    #[serde(skip_serializing_if = "is_zero_u64")]
224    pub number: u64,
225    /// LSN at the time of this checkpoint.
226    pub lsn: u64,
227    /// Byte offset of the checkpoint within the log file (pre-8.0.30 only).
228    #[serde(skip_serializing_if = "is_zero_u32")]
229    pub offset: u32,
230    /// Log buffer size at checkpoint time (pre-8.0.30 only).
231    #[serde(skip_serializing_if = "is_zero_u32")]
232    pub buf_size: u32,
233    /// LSN up to which log has been archived (pre-8.0.30 only).
234    #[serde(skip_serializing_if = "is_zero_u64")]
235    pub archived_lsn: u64,
236}
237
238fn is_zero_u64(v: &u64) -> bool {
239    *v == 0
240}
241
242impl LogCheckpoint {
243    /// Parse a checkpoint from a 512-byte block.
244    ///
245    /// Reads all fields for backward compatibility. In MySQL 8.0.30+ (format
246    /// version 6), only the `lsn` field at offset 8 is meaningful; all other
247    /// fields will be zero.
248    ///
249    /// # Examples
250    ///
251    /// ```
252    /// use idb::innodb::log::{LogCheckpoint, LOG_BLOCK_SIZE,
253    ///     LOG_CHECKPOINT_NO, LOG_CHECKPOINT_LSN, LOG_CHECKPOINT_OFFSET,
254    ///     LOG_CHECKPOINT_BUF_SIZE, LOG_CHECKPOINT_ARCHIVED_LSN};
255    /// use byteorder::{BigEndian, ByteOrder};
256    ///
257    /// let mut block = vec![0u8; LOG_BLOCK_SIZE];
258    /// BigEndian::write_u64(&mut block[LOG_CHECKPOINT_NO..], 42);
259    /// BigEndian::write_u64(&mut block[LOG_CHECKPOINT_LSN..], 0xDEADBEEF);
260    /// BigEndian::write_u32(&mut block[LOG_CHECKPOINT_OFFSET..], 2048);
261    /// BigEndian::write_u32(&mut block[LOG_CHECKPOINT_BUF_SIZE..], 65536);
262    /// BigEndian::write_u64(&mut block[LOG_CHECKPOINT_ARCHIVED_LSN..], 0xCAFEBABE);
263    ///
264    /// let cp = LogCheckpoint::parse(&block).unwrap();
265    /// assert_eq!(cp.number, 42);
266    /// assert_eq!(cp.lsn, 0xDEADBEEF);
267    /// assert_eq!(cp.offset, 2048);
268    /// assert_eq!(cp.buf_size, 65536);
269    /// assert_eq!(cp.archived_lsn, 0xCAFEBABE);
270    /// ```
271    pub fn parse(block: &[u8]) -> Option<Self> {
272        if block.len() < LOG_BLOCK_SIZE {
273            return None;
274        }
275
276        let number = BigEndian::read_u64(&block[LOG_CHECKPOINT_NO..]);
277        let lsn = BigEndian::read_u64(&block[LOG_CHECKPOINT_LSN..]);
278        let offset = BigEndian::read_u32(&block[LOG_CHECKPOINT_OFFSET..]);
279        let buf_size = BigEndian::read_u32(&block[LOG_CHECKPOINT_BUF_SIZE..]);
280        let archived_lsn = BigEndian::read_u64(&block[LOG_CHECKPOINT_ARCHIVED_LSN..]);
281
282        Some(LogCheckpoint {
283            number,
284            lsn,
285            offset,
286            buf_size,
287            archived_lsn,
288        })
289    }
290}
291
292/// Log block header (first 12 bytes of each 512-byte block).
293///
294/// The block header layout is consistent across MySQL versions:
295///
296/// | Offset | Size | Field |
297/// |--------|------|-------|
298/// | 0 | 4 | Block number (bit 31 = flush flag) |
299/// | 4 | 2 | Data length (including header bytes) |
300/// | 6 | 2 | First record group offset |
301/// | 8 | 4 | Epoch number (called checkpoint_no in older versions) |
302///
303/// In MySQL < 8.0.30, the header was 14 bytes with 2 extra bytes at offset
304/// 12-13 that were part of the checkpoint number. In 8.0.30+ the header is
305/// 12 bytes and offset 8 stores the epoch number.
306#[derive(Debug, Clone, Serialize)]
307pub struct LogBlockHeader {
308    /// Block number (with flush bit masked out).
309    pub block_no: u32,
310    /// Whether this block was the first in a flush batch (bit 31).
311    pub flush_flag: bool,
312    /// Number of bytes of log data in this block (including header).
313    pub data_len: u16,
314    /// Offset of the first log record group starting in this block.
315    pub first_rec_group: u16,
316    /// Epoch number (MySQL 8.0.30+) or checkpoint number (older versions).
317    ///
318    /// In MySQL 8.0.30+ and 9.x, this field stores the epoch number used
319    /// for log block validation. In older versions, it was the checkpoint
320    /// sequence number.
321    pub epoch_no: u32,
322}
323
324impl LogBlockHeader {
325    /// Parse a log block header from a 512-byte block.
326    ///
327    /// # Examples
328    ///
329    /// ```
330    /// use idb::innodb::log::{LogBlockHeader, LOG_BLOCK_HDR_SIZE};
331    /// use byteorder::{BigEndian, ByteOrder};
332    ///
333    /// let mut block = vec![0u8; LOG_BLOCK_HDR_SIZE];
334    /// BigEndian::write_u32(&mut block[0..], 0x80000005); // flush bit + block_no=5
335    /// BigEndian::write_u16(&mut block[4..], 200);        // data_len
336    /// BigEndian::write_u16(&mut block[6..], 14);         // first_rec_group
337    /// BigEndian::write_u32(&mut block[8..], 3);          // epoch_no
338    ///
339    /// let hdr = LogBlockHeader::parse(&block).unwrap();
340    /// assert_eq!(hdr.block_no, 5);
341    /// assert!(hdr.flush_flag);
342    /// assert_eq!(hdr.data_len, 200);
343    /// assert_eq!(hdr.first_rec_group, 14);
344    /// assert_eq!(hdr.epoch_no, 3);
345    /// assert!(hdr.has_data());
346    /// ```
347    pub fn parse(block: &[u8]) -> Option<Self> {
348        if block.len() < LOG_BLOCK_HDR_SIZE {
349            return None;
350        }
351
352        let raw_block_no = BigEndian::read_u32(&block[0..]);
353        let flush_flag = (raw_block_no & LOG_BLOCK_FLUSH_BIT_MASK) != 0;
354        let block_no = raw_block_no & !LOG_BLOCK_FLUSH_BIT_MASK;
355
356        let data_len = BigEndian::read_u16(&block[4..]);
357        let first_rec_group = BigEndian::read_u16(&block[6..]);
358        let epoch_no = BigEndian::read_u32(&block[8..]);
359
360        Some(LogBlockHeader {
361            block_no,
362            flush_flag,
363            data_len,
364            first_rec_group,
365            epoch_no,
366        })
367    }
368
369    /// Backward-compatible accessor: returns `epoch_no` as the checkpoint number.
370    ///
371    /// In MySQL < 8.0.30, offset 8 stored the checkpoint number. In 8.0.30+
372    /// it stores the epoch number. This accessor preserves API compatibility.
373    pub fn checkpoint_no(&self) -> u32 {
374        self.epoch_no
375    }
376
377    /// Returns true if this block contains log data (data_len > header size).
378    pub fn has_data(&self) -> bool {
379        self.data_len as usize > LOG_BLOCK_HDR_SIZE
380    }
381}
382
383/// Log block trailer (last 4 bytes of each 512-byte block).
384#[derive(Debug, Clone, Serialize)]
385pub struct LogBlockTrailer {
386    /// CRC-32C checksum of the block (bytes 0..508).
387    pub checksum: u32,
388}
389
390impl LogBlockTrailer {
391    /// Parse a log block trailer from a 512-byte block.
392    pub fn parse(block: &[u8]) -> Option<Self> {
393        if block.len() < LOG_BLOCK_SIZE {
394            return None;
395        }
396
397        let checksum = BigEndian::read_u32(&block[LOG_BLOCK_CHECKSUM_OFFSET..]);
398
399        Some(LogBlockTrailer { checksum })
400    }
401}
402
403/// Validate a log block's CRC-32C checksum.
404///
405/// The checksum covers bytes 0..508 of the block (everything except the checksum field itself).
406pub fn validate_log_block_checksum(block: &[u8]) -> bool {
407    if block.len() < LOG_BLOCK_SIZE {
408        return false;
409    }
410    let stored = BigEndian::read_u32(&block[LOG_BLOCK_CHECKSUM_OFFSET..]);
411    let calculated = crc32c::crc32c(&block[..LOG_BLOCK_CHECKSUM_OFFSET]);
412    stored == calculated
413}
414
415/// MLOG record types from MySQL `mtr0types.h`.
416///
417/// Type codes are assigned to match the MySQL source exactly. Types suffixed
418/// with `_8027` are the pre-8.0.28 variants; MySQL 8.0.28+ introduced new
419/// type codes (67-76) for records with row versioning support. The old codes
420/// are retained for backward compatibility with older redo logs.
421///
422/// In MySQL 9.x, the `_8027` types are officially marked obsolete but may
423/// still appear in redo logs created by MySQL 8.0.27 and earlier.
424#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
425pub enum MlogRecordType {
426    // ── Basic write types ────────────────────────────────────────────
427    /// Write 1 byte to a page (type 1).
428    Mlog1Byte,
429    /// Write 2 bytes to a page (type 2).
430    Mlog2Bytes,
431    /// Write 4 bytes to a page (type 4).
432    Mlog4Bytes,
433    /// Write 8 bytes to a page (type 8).
434    Mlog8Bytes,
435
436    // ── Pre-8.0.28 record types (9-18) ──────────────────────────────
437    /// Insert record, pre-8.0.28 format (type 9).
438    MlogRecInsert8027,
439    /// Clustered index delete-mark, pre-8.0.28 format (type 10).
440    MlogRecClustDeleteMark8027,
441    /// Secondary index delete-mark (type 11).
442    MlogRecSecDeleteMark,
443    /// Update in place, pre-8.0.28 format (type 13).
444    MlogRecUpdateInPlace8027,
445    /// Delete record, pre-8.0.28 format (type 14).
446    MlogRecDelete8027,
447    /// Delete from end of page list, pre-8.0.28 format (type 15).
448    MlogListEndDelete8027,
449    /// Delete from start of page list, pre-8.0.28 format (type 16).
450    MlogListStartDelete8027,
451    /// End-copy of created page list, pre-8.0.28 format (type 17).
452    MlogListEndCopyCreated8027,
453    /// Page reorganize, pre-8.0.28 format (type 18).
454    MlogPageReorganize8027,
455
456    // ── Page and undo types (19-24) ─────────────────────────────────
457    /// Create a page (type 19).
458    MlogPageCreate,
459    /// Insert undo log record (type 20).
460    MlogUndoInsert,
461    /// Erase undo log page end (type 21).
462    MlogUndoEraseEnd,
463    /// Initialize undo log header (type 22).
464    MlogUndoInit,
465    /// Reuse undo log header (type 24).
466    MlogUndoHdrReuse,
467
468    // ── Types added/renumbered in MySQL 8.0 (25-35) ─────────────────
469    /// Create undo log header (type 25).
470    MlogUndoHdrCreate,
471    /// Set minimum record mark (type 26).
472    MlogRecMinMark,
473    /// Initialize insert buffer bitmap (type 27).
474    MlogIbufBitmapInit,
475    /// LSN marker, debug only (type 28).
476    MlogLsn,
477    /// Initialize file page, deprecated (type 29).
478    MlogInitFilePage,
479    /// Write a string to a page (type 30).
480    MlogWriteString,
481    /// End of multi-record mini-transaction (type 31).
482    MlogMultiRecEnd,
483    /// Dummy record for padding (type 32).
484    MlogDummyRecord,
485    /// Create a tablespace file (type 33).
486    MlogFileCreate,
487    /// Rename a tablespace file (type 34).
488    MlogFileRename,
489    /// Delete a tablespace file (type 35).
490    MlogFileDelete,
491
492    // ── Compact page types (36-46, pre-8.0.28 variants) ─────────────
493    /// Set minimum record mark, compact format (type 36).
494    MlogCompRecMinMark,
495    /// Create compact page (type 37).
496    MlogCompPageCreate,
497    /// Insert record, compact format, pre-8.0.28 (type 38).
498    MlogCompRecInsert8027,
499    /// Clustered delete-mark, compact format, pre-8.0.28 (type 39).
500    MlogCompRecClustDeleteMark8027,
501    /// Secondary delete-mark, compact format (type 40).
502    MlogCompRecSecDeleteMark,
503    /// Update in place, compact format, pre-8.0.28 (type 41).
504    MlogCompRecUpdateInPlace8027,
505    /// Delete record, compact format, pre-8.0.28 (type 42).
506    MlogCompRecDelete8027,
507    /// Delete from end of list, compact format, pre-8.0.28 (type 43).
508    MlogCompListEndDelete8027,
509    /// Delete from start of list, compact format, pre-8.0.28 (type 44).
510    MlogCompListStartDelete8027,
511    /// End-copy created, compact format, pre-8.0.28 (type 45).
512    MlogCompListEndCopyCreated8027,
513    /// Page reorganize, compact format, pre-8.0.28 (type 46).
514    MlogCompPageReorganize8027,
515
516    // ── Compressed page types (48-53) ───────────────────────────────
517    /// Write node pointer in compressed page (type 48).
518    MlogZipWriteNodePtr,
519    /// Write BLOB pointer in compressed page (type 49).
520    MlogZipWriteBlobPtr,
521    /// Write header in compressed page (type 50).
522    MlogZipWriteHeader,
523    /// Compress a page (type 51).
524    MlogZipPageCompress,
525    /// Compress page with no data, pre-8.0.28 (type 52).
526    MlogZipPageCompressNoData8027,
527    /// Reorganize compressed page, pre-8.0.28 (type 53).
528    MlogZipPageReorganize8027,
529
530    // ── Extended types (57-66) ──────────────────────────────────────
531    /// Create R-Tree page (type 57).
532    MlogPageCreateRTree,
533    /// Create compact R-Tree page (type 58).
534    MlogCompPageCreateRTree,
535    /// Initialize file page v2 (type 59).
536    MlogInitFilePage2,
537    /// Index load notification (type 61).
538    MlogIndexLoad,
539    /// Table dynamic metadata (type 62).
540    MlogTableDynamicMeta,
541    /// Create SDI page (type 63).
542    MlogPageCreateSdi,
543    /// Create compact SDI page (type 64).
544    MlogCompPageCreateSdi,
545    /// Extend a tablespace file (type 65).
546    MlogFileExtend,
547    /// Test record, unit tests only (type 66).
548    MlogTest,
549
550    // ── MySQL 8.0.28+ new record types (67-76) ─────────────────────
551    /// Insert record with row versioning (type 67).
552    MlogRecInsert,
553    /// Clustered index delete-mark with row versioning (type 68).
554    MlogRecClustDeleteMark,
555    /// Delete record with row versioning (type 69).
556    MlogRecDelete,
557    /// Update in place with row versioning (type 70).
558    MlogRecUpdateInPlace,
559    /// End-copy of created page list with row versioning (type 71).
560    MlogListEndCopyCreated,
561    /// Page reorganize with row versioning (type 72).
562    MlogPageReorganize,
563    /// Compressed page reorganize with row versioning (type 73).
564    MlogZipPageReorganize,
565    /// Compress page with no data, with row versioning (type 74).
566    MlogZipPageCompressNoData,
567    /// Delete from end of page list with row versioning (type 75).
568    MlogListEndDelete,
569    /// Delete from start of page list with row versioning (type 76).
570    MlogListStartDelete,
571
572    /// Unknown or unrecognized record type.
573    Unknown(u8),
574}
575
576impl MlogRecordType {
577    /// Convert a u8 type code to MlogRecordType.
578    ///
579    /// Maps all known type codes from MySQL `mtr0types.h` across versions
580    /// 5.7 through 9.1. Unrecognized codes are wrapped in `Unknown(N)`.
581    ///
582    /// # Examples
583    ///
584    /// ```
585    /// use idb::innodb::log::MlogRecordType;
586    ///
587    /// let rec_type = MlogRecordType::from_u8(1);
588    /// assert_eq!(rec_type, MlogRecordType::Mlog1Byte);
589    /// assert_eq!(rec_type.name(), "MLOG_1BYTE");
590    ///
591    /// // Pre-8.0.28 insert at type code 9
592    /// let insert_old = MlogRecordType::from_u8(9);
593    /// assert_eq!(insert_old, MlogRecordType::MlogRecInsert8027);
594    ///
595    /// // Post-8.0.28 insert at type code 67
596    /// let insert_new = MlogRecordType::from_u8(67);
597    /// assert_eq!(insert_new, MlogRecordType::MlogRecInsert);
598    ///
599    /// // Unknown type codes are preserved
600    /// let unknown = MlogRecordType::from_u8(255);
601    /// assert_eq!(unknown, MlogRecordType::Unknown(255));
602    /// assert_eq!(format!("{}", unknown), "UNKNOWN(255)");
603    /// ```
604    pub fn from_u8(val: u8) -> Self {
605        match val {
606            1 => MlogRecordType::Mlog1Byte,
607            2 => MlogRecordType::Mlog2Bytes,
608            4 => MlogRecordType::Mlog4Bytes,
609            8 => MlogRecordType::Mlog8Bytes,
610            9 => MlogRecordType::MlogRecInsert8027,
611            10 => MlogRecordType::MlogRecClustDeleteMark8027,
612            11 => MlogRecordType::MlogRecSecDeleteMark,
613            13 => MlogRecordType::MlogRecUpdateInPlace8027,
614            14 => MlogRecordType::MlogRecDelete8027,
615            15 => MlogRecordType::MlogListEndDelete8027,
616            16 => MlogRecordType::MlogListStartDelete8027,
617            17 => MlogRecordType::MlogListEndCopyCreated8027,
618            18 => MlogRecordType::MlogPageReorganize8027,
619            19 => MlogRecordType::MlogPageCreate,
620            20 => MlogRecordType::MlogUndoInsert,
621            21 => MlogRecordType::MlogUndoEraseEnd,
622            22 => MlogRecordType::MlogUndoInit,
623            24 => MlogRecordType::MlogUndoHdrReuse,
624            25 => MlogRecordType::MlogUndoHdrCreate,
625            26 => MlogRecordType::MlogRecMinMark,
626            27 => MlogRecordType::MlogIbufBitmapInit,
627            28 => MlogRecordType::MlogLsn,
628            29 => MlogRecordType::MlogInitFilePage,
629            30 => MlogRecordType::MlogWriteString,
630            31 => MlogRecordType::MlogMultiRecEnd,
631            32 => MlogRecordType::MlogDummyRecord,
632            33 => MlogRecordType::MlogFileCreate,
633            34 => MlogRecordType::MlogFileRename,
634            35 => MlogRecordType::MlogFileDelete,
635            36 => MlogRecordType::MlogCompRecMinMark,
636            37 => MlogRecordType::MlogCompPageCreate,
637            38 => MlogRecordType::MlogCompRecInsert8027,
638            39 => MlogRecordType::MlogCompRecClustDeleteMark8027,
639            40 => MlogRecordType::MlogCompRecSecDeleteMark,
640            41 => MlogRecordType::MlogCompRecUpdateInPlace8027,
641            42 => MlogRecordType::MlogCompRecDelete8027,
642            43 => MlogRecordType::MlogCompListEndDelete8027,
643            44 => MlogRecordType::MlogCompListStartDelete8027,
644            45 => MlogRecordType::MlogCompListEndCopyCreated8027,
645            46 => MlogRecordType::MlogCompPageReorganize8027,
646            48 => MlogRecordType::MlogZipWriteNodePtr,
647            49 => MlogRecordType::MlogZipWriteBlobPtr,
648            50 => MlogRecordType::MlogZipWriteHeader,
649            51 => MlogRecordType::MlogZipPageCompress,
650            52 => MlogRecordType::MlogZipPageCompressNoData8027,
651            53 => MlogRecordType::MlogZipPageReorganize8027,
652            57 => MlogRecordType::MlogPageCreateRTree,
653            58 => MlogRecordType::MlogCompPageCreateRTree,
654            59 => MlogRecordType::MlogInitFilePage2,
655            61 => MlogRecordType::MlogIndexLoad,
656            62 => MlogRecordType::MlogTableDynamicMeta,
657            63 => MlogRecordType::MlogPageCreateSdi,
658            64 => MlogRecordType::MlogCompPageCreateSdi,
659            65 => MlogRecordType::MlogFileExtend,
660            66 => MlogRecordType::MlogTest,
661            67 => MlogRecordType::MlogRecInsert,
662            68 => MlogRecordType::MlogRecClustDeleteMark,
663            69 => MlogRecordType::MlogRecDelete,
664            70 => MlogRecordType::MlogRecUpdateInPlace,
665            71 => MlogRecordType::MlogListEndCopyCreated,
666            72 => MlogRecordType::MlogPageReorganize,
667            73 => MlogRecordType::MlogZipPageReorganize,
668            74 => MlogRecordType::MlogZipPageCompressNoData,
669            75 => MlogRecordType::MlogListEndDelete,
670            76 => MlogRecordType::MlogListStartDelete,
671            v => MlogRecordType::Unknown(v),
672        }
673    }
674
675    /// Display name for this record type.
676    pub fn name(&self) -> &str {
677        match self {
678            MlogRecordType::Mlog1Byte => "MLOG_1BYTE",
679            MlogRecordType::Mlog2Bytes => "MLOG_2BYTES",
680            MlogRecordType::Mlog4Bytes => "MLOG_4BYTES",
681            MlogRecordType::Mlog8Bytes => "MLOG_8BYTES",
682            MlogRecordType::MlogRecInsert8027 => "MLOG_REC_INSERT_8027",
683            MlogRecordType::MlogRecClustDeleteMark8027 => "MLOG_REC_CLUST_DELETE_MARK_8027",
684            MlogRecordType::MlogRecSecDeleteMark => "MLOG_REC_SEC_DELETE_MARK",
685            MlogRecordType::MlogRecUpdateInPlace8027 => "MLOG_REC_UPDATE_IN_PLACE_8027",
686            MlogRecordType::MlogRecDelete8027 => "MLOG_REC_DELETE_8027",
687            MlogRecordType::MlogListEndDelete8027 => "MLOG_LIST_END_DELETE_8027",
688            MlogRecordType::MlogListStartDelete8027 => "MLOG_LIST_START_DELETE_8027",
689            MlogRecordType::MlogListEndCopyCreated8027 => "MLOG_LIST_END_COPY_CREATED_8027",
690            MlogRecordType::MlogPageReorganize8027 => "MLOG_PAGE_REORGANIZE_8027",
691            MlogRecordType::MlogPageCreate => "MLOG_PAGE_CREATE",
692            MlogRecordType::MlogUndoInsert => "MLOG_UNDO_INSERT",
693            MlogRecordType::MlogUndoEraseEnd => "MLOG_UNDO_ERASE_END",
694            MlogRecordType::MlogUndoInit => "MLOG_UNDO_INIT",
695            MlogRecordType::MlogUndoHdrReuse => "MLOG_UNDO_HDR_REUSE",
696            MlogRecordType::MlogUndoHdrCreate => "MLOG_UNDO_HDR_CREATE",
697            MlogRecordType::MlogRecMinMark => "MLOG_REC_MIN_MARK",
698            MlogRecordType::MlogIbufBitmapInit => "MLOG_IBUF_BITMAP_INIT",
699            MlogRecordType::MlogLsn => "MLOG_LSN",
700            MlogRecordType::MlogInitFilePage => "MLOG_INIT_FILE_PAGE",
701            MlogRecordType::MlogWriteString => "MLOG_WRITE_STRING",
702            MlogRecordType::MlogMultiRecEnd => "MLOG_MULTI_REC_END",
703            MlogRecordType::MlogDummyRecord => "MLOG_DUMMY_RECORD",
704            MlogRecordType::MlogFileCreate => "MLOG_FILE_CREATE",
705            MlogRecordType::MlogFileRename => "MLOG_FILE_RENAME",
706            MlogRecordType::MlogFileDelete => "MLOG_FILE_DELETE",
707            MlogRecordType::MlogCompRecMinMark => "MLOG_COMP_REC_MIN_MARK",
708            MlogRecordType::MlogCompPageCreate => "MLOG_COMP_PAGE_CREATE",
709            MlogRecordType::MlogCompRecInsert8027 => "MLOG_COMP_REC_INSERT_8027",
710            MlogRecordType::MlogCompRecClustDeleteMark8027 => {
711                "MLOG_COMP_REC_CLUST_DELETE_MARK_8027"
712            }
713            MlogRecordType::MlogCompRecSecDeleteMark => "MLOG_COMP_REC_SEC_DELETE_MARK",
714            MlogRecordType::MlogCompRecUpdateInPlace8027 => "MLOG_COMP_REC_UPDATE_IN_PLACE_8027",
715            MlogRecordType::MlogCompRecDelete8027 => "MLOG_COMP_REC_DELETE_8027",
716            MlogRecordType::MlogCompListEndDelete8027 => "MLOG_COMP_LIST_END_DELETE_8027",
717            MlogRecordType::MlogCompListStartDelete8027 => "MLOG_COMP_LIST_START_DELETE_8027",
718            MlogRecordType::MlogCompListEndCopyCreated8027 => {
719                "MLOG_COMP_LIST_END_COPY_CREATED_8027"
720            }
721            MlogRecordType::MlogCompPageReorganize8027 => "MLOG_COMP_PAGE_REORGANIZE_8027",
722            MlogRecordType::MlogZipWriteNodePtr => "MLOG_ZIP_WRITE_NODE_PTR",
723            MlogRecordType::MlogZipWriteBlobPtr => "MLOG_ZIP_WRITE_BLOB_PTR",
724            MlogRecordType::MlogZipWriteHeader => "MLOG_ZIP_WRITE_HEADER",
725            MlogRecordType::MlogZipPageCompress => "MLOG_ZIP_PAGE_COMPRESS",
726            MlogRecordType::MlogZipPageCompressNoData8027 => "MLOG_ZIP_PAGE_COMPRESS_NO_DATA_8027",
727            MlogRecordType::MlogZipPageReorganize8027 => "MLOG_ZIP_PAGE_REORGANIZE_8027",
728            MlogRecordType::MlogPageCreateRTree => "MLOG_PAGE_CREATE_RTREE",
729            MlogRecordType::MlogCompPageCreateRTree => "MLOG_COMP_PAGE_CREATE_RTREE",
730            MlogRecordType::MlogInitFilePage2 => "MLOG_INIT_FILE_PAGE2",
731            MlogRecordType::MlogIndexLoad => "MLOG_INDEX_LOAD",
732            MlogRecordType::MlogTableDynamicMeta => "MLOG_TABLE_DYNAMIC_META",
733            MlogRecordType::MlogPageCreateSdi => "MLOG_PAGE_CREATE_SDI",
734            MlogRecordType::MlogCompPageCreateSdi => "MLOG_COMP_PAGE_CREATE_SDI",
735            MlogRecordType::MlogFileExtend => "MLOG_FILE_EXTEND",
736            MlogRecordType::MlogTest => "MLOG_TEST",
737            MlogRecordType::MlogRecInsert => "MLOG_REC_INSERT",
738            MlogRecordType::MlogRecClustDeleteMark => "MLOG_REC_CLUST_DELETE_MARK",
739            MlogRecordType::MlogRecDelete => "MLOG_REC_DELETE",
740            MlogRecordType::MlogRecUpdateInPlace => "MLOG_REC_UPDATE_IN_PLACE",
741            MlogRecordType::MlogListEndCopyCreated => "MLOG_LIST_END_COPY_CREATED",
742            MlogRecordType::MlogPageReorganize => "MLOG_PAGE_REORGANIZE",
743            MlogRecordType::MlogZipPageReorganize => "MLOG_ZIP_PAGE_REORGANIZE",
744            MlogRecordType::MlogZipPageCompressNoData => "MLOG_ZIP_PAGE_COMPRESS_NO_DATA",
745            MlogRecordType::MlogListEndDelete => "MLOG_LIST_END_DELETE",
746            MlogRecordType::MlogListStartDelete => "MLOG_LIST_START_DELETE",
747            MlogRecordType::Unknown(_) => "UNKNOWN",
748        }
749    }
750}
751
752impl std::fmt::Display for MlogRecordType {
753    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
754        match self {
755            MlogRecordType::Unknown(v) => write!(f, "UNKNOWN({})", v),
756            _ => write!(f, "{}", self.name()),
757        }
758    }
759}
760
761/// A single parsed MLOG record from a redo log data block.
762///
763/// Each record carries the type of redo operation and (for page-referencing
764/// types) the `space_id` and `page_no` that the operation targets.  These
765/// two fields are encoded as InnoDB compressed integers immediately after
766/// the type byte.
767///
768/// ## Limitations
769///
770/// * Records that span block boundaries are not decoded — only intra-block
771///   records are returned.
772/// * The exact record payload length is not determined (would require schema
773///   context), so records are identified by scanning for valid type bytes.
774#[derive(Debug, Clone, Serialize)]
775pub struct MlogRecord {
776    /// Offset within the block payload (bytes from block start).
777    pub block_offset: usize,
778    /// MLOG record type (single-rec flag stripped).
779    pub record_type: MlogRecordType,
780    /// Whether the single-record-group flag (bit 7 of type byte) was set.
781    pub single_rec: bool,
782    /// Tablespace ID (InnoDB compressed integer), if this type carries a page reference.
783    #[serde(skip_serializing_if = "Option::is_none")]
784    pub space_id: Option<u32>,
785    /// Page number within the tablespace.
786    #[serde(skip_serializing_if = "Option::is_none")]
787    pub page_no: Option<u32>,
788}
789
790impl MlogRecordType {
791    /// Returns `true` for MLOG types that do NOT carry a `(space_id, page_no)` pair.
792    ///
793    /// These are: multi-rec-end, dummy, file-level ops, LSN marker, and test.
794    fn is_non_page_type(&self) -> bool {
795        matches!(
796            self,
797            MlogRecordType::MlogMultiRecEnd
798                | MlogRecordType::MlogDummyRecord
799                | MlogRecordType::MlogFileCreate
800                | MlogRecordType::MlogFileRename
801                | MlogRecordType::MlogFileDelete
802                | MlogRecordType::MlogFileExtend
803                | MlogRecordType::MlogLsn
804                | MlogRecordType::MlogTest
805                | MlogRecordType::MlogTableDynamicMeta
806        )
807    }
808
809    /// Returns `true` if the raw type code is in the known range (1..=76, excluding gaps).
810    fn is_known_type_code(code: u8) -> bool {
811        matches!(
812            code,
813            1..=2 | 4 | 8..=11 | 13..=22 | 24..=35 | 36..=46 | 48..=53 | 57..=59 | 61..=76
814        )
815    }
816}
817
818/// Parse MLOG records from a single redo log data block.
819///
820/// Walks the block payload from `LOG_BLOCK_HDR_SIZE` up to `hdr.data_len`,
821/// extracting the record type and, for page-referencing types, the compressed
822/// `space_id` and `page_no` fields.
823///
824/// Records are identified by scanning for valid type bytes.  Because the
825/// exact record payload length is not determined, some false positives may
826/// occur when data bytes happen to look like valid type codes.  To mitigate
827/// this, decoded `space_id` values above 2^24 are treated as implausible
828/// and the record is emitted without page references.
829pub fn parse_mlog_records(block: &[u8], hdr: &LogBlockHeader) -> Vec<MlogRecord> {
830    let data_end = std::cmp::min(hdr.data_len as usize, block.len());
831    if data_end <= LOG_BLOCK_HDR_SIZE {
832        return Vec::new();
833    }
834
835    let mut records = Vec::new();
836    let mut pos = LOG_BLOCK_HDR_SIZE;
837
838    while pos < data_end {
839        let raw_type = block[pos];
840        // Skip zero/padding bytes
841        if raw_type == 0 {
842            pos += 1;
843            continue;
844        }
845
846        let single_rec = (raw_type & 0x80) != 0;
847        let type_code = raw_type & 0x7F;
848
849        // Only process known type codes
850        if !MlogRecordType::is_known_type_code(type_code) {
851            pos += 1;
852            continue;
853        }
854
855        let record_type = MlogRecordType::from_u8(type_code);
856        let block_offset = pos;
857        pos += 1; // consume type byte
858
859        if record_type.is_non_page_type() {
860            records.push(MlogRecord {
861                block_offset,
862                record_type,
863                single_rec,
864                space_id: None,
865                page_no: None,
866            });
867            continue;
868        }
869
870        // Try to read compressed space_id and page_no
871        let (space_id, page_no) = if let Some((sid, sid_len)) =
872            crate::innodb::undo::read_compressed(block, pos)
873        {
874            let next = pos + sid_len;
875            if sid <= 0x00FF_FFFF {
876                // plausible space_id
877                if let Some((pno, pno_len)) = crate::innodb::undo::read_compressed(block, next) {
878                    pos = next + pno_len;
879                    (Some(sid as u32), Some(pno as u32))
880                } else {
881                    pos = next;
882                    (Some(sid as u32), None)
883                }
884            } else {
885                // implausible space_id — skip
886                (None, None)
887            }
888        } else {
889            (None, None)
890        };
891
892        records.push(MlogRecord {
893            block_offset,
894            record_type,
895            single_rec,
896            space_id,
897            page_no,
898        });
899    }
900
901    records
902}
903
904/// Compute the approximate LSN for a record within a redo log block.
905///
906/// Each data block carries `LOG_BLOCK_SIZE - LOG_BLOCK_HDR_SIZE - LOG_BLOCK_TRL_SIZE`
907/// = 494 bytes of useful log data.  The LSN of a record is:
908///
909/// ```text
910/// start_lsn + block_idx * 494 + (payload_offset - LOG_BLOCK_HDR_SIZE)
911/// ```
912///
913/// where `block_idx` is the data block index (0-based, i.e. the block's
914/// position minus `LOG_FILE_HDR_BLOCKS`).
915pub fn compute_record_lsn(start_lsn: u64, block_idx: u64, payload_offset: usize) -> u64 {
916    const PAYLOAD_PER_BLOCK: u64 =
917        (LOG_BLOCK_SIZE - LOG_BLOCK_HDR_SIZE - LOG_BLOCK_TRL_SIZE) as u64;
918    let offset_in_payload = payload_offset.saturating_sub(LOG_BLOCK_HDR_SIZE) as u64;
919    start_lsn + block_idx * PAYLOAD_PER_BLOCK + offset_in_payload
920}
921
922/// Redo log file reader.
923pub struct LogFile {
924    reader: Box<dyn ReadSeek>,
925    file_size: u64,
926}
927
928impl LogFile {
929    /// Open a redo log file.
930    #[cfg(not(target_arch = "wasm32"))]
931    pub fn open(path: &str) -> Result<Self, IdbError> {
932        let file = std::fs::File::open(path)
933            .map_err(|e| IdbError::Io(format!("Cannot open {}: {}", path, e)))?;
934        let file_size = file
935            .metadata()
936            .map_err(|e| IdbError::Io(format!("Cannot stat {}: {}", path, e)))?
937            .len();
938
939        Self::init(Box::new(file), file_size)
940    }
941
942    /// Create a log file reader from an in-memory byte buffer.
943    ///
944    /// The buffer must be at least 2048 bytes (4 blocks of 512 bytes each for
945    /// the header and checkpoint blocks).
946    ///
947    /// # Examples
948    ///
949    /// ```no_run
950    /// use idb::innodb::log::LogFile;
951    ///
952    /// // Build a minimal valid redo log (4 header blocks + 1 data block)
953    /// let data = vec![0u8; 512 * 5];
954    /// let mut log = LogFile::from_bytes(data).unwrap();
955    /// let header = log.read_header().unwrap();
956    /// println!("Created by: {}", header.created_by);
957    /// ```
958    pub fn from_bytes(data: Vec<u8>) -> Result<Self, IdbError> {
959        let file_size = data.len() as u64;
960        Self::init(Box::new(Cursor::new(data)), file_size)
961    }
962
963    fn init(reader: Box<dyn ReadSeek>, file_size: u64) -> Result<Self, IdbError> {
964        if file_size < (LOG_FILE_HDR_BLOCKS as usize * LOG_BLOCK_SIZE) as u64 {
965            return Err(IdbError::Parse(format!(
966                "File is too small for a redo log ({} bytes, minimum {})",
967                file_size,
968                LOG_FILE_HDR_BLOCKS as usize * LOG_BLOCK_SIZE
969            )));
970        }
971
972        Ok(LogFile { reader, file_size })
973    }
974
975    /// Total number of 512-byte blocks in the file.
976    pub fn block_count(&self) -> u64 {
977        self.file_size / LOG_BLOCK_SIZE as u64
978    }
979
980    /// Number of data blocks (excluding the 4 header/checkpoint blocks).
981    pub fn data_block_count(&self) -> u64 {
982        self.block_count().saturating_sub(LOG_FILE_HDR_BLOCKS)
983    }
984
985    /// Read a single 512-byte block by block number.
986    pub fn read_block(&mut self, block_no: u64) -> Result<Vec<u8>, IdbError> {
987        let offset = block_no * LOG_BLOCK_SIZE as u64;
988        if offset + LOG_BLOCK_SIZE as u64 > self.file_size {
989            return Err(IdbError::Io(format!(
990                "Block {} is beyond end of file (offset {}, file size {})",
991                block_no, offset, self.file_size
992            )));
993        }
994
995        self.reader
996            .seek(SeekFrom::Start(offset))
997            .map_err(|e| IdbError::Io(format!("Seek error: {}", e)))?;
998
999        let mut buf = vec![0u8; LOG_BLOCK_SIZE];
1000        self.reader
1001            .read_exact(&mut buf)
1002            .map_err(|e| IdbError::Io(format!("Read error at block {}: {}", block_no, e)))?;
1003
1004        Ok(buf)
1005    }
1006
1007    /// Read and parse the log file header (block 0).
1008    pub fn read_header(&mut self) -> Result<LogFileHeader, IdbError> {
1009        let block = self.read_block(0)?;
1010        LogFileHeader::parse(&block)
1011            .ok_or_else(|| IdbError::Parse("Failed to parse log file header (block 0)".to_string()))
1012    }
1013
1014    /// Read and parse a checkpoint (slot 0 = block 1, slot 1 = block 3).
1015    pub fn read_checkpoint(&mut self, slot: u8) -> Result<LogCheckpoint, IdbError> {
1016        let block_no = match slot {
1017            0 => 1,
1018            1 => 3,
1019            _ => {
1020                return Err(IdbError::Argument(format!(
1021                    "Invalid checkpoint slot {} (must be 0 or 1)",
1022                    slot
1023                )))
1024            }
1025        };
1026        let block = self.read_block(block_no)?;
1027        LogCheckpoint::parse(&block).ok_or_else(|| {
1028            IdbError::Parse(format!("Failed to parse checkpoint at block {}", block_no))
1029        })
1030    }
1031
1032    /// File size in bytes.
1033    pub fn file_size(&self) -> u64 {
1034        self.file_size
1035    }
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040    use super::*;
1041
1042    fn make_block() -> Vec<u8> {
1043        vec![0u8; LOG_BLOCK_SIZE]
1044    }
1045
1046    #[test]
1047    fn test_log_block_header_parse() {
1048        let mut block = make_block();
1049        // block_no = 42 (no flush bit)
1050        BigEndian::write_u32(&mut block[0..], 42);
1051        // data_len = 200
1052        BigEndian::write_u16(&mut block[4..], 200);
1053        // first_rec_group = 50
1054        BigEndian::write_u16(&mut block[6..], 50);
1055        // epoch_no = 7
1056        BigEndian::write_u32(&mut block[8..], 7);
1057
1058        let hdr = LogBlockHeader::parse(&block).unwrap();
1059        assert_eq!(hdr.block_no, 42);
1060        assert!(!hdr.flush_flag);
1061        assert_eq!(hdr.data_len, 200);
1062        assert_eq!(hdr.first_rec_group, 50);
1063        assert_eq!(hdr.epoch_no, 7);
1064        assert_eq!(hdr.checkpoint_no(), 7);
1065        assert!(hdr.has_data());
1066    }
1067
1068    #[test]
1069    fn test_log_block_flush_bit() {
1070        let mut block = make_block();
1071        // Set flush bit (bit 31) + block_no = 100
1072        BigEndian::write_u32(&mut block[0..], 0x80000064);
1073        BigEndian::write_u16(&mut block[4..], 14); // data_len = header only
1074
1075        let hdr = LogBlockHeader::parse(&block).unwrap();
1076        assert!(hdr.flush_flag);
1077        assert_eq!(hdr.block_no, 100);
1078        assert!(!hdr.has_data());
1079    }
1080
1081    #[test]
1082    fn test_log_block_header_empty() {
1083        let block = make_block();
1084        let hdr = LogBlockHeader::parse(&block).unwrap();
1085        assert_eq!(hdr.block_no, 0);
1086        assert!(!hdr.flush_flag);
1087        assert_eq!(hdr.data_len, 0);
1088        assert_eq!(hdr.first_rec_group, 0);
1089        assert_eq!(hdr.epoch_no, 0);
1090        assert!(!hdr.has_data());
1091    }
1092
1093    #[test]
1094    fn test_log_block_header_too_small() {
1095        let block = vec![0u8; 10]; // less than LOG_BLOCK_HDR_SIZE
1096        assert!(LogBlockHeader::parse(&block).is_none());
1097    }
1098
1099    #[test]
1100    fn test_log_block_trailer_parse() {
1101        let mut block = make_block();
1102        BigEndian::write_u32(&mut block[LOG_BLOCK_CHECKSUM_OFFSET..], 0xCAFEBABE);
1103
1104        let trailer = LogBlockTrailer::parse(&block).unwrap();
1105        assert_eq!(trailer.checksum, 0xCAFEBABE);
1106    }
1107
1108    #[test]
1109    fn test_log_file_header_format_v6() {
1110        let mut block = make_block();
1111        BigEndian::write_u32(&mut block[LOG_HEADER_FORMAT..], 6);
1112        BigEndian::write_u32(&mut block[LOG_HEADER_LOG_UUID..], 0xABCD1234);
1113        BigEndian::write_u64(&mut block[LOG_HEADER_START_LSN..], 0x00000000001A2B3C);
1114        let creator = b"MySQL 9.0.1";
1115        block[LOG_HEADER_CREATED_BY..LOG_HEADER_CREATED_BY + creator.len()]
1116            .copy_from_slice(creator);
1117
1118        let hdr = LogFileHeader::parse(&block).unwrap();
1119        assert_eq!(hdr.format_version, 6);
1120        assert_eq!(hdr.group_id(), 6);
1121        assert_eq!(hdr.start_lsn, 0x1A2B3C);
1122        assert_eq!(hdr.log_uuid, 0xABCD1234);
1123        assert_eq!(hdr.created_by, "MySQL 9.0.1");
1124    }
1125
1126    #[test]
1127    fn test_log_file_header_pre_8030() {
1128        let mut block = make_block();
1129        // Pre-8.0.30: format_version=1, start_lsn at offset 4
1130        BigEndian::write_u32(&mut block[LOG_HEADER_FORMAT..], 1);
1131        BigEndian::write_u64(&mut block[4..], 0x00000000001A2B3C); // start_lsn at offset 4
1132        let creator = b"MySQL 5.7.44";
1133        block[LOG_HEADER_CREATED_BY..LOG_HEADER_CREATED_BY + creator.len()]
1134            .copy_from_slice(creator);
1135
1136        let hdr = LogFileHeader::parse(&block).unwrap();
1137        assert_eq!(hdr.format_version, 1);
1138        assert_eq!(hdr.start_lsn, 0x1A2B3C);
1139        assert_eq!(hdr.log_uuid, 0); // no log_uuid in pre-8.0.30
1140        assert_eq!(hdr.created_by, "MySQL 5.7.44");
1141    }
1142
1143    #[test]
1144    fn test_log_file_header_format_v5() {
1145        let mut block = make_block();
1146        // Format version 5 (MySQL 8.0.28): pre-8.0.30 layout
1147        BigEndian::write_u32(&mut block[LOG_HEADER_FORMAT..], 5);
1148        BigEndian::write_u64(&mut block[4..], 0x00000000DEADBEEF);
1149        let creator = b"MySQL 8.0.28";
1150        block[LOG_HEADER_CREATED_BY..LOG_HEADER_CREATED_BY + creator.len()]
1151            .copy_from_slice(creator);
1152
1153        let hdr = LogFileHeader::parse(&block).unwrap();
1154        assert_eq!(hdr.format_version, 5);
1155        assert_eq!(hdr.start_lsn, 0xDEADBEEF);
1156        assert_eq!(hdr.log_uuid, 0);
1157        assert_eq!(hdr.created_by, "MySQL 8.0.28");
1158    }
1159
1160    #[test]
1161    fn test_log_file_header_empty_created_by() {
1162        let block = make_block();
1163        let hdr = LogFileHeader::parse(&block).unwrap();
1164        assert_eq!(hdr.created_by, "");
1165    }
1166
1167    #[test]
1168    fn test_log_checkpoint_parse() {
1169        let mut block = make_block();
1170        BigEndian::write_u64(&mut block[LOG_CHECKPOINT_NO..], 99);
1171        BigEndian::write_u64(&mut block[LOG_CHECKPOINT_LSN..], 0x00000000DEADBEEF);
1172        BigEndian::write_u32(&mut block[LOG_CHECKPOINT_OFFSET..], 2048);
1173        BigEndian::write_u32(&mut block[LOG_CHECKPOINT_BUF_SIZE..], 65536);
1174        BigEndian::write_u64(
1175            &mut block[LOG_CHECKPOINT_ARCHIVED_LSN..],
1176            0x00000000CAFEBABE,
1177        );
1178
1179        let cp = LogCheckpoint::parse(&block).unwrap();
1180        assert_eq!(cp.number, 99);
1181        assert_eq!(cp.lsn, 0xDEADBEEF);
1182        assert_eq!(cp.offset, 2048);
1183        assert_eq!(cp.buf_size, 65536);
1184        assert_eq!(cp.archived_lsn, 0xCAFEBABE);
1185    }
1186
1187    #[test]
1188    fn test_log_checkpoint_format_v6() {
1189        // In MySQL 8.0.30+ (format v6), only LSN at offset 8 is set
1190        let mut block = make_block();
1191        BigEndian::write_u64(&mut block[LOG_CHECKPOINT_LSN..], 32193931);
1192
1193        let cp = LogCheckpoint::parse(&block).unwrap();
1194        assert_eq!(cp.lsn, 32193931);
1195        assert_eq!(cp.number, 0);
1196        assert_eq!(cp.offset, 0);
1197        assert_eq!(cp.buf_size, 0);
1198        assert_eq!(cp.archived_lsn, 0);
1199    }
1200
1201    #[test]
1202    fn test_mlog_record_type_basic_writes() {
1203        assert_eq!(MlogRecordType::from_u8(1), MlogRecordType::Mlog1Byte);
1204        assert_eq!(MlogRecordType::from_u8(2), MlogRecordType::Mlog2Bytes);
1205        assert_eq!(MlogRecordType::from_u8(4), MlogRecordType::Mlog4Bytes);
1206        assert_eq!(MlogRecordType::from_u8(8), MlogRecordType::Mlog8Bytes);
1207    }
1208
1209    #[test]
1210    fn test_mlog_record_type_pre_8028() {
1211        // Pre-8.0.28 types at old positions
1212        assert_eq!(
1213            MlogRecordType::from_u8(9),
1214            MlogRecordType::MlogRecInsert8027
1215        );
1216        assert_eq!(
1217            MlogRecordType::from_u8(10),
1218            MlogRecordType::MlogRecClustDeleteMark8027
1219        );
1220        assert_eq!(
1221            MlogRecordType::from_u8(18),
1222            MlogRecordType::MlogPageReorganize8027
1223        );
1224        assert_eq!(
1225            MlogRecordType::from_u8(38),
1226            MlogRecordType::MlogCompRecInsert8027
1227        );
1228        assert_eq!(
1229            MlogRecordType::from_u8(52),
1230            MlogRecordType::MlogZipPageCompressNoData8027
1231        );
1232        assert_eq!(
1233            MlogRecordType::from_u8(53),
1234            MlogRecordType::MlogZipPageReorganize8027
1235        );
1236    }
1237
1238    #[test]
1239    fn test_mlog_record_type_corrected_mappings() {
1240        // Types that were previously mapped to wrong codes
1241        assert_eq!(
1242            MlogRecordType::from_u8(25),
1243            MlogRecordType::MlogUndoHdrCreate
1244        );
1245        assert_eq!(MlogRecordType::from_u8(26), MlogRecordType::MlogRecMinMark);
1246        assert_eq!(
1247            MlogRecordType::from_u8(27),
1248            MlogRecordType::MlogIbufBitmapInit
1249        );
1250        assert_eq!(MlogRecordType::from_u8(28), MlogRecordType::MlogLsn);
1251        assert_eq!(
1252            MlogRecordType::from_u8(29),
1253            MlogRecordType::MlogInitFilePage
1254        );
1255        assert_eq!(MlogRecordType::from_u8(30), MlogRecordType::MlogWriteString);
1256        assert_eq!(MlogRecordType::from_u8(31), MlogRecordType::MlogMultiRecEnd);
1257        assert_eq!(MlogRecordType::from_u8(32), MlogRecordType::MlogDummyRecord);
1258        assert_eq!(MlogRecordType::from_u8(33), MlogRecordType::MlogFileCreate);
1259        assert_eq!(MlogRecordType::from_u8(34), MlogRecordType::MlogFileRename);
1260        assert_eq!(MlogRecordType::from_u8(35), MlogRecordType::MlogFileDelete);
1261        assert_eq!(
1262            MlogRecordType::from_u8(36),
1263            MlogRecordType::MlogCompRecMinMark
1264        );
1265        assert_eq!(
1266            MlogRecordType::from_u8(37),
1267            MlogRecordType::MlogCompPageCreate
1268        );
1269    }
1270
1271    #[test]
1272    fn test_mlog_record_type_post_8028() {
1273        // New types added in MySQL 8.0.28+
1274        assert_eq!(MlogRecordType::from_u8(67), MlogRecordType::MlogRecInsert);
1275        assert_eq!(
1276            MlogRecordType::from_u8(68),
1277            MlogRecordType::MlogRecClustDeleteMark
1278        );
1279        assert_eq!(MlogRecordType::from_u8(69), MlogRecordType::MlogRecDelete);
1280        assert_eq!(
1281            MlogRecordType::from_u8(70),
1282            MlogRecordType::MlogRecUpdateInPlace
1283        );
1284        assert_eq!(
1285            MlogRecordType::from_u8(71),
1286            MlogRecordType::MlogListEndCopyCreated
1287        );
1288        assert_eq!(
1289            MlogRecordType::from_u8(72),
1290            MlogRecordType::MlogPageReorganize
1291        );
1292        assert_eq!(
1293            MlogRecordType::from_u8(73),
1294            MlogRecordType::MlogZipPageReorganize
1295        );
1296        assert_eq!(
1297            MlogRecordType::from_u8(74),
1298            MlogRecordType::MlogZipPageCompressNoData
1299        );
1300        assert_eq!(
1301            MlogRecordType::from_u8(75),
1302            MlogRecordType::MlogListEndDelete
1303        );
1304        assert_eq!(
1305            MlogRecordType::from_u8(76),
1306            MlogRecordType::MlogListStartDelete
1307        );
1308    }
1309
1310    #[test]
1311    fn test_mlog_record_type_extended() {
1312        // Types in the 57-66 range
1313        assert_eq!(
1314            MlogRecordType::from_u8(57),
1315            MlogRecordType::MlogPageCreateRTree
1316        );
1317        assert_eq!(
1318            MlogRecordType::from_u8(59),
1319            MlogRecordType::MlogInitFilePage2
1320        );
1321        assert_eq!(MlogRecordType::from_u8(61), MlogRecordType::MlogIndexLoad);
1322        assert_eq!(
1323            MlogRecordType::from_u8(62),
1324            MlogRecordType::MlogTableDynamicMeta
1325        );
1326        assert_eq!(
1327            MlogRecordType::from_u8(63),
1328            MlogRecordType::MlogPageCreateSdi
1329        );
1330        assert_eq!(
1331            MlogRecordType::from_u8(64),
1332            MlogRecordType::MlogCompPageCreateSdi
1333        );
1334        assert_eq!(MlogRecordType::from_u8(65), MlogRecordType::MlogFileExtend);
1335        assert_eq!(MlogRecordType::from_u8(66), MlogRecordType::MlogTest);
1336    }
1337
1338    #[test]
1339    fn test_mlog_record_type_unknown() {
1340        assert_eq!(MlogRecordType::from_u8(0), MlogRecordType::Unknown(0));
1341        assert_eq!(MlogRecordType::from_u8(255), MlogRecordType::Unknown(255));
1342        assert_eq!(MlogRecordType::from_u8(100), MlogRecordType::Unknown(100));
1343        // Gaps in the enum: 3, 5, 6, 7, 12, 23, 47, 54-56, 60, 77+
1344        assert_eq!(MlogRecordType::from_u8(3), MlogRecordType::Unknown(3));
1345        assert_eq!(MlogRecordType::from_u8(12), MlogRecordType::Unknown(12));
1346        assert_eq!(MlogRecordType::from_u8(23), MlogRecordType::Unknown(23));
1347        assert_eq!(MlogRecordType::from_u8(47), MlogRecordType::Unknown(47));
1348        assert_eq!(MlogRecordType::from_u8(60), MlogRecordType::Unknown(60));
1349        assert_eq!(MlogRecordType::from_u8(77), MlogRecordType::Unknown(77));
1350    }
1351
1352    #[test]
1353    fn test_mlog_record_type_name() {
1354        assert_eq!(MlogRecordType::Mlog1Byte.name(), "MLOG_1BYTE");
1355        assert_eq!(MlogRecordType::MlogRecInsert.name(), "MLOG_REC_INSERT");
1356        assert_eq!(
1357            MlogRecordType::MlogRecInsert8027.name(),
1358            "MLOG_REC_INSERT_8027"
1359        );
1360        assert_eq!(MlogRecordType::MlogFileExtend.name(), "MLOG_FILE_EXTEND");
1361        assert_eq!(MlogRecordType::MlogLsn.name(), "MLOG_LSN");
1362        assert_eq!(MlogRecordType::Unknown(99).name(), "UNKNOWN");
1363    }
1364
1365    #[test]
1366    fn test_mlog_record_type_display() {
1367        assert_eq!(format!("{}", MlogRecordType::Mlog1Byte), "MLOG_1BYTE");
1368        assert_eq!(format!("{}", MlogRecordType::Unknown(99)), "UNKNOWN(99)");
1369        assert_eq!(
1370            format!("{}", MlogRecordType::MlogListEndDelete),
1371            "MLOG_LIST_END_DELETE"
1372        );
1373    }
1374
1375    #[test]
1376    fn test_log_block_checksum_validation() {
1377        let mut block = make_block();
1378        // Put some data in the block
1379        BigEndian::write_u32(&mut block[0..], 5); // block_no = 5
1380        BigEndian::write_u16(&mut block[4..], 100); // data_len
1381        BigEndian::write_u16(&mut block[6..], 12); // first_rec_group
1382        block[12] = 0xAB; // some log data
1383
1384        // Calculate and store the correct CRC-32C
1385        let crc = crc32c::crc32c(&block[..LOG_BLOCK_CHECKSUM_OFFSET]);
1386        BigEndian::write_u32(&mut block[LOG_BLOCK_CHECKSUM_OFFSET..], crc);
1387
1388        assert!(validate_log_block_checksum(&block));
1389    }
1390
1391    #[test]
1392    fn test_log_from_bytes_empty() {
1393        let result = LogFile::from_bytes(vec![]);
1394        match result {
1395            Err(e) => assert!(
1396                e.to_string().contains("too small"),
1397                "Expected 'too small' in: {e}"
1398            ),
1399            Ok(_) => panic!("Expected error for empty input"),
1400        }
1401    }
1402
1403    #[test]
1404    fn test_log_from_bytes_too_small() {
1405        let result = LogFile::from_bytes(vec![0u8; 100]);
1406        match result {
1407            Err(e) => assert!(
1408                e.to_string().contains("too small"),
1409                "Expected 'too small' in: {e}"
1410            ),
1411            Ok(_) => panic!("Expected error for 100-byte input"),
1412        }
1413    }
1414
1415    #[test]
1416    fn test_log_block_checksum_invalid() {
1417        let mut block = make_block();
1418        BigEndian::write_u32(&mut block[0..], 5);
1419        // Wrong checksum
1420        BigEndian::write_u32(&mut block[LOG_BLOCK_CHECKSUM_OFFSET..], 0xDEADDEAD);
1421
1422        assert!(!validate_log_block_checksum(&block));
1423    }
1424}