Skip to main content

idb/cli/
pages.rs

1use std::io::Write;
2
3use colored::Colorize;
4use serde::Serialize;
5
6use crate::cli::{wprint, wprintln};
7use crate::innodb::checksum;
8use crate::innodb::compression;
9use crate::innodb::encryption;
10use crate::innodb::index::{FsegHeader, IndexHeader, SystemRecords};
11use crate::innodb::lob::{BlobPageHeader, LobFirstPageHeader};
12use crate::innodb::page::{FilHeader, FspHeader};
13use crate::innodb::page_types::PageType;
14use crate::innodb::tablespace::Tablespace;
15use crate::innodb::undo::{UndoPageHeader, UndoSegmentHeader};
16use crate::util::hex::format_offset;
17use crate::IdbError;
18
19/// Options for the `inno pages` subcommand.
20pub struct PagesOptions {
21    /// Path to the InnoDB tablespace file (.ibd).
22    pub file: String,
23    /// If set, display only this specific page number.
24    pub page: Option<u64>,
25    /// Show additional detail (checksum status, FSEG internals).
26    pub verbose: bool,
27    /// Include empty/allocated pages in the output.
28    pub show_empty: bool,
29    /// Use compact one-line-per-page list format.
30    pub list_mode: bool,
31    /// Filter output to pages matching this type name (e.g. "INDEX", "UNDO").
32    pub filter_type: Option<String>,
33    /// Override the auto-detected page size.
34    pub page_size: Option<u32>,
35    /// Emit output as JSON.
36    pub json: bool,
37    /// Path to MySQL keyring file for decrypting encrypted tablespaces.
38    pub keyring: Option<String>,
39}
40
41/// JSON-serializable detailed page info.
42#[derive(Serialize)]
43struct PageDetailJson {
44    page_number: u64,
45    header: FilHeader,
46    page_type_name: String,
47    page_type_description: String,
48    byte_start: u64,
49    byte_end: u64,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    index_header: Option<IndexHeader>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    fsp_header: Option<FspHeader>,
54}
55
56/// Perform deep structural analysis of pages in an InnoDB tablespace.
57///
58/// Unlike `parse` which only decodes FIL headers, this command dives into
59/// page-type-specific internal structures:
60///
61/// - **INDEX pages** (type 17855): Decodes the index header (index ID, B+Tree
62///   level, record counts, heap top, garbage bytes, insert direction), FSEG
63///   inode pointers for leaf and non-leaf segments, and infimum/supremum system
64///   record metadata.
65/// - **UNDO pages** (type 2): Shows the undo page header (type, start/free
66///   offsets, used bytes) and segment header (state, last log offset).
67/// - **BLOB/ZBLOB pages** (types 10, 11, 12): Shows data length and next-page
68///   chain pointer for old-style externally stored columns.
69/// - **LOB_FIRST pages** (MySQL 8.0+): Shows version, flags, total data length,
70///   and transaction ID for new-style LOB first pages.
71/// - **Page 0** (FSP_HDR): Shows extended FSP header fields including
72///   compression algorithm, encryption flags, and first unused segment ID.
73///
74/// In **list mode** (`-l`), output is a compact one-line-per-page summary
75/// showing page number, type, description, and byte offset. In **detail mode**
76/// (the default), each page gets a full multi-section breakdown. Use `-t` to
77/// filter by page type name (supports aliases like "undo", "blob", "lob",
78/// "sdi", "compressed", "encrypted").
79pub fn execute(opts: &PagesOptions, writer: &mut dyn Write) -> Result<(), IdbError> {
80    let mut ts = match opts.page_size {
81        Some(ps) => Tablespace::open_with_page_size(&opts.file, ps)?,
82        None => Tablespace::open(&opts.file)?,
83    };
84
85    if let Some(ref keyring_path) = opts.keyring {
86        crate::cli::setup_decryption(&mut ts, keyring_path)?;
87    }
88
89    let page_size = ts.page_size();
90
91    if opts.json {
92        return execute_json(opts, &mut ts, page_size, writer);
93    }
94
95    if let Some(page_num) = opts.page {
96        let page_data = ts.read_page(page_num)?;
97        print_full_page(&page_data, page_num, page_size, opts.verbose, writer)?;
98        return Ok(());
99    }
100
101    // Print FSP header unless filtering by type
102    if opts.filter_type.is_none() {
103        let page0 = ts.read_page(0)?;
104        if let Some(fsp) = FspHeader::parse(&page0) {
105            print_fsp_header_detail(&fsp, &page0, opts.verbose, ts.vendor_info(), writer)?;
106        }
107    }
108
109    for page_num in 0..ts.page_count() {
110        let page_data = ts.read_page(page_num)?;
111        let header = match FilHeader::parse(&page_data) {
112            Some(h) => h,
113            None => continue,
114        };
115
116        // Skip empty pages unless --show-empty
117        if !opts.show_empty && header.checksum == 0 && header.page_type == PageType::Allocated {
118            continue;
119        }
120
121        // Filter by type
122        if let Some(ref filter) = opts.filter_type {
123            if !matches_page_type_filter(&header.page_type, filter) {
124                continue;
125            }
126        }
127
128        if opts.list_mode {
129            print_list_line(&page_data, page_num, page_size, writer)?;
130        } else {
131            print_full_page(&page_data, page_num, page_size, opts.verbose, writer)?;
132        }
133    }
134
135    Ok(())
136}
137
138/// Execute pages in JSON output mode.
139fn execute_json(
140    opts: &PagesOptions,
141    ts: &mut Tablespace,
142    page_size: u32,
143    writer: &mut dyn Write,
144) -> Result<(), IdbError> {
145    let mut pages = Vec::new();
146
147    let range: Box<dyn Iterator<Item = u64>> = if let Some(p) = opts.page {
148        Box::new(std::iter::once(p))
149    } else {
150        Box::new(0..ts.page_count())
151    };
152
153    for page_num in range {
154        let page_data = ts.read_page(page_num)?;
155        let header = match FilHeader::parse(&page_data) {
156            Some(h) => h,
157            None => continue,
158        };
159
160        if !opts.show_empty && header.checksum == 0 && header.page_type == PageType::Allocated {
161            continue;
162        }
163
164        if let Some(ref filter) = opts.filter_type {
165            if !matches_page_type_filter(&header.page_type, filter) {
166                continue;
167            }
168        }
169
170        let pt = header.page_type;
171        let byte_start = page_num * page_size as u64;
172
173        let index_header = if pt == PageType::Index {
174            IndexHeader::parse(&page_data)
175        } else {
176            None
177        };
178
179        let fsp_header = if page_num == 0 {
180            FspHeader::parse(&page_data)
181        } else {
182            None
183        };
184
185        pages.push(PageDetailJson {
186            page_number: page_num,
187            page_type_name: pt.name().to_string(),
188            page_type_description: pt.description().to_string(),
189            byte_start,
190            byte_end: byte_start + page_size as u64,
191            header,
192            index_header,
193            fsp_header,
194        });
195    }
196
197    let json = serde_json::to_string_pretty(&pages)
198        .map_err(|e| IdbError::Parse(format!("JSON serialization error: {}", e)))?;
199    wprintln!(writer, "{}", json)?;
200    Ok(())
201}
202
203/// Print a compact one-line summary per page (list mode).
204fn print_list_line(
205    page_data: &[u8],
206    page_num: u64,
207    page_size: u32,
208    writer: &mut dyn Write,
209) -> Result<(), IdbError> {
210    let header = match FilHeader::parse(page_data) {
211        Some(h) => h,
212        None => return Ok(()),
213    };
214
215    let pt = header.page_type;
216    let byte_start = page_num * page_size as u64;
217
218    wprint!(
219        writer,
220        "-- Page {} - {}: {}",
221        page_num,
222        pt.name(),
223        pt.description()
224    )?;
225
226    if pt == PageType::Index {
227        if let Some(idx) = IndexHeader::parse(page_data) {
228            wprint!(writer, ", Index ID: {}", idx.index_id)?;
229        }
230    }
231
232    wprintln!(writer, ", Byte Start: {}", format_offset(byte_start))?;
233    Ok(())
234}
235
236/// Print full detailed information about a page.
237fn print_full_page(
238    page_data: &[u8],
239    page_num: u64,
240    page_size: u32,
241    verbose: bool,
242    writer: &mut dyn Write,
243) -> Result<(), IdbError> {
244    let header = match FilHeader::parse(page_data) {
245        Some(h) => h,
246        None => {
247            eprintln!("Could not parse FIL header for page {}", page_num);
248            return Ok(());
249        }
250    };
251
252    let byte_start = page_num * page_size as u64;
253    let byte_end = byte_start + page_size as u64;
254    let pt = header.page_type;
255
256    // FIL Header
257    wprintln!(writer)?;
258    wprintln!(writer, "=== HEADER: Page {}", header.page_number)?;
259    wprintln!(writer, "Byte Start: {}", format_offset(byte_start))?;
260    wprintln!(
261        writer,
262        "Page Type: {}\n-- {}: {} - {}",
263        pt.as_u16(),
264        pt.name(),
265        pt.description(),
266        pt.usage()
267    )?;
268
269    wprint!(writer, "Prev Page: ")?;
270    if !header.has_prev() {
271        wprintln!(writer, "Not used.")?;
272    } else {
273        wprintln!(writer, "{}", header.prev_page)?;
274    }
275
276    wprint!(writer, "Next Page: ")?;
277    if !header.has_next() {
278        wprintln!(writer, "Not used.")?;
279    } else {
280        wprintln!(writer, "{}", header.next_page)?;
281    }
282
283    wprintln!(writer, "LSN: {}", header.lsn)?;
284    wprintln!(writer, "Space ID: {}", header.space_id)?;
285    wprintln!(writer, "Checksum: {}", header.checksum)?;
286
287    // INDEX-specific headers
288    if pt == PageType::Index {
289        if let Some(idx) = IndexHeader::parse(page_data) {
290            wprintln!(writer)?;
291            print_index_header(&idx, header.page_number, verbose, writer)?;
292
293            wprintln!(writer)?;
294            print_fseg_headers(page_data, header.page_number, &idx, verbose, writer)?;
295
296            wprintln!(writer)?;
297            print_system_records(page_data, header.page_number, writer)?;
298        }
299    }
300
301    // BLOB page-specific headers (old-style)
302    if matches!(pt, PageType::Blob | PageType::ZBlob | PageType::ZBlob2) {
303        if let Some(blob_hdr) = BlobPageHeader::parse(page_data) {
304            wprintln!(writer)?;
305            wprintln!(writer, "=== BLOB Header: Page {}", header.page_number)?;
306            wprintln!(writer, "Data Length: {} bytes", blob_hdr.part_len)?;
307            if blob_hdr.has_next() {
308                wprintln!(writer, "Next BLOB Page: {}", blob_hdr.next_page_no)?;
309            } else {
310                wprintln!(writer, "Next BLOB Page: None (last in chain)")?;
311            }
312        }
313    }
314
315    // LOB first page header (MySQL 8.0+ new-style)
316    if pt == PageType::LobFirst {
317        if let Some(lob_hdr) = LobFirstPageHeader::parse(page_data) {
318            wprintln!(writer)?;
319            wprintln!(
320                writer,
321                "=== LOB First Page Header: Page {}",
322                header.page_number
323            )?;
324            wprintln!(writer, "Version: {}", lob_hdr.version)?;
325            wprintln!(writer, "Flags: {}", lob_hdr.flags)?;
326            wprintln!(writer, "Total Data Length: {} bytes", lob_hdr.data_len)?;
327            if lob_hdr.trx_id > 0 {
328                wprintln!(writer, "Transaction ID: {}", lob_hdr.trx_id)?;
329            }
330        }
331    }
332
333    // Undo log page-specific headers
334    if pt == PageType::UndoLog {
335        if let Some(undo_hdr) = UndoPageHeader::parse(page_data) {
336            wprintln!(writer)?;
337            wprintln!(writer, "=== UNDO Header: Page {}", header.page_number)?;
338            wprintln!(
339                writer,
340                "Undo Type: {} ({})",
341                undo_hdr.page_type.name(),
342                undo_hdr.page_type.name()
343            )?;
344            wprintln!(writer, "Log Start Offset: {}", undo_hdr.start)?;
345            wprintln!(writer, "Free Offset: {}", undo_hdr.free)?;
346            wprintln!(
347                writer,
348                "Used Bytes: {}",
349                undo_hdr.free.saturating_sub(undo_hdr.start)
350            )?;
351
352            if let Some(seg_hdr) = UndoSegmentHeader::parse(page_data) {
353                wprintln!(writer, "Segment State: {}", seg_hdr.state.name())?;
354                wprintln!(writer, "Last Log Offset: {}", seg_hdr.last_log)?;
355            }
356        }
357    }
358
359    // FIL Trailer
360    wprintln!(writer)?;
361    let ps = page_size as usize;
362    if page_data.len() >= ps {
363        let trailer_offset = ps - 8;
364        if let Some(trailer) = crate::innodb::page::FilTrailer::parse(&page_data[trailer_offset..])
365        {
366            wprintln!(writer, "=== TRAILER: Page {}", header.page_number)?;
367            wprintln!(writer, "Old-style Checksum: {}", trailer.checksum)?;
368            wprintln!(writer, "Low 32 bits of LSN: {}", trailer.lsn_low32)?;
369            wprintln!(writer, "Byte End: {}", format_offset(byte_end))?;
370
371            if verbose {
372                let csum_result = checksum::validate_checksum(page_data, page_size, None);
373                let status = if csum_result.valid {
374                    "OK".green().to_string()
375                } else {
376                    "MISMATCH".red().to_string()
377                };
378                wprintln!(
379                    writer,
380                    "Checksum Status: {} ({:?})",
381                    status,
382                    csum_result.algorithm
383                )?;
384
385                let lsn_valid = checksum::validate_lsn(page_data, page_size);
386                let lsn_status = if lsn_valid {
387                    "OK".green().to_string()
388                } else {
389                    "MISMATCH".red().to_string()
390                };
391                wprintln!(writer, "LSN Consistency: {}", lsn_status)?;
392            }
393        }
394    }
395
396    Ok(())
397}
398
399/// Print the INDEX page header details.
400fn print_index_header(
401    idx: &IndexHeader,
402    page_num: u32,
403    verbose: bool,
404    writer: &mut dyn Write,
405) -> Result<(), IdbError> {
406    wprintln!(writer, "=== INDEX Header: Page {}", page_num)?;
407    wprintln!(writer, "Index ID: {}", idx.index_id)?;
408    wprintln!(writer, "Node Level: {}", idx.level)?;
409
410    if idx.max_trx_id > 0 {
411        wprintln!(writer, "Max Transaction ID: {}", idx.max_trx_id)?;
412    } else {
413        wprintln!(writer, "-- Secondary Index")?;
414    }
415
416    wprintln!(writer, "Directory Slots: {}", idx.n_dir_slots)?;
417    if verbose {
418        wprintln!(writer, "-- Number of slots in page directory")?;
419    }
420
421    wprintln!(writer, "Heap Top: {}", idx.heap_top)?;
422    if verbose {
423        wprintln!(writer, "-- Pointer to record heap top")?;
424    }
425
426    wprintln!(writer, "Records in Page: {}", idx.n_recs)?;
427    wprintln!(
428        writer,
429        "Records in Heap: {} (compact: {})",
430        idx.n_heap(),
431        idx.is_compact()
432    )?;
433    if verbose {
434        wprintln!(writer, "-- Number of records in heap")?;
435    }
436
437    wprintln!(writer, "Start of Free Record List: {}", idx.free)?;
438    wprintln!(writer, "Garbage Bytes: {}", idx.garbage)?;
439    if verbose {
440        wprintln!(writer, "-- Number of bytes in deleted records.")?;
441    }
442
443    wprintln!(writer, "Last Insert: {}", idx.last_insert)?;
444    wprintln!(
445        writer,
446        "Last Insert Direction: {} - {}",
447        idx.direction,
448        idx.direction_name()
449    )?;
450    wprintln!(writer, "Inserts in this direction: {}", idx.n_direction)?;
451    if verbose {
452        wprintln!(
453            writer,
454            "-- Number of consecutive inserts in this direction."
455        )?;
456    }
457
458    Ok(())
459}
460
461/// Print FSEG (file segment) header details.
462fn print_fseg_headers(
463    page_data: &[u8],
464    page_num: u32,
465    idx: &IndexHeader,
466    verbose: bool,
467    writer: &mut dyn Write,
468) -> Result<(), IdbError> {
469    wprintln!(
470        writer,
471        "=== FSEG_HDR - File Segment Header: Page {}",
472        page_num
473    )?;
474
475    if let Some(leaf) = FsegHeader::parse_leaf(page_data) {
476        wprintln!(writer, "Inode Space ID: {}", leaf.space_id)?;
477        wprintln!(writer, "Inode Page Number: {}", leaf.page_no)?;
478        wprintln!(writer, "Inode Offset: {}", leaf.offset)?;
479    }
480
481    if idx.is_leaf() {
482        if let Some(internal) = FsegHeader::parse_internal(page_data) {
483            wprintln!(writer, "Non-leaf Space ID: {}", internal.space_id)?;
484            if verbose {
485                wprintln!(writer, "Non-leaf Page Number: {}", internal.page_no)?;
486                wprintln!(writer, "Non-leaf Offset: {}", internal.offset)?;
487            }
488        }
489    }
490
491    Ok(())
492}
493
494/// Print system records (infimum/supremum) info.
495fn print_system_records(
496    page_data: &[u8],
497    page_num: u32,
498    writer: &mut dyn Write,
499) -> Result<(), IdbError> {
500    let sys = match SystemRecords::parse(page_data) {
501        Some(s) => s,
502        None => return Ok(()),
503    };
504
505    wprintln!(writer, "=== INDEX System Records: Page {}", page_num)?;
506    wprintln!(
507        writer,
508        "Index Record Status: {} - (Decimal: {}) {}",
509        sys.rec_status,
510        sys.rec_status,
511        sys.rec_status_name()
512    )?;
513    wprintln!(writer, "Number of records owned: {}", sys.n_owned)?;
514    wprintln!(writer, "Deleted: {}", if sys.deleted { "1" } else { "0" })?;
515    wprintln!(writer, "Heap Number: {}", sys.heap_no)?;
516    wprintln!(writer, "Next Record Offset (Infimum): {}", sys.infimum_next)?;
517    wprintln!(
518        writer,
519        "Next Record Offset (Supremum): {}",
520        sys.supremum_next
521    )?;
522    wprintln!(
523        writer,
524        "Left-most node on non-leaf level: {}",
525        if sys.min_rec { "1" } else { "0" }
526    )?;
527
528    Ok(())
529}
530
531/// Print detailed FSP header with additional fields.
532fn print_fsp_header_detail(
533    fsp: &FspHeader,
534    page0: &[u8],
535    verbose: bool,
536    vendor_info: &crate::innodb::vendor::VendorInfo,
537    writer: &mut dyn Write,
538) -> Result<(), IdbError> {
539    wprintln!(writer, "=== File Header")?;
540    wprintln!(writer, "Vendor: {}", vendor_info)?;
541    wprintln!(writer, "Space ID: {}", fsp.space_id)?;
542    if verbose {
543        wprintln!(writer, "-- Offset 38, Length 4")?;
544    }
545    wprintln!(writer, "Size: {}", fsp.size)?;
546    wprintln!(writer, "Flags: {}", fsp.flags)?;
547    wprintln!(
548        writer,
549        "Page Free Limit: {} (this should always be 64 on a single-table file)",
550        fsp.free_limit
551    )?;
552
553    // Compression and encryption detection from flags
554    let comp = compression::detect_compression(fsp.flags, Some(vendor_info));
555    let enc = encryption::detect_encryption(fsp.flags, Some(vendor_info));
556    if comp != compression::CompressionAlgorithm::None {
557        wprintln!(writer, "Compression: {}", comp)?;
558    }
559    if enc != encryption::EncryptionAlgorithm::None {
560        wprintln!(writer, "Encryption: {}", enc)?;
561
562        // Display detailed encryption info if available
563        if let Some(info) = encryption::parse_encryption_info(page0, fsp.page_size_from_flags_with_vendor(vendor_info)) {
564            let version_desc = match info.magic_version {
565                1 => "V1",
566                2 => "V2",
567                3 => "V3 (MySQL 8.0.5+)",
568                _ => "Unknown",
569            };
570            wprintln!(writer, "  Master Key ID: {}", info.master_key_id)?;
571            wprintln!(writer, "  Server UUID:   {}", info.server_uuid)?;
572            wprintln!(writer, "  Magic:         {}", version_desc)?;
573        }
574    }
575
576    // Try to read the first unused segment ID (at FSP offset 72, 8 bytes)
577    let seg_id_offset = crate::innodb::constants::FIL_PAGE_DATA + 72;
578    if page0.len() >= seg_id_offset + 8 {
579        use byteorder::ByteOrder;
580        let seg_id = byteorder::BigEndian::read_u64(&page0[seg_id_offset..]);
581        wprintln!(writer, "First Unused Segment ID: {}", seg_id)?;
582    }
583
584    Ok(())
585}
586
587/// Check if a page type matches the user-provided filter string.
588///
589/// Matches against the page type name (case-insensitive). Supports
590/// short aliases like "index", "undo", "blob", "sdi", etc.
591fn matches_page_type_filter(page_type: &PageType, filter: &str) -> bool {
592    let filter_upper = filter.to_uppercase();
593    let type_name = page_type.name();
594
595    // Exact match on type name
596    if type_name == filter_upper {
597        return true;
598    }
599
600    // Common aliases and prefix matching
601    match filter_upper.as_str() {
602        "UNDO" => *page_type == PageType::UndoLog,
603        "BLOB" => matches!(
604            page_type,
605            PageType::Blob | PageType::ZBlob | PageType::ZBlob2
606        ),
607        "LOB" => matches!(
608            page_type,
609            PageType::LobIndex | PageType::LobData | PageType::LobFirst
610        ),
611        "SDI" => matches!(page_type, PageType::Sdi | PageType::SdiBlob),
612        "COMPRESSED" | "COMP" => matches!(
613            page_type,
614            PageType::Compressed
615                | PageType::CompressedEncrypted
616                | PageType::PageCompressed
617                | PageType::PageCompressedEncrypted
618        ),
619        "ENCRYPTED" | "ENC" => matches!(
620            page_type,
621            PageType::Encrypted
622                | PageType::CompressedEncrypted
623                | PageType::EncryptedRtree
624                | PageType::PageCompressedEncrypted
625        ),
626        "INSTANT" => *page_type == PageType::Instant,
627        _ => type_name.contains(&filter_upper),
628    }
629}