Skip to main content

idb/cli/
simulate.rs

1//! CLI implementation for the `inno simulate` subcommand.
2//!
3//! Simulates InnoDB crash recovery levels 1-6 (`innodb_force_recovery`) to
4//! predict data recoverability at each level without modifying any files.
5
6use std::io::Write;
7use std::path::Path;
8
9use rayon::prelude::*;
10
11use crate::cli::{create_progress_bar, csv_escape, open_tablespace, setup_decryption, wprintln};
12use crate::innodb::sdi;
13use crate::innodb::simulate::{self, SimulationReport};
14use crate::util::fs::find_tablespace_files;
15use crate::IdbError;
16
17/// Options for the `inno simulate` subcommand.
18pub struct SimulateOptions {
19    /// Path to a single InnoDB tablespace file (.ibd).
20    pub file: Option<String>,
21    /// Path to a MySQL data directory (simulates all tablespaces).
22    pub datadir: Option<String>,
23    /// Show detailed analysis at a specific recovery level (1-6).
24    pub level: Option<u8>,
25    /// Show per-page details.
26    pub verbose: bool,
27    /// Output in JSON format.
28    pub json: bool,
29    /// Output in CSV format.
30    pub csv: bool,
31    /// Override page size.
32    pub page_size: Option<u32>,
33    /// Path to MySQL keyring file.
34    pub keyring: Option<String>,
35    /// Use memory-mapped I/O.
36    pub mmap: bool,
37    /// Maximum directory recursion depth.
38    pub depth: Option<u32>,
39}
40
41/// Execute the `inno simulate` subcommand.
42pub fn execute(opts: &SimulateOptions, writer: &mut dyn Write) -> Result<(), IdbError> {
43    if let Some(ref file) = opts.file {
44        execute_single(opts, file, writer)
45    } else if let Some(ref datadir) = opts.datadir {
46        execute_directory(opts, datadir, writer)
47    } else {
48        Err(IdbError::Argument(
49            "Either --file or --datadir must be specified".to_string(),
50        ))
51    }
52}
53
54/// Simulate recovery for a single tablespace file.
55fn execute_single(
56    opts: &SimulateOptions,
57    file: &str,
58    writer: &mut dyn Write,
59) -> Result<(), IdbError> {
60    let mut ts = open_tablespace(file, opts.page_size, opts.mmap)?;
61    if let Some(ref kp) = opts.keyring {
62        setup_decryption(&mut ts, kp)?;
63    }
64
65    // Try to extract SDI for name resolution (soft-fail)
66    let sdi_json = extract_sdi_json(&mut ts);
67
68    let report = simulate::simulate_recovery(&mut ts, sdi_json.as_deref(), file, opts.verbose)?;
69
70    if opts.json {
71        let json =
72            serde_json::to_string_pretty(&report).map_err(|e| IdbError::Parse(e.to_string()))?;
73        wprintln!(writer, "{}", json)?;
74    } else if opts.csv {
75        print_csv(writer, &[report], opts.level)?;
76    } else {
77        print_text(writer, &report, opts.level)?;
78    }
79
80    Ok(())
81}
82
83/// Simulate recovery for all tablespaces in a directory.
84fn execute_directory(
85    opts: &SimulateOptions,
86    datadir: &str,
87    writer: &mut dyn Write,
88) -> Result<(), IdbError> {
89    let files = find_tablespace_files(Path::new(datadir), &["ibd"], opts.depth)?;
90
91    if files.is_empty() {
92        wprintln!(writer, "No .ibd files found in {}", datadir)?;
93        return Ok(());
94    }
95
96    let pb = create_progress_bar(files.len() as u64, "files");
97
98    let reports: Vec<SimulationReport> = files
99        .par_iter()
100        .filter_map(|path| {
101            let file_str = path.to_str()?;
102            let result = simulate_file(file_str, opts);
103            pb.inc(1);
104            result.ok()
105        })
106        .collect();
107
108    pb.finish_and_clear();
109
110    if opts.json {
111        let json =
112            serde_json::to_string_pretty(&reports).map_err(|e| IdbError::Parse(e.to_string()))?;
113        wprintln!(writer, "{}", json)?;
114    } else if opts.csv {
115        print_csv(writer, &reports, opts.level)?;
116    } else {
117        print_directory_text(writer, &reports, opts.level)?;
118    }
119
120    Ok(())
121}
122
123/// Simulate recovery for a single file (used in parallel directory scan).
124fn simulate_file(file: &str, opts: &SimulateOptions) -> Result<SimulationReport, IdbError> {
125    let mut ts = open_tablespace(file, opts.page_size, opts.mmap)?;
126    if let Some(ref kp) = opts.keyring {
127        setup_decryption(&mut ts, kp)?;
128    }
129    let sdi_json = extract_sdi_json(&mut ts);
130    simulate::simulate_recovery(&mut ts, sdi_json.as_deref(), file, false)
131}
132
133/// Try to extract SDI JSON from a tablespace (returns None on failure).
134fn extract_sdi_json(ts: &mut crate::innodb::tablespace::Tablespace) -> Option<String> {
135    let sdi_pages = sdi::find_sdi_pages(ts).ok()?;
136    if sdi_pages.is_empty() {
137        return None;
138    }
139    let records = sdi::extract_sdi_from_pages(ts, &sdi_pages).ok()?;
140    records
141        .into_iter()
142        .find(|r| r.sdi_type == 1)
143        .map(|r| r.data)
144}
145
146// ---------------------------------------------------------------------------
147// Text output
148// ---------------------------------------------------------------------------
149
150/// Print single-file simulation report as human-readable text.
151fn print_text(
152    writer: &mut dyn Write,
153    report: &SimulationReport,
154    filter_level: Option<u8>,
155) -> Result<(), IdbError> {
156    wprintln!(writer, "Crash Recovery Simulation: {}", report.file)?;
157    wprintln!(
158        writer,
159        "  Pages: {} total ({} intact, {} corrupt, {} empty)",
160        report.total_pages,
161        report.page_summary.intact,
162        report.page_summary.corrupt,
163        report.page_summary.empty,
164    )?;
165    wprintln!(writer, "  Vendor: {}", report.vendor)?;
166    wprintln!(writer)?;
167
168    // Recommended level
169    let plan = &report.plan;
170    wprintln!(
171        writer,
172        "  Recommended Recovery Level: {} ({})",
173        plan.recommended_level,
174        plan.levels[plan.recommended_level as usize].name,
175    )?;
176    wprintln!(writer, "  Rationale: {}", plan.rationale)?;
177    wprintln!(writer)?;
178
179    // Level comparison table
180    if filter_level.is_none() {
181        wprintln!(
182            writer,
183            "  {:>5}  {:<30}  {:>9}  {:>12}",
184            "Level",
185            "Name",
186            "Tables OK",
187            "Data at Risk"
188        )?;
189        wprintln!(
190            writer,
191            "  {:>5}  {:<30}  {:>9}  {:>12}",
192            "-----",
193            "------------------------------",
194            "---------",
195            "------------"
196        )?;
197
198        for la in &plan.levels {
199            let marker = if la.level == plan.recommended_level {
200                "*"
201            } else {
202                " "
203            };
204            let suffix = if la.level == plan.recommended_level {
205                "  <-- recommended"
206            } else {
207                ""
208            };
209            wprintln!(
210                writer,
211                "  {:>4}{}  {:<30}  {:>4}/{:<4}  {:>11.1}%{}",
212                la.level,
213                marker,
214                la.name,
215                la.tables_accessible,
216                la.total_tables,
217                la.pct_overall_risk,
218                suffix,
219            )?;
220        }
221        wprintln!(writer)?;
222    }
223
224    // Per-table impact
225    if !report.tables.is_empty() {
226        wprintln!(
227            writer,
228            "  {:<30}  {:>13}  {:>15}  {:>9}",
229            "Table",
230            "Corrupt Pages",
231            "Records at Risk",
232            "Min Level"
233        )?;
234        wprintln!(
235            writer,
236            "  {:<30}  {:>13}  {:>15}  {:>9}",
237            "------------------------------",
238            "-------------",
239            "---------------",
240            "---------"
241        )?;
242
243        for table in &report.tables {
244            let name = table.table_name.as_deref().unwrap_or("(unknown)");
245            let total_corrupt: u64 = table.indexes.iter().map(|i| i.corrupt_pages).sum();
246            let records_at_risk = table
247                .data_loss_by_level
248                .get(&1)
249                .map(|e| e.records_at_risk)
250                .unwrap_or(0);
251            // Min level needed for this table = max of levels needed for its corrupt pages
252            let min_level = if total_corrupt > 0 { 1 } else { 0 };
253
254            wprintln!(
255                writer,
256                "  {:<30}  {:>13}  {:>15}  {:>9}",
257                name,
258                total_corrupt,
259                format!("~{}", records_at_risk),
260                min_level,
261            )?;
262        }
263        wprintln!(writer)?;
264    }
265
266    // Verbose: per-page details
267    if !report.pages.is_empty() {
268        wprintln!(writer, "  Page Details:")?;
269        for p in &report.pages {
270            if p.min_recovery_level > 0 || filter_level.is_some() {
271                if let Some(fl) = filter_level {
272                    if p.min_recovery_level > fl {
273                        continue;
274                    }
275                }
276                wprintln!(
277                    writer,
278                    "    Page {:>6}  {:>12}  checksum={}  level_needed={}{}",
279                    p.page_number,
280                    p.page_type,
281                    if p.checksum_valid { "OK" } else { "FAIL" },
282                    p.min_recovery_level,
283                    p.corruption_pattern
284                        .as_ref()
285                        .map(|c| format!("  pattern={}", c))
286                        .unwrap_or_default(),
287                )?;
288            }
289        }
290        wprintln!(writer)?;
291    }
292
293    Ok(())
294}
295
296/// Print directory-level summary.
297fn print_directory_text(
298    writer: &mut dyn Write,
299    reports: &[SimulationReport],
300    filter_level: Option<u8>,
301) -> Result<(), IdbError> {
302    let total_files = reports.len();
303    let files_needing_recovery = reports
304        .iter()
305        .filter(|r| r.plan.recommended_level > 0)
306        .count();
307    let max_recommended = reports
308        .iter()
309        .map(|r| r.plan.recommended_level)
310        .max()
311        .unwrap_or(0);
312
313    wprintln!(writer, "Crash Recovery Simulation: {} files", total_files)?;
314    wprintln!(
315        writer,
316        "  Files needing recovery: {}",
317        files_needing_recovery
318    )?;
319    wprintln!(writer, "  Maximum recommended level: {}", max_recommended)?;
320    wprintln!(writer)?;
321
322    if files_needing_recovery > 0 {
323        wprintln!(
324            writer,
325            "  {:<50}  {:>7}  {:>13}  {:>15}",
326            "File",
327            "Level",
328            "Corrupt Pages",
329            "Records at Risk"
330        )?;
331        wprintln!(
332            writer,
333            "  {:<50}  {:>7}  {:>13}  {:>15}",
334            "--------------------------------------------------",
335            "-------",
336            "-------------",
337            "---------------"
338        )?;
339
340        for report in reports {
341            if report.plan.recommended_level == 0 && filter_level.is_none() {
342                continue;
343            }
344            if let Some(fl) = filter_level {
345                if report.plan.recommended_level > fl {
346                    continue;
347                }
348            }
349            let total_records_at_risk: u64 = report
350                .tables
351                .iter()
352                .filter_map(|t| t.data_loss_by_level.get(&1))
353                .map(|e| e.records_at_risk)
354                .sum();
355            wprintln!(
356                writer,
357                "  {:<50}  {:>7}  {:>13}  {:>15}",
358                report.file,
359                report.plan.recommended_level,
360                report.page_summary.corrupt,
361                format!("~{}", total_records_at_risk),
362            )?;
363        }
364        wprintln!(writer)?;
365    }
366
367    Ok(())
368}
369
370// ---------------------------------------------------------------------------
371// CSV output
372// ---------------------------------------------------------------------------
373
374/// Print CSV output for one or more reports.
375fn print_csv(
376    writer: &mut dyn Write,
377    reports: &[SimulationReport],
378    filter_level: Option<u8>,
379) -> Result<(), IdbError> {
380    wprintln!(
381        writer,
382        "file,table,index,index_id,level,accessible,corrupt_pages,records_at_risk,pct_at_risk"
383    )?;
384
385    for report in reports {
386        for table in &report.tables {
387            let table_name = table.table_name.as_deref().unwrap_or("");
388            for index in &table.indexes {
389                let index_name = index.index_name.as_deref().unwrap_or("");
390                for level in 0..=6u8 {
391                    if let Some(fl) = filter_level {
392                        if level != fl {
393                            continue;
394                        }
395                    }
396                    let records_at_risk = index
397                        .lost_records_by_level
398                        .get(&level)
399                        .copied()
400                        .unwrap_or(0);
401                    let total = index.total_records + records_at_risk;
402                    let pct = if total > 0 {
403                        (records_at_risk as f64 / total as f64) * 100.0
404                    } else {
405                        0.0
406                    };
407                    let accessible = if level == 0 && index.corrupt_pages > 0 {
408                        "false"
409                    } else {
410                        "true"
411                    };
412                    wprintln!(
413                        writer,
414                        "{},{},{},{},{},{},{},{},{:.2}",
415                        csv_escape(&report.file),
416                        csv_escape(table_name),
417                        csv_escape(index_name),
418                        index.index_id,
419                        level,
420                        accessible,
421                        index.corrupt_pages,
422                        records_at_risk,
423                        pct,
424                    )?;
425                }
426            }
427        }
428    }
429
430    Ok(())
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use crate::innodb::simulate::SimulationReport;
437
438    #[test]
439    fn test_extract_sdi_json_no_sdi() {
440        // A tablespace without SDI pages should return None
441        use crate::innodb::constants::*;
442        use crate::innodb::tablespace::Tablespace;
443        use byteorder::{BigEndian, ByteOrder};
444
445        let page_size = 16384u32;
446        let ps = page_size as usize;
447
448        // Build a minimal valid tablespace (FSP_HDR + 1 INDEX page)
449        let mut fsp = vec![0u8; ps];
450        BigEndian::write_u32(&mut fsp[FIL_PAGE_OFFSET..], 0);
451        BigEndian::write_u16(&mut fsp[FIL_PAGE_TYPE..], 8); // FSP_HDR
452        BigEndian::write_u32(&mut fsp[FIL_PAGE_SPACE_ID..], 1);
453        BigEndian::write_u64(&mut fsp[FIL_PAGE_LSN..], 1000);
454        BigEndian::write_u32(&mut fsp[ps - 4..], 1000);
455        let crc1 = crc32c::crc32c(&fsp[4..26]);
456        let crc2 = crc32c::crc32c(&fsp[38..ps - 8]);
457        BigEndian::write_u32(&mut fsp[0..4], crc1 ^ crc2);
458        BigEndian::write_u32(&mut fsp[ps - 8..ps - 4], crc1 ^ crc2);
459
460        let mut ts = Tablespace::from_bytes(fsp).unwrap();
461        assert!(extract_sdi_json(&mut ts).is_none());
462    }
463
464    #[test]
465    fn test_json_output_format() {
466        // Build a minimal report and verify JSON serialization
467        let report = SimulationReport {
468            file: "test.ibd".to_string(),
469            page_size: 16384,
470            total_pages: 10,
471            vendor: "MySQL".to_string(),
472            page_summary: simulate::PageSummary {
473                intact: 10,
474                corrupt: 0,
475                empty: 0,
476                unreadable: 0,
477            },
478            pages: Vec::new(),
479            tables: Vec::new(),
480            plan: simulate::RecoveryPlan {
481                recommended_level: 0,
482                rationale: "No corrupt pages.".to_string(),
483                levels: Vec::new(),
484            },
485        };
486
487        let json = serde_json::to_string_pretty(&report).unwrap();
488        assert!(json.contains("\"recommended_level\": 0"));
489        assert!(json.contains("\"file\": \"test.ibd\""));
490    }
491}