Skip to main content

idb/cli/
info.rs

1use std::io::Write;
2
3use byteorder::{BigEndian, ByteOrder};
4use colored::Colorize;
5use serde::Serialize;
6
7use crate::cli::wprintln;
8use crate::innodb::constants::*;
9use crate::innodb::page::FilHeader;
10use crate::IdbError;
11
12/// Options for the `inno info` subcommand.
13pub struct InfoOptions {
14    /// Inspect the `ibdata1` page 0 header.
15    pub ibdata: bool,
16    /// Compare `ibdata1` and redo log checkpoint LSNs.
17    pub lsn_check: bool,
18    /// MySQL data directory path (defaults to `/var/lib/mysql`).
19    pub datadir: Option<String>,
20    /// Database name (for MySQL table/index queries, requires `mysql` feature).
21    pub database: Option<String>,
22    /// Table name (for MySQL table/index queries, requires `mysql` feature).
23    pub table: Option<String>,
24    /// MySQL host for live queries.
25    pub host: Option<String>,
26    /// MySQL port for live queries.
27    pub port: Option<u16>,
28    /// MySQL user for live queries.
29    pub user: Option<String>,
30    /// MySQL password for live queries.
31    pub password: Option<String>,
32    /// Path to a MySQL defaults file (`.my.cnf`).
33    pub defaults_file: Option<String>,
34    /// Emit output as JSON.
35    pub json: bool,
36    /// Override the auto-detected page size.
37    pub page_size: Option<u32>,
38}
39
40#[derive(Serialize)]
41struct IbdataInfoJson {
42    ibdata_file: String,
43    page_checksum: u32,
44    page_number: u32,
45    page_type: u16,
46    lsn: u64,
47    flush_lsn: u64,
48    space_id: u32,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    redo_checkpoint_1_lsn: Option<u64>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    redo_checkpoint_2_lsn: Option<u64>,
53}
54
55#[derive(Serialize)]
56struct LsnCheckJson {
57    ibdata_lsn: u64,
58    redo_checkpoint_lsn: u64,
59    in_sync: bool,
60}
61
62/// Display InnoDB system-level information from the data directory or a live instance.
63///
64/// Operates in three mutually exclusive modes:
65///
66/// - **`--ibdata`**: Reads page 0 of `ibdata1` (the system tablespace) and
67///   decodes its FIL header — checksum, page type, LSN, flush LSN, and space ID.
68///   Also attempts to read checkpoint LSNs from the redo log, trying the
69///   MySQL 8.0.30+ `#innodb_redo/#ib_redo*` directory first, then falling back
70///   to the legacy `ib_logfile0`. This gives a quick snapshot of the system
71///   tablespace state without starting MySQL.
72///
73/// - **`--lsn-check`**: Compares the LSN from the `ibdata1` page 0 header with
74///   the latest redo log checkpoint LSN. If they match, the system is "in sync";
75///   if not, the difference in bytes is reported. This is useful for diagnosing
76///   whether InnoDB shut down cleanly or needs crash recovery.
77///
78/// - **`-D <database> -t <table>`** (requires the `mysql` feature): Connects to
79///   a live MySQL instance and queries `INFORMATION_SCHEMA.INNODB_TABLES` and
80///   `INNODB_INDEXES` for the space ID, table ID, index names, and root page
81///   numbers. Also parses `SHOW ENGINE INNODB STATUS` for the current log
82///   sequence number and transaction ID counter. Connection parameters come
83///   from CLI flags or a `.my.cnf` defaults file.
84pub fn execute(opts: &InfoOptions, writer: &mut dyn Write) -> Result<(), IdbError> {
85    if opts.ibdata || opts.lsn_check {
86        let datadir = opts.datadir.as_deref().unwrap_or("/var/lib/mysql");
87        let datadir_path = std::path::Path::new(datadir);
88
89        if !datadir_path.is_dir() {
90            return Err(IdbError::Argument(format!(
91                "Data directory does not exist: {}",
92                datadir
93            )));
94        }
95
96        if opts.ibdata {
97            return execute_ibdata(opts, datadir_path, writer);
98        }
99        if opts.lsn_check {
100            return execute_lsn_check(opts, datadir_path, writer);
101        }
102    }
103
104    #[cfg(feature = "mysql")]
105    {
106        if opts.database.is_some() || opts.table.is_some() {
107            return execute_table_info(opts, writer);
108        }
109    }
110
111    #[cfg(not(feature = "mysql"))]
112    {
113        if opts.database.is_some() || opts.table.is_some() {
114            return Err(IdbError::Argument(
115                "MySQL support not compiled. Rebuild with: cargo build --features mysql"
116                    .to_string(),
117            ));
118        }
119    }
120
121    // No mode specified, show help
122    wprintln!(writer, "Usage:")?;
123    wprintln!(
124        writer,
125        "  idb info --ibdata -d <datadir>          Read ibdata1 page 0 header"
126    )?;
127    wprintln!(
128        writer,
129        "  idb info --lsn-check -d <datadir>       Compare ibdata1 and redo log LSNs"
130    )?;
131    wprintln!(writer, "  idb info -D <database> -t <table>       Show table/index info (requires --features mysql)")?;
132    Ok(())
133}
134
135fn execute_ibdata(
136    opts: &InfoOptions,
137    datadir: &std::path::Path,
138    writer: &mut dyn Write,
139) -> Result<(), IdbError> {
140    let ibdata_path = datadir.join("ibdata1");
141    if !ibdata_path.exists() {
142        return Err(IdbError::Io(format!(
143            "ibdata1 not found in {}",
144            datadir.display()
145        )));
146    }
147
148    // Read page 0 of ibdata1
149    let page0 = read_file_bytes(&ibdata_path, 0, SIZE_PAGE_DEFAULT as usize)?;
150    let header = FilHeader::parse(&page0)
151        .ok_or_else(|| IdbError::Parse("Cannot parse ibdata1 page 0 FIL header".to_string()))?;
152
153    // Try to read redo log checkpoint LSNs
154    let (cp1_lsn, cp2_lsn) = read_redo_checkpoint_lsns(datadir);
155
156    if opts.json {
157        let info = IbdataInfoJson {
158            ibdata_file: ibdata_path.display().to_string(),
159            page_checksum: header.checksum,
160            page_number: header.page_number,
161            page_type: header.page_type.as_u16(),
162            lsn: header.lsn,
163            flush_lsn: header.flush_lsn,
164            space_id: header.space_id,
165            redo_checkpoint_1_lsn: cp1_lsn,
166            redo_checkpoint_2_lsn: cp2_lsn,
167        };
168        let json = serde_json::to_string_pretty(&info)
169            .map_err(|e| IdbError::Parse(format!("JSON serialization error: {}", e)))?;
170        wprintln!(writer, "{}", json)?;
171        return Ok(());
172    }
173
174    wprintln!(writer, "{}", "ibdata1 Page 0 Header".bold())?;
175    wprintln!(writer, "  File:       {}", ibdata_path.display())?;
176    wprintln!(writer, "  Checksum:   {}", header.checksum)?;
177    wprintln!(writer, "  Page No:    {}", header.page_number)?;
178    wprintln!(
179        writer,
180        "  Page Type:  {} ({})",
181        header.page_type.as_u16(),
182        header.page_type.name()
183    )?;
184    wprintln!(writer, "  LSN:        {}", header.lsn)?;
185    wprintln!(writer, "  Flush LSN:  {}", header.flush_lsn)?;
186    wprintln!(writer, "  Space ID:   {}", header.space_id)?;
187    wprintln!(writer)?;
188
189    if let Some(lsn) = cp1_lsn {
190        wprintln!(writer, "Redo Log Checkpoint 1 LSN: {}", lsn)?;
191    }
192    if let Some(lsn) = cp2_lsn {
193        wprintln!(writer, "Redo Log Checkpoint 2 LSN: {}", lsn)?;
194    }
195
196    Ok(())
197}
198
199fn execute_lsn_check(
200    opts: &InfoOptions,
201    datadir: &std::path::Path,
202    writer: &mut dyn Write,
203) -> Result<(), IdbError> {
204    let ibdata_path = datadir.join("ibdata1");
205    if !ibdata_path.exists() {
206        return Err(IdbError::Io(format!(
207            "ibdata1 not found in {}",
208            datadir.display()
209        )));
210    }
211
212    // Read ibdata1 LSN from page 0 header (offset 16, 8 bytes)
213    let page0 = read_file_bytes(&ibdata_path, 0, SIZE_PAGE_DEFAULT as usize)?;
214    let ibdata_lsn = BigEndian::read_u64(&page0[FIL_PAGE_LSN..]);
215
216    // Read redo log checkpoint LSN
217    let (cp1_lsn, _cp2_lsn) = read_redo_checkpoint_lsns(datadir);
218
219    let redo_lsn = cp1_lsn.unwrap_or(0);
220    let in_sync = ibdata_lsn == redo_lsn;
221
222    if opts.json {
223        let check = LsnCheckJson {
224            ibdata_lsn,
225            redo_checkpoint_lsn: redo_lsn,
226            in_sync,
227        };
228        let json = serde_json::to_string_pretty(&check)
229            .map_err(|e| IdbError::Parse(format!("JSON serialization error: {}", e)))?;
230        wprintln!(writer, "{}", json)?;
231        return Ok(());
232    }
233
234    wprintln!(writer, "{}", "LSN Sync Check".bold())?;
235    wprintln!(writer, "  ibdata1 LSN:          {}", ibdata_lsn)?;
236    wprintln!(writer, "  Redo checkpoint LSN:  {}", redo_lsn)?;
237
238    if in_sync {
239        wprintln!(writer, "  Status: {}", "IN SYNC".green())?;
240    } else {
241        wprintln!(writer, "  Status: {}", "OUT OF SYNC".red())?;
242        wprintln!(
243            writer,
244            "  Difference: {} bytes",
245            ibdata_lsn.abs_diff(redo_lsn)
246        )?;
247    }
248
249    Ok(())
250}
251
252/// Read checkpoint LSNs from redo log files.
253///
254/// Tries MySQL 8.0+ (#innodb_redo/#ib_redo*) format first, falls back to
255/// legacy ib_logfile0.
256fn read_redo_checkpoint_lsns(datadir: &std::path::Path) -> (Option<u64>, Option<u64>) {
257    // Checkpoint 1 is at offset 512+8=520 in ib_logfile0 (LSN field at +8 within checkpoint)
258    // Checkpoint 2 is at offset 1536+8=1544
259    const CP1_OFFSET: u64 = 512 + 8;
260    const CP2_OFFSET: u64 = 1536 + 8;
261
262    // Try MySQL 8.0.30+ redo log in #innodb_redo/ directory
263    let redo_dir = datadir.join("#innodb_redo");
264    if redo_dir.is_dir() {
265        // Find the first #ib_redo* file
266        if let Ok(entries) = std::fs::read_dir(&redo_dir) {
267            let mut redo_files: Vec<_> = entries
268                .filter_map(|e| e.ok())
269                .filter(|e| e.file_name().to_string_lossy().starts_with("#ib_redo"))
270                .collect();
271            redo_files.sort_by_key(|e| e.file_name());
272            if let Some(first) = redo_files.first() {
273                let path = first.path();
274                let cp1 = read_u64_at(&path, CP1_OFFSET);
275                let cp2 = read_u64_at(&path, CP2_OFFSET);
276                return (cp1, cp2);
277            }
278        }
279    }
280
281    // Try legacy ib_logfile0
282    let logfile0 = datadir.join("ib_logfile0");
283    if logfile0.exists() {
284        let cp1 = read_u64_at(&logfile0, CP1_OFFSET);
285        let cp2 = read_u64_at(&logfile0, CP2_OFFSET);
286        return (cp1, cp2);
287    }
288
289    (None, None)
290}
291
292fn read_file_bytes(
293    path: &std::path::Path,
294    offset: u64,
295    length: usize,
296) -> Result<Vec<u8>, IdbError> {
297    use std::io::{Read, Seek, SeekFrom};
298
299    let mut file = std::fs::File::open(path)
300        .map_err(|e| IdbError::Io(format!("Cannot open {}: {}", path.display(), e)))?;
301
302    file.seek(SeekFrom::Start(offset))
303        .map_err(|e| IdbError::Io(format!("Cannot seek in {}: {}", path.display(), e)))?;
304
305    let mut buf = vec![0u8; length];
306    file.read_exact(&mut buf)
307        .map_err(|e| IdbError::Io(format!("Cannot read from {}: {}", path.display(), e)))?;
308
309    Ok(buf)
310}
311
312fn read_u64_at(path: &std::path::Path, offset: u64) -> Option<u64> {
313    let bytes = read_file_bytes(path, offset, 8).ok()?;
314    Some(BigEndian::read_u64(&bytes))
315}
316
317// MySQL connection mode (feature-gated)
318#[cfg(feature = "mysql")]
319fn execute_table_info(opts: &InfoOptions, writer: &mut dyn Write) -> Result<(), IdbError> {
320    use mysql_async::prelude::*;
321
322    let database = opts
323        .database
324        .as_deref()
325        .ok_or_else(|| IdbError::Argument("Database name required (-D <database>)".to_string()))?;
326    let table = opts
327        .table
328        .as_deref()
329        .ok_or_else(|| IdbError::Argument("Table name required (-t <table>)".to_string()))?;
330
331    // Build MySQL config from CLI args or defaults file
332    let mut config = crate::util::mysql::MysqlConfig::default();
333
334    // Try to load defaults file
335    if let Some(ref df) = opts.defaults_file {
336        if let Some(parsed) = crate::util::mysql::parse_defaults_file(std::path::Path::new(df)) {
337            config = parsed;
338        }
339    } else if let Some(df) = crate::util::mysql::find_defaults_file() {
340        if let Some(parsed) = crate::util::mysql::parse_defaults_file(&df) {
341            config = parsed;
342        }
343    }
344
345    // CLI args override defaults file
346    if let Some(ref h) = opts.host {
347        config.host = h.clone();
348    }
349    if let Some(p) = opts.port {
350        config.port = p;
351    }
352    if let Some(ref u) = opts.user {
353        config.user = u.clone();
354    }
355    if opts.password.is_some() {
356        config.password = opts.password.clone();
357    }
358    config.database = Some(database.to_string());
359
360    let rt = tokio::runtime::Builder::new_current_thread()
361        .enable_all()
362        .build()
363        .map_err(|e| IdbError::Io(format!("Cannot create async runtime: {}", e)))?;
364
365    rt.block_on(async {
366        let pool = mysql_async::Pool::new(config.to_opts());
367        let mut conn = pool
368            .get_conn()
369            .await
370            .map_err(|e| IdbError::Io(format!("MySQL connection failed: {}", e)))?;
371
372        // Query table info — try MySQL 8.0+ tables first
373        let table_query = format!(
374            "SELECT SPACE, TABLE_ID FROM information_schema.innodb_tables WHERE NAME = '{}/{}'",
375            database, table
376        );
377        let table_rows: Vec<(u64, u64)> = conn
378            .query(&table_query)
379            .await
380            .unwrap_or_default();
381
382        if table_rows.is_empty() {
383            // Try MySQL 5.7 system tables
384            let sys_query = format!(
385                "SELECT SPACE, TABLE_ID FROM information_schema.innodb_sys_tables WHERE NAME = '{}/{}'",
386                database, table
387            );
388            let sys_rows: Vec<(u64, u64)> = conn
389                .query(&sys_query)
390                .await
391                .unwrap_or_default();
392
393            if sys_rows.is_empty() {
394                wprintln!(writer, "Table {}.{} not found in InnoDB system tables.", database, table)?;
395                pool.disconnect().await.ok();
396                return Ok(());
397            }
398
399            print_table_info(writer, database, table, &sys_rows)?;
400        } else {
401            print_table_info(writer, database, table, &table_rows)?;
402        }
403
404        // Query index info
405        let idx_query = format!(
406            "SELECT NAME, INDEX_ID, PAGE_NO FROM information_schema.innodb_indexes \
407             WHERE TABLE_ID = (SELECT TABLE_ID FROM information_schema.innodb_tables WHERE NAME = '{}/{}')",
408            database, table
409        );
410        let idx_rows: Vec<(String, u64, u64)> = conn
411            .query(&idx_query)
412            .await
413            .unwrap_or_default();
414
415        if !idx_rows.is_empty() {
416            wprintln!(writer)?;
417            wprintln!(writer, "{}", "Indexes:".bold())?;
418            for (name, index_id, root_page) in &idx_rows {
419                wprintln!(writer, "  {} (index_id={}, root_page={})", name, index_id, root_page)?;
420            }
421        }
422
423        // Parse SHOW ENGINE INNODB STATUS for key metrics
424        let status_rows: Vec<(String, String, String)> = conn
425            .query("SHOW ENGINE INNODB STATUS")
426            .await
427            .unwrap_or_default();
428
429        if let Some((_type, _name, status)) = status_rows.first() {
430            wprintln!(writer)?;
431            wprintln!(writer, "{}", "InnoDB Status:".bold())?;
432            for line in status.lines() {
433                if line.starts_with("Log sequence number") || line.starts_with("Log flushed up to") {
434                    wprintln!(writer, "  {}", line.trim())?;
435                }
436                if line.starts_with("Trx id counter") {
437                    wprintln!(writer, "  {}", line.trim())?;
438                }
439            }
440        }
441
442        pool.disconnect().await.ok();
443        Ok(())
444    })
445}
446
447#[cfg(feature = "mysql")]
448fn print_table_info(
449    writer: &mut dyn Write,
450    database: &str,
451    table: &str,
452    rows: &[(u64, u64)],
453) -> Result<(), IdbError> {
454    wprintln!(
455        writer,
456        "{}",
457        format!("Table: {}.{}", database, table).bold()
458    )?;
459    for (space_id, table_id) in rows {
460        wprintln!(writer, "  Space ID:  {}", space_id)?;
461        wprintln!(writer, "  Table ID:  {}", table_id)?;
462    }
463    Ok(())
464}