Skip to main content

idb/cli/
diff.rs

1use std::io::Write;
2
3use colored::Colorize;
4use serde::Serialize;
5
6use crate::cli::{create_progress_bar, wprintln};
7use crate::innodb::constants::SIZE_FIL_HEAD;
8use crate::innodb::page::FilHeader;
9use crate::innodb::tablespace::Tablespace;
10use crate::IdbError;
11
12/// Options for the `inno diff` subcommand.
13pub struct DiffOptions {
14    /// Path to the first InnoDB tablespace file.
15    pub file1: String,
16    /// Path to the second InnoDB tablespace file.
17    pub file2: String,
18    /// Show per-page header field diffs.
19    pub verbose: bool,
20    /// Show byte-range diffs (requires verbose).
21    pub byte_ranges: bool,
22    /// Compare a single page only.
23    pub page: Option<u64>,
24    /// Emit output as JSON.
25    pub json: bool,
26    /// Override the auto-detected page size.
27    pub page_size: Option<u32>,
28    /// Path to MySQL keyring file for decrypting encrypted tablespaces.
29    pub keyring: Option<String>,
30}
31
32// ── JSON output structs ─────────────────────────────────────────────
33
34#[derive(Serialize)]
35struct DiffReport {
36    file1: FileInfo,
37    file2: FileInfo,
38    page_size_mismatch: bool,
39    summary: DiffSummary,
40    #[serde(skip_serializing_if = "Vec::is_empty")]
41    modified_pages: Vec<PageDiff>,
42}
43
44#[derive(Serialize)]
45struct FileInfo {
46    path: String,
47    page_count: u64,
48    page_size: u32,
49}
50
51#[derive(Serialize)]
52struct DiffSummary {
53    identical: u64,
54    modified: u64,
55    only_in_file1: u64,
56    only_in_file2: u64,
57}
58
59#[derive(Serialize)]
60struct PageDiff {
61    page_number: u64,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    file1_header: Option<HeaderFields>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    file2_header: Option<HeaderFields>,
66    #[serde(skip_serializing_if = "Vec::is_empty")]
67    changed_fields: Vec<FieldChange>,
68    #[serde(skip_serializing_if = "Vec::is_empty")]
69    byte_ranges: Vec<ByteRange>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    total_bytes_changed: Option<usize>,
72}
73
74#[derive(Serialize)]
75struct HeaderFields {
76    checksum: String,
77    page_number: u32,
78    prev_page: String,
79    next_page: String,
80    lsn: u64,
81    page_type: String,
82    flush_lsn: u64,
83    space_id: u32,
84}
85
86#[derive(Serialize)]
87struct FieldChange {
88    field: String,
89    old_value: String,
90    new_value: String,
91}
92
93#[derive(Serialize)]
94struct ByteRange {
95    start: usize,
96    end: usize,
97    length: usize,
98}
99
100// ── Helpers ─────────────────────────────────────────────────────────
101
102fn header_to_fields(h: &FilHeader) -> HeaderFields {
103    HeaderFields {
104        checksum: format!("0x{:08X}", h.checksum),
105        page_number: h.page_number,
106        prev_page: format!("0x{:08X}", h.prev_page),
107        next_page: format!("0x{:08X}", h.next_page),
108        lsn: h.lsn,
109        page_type: h.page_type.name().to_string(),
110        flush_lsn: h.flush_lsn,
111        space_id: h.space_id,
112    }
113}
114
115fn compare_headers(h1: &FilHeader, h2: &FilHeader) -> Vec<FieldChange> {
116    let mut changes = Vec::new();
117
118    if h1.checksum != h2.checksum {
119        changes.push(FieldChange {
120            field: "Checksum".to_string(),
121            old_value: format!("0x{:08X}", h1.checksum),
122            new_value: format!("0x{:08X}", h2.checksum),
123        });
124    }
125    if h1.page_number != h2.page_number {
126        changes.push(FieldChange {
127            field: "Page Number".to_string(),
128            old_value: h1.page_number.to_string(),
129            new_value: h2.page_number.to_string(),
130        });
131    }
132    if h1.prev_page != h2.prev_page {
133        changes.push(FieldChange {
134            field: "Prev Page".to_string(),
135            old_value: format!("0x{:08X}", h1.prev_page),
136            new_value: format!("0x{:08X}", h2.prev_page),
137        });
138    }
139    if h1.next_page != h2.next_page {
140        changes.push(FieldChange {
141            field: "Next Page".to_string(),
142            old_value: format!("0x{:08X}", h1.next_page),
143            new_value: format!("0x{:08X}", h2.next_page),
144        });
145    }
146    if h1.lsn != h2.lsn {
147        changes.push(FieldChange {
148            field: "LSN".to_string(),
149            old_value: h1.lsn.to_string(),
150            new_value: h2.lsn.to_string(),
151        });
152    }
153    if h1.page_type != h2.page_type {
154        changes.push(FieldChange {
155            field: "Page Type".to_string(),
156            old_value: h1.page_type.name().to_string(),
157            new_value: h2.page_type.name().to_string(),
158        });
159    }
160    if h1.flush_lsn != h2.flush_lsn {
161        changes.push(FieldChange {
162            field: "Flush LSN".to_string(),
163            old_value: h1.flush_lsn.to_string(),
164            new_value: h2.flush_lsn.to_string(),
165        });
166    }
167    if h1.space_id != h2.space_id {
168        changes.push(FieldChange {
169            field: "Space ID".to_string(),
170            old_value: h1.space_id.to_string(),
171            new_value: h2.space_id.to_string(),
172        });
173    }
174
175    changes
176}
177
178fn find_diff_ranges(data1: &[u8], data2: &[u8]) -> Vec<ByteRange> {
179    let len = data1.len().min(data2.len());
180    let mut ranges = Vec::new();
181    let mut in_diff = false;
182    let mut start = 0;
183
184    for i in 0..len {
185        if data1[i] != data2[i] {
186            if !in_diff {
187                in_diff = true;
188                start = i;
189            }
190        } else if in_diff {
191            in_diff = false;
192            ranges.push(ByteRange {
193                start,
194                end: i,
195                length: i - start,
196            });
197        }
198    }
199    if in_diff {
200        ranges.push(ByteRange {
201            start,
202            end: len,
203            length: len - start,
204        });
205    }
206
207    ranges
208}
209
210/// Compare two InnoDB tablespace files page-by-page.
211pub fn execute(opts: &DiffOptions, writer: &mut dyn Write) -> Result<(), IdbError> {
212    let mut ts1 = match opts.page_size {
213        Some(ps) => Tablespace::open_with_page_size(&opts.file1, ps)?,
214        None => Tablespace::open(&opts.file1)?,
215    };
216    let mut ts2 = match opts.page_size {
217        Some(ps) => Tablespace::open_with_page_size(&opts.file2, ps)?,
218        None => Tablespace::open(&opts.file2)?,
219    };
220
221    if let Some(ref keyring_path) = opts.keyring {
222        crate::cli::setup_decryption(&mut ts1, keyring_path)?;
223        crate::cli::setup_decryption(&mut ts2, keyring_path)?;
224    }
225
226    let ps1 = ts1.page_size();
227    let ps2 = ts2.page_size();
228    let pc1 = ts1.page_count();
229    let pc2 = ts2.page_count();
230
231    let page_size_mismatch = ps1 != ps2;
232
233    if opts.json {
234        return execute_json(opts, &mut ts1, &mut ts2, page_size_mismatch, writer);
235    }
236
237    // Text output
238    wprintln!(writer, "Comparing:")?;
239    wprintln!(
240        writer,
241        "  File 1: {} ({} pages, {} bytes/page)",
242        opts.file1, pc1, ps1
243    )?;
244    wprintln!(
245        writer,
246        "  File 2: {} ({} pages, {} bytes/page)",
247        opts.file2, pc2, ps2
248    )?;
249    wprintln!(writer)?;
250
251    if page_size_mismatch {
252        wprintln!(
253            writer,
254            "{}",
255            format!(
256                "WARNING: Page size mismatch ({} vs {}). Comparing FIL headers only.",
257                ps1, ps2
258            )
259            .yellow()
260        )?;
261        wprintln!(writer)?;
262    }
263
264    // Determine comparison range
265    let (start_page, end_page) = match opts.page {
266        Some(p) => {
267            if p >= pc1 && p >= pc2 {
268                return Err(IdbError::Argument(format!(
269                    "Page {} out of range (file1 has {} pages, file2 has {} pages)",
270                    p, pc1, pc2
271                )));
272            }
273            (p, p + 1)
274        }
275        None => (0, pc1.max(pc2)),
276    };
277
278    let common_pages = pc1.min(pc2);
279    let mut identical = 0u64;
280    let mut modified = 0u64;
281    let mut only_in_file1 = 0u64;
282    let mut only_in_file2 = 0u64;
283    let mut modified_page_nums: Vec<u64> = Vec::new();
284
285    let total = end_page - start_page;
286    let pb = create_progress_bar(total, "pages");
287
288    for page_num in start_page..end_page {
289        pb.inc(1);
290
291        // Pages only in one file
292        if page_num >= pc1 {
293            only_in_file2 += 1;
294            continue;
295        }
296        if page_num >= pc2 {
297            only_in_file1 += 1;
298            continue;
299        }
300
301        let data1 = ts1.read_page(page_num)?;
302        let data2 = ts2.read_page(page_num)?;
303
304        if page_size_mismatch {
305            // Compare only FIL headers (first 38 bytes)
306            let cmp_len = SIZE_FIL_HEAD.min(data1.len()).min(data2.len());
307            if data1[..cmp_len] == data2[..cmp_len] {
308                identical += 1;
309            } else {
310                modified += 1;
311                modified_page_nums.push(page_num);
312
313                if opts.verbose {
314                    print_page_diff(writer, page_num, &data1, &data2, opts.byte_ranges, true)?;
315                }
316            }
317        } else {
318            // Full page comparison
319            if data1 == data2 {
320                identical += 1;
321            } else {
322                modified += 1;
323                modified_page_nums.push(page_num);
324
325                if opts.verbose {
326                    print_page_diff(writer, page_num, &data1, &data2, opts.byte_ranges, false)?;
327                }
328            }
329        }
330    }
331
332    pb.finish_and_clear();
333
334    // Count pages beyond common range for non-single-page mode
335    if opts.page.is_none() {
336        if pc1 > common_pages {
337            only_in_file1 = pc1 - common_pages;
338        }
339        if pc2 > common_pages {
340            only_in_file2 = pc2 - common_pages;
341        }
342    }
343
344    // Print summary
345    wprintln!(writer, "Summary:")?;
346    wprintln!(writer, "  Identical pages:  {}", identical)?;
347    if modified > 0 {
348        wprintln!(
349            writer,
350            "  Modified pages:   {}",
351            format!("{}", modified).red()
352        )?;
353    } else {
354        wprintln!(writer, "  Modified pages:   {}", modified)?;
355    }
356    wprintln!(writer, "  Only in file 1:   {}", only_in_file1)?;
357    wprintln!(writer, "  Only in file 2:   {}", only_in_file2)?;
358
359    if !modified_page_nums.is_empty() {
360        wprintln!(writer)?;
361        let nums: Vec<String> = modified_page_nums.iter().map(|n| n.to_string()).collect();
362        wprintln!(writer, "Modified pages: {}", nums.join(", "))?;
363    }
364
365    Ok(())
366}
367
368fn print_page_diff(
369    writer: &mut dyn Write,
370    page_num: u64,
371    data1: &[u8],
372    data2: &[u8],
373    show_byte_ranges: bool,
374    header_only: bool,
375) -> Result<(), IdbError> {
376    wprintln!(writer, "Page {}: {}", page_num, "MODIFIED".red())?;
377
378    let h1 = FilHeader::parse(data1);
379    let h2 = FilHeader::parse(data2);
380
381    match (h1, h2) {
382        (Some(h1), Some(h2)) => {
383            let changes = compare_headers(&h1, &h2);
384            if changes.is_empty() {
385                wprintln!(writer, "  FIL header: identical (data content differs)")?;
386            } else {
387                for c in &changes {
388                    wprintln!(writer, "  {}: {} -> {}", c.field, c.old_value, c.new_value)?;
389                }
390            }
391
392            // Report unchanged page type for context
393            if h1.page_type == h2.page_type
394                && !changes.iter().any(|c| c.field == "Page Type")
395            {
396                wprintln!(writer, "  Page Type: {} (unchanged)", h1.page_type.name())?;
397            }
398        }
399        _ => {
400            wprintln!(writer, "  (could not parse one or both FIL headers)")?;
401        }
402    }
403
404    if show_byte_ranges && !header_only {
405        let ranges = find_diff_ranges(data1, data2);
406        if !ranges.is_empty() {
407            wprintln!(writer, "  Byte diff ranges:")?;
408            for r in &ranges {
409                wprintln!(writer, "    {}-{} ({} bytes)", r.start, r.end, r.length)?;
410            }
411            let total_changed: usize = ranges.iter().map(|r| r.length).sum();
412            let page_size = data1.len();
413            let pct = (total_changed as f64 / page_size as f64) * 100.0;
414            wprintln!(
415                writer,
416                "  Total: {} bytes changed ({:.1}% of page)",
417                total_changed,
418                pct
419            )?;
420        }
421    }
422
423    wprintln!(writer)?;
424    Ok(())
425}
426
427fn execute_json(
428    opts: &DiffOptions,
429    ts1: &mut Tablespace,
430    ts2: &mut Tablespace,
431    page_size_mismatch: bool,
432    writer: &mut dyn Write,
433) -> Result<(), IdbError> {
434    let ps1 = ts1.page_size();
435    let ps2 = ts2.page_size();
436    let pc1 = ts1.page_count();
437    let pc2 = ts2.page_count();
438
439    let (start_page, end_page) = match opts.page {
440        Some(p) => {
441            if p >= pc1 && p >= pc2 {
442                return Err(IdbError::Argument(format!(
443                    "Page {} out of range (file1 has {} pages, file2 has {} pages)",
444                    p, pc1, pc2
445                )));
446            }
447            (p, p + 1)
448        }
449        None => (0, pc1.max(pc2)),
450    };
451
452    let mut identical = 0u64;
453    let mut modified = 0u64;
454    let mut only_in_file1 = 0u64;
455    let mut only_in_file2 = 0u64;
456    let mut modified_pages: Vec<PageDiff> = Vec::new();
457
458    for page_num in start_page..end_page {
459        if page_num >= pc1 {
460            only_in_file2 += 1;
461            continue;
462        }
463        if page_num >= pc2 {
464            only_in_file1 += 1;
465            continue;
466        }
467
468        let data1 = ts1.read_page(page_num)?;
469        let data2 = ts2.read_page(page_num)?;
470
471        let is_equal = if page_size_mismatch {
472            let cmp_len = SIZE_FIL_HEAD.min(data1.len()).min(data2.len());
473            data1[..cmp_len] == data2[..cmp_len]
474        } else {
475            data1 == data2
476        };
477
478        if is_equal {
479            identical += 1;
480        } else {
481            modified += 1;
482
483            let h1 = FilHeader::parse(&data1);
484            let h2 = FilHeader::parse(&data2);
485
486            let (file1_header, file2_header, changed_fields) = match (&h1, &h2) {
487                (Some(h1), Some(h2)) => {
488                    let changes = compare_headers(h1, h2);
489                    (
490                        Some(header_to_fields(h1)),
491                        Some(header_to_fields(h2)),
492                        changes,
493                    )
494                }
495                _ => (
496                    h1.as_ref().map(header_to_fields),
497                    h2.as_ref().map(header_to_fields),
498                    Vec::new(),
499                ),
500            };
501
502            let (byte_ranges, total_bytes_changed) =
503                if opts.byte_ranges && !page_size_mismatch {
504                    let ranges = find_diff_ranges(&data1, &data2);
505                    let total: usize = ranges.iter().map(|r| r.length).sum();
506                    (ranges, Some(total))
507                } else {
508                    (Vec::new(), None)
509                };
510
511            modified_pages.push(PageDiff {
512                page_number: page_num,
513                file1_header,
514                file2_header,
515                changed_fields,
516                byte_ranges,
517                total_bytes_changed,
518            });
519        }
520    }
521
522    // For non-single-page mode, count pages beyond common range
523    if opts.page.is_none() {
524        let common = pc1.min(pc2);
525        if pc1 > common {
526            only_in_file1 = pc1 - common;
527        }
528        if pc2 > common {
529            only_in_file2 = pc2 - common;
530        }
531    }
532
533    let report = DiffReport {
534        file1: FileInfo {
535            path: opts.file1.clone(),
536            page_count: pc1,
537            page_size: ps1,
538        },
539        file2: FileInfo {
540            path: opts.file2.clone(),
541            page_count: pc2,
542            page_size: ps2,
543        },
544        page_size_mismatch,
545        summary: DiffSummary {
546            identical,
547            modified,
548            only_in_file1,
549            only_in_file2,
550        },
551        modified_pages,
552    };
553
554    let json = serde_json::to_string_pretty(&report)
555        .map_err(|e| IdbError::Parse(format!("JSON serialization error: {}", e)))?;
556    wprintln!(writer, "{}", json)?;
557
558    Ok(())
559}