1use 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
17pub struct SimulateOptions {
19 pub file: Option<String>,
21 pub datadir: Option<String>,
23 pub level: Option<u8>,
25 pub verbose: bool,
27 pub json: bool,
29 pub csv: bool,
31 pub page_size: Option<u32>,
33 pub keyring: Option<String>,
35 pub mmap: bool,
37 pub depth: Option<u32>,
39}
40
41pub 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
54fn 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 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
83fn 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
123fn 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
133fn 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
146fn 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 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 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 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 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 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
296fn 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
370fn 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 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 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); 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 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}