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,
243        pc1,
244        ps1
245    )?;
246    wprintln!(
247        writer,
248        "  File 2: {} ({} pages, {} bytes/page)",
249        opts.file2,
250        pc2,
251        ps2
252    )?;
253    wprintln!(writer)?;
254
255    if page_size_mismatch {
256        wprintln!(
257            writer,
258            "{}",
259            format!(
260                "WARNING: Page size mismatch ({} vs {}). Comparing FIL headers only.",
261                ps1, ps2
262            )
263            .yellow()
264        )?;
265        wprintln!(writer)?;
266    }
267
268    // Determine comparison range
269    let (start_page, end_page) = match opts.page {
270        Some(p) => {
271            if p >= pc1 && p >= pc2 {
272                return Err(IdbError::Argument(format!(
273                    "Page {} out of range (file1 has {} pages, file2 has {} pages)",
274                    p, pc1, pc2
275                )));
276            }
277            (p, p + 1)
278        }
279        None => (0, pc1.max(pc2)),
280    };
281
282    let common_pages = pc1.min(pc2);
283    let mut identical = 0u64;
284    let mut modified = 0u64;
285    let mut only_in_file1 = 0u64;
286    let mut only_in_file2 = 0u64;
287    let mut modified_page_nums: Vec<u64> = Vec::new();
288
289    let total = end_page - start_page;
290    let pb = create_progress_bar(total, "pages");
291
292    for page_num in start_page..end_page {
293        pb.inc(1);
294
295        // Pages only in one file
296        if page_num >= pc1 {
297            only_in_file2 += 1;
298            continue;
299        }
300        if page_num >= pc2 {
301            only_in_file1 += 1;
302            continue;
303        }
304
305        let data1 = ts1.read_page(page_num)?;
306        let data2 = ts2.read_page(page_num)?;
307
308        if page_size_mismatch {
309            // Compare only FIL headers (first 38 bytes)
310            let cmp_len = SIZE_FIL_HEAD.min(data1.len()).min(data2.len());
311            if data1[..cmp_len] == data2[..cmp_len] {
312                identical += 1;
313            } else {
314                modified += 1;
315                modified_page_nums.push(page_num);
316
317                if opts.verbose {
318                    print_page_diff(writer, page_num, &data1, &data2, opts.byte_ranges, true)?;
319                }
320            }
321        } else {
322            // Full page comparison
323            if data1 == data2 {
324                identical += 1;
325            } else {
326                modified += 1;
327                modified_page_nums.push(page_num);
328
329                if opts.verbose {
330                    print_page_diff(writer, page_num, &data1, &data2, opts.byte_ranges, false)?;
331                }
332            }
333        }
334    }
335
336    pb.finish_and_clear();
337
338    // Count pages beyond common range for non-single-page mode
339    if opts.page.is_none() {
340        if pc1 > common_pages {
341            only_in_file1 = pc1 - common_pages;
342        }
343        if pc2 > common_pages {
344            only_in_file2 = pc2 - common_pages;
345        }
346    }
347
348    // Print summary
349    wprintln!(writer, "Summary:")?;
350    wprintln!(writer, "  Identical pages:  {}", identical)?;
351    if modified > 0 {
352        wprintln!(
353            writer,
354            "  Modified pages:   {}",
355            format!("{}", modified).red()
356        )?;
357    } else {
358        wprintln!(writer, "  Modified pages:   {}", modified)?;
359    }
360    wprintln!(writer, "  Only in file 1:   {}", only_in_file1)?;
361    wprintln!(writer, "  Only in file 2:   {}", only_in_file2)?;
362
363    if !modified_page_nums.is_empty() {
364        wprintln!(writer)?;
365        let nums: Vec<String> = modified_page_nums.iter().map(|n| n.to_string()).collect();
366        wprintln!(writer, "Modified pages: {}", nums.join(", "))?;
367    }
368
369    Ok(())
370}
371
372fn print_page_diff(
373    writer: &mut dyn Write,
374    page_num: u64,
375    data1: &[u8],
376    data2: &[u8],
377    show_byte_ranges: bool,
378    header_only: bool,
379) -> Result<(), IdbError> {
380    wprintln!(writer, "Page {}: {}", page_num, "MODIFIED".red())?;
381
382    let h1 = FilHeader::parse(data1);
383    let h2 = FilHeader::parse(data2);
384
385    match (h1, h2) {
386        (Some(h1), Some(h2)) => {
387            let changes = compare_headers(&h1, &h2);
388            if changes.is_empty() {
389                wprintln!(writer, "  FIL header: identical (data content differs)")?;
390            } else {
391                for c in &changes {
392                    wprintln!(writer, "  {}: {} -> {}", c.field, c.old_value, c.new_value)?;
393                }
394            }
395
396            // Report unchanged page type for context
397            if h1.page_type == h2.page_type && !changes.iter().any(|c| c.field == "Page Type") {
398                wprintln!(writer, "  Page Type: {} (unchanged)", h1.page_type.name())?;
399            }
400        }
401        _ => {
402            wprintln!(writer, "  (could not parse one or both FIL headers)")?;
403        }
404    }
405
406    if show_byte_ranges && !header_only {
407        let ranges = find_diff_ranges(data1, data2);
408        if !ranges.is_empty() {
409            wprintln!(writer, "  Byte diff ranges:")?;
410            for r in &ranges {
411                wprintln!(writer, "    {}-{} ({} bytes)", r.start, r.end, r.length)?;
412            }
413            let total_changed: usize = ranges.iter().map(|r| r.length).sum();
414            let page_size = data1.len();
415            let pct = (total_changed as f64 / page_size as f64) * 100.0;
416            wprintln!(
417                writer,
418                "  Total: {} bytes changed ({:.1}% of page)",
419                total_changed,
420                pct
421            )?;
422        }
423    }
424
425    wprintln!(writer)?;
426    Ok(())
427}
428
429fn execute_json(
430    opts: &DiffOptions,
431    ts1: &mut Tablespace,
432    ts2: &mut Tablespace,
433    page_size_mismatch: bool,
434    writer: &mut dyn Write,
435) -> Result<(), IdbError> {
436    let ps1 = ts1.page_size();
437    let ps2 = ts2.page_size();
438    let pc1 = ts1.page_count();
439    let pc2 = ts2.page_count();
440
441    let (start_page, end_page) = match opts.page {
442        Some(p) => {
443            if p >= pc1 && p >= pc2 {
444                return Err(IdbError::Argument(format!(
445                    "Page {} out of range (file1 has {} pages, file2 has {} pages)",
446                    p, pc1, pc2
447                )));
448            }
449            (p, p + 1)
450        }
451        None => (0, pc1.max(pc2)),
452    };
453
454    let mut identical = 0u64;
455    let mut modified = 0u64;
456    let mut only_in_file1 = 0u64;
457    let mut only_in_file2 = 0u64;
458    let mut modified_pages: Vec<PageDiff> = Vec::new();
459
460    for page_num in start_page..end_page {
461        if page_num >= pc1 {
462            only_in_file2 += 1;
463            continue;
464        }
465        if page_num >= pc2 {
466            only_in_file1 += 1;
467            continue;
468        }
469
470        let data1 = ts1.read_page(page_num)?;
471        let data2 = ts2.read_page(page_num)?;
472
473        let is_equal = if page_size_mismatch {
474            let cmp_len = SIZE_FIL_HEAD.min(data1.len()).min(data2.len());
475            data1[..cmp_len] == data2[..cmp_len]
476        } else {
477            data1 == data2
478        };
479
480        if is_equal {
481            identical += 1;
482        } else {
483            modified += 1;
484
485            let h1 = FilHeader::parse(&data1);
486            let h2 = FilHeader::parse(&data2);
487
488            let (file1_header, file2_header, changed_fields) = match (&h1, &h2) {
489                (Some(h1), Some(h2)) => {
490                    let changes = compare_headers(h1, h2);
491                    (
492                        Some(header_to_fields(h1)),
493                        Some(header_to_fields(h2)),
494                        changes,
495                    )
496                }
497                _ => (
498                    h1.as_ref().map(header_to_fields),
499                    h2.as_ref().map(header_to_fields),
500                    Vec::new(),
501                ),
502            };
503
504            let (byte_ranges, total_bytes_changed) = if opts.byte_ranges && !page_size_mismatch {
505                let ranges = find_diff_ranges(&data1, &data2);
506                let total: usize = ranges.iter().map(|r| r.length).sum();
507                (ranges, Some(total))
508            } else {
509                (Vec::new(), None)
510            };
511
512            modified_pages.push(PageDiff {
513                page_number: page_num,
514                file1_header,
515                file2_header,
516                changed_fields,
517                byte_ranges,
518                total_bytes_changed,
519            });
520        }
521    }
522
523    // For non-single-page mode, count pages beyond common range
524    if opts.page.is_none() {
525        let common = pc1.min(pc2);
526        if pc1 > common {
527            only_in_file1 = pc1 - common;
528        }
529        if pc2 > common {
530            only_in_file2 = pc2 - common;
531        }
532    }
533
534    let report = DiffReport {
535        file1: FileInfo {
536            path: opts.file1.clone(),
537            page_count: pc1,
538            page_size: ps1,
539        },
540        file2: FileInfo {
541            path: opts.file2.clone(),
542            page_count: pc2,
543            page_size: ps2,
544        },
545        page_size_mismatch,
546        summary: DiffSummary {
547            identical,
548            modified,
549            only_in_file1,
550            only_in_file2,
551        },
552        modified_pages,
553    };
554
555    let json = serde_json::to_string_pretty(&report)
556        .map_err(|e| IdbError::Parse(format!("JSON serialization error: {}", e)))?;
557    wprintln!(writer, "{}", json)?;
558
559    Ok(())
560}