1use byteorder::{BigEndian, ByteOrder};
21use serde::Serialize;
22
23use crate::innodb::constants::*;
24use crate::innodb::page::FilHeader;
25use crate::innodb::page_types::PageType;
26use crate::innodb::vendor::{InnoDbVendor, VendorInfo};
27use crate::IdbError;
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
49pub struct MysqlVersion {
50 pub major: u32,
51 pub minor: u32,
52 pub patch: u32,
53}
54
55impl MysqlVersion {
56 pub fn parse(s: &str) -> Result<Self, IdbError> {
72 let parts: Vec<&str> = s.split('.').collect();
73 if parts.len() != 3 {
74 return Err(IdbError::Argument(format!(
75 "Invalid MySQL version '{}': expected format X.Y.Z",
76 s
77 )));
78 }
79 let major = parts[0]
80 .parse::<u32>()
81 .map_err(|_| IdbError::Argument(format!("Invalid major version in '{}'", s)))?;
82 let minor = parts[1]
83 .parse::<u32>()
84 .map_err(|_| IdbError::Argument(format!("Invalid minor version in '{}'", s)))?;
85 let patch = parts[2]
86 .parse::<u32>()
87 .map_err(|_| IdbError::Argument(format!("Invalid patch version in '{}'", s)))?;
88 Ok(MysqlVersion {
89 major,
90 minor,
91 patch,
92 })
93 }
94
95 pub fn from_id(version_id: u64) -> Self {
106 MysqlVersion {
107 major: (version_id / 10000) as u32,
108 minor: ((version_id % 10000) / 100) as u32,
109 patch: (version_id % 100) as u32,
110 }
111 }
112
113 pub fn to_id(&self) -> u64 {
124 (self.major as u64) * 10000 + (self.minor as u64) * 100 + self.patch as u64
125 }
126
127 pub fn is_at_least(&self, other: &MysqlVersion) -> bool {
141 (self.major, self.minor, self.patch) >= (other.major, other.minor, other.patch)
142 }
143}
144
145impl std::fmt::Display for MysqlVersion {
146 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
148 }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
163pub enum Severity {
164 Info,
166 Warning,
168 Error,
170}
171
172impl std::fmt::Display for Severity {
173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174 match self {
175 Severity::Info => write!(f, "info"),
176 Severity::Warning => write!(f, "warning"),
177 Severity::Error => write!(f, "error"),
178 }
179 }
180}
181
182#[derive(Debug, Clone, Serialize)]
184pub struct CompatCheck {
185 pub check: String,
187 pub message: String,
189 pub severity: Severity,
191 #[serde(skip_serializing_if = "Option::is_none")]
193 pub current_value: Option<String>,
194 #[serde(skip_serializing_if = "Option::is_none")]
196 pub expected: Option<String>,
197}
198
199#[derive(Debug, Clone, Serialize)]
201pub struct TablespaceInfo {
202 pub page_size: u32,
204 pub fsp_flags: u32,
206 pub space_id: u32,
208 #[serde(skip_serializing_if = "Option::is_none")]
210 pub row_format: Option<String>,
211 pub has_sdi: bool,
213 pub is_encrypted: bool,
215 pub vendor: VendorInfo,
217 #[serde(skip_serializing_if = "Option::is_none")]
219 pub mysql_version_id: Option<u64>,
220 pub has_compressed_pages: bool,
222 pub has_instant_columns: bool,
224}
225
226#[derive(Debug, Clone, Serialize)]
228pub struct CompatReport {
229 pub file: String,
231 pub target_version: String,
233 #[serde(skip_serializing_if = "Option::is_none")]
235 pub source_version: Option<String>,
236 pub compatible: bool,
238 pub checks: Vec<CompatCheck>,
240 pub summary: CompatSummary,
242}
243
244#[derive(Debug, Clone, Serialize)]
246pub struct CompatSummary {
247 pub total_checks: usize,
249 pub errors: usize,
251 pub warnings: usize,
253 pub info: usize,
255}
256
257pub fn extract_tablespace_info(
262 ts: &mut crate::innodb::tablespace::Tablespace,
263) -> Result<TablespaceInfo, IdbError> {
264 let page_size = ts.page_size();
265 let page0 = ts.read_page(0)?;
266 let vendor = ts.vendor_info().clone();
267 let fsp_flags = if page0.len() >= (FIL_PAGE_DATA + FSP_SPACE_FLAGS + 4) {
268 BigEndian::read_u32(&page0[FIL_PAGE_DATA + FSP_SPACE_FLAGS..])
269 } else {
270 0
271 };
272 let space_id = ts
273 .fsp_header()
274 .map(|h| h.space_id)
275 .unwrap_or_else(|| FilHeader::parse(&page0).map(|h| h.space_id).unwrap_or(0));
276 let is_encrypted = ts.encryption_info().is_some();
277
278 let sdi_pages = crate::innodb::sdi::find_sdi_pages(ts).unwrap_or_default();
280 let has_sdi = !sdi_pages.is_empty();
281
282 let mut mysql_version_id = None;
284 let mut row_format = None;
285 let has_instant_columns = false;
286
287 if has_sdi {
288 if let Ok(records) = crate::innodb::sdi::extract_sdi_from_pages(ts, &sdi_pages) {
289 for rec in &records {
290 if rec.sdi_type == 1 {
291 if let Ok(envelope) =
292 serde_json::from_str::<crate::innodb::schema::SdiEnvelope>(&rec.data)
293 {
294 mysql_version_id = Some(envelope.mysqld_version_id);
295 let rf_code = envelope.dd_object.row_format;
296 row_format =
297 Some(crate::innodb::schema::row_format_name(rf_code).to_string());
298 }
301 }
302 }
303 }
304 }
305
306 let has_compressed_pages = {
308 let page_count = ts.page_count();
309 let mut found = false;
310 let check_count = page_count.min(10);
312 for i in 0..check_count {
313 if let Ok(page) = ts.read_page(i) {
314 if let Some(hdr) = FilHeader::parse(&page) {
315 if hdr.page_type == PageType::Compressed {
316 found = true;
317 break;
318 }
319 }
320 }
321 }
322 found
323 };
324
325 Ok(TablespaceInfo {
326 page_size,
327 fsp_flags,
328 space_id,
329 row_format,
330 has_sdi,
331 is_encrypted,
332 vendor,
333 mysql_version_id,
334 has_compressed_pages,
335 has_instant_columns,
336 })
337}
338
339pub fn check_compatibility(info: &TablespaceInfo, target: &MysqlVersion) -> Vec<CompatCheck> {
344 let mut checks = Vec::new();
345
346 check_page_size(info, target, &mut checks);
347 check_row_format(info, target, &mut checks);
348 check_sdi_presence(info, target, &mut checks);
349 check_encryption(info, target, &mut checks);
350 check_vendor_compatibility(info, target, &mut checks);
351 check_compression(info, target, &mut checks);
352
353 checks
354}
355
356pub fn build_compat_report(
361 info: &TablespaceInfo,
362 target: &MysqlVersion,
363 file: &str,
364) -> CompatReport {
365 let checks = check_compatibility(info, target);
366
367 let errors = checks
368 .iter()
369 .filter(|c| c.severity == Severity::Error)
370 .count();
371 let warnings = checks
372 .iter()
373 .filter(|c| c.severity == Severity::Warning)
374 .count();
375 let info_count = checks
376 .iter()
377 .filter(|c| c.severity == Severity::Info)
378 .count();
379
380 let source_version = info.mysql_version_id.map(|id| {
381 let v = MysqlVersion::from_id(id);
382 v.to_string()
383 });
384
385 CompatReport {
386 file: file.to_string(),
387 target_version: target.to_string(),
388 source_version,
389 compatible: errors == 0,
390 checks,
391 summary: CompatSummary {
392 total_checks: errors + warnings + info_count,
393 errors,
394 warnings,
395 info: info_count,
396 },
397 }
398}
399
400fn check_page_size(info: &TablespaceInfo, target: &MysqlVersion, checks: &mut Vec<CompatCheck>) {
403 let non_default = info.page_size != SIZE_PAGE_DEFAULT;
405 if non_default
406 && !target.is_at_least(&MysqlVersion {
407 major: 5,
408 minor: 7,
409 patch: 6,
410 })
411 {
412 checks.push(CompatCheck {
413 check: "page_size".to_string(),
414 message: format!(
415 "Non-default page size {} requires MySQL 5.7.6+",
416 info.page_size
417 ),
418 severity: Severity::Error,
419 current_value: Some(info.page_size.to_string()),
420 expected: Some("16384".to_string()),
421 });
422 } else if non_default {
423 checks.push(CompatCheck {
424 check: "page_size".to_string(),
425 message: format!("Non-default page size {} is supported", info.page_size),
426 severity: Severity::Info,
427 current_value: Some(info.page_size.to_string()),
428 expected: None,
429 });
430 }
431}
432
433fn check_row_format(info: &TablespaceInfo, target: &MysqlVersion, checks: &mut Vec<CompatCheck>) {
434 if let Some(ref rf) = info.row_format {
435 let rf_upper = rf.to_uppercase();
436 if rf_upper == "COMPRESSED"
438 && target.is_at_least(&MysqlVersion {
439 major: 8,
440 minor: 4,
441 patch: 0,
442 })
443 {
444 checks.push(CompatCheck {
445 check: "row_format".to_string(),
446 message: "ROW_FORMAT=COMPRESSED is deprecated in MySQL 8.4+".to_string(),
447 severity: Severity::Warning,
448 current_value: Some(rf.clone()),
449 expected: Some("DYNAMIC".to_string()),
450 });
451 }
452 if rf_upper == "REDUNDANT"
454 && target.is_at_least(&MysqlVersion {
455 major: 9,
456 minor: 0,
457 patch: 0,
458 })
459 {
460 checks.push(CompatCheck {
461 check: "row_format".to_string(),
462 message: "ROW_FORMAT=REDUNDANT is deprecated in MySQL 9.0+".to_string(),
463 severity: Severity::Warning,
464 current_value: Some(rf.clone()),
465 expected: Some("DYNAMIC".to_string()),
466 });
467 }
468 }
469}
470
471fn check_sdi_presence(info: &TablespaceInfo, target: &MysqlVersion, checks: &mut Vec<CompatCheck>) {
472 if target.is_at_least(&MysqlVersion {
474 major: 8,
475 minor: 0,
476 patch: 0,
477 }) && !info.has_sdi
478 {
479 checks.push(CompatCheck {
480 check: "sdi".to_string(),
481 message: "Tablespace lacks SDI metadata required by MySQL 8.0+".to_string(),
482 severity: Severity::Error,
483 current_value: Some("absent".to_string()),
484 expected: Some("present".to_string()),
485 });
486 } else if info.has_sdi
487 && !target.is_at_least(&MysqlVersion {
488 major: 8,
489 minor: 0,
490 patch: 0,
491 })
492 {
493 checks.push(CompatCheck {
494 check: "sdi".to_string(),
495 message: "Tablespace has SDI metadata not recognized by MySQL < 8.0".to_string(),
496 severity: Severity::Warning,
497 current_value: Some("present".to_string()),
498 expected: Some("absent".to_string()),
499 });
500 }
501}
502
503fn check_encryption(info: &TablespaceInfo, target: &MysqlVersion, checks: &mut Vec<CompatCheck>) {
504 if info.is_encrypted
506 && !target.is_at_least(&MysqlVersion {
507 major: 5,
508 minor: 7,
509 patch: 11,
510 })
511 {
512 checks.push(CompatCheck {
513 check: "encryption".to_string(),
514 message: "Tablespace encryption requires MySQL 5.7.11+".to_string(),
515 severity: Severity::Error,
516 current_value: Some("encrypted".to_string()),
517 expected: Some("unencrypted".to_string()),
518 });
519 }
520}
521
522fn check_vendor_compatibility(
523 info: &TablespaceInfo,
524 target: &MysqlVersion,
525 checks: &mut Vec<CompatCheck>,
526) {
527 if info.vendor.vendor == InnoDbVendor::MariaDB {
529 checks.push(CompatCheck {
530 check: "vendor".to_string(),
531 message: "MariaDB tablespace is not compatible with MySQL".to_string(),
532 severity: Severity::Error,
533 current_value: Some(info.vendor.to_string()),
534 expected: Some("MySQL".to_string()),
535 });
536 }
537 if info.vendor.vendor == InnoDbVendor::Percona {
539 checks.push(CompatCheck {
540 check: "vendor".to_string(),
541 message: "Percona XtraDB tablespace is binary-compatible with MySQL".to_string(),
542 severity: Severity::Info,
543 current_value: Some(info.vendor.to_string()),
544 expected: None,
545 });
546 }
547 let _ = target;
549}
550
551fn check_compression(info: &TablespaceInfo, _target: &MysqlVersion, checks: &mut Vec<CompatCheck>) {
552 if info.has_compressed_pages {
553 checks.push(CompatCheck {
554 check: "compression".to_string(),
555 message: "Tablespace uses page compression".to_string(),
556 severity: Severity::Info,
557 current_value: Some("compressed".to_string()),
558 expected: None,
559 });
560 }
561}
562
563#[derive(Debug, Clone, Serialize)]
565pub struct ScanFileResult {
566 pub file: String,
568 pub compatible: bool,
570 #[serde(skip_serializing_if = "Option::is_none")]
572 pub error: Option<String>,
573 #[serde(skip_serializing_if = "Vec::is_empty")]
575 pub checks: Vec<CompatCheck>,
576}
577
578#[derive(Debug, Clone, Serialize)]
580pub struct ScanCompatReport {
581 pub target_version: String,
583 pub files_scanned: usize,
585 pub files_compatible: usize,
587 pub files_incompatible: usize,
589 pub files_error: usize,
591 pub results: Vec<ScanFileResult>,
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598 use crate::innodb::vendor::MariaDbFormat;
599
600 #[test]
601 fn test_version_parse_valid() {
602 let v = MysqlVersion::parse("8.0.32").unwrap();
603 assert_eq!(v.major, 8);
604 assert_eq!(v.minor, 0);
605 assert_eq!(v.patch, 32);
606 }
607
608 #[test]
609 fn test_version_parse_invalid_format() {
610 assert!(MysqlVersion::parse("8.0").is_err());
611 assert!(MysqlVersion::parse("8").is_err());
612 assert!(MysqlVersion::parse("").is_err());
613 assert!(MysqlVersion::parse("8.0.x").is_err());
614 }
615
616 #[test]
617 fn test_version_from_id() {
618 let v = MysqlVersion::from_id(80032);
619 assert_eq!(v.major, 8);
620 assert_eq!(v.minor, 0);
621 assert_eq!(v.patch, 32);
622
623 let v = MysqlVersion::from_id(90001);
624 assert_eq!(v.major, 9);
625 assert_eq!(v.minor, 0);
626 assert_eq!(v.patch, 1);
627 }
628
629 #[test]
630 fn test_version_to_id() {
631 let v = MysqlVersion::parse("8.0.32").unwrap();
632 assert_eq!(v.to_id(), 80032);
633
634 let v = MysqlVersion::parse("9.0.1").unwrap();
635 assert_eq!(v.to_id(), 90001);
636 }
637
638 #[test]
639 fn test_version_display() {
640 let v = MysqlVersion::parse("8.4.0").unwrap();
641 assert_eq!(v.to_string(), "8.4.0");
642 }
643
644 #[test]
645 fn test_version_is_at_least() {
646 let v8 = MysqlVersion::parse("8.0.0").unwrap();
647 let v84 = MysqlVersion::parse("8.4.0").unwrap();
648 let v9 = MysqlVersion::parse("9.0.0").unwrap();
649
650 assert!(v9.is_at_least(&v84));
651 assert!(v9.is_at_least(&v8));
652 assert!(v84.is_at_least(&v8));
653 assert!(v8.is_at_least(&v8));
654 assert!(!v8.is_at_least(&v84));
655 assert!(!v84.is_at_least(&v9));
656 }
657
658 #[test]
659 fn test_severity_display() {
660 assert_eq!(Severity::Info.to_string(), "info");
661 assert_eq!(Severity::Warning.to_string(), "warning");
662 assert_eq!(Severity::Error.to_string(), "error");
663 }
664
665 #[test]
666 fn test_check_page_size_default() {
667 let info = TablespaceInfo {
668 page_size: 16384,
669 fsp_flags: 0,
670 space_id: 1,
671 row_format: None,
672 has_sdi: true,
673 is_encrypted: false,
674 vendor: VendorInfo::mysql(),
675 mysql_version_id: None,
676 has_compressed_pages: false,
677 has_instant_columns: false,
678 };
679 let target = MysqlVersion::parse("8.0.0").unwrap();
680 let mut checks = Vec::new();
681 check_page_size(&info, &target, &mut checks);
682 assert!(checks.is_empty());
684 }
685
686 #[test]
687 fn test_check_page_size_non_default_old_mysql() {
688 let info = TablespaceInfo {
689 page_size: 8192,
690 fsp_flags: 0,
691 space_id: 1,
692 row_format: None,
693 has_sdi: false,
694 is_encrypted: false,
695 vendor: VendorInfo::mysql(),
696 mysql_version_id: None,
697 has_compressed_pages: false,
698 has_instant_columns: false,
699 };
700 let target = MysqlVersion::parse("5.6.0").unwrap();
701 let mut checks = Vec::new();
702 check_page_size(&info, &target, &mut checks);
703 assert_eq!(checks.len(), 1);
704 assert_eq!(checks[0].severity, Severity::Error);
705 }
706
707 #[test]
708 fn test_check_sdi_missing_for_8_0() {
709 let info = TablespaceInfo {
710 page_size: 16384,
711 fsp_flags: 0,
712 space_id: 1,
713 row_format: None,
714 has_sdi: false,
715 is_encrypted: false,
716 vendor: VendorInfo::mysql(),
717 mysql_version_id: None,
718 has_compressed_pages: false,
719 has_instant_columns: false,
720 };
721 let target = MysqlVersion::parse("8.0.0").unwrap();
722 let mut checks = Vec::new();
723 check_sdi_presence(&info, &target, &mut checks);
724 assert_eq!(checks.len(), 1);
725 assert_eq!(checks[0].severity, Severity::Error);
726 assert!(checks[0].message.contains("lacks SDI"));
727 }
728
729 #[test]
730 fn test_check_sdi_present_for_pre_8() {
731 let info = TablespaceInfo {
732 page_size: 16384,
733 fsp_flags: 0,
734 space_id: 1,
735 row_format: None,
736 has_sdi: true,
737 is_encrypted: false,
738 vendor: VendorInfo::mysql(),
739 mysql_version_id: None,
740 has_compressed_pages: false,
741 has_instant_columns: false,
742 };
743 let target = MysqlVersion::parse("5.7.44").unwrap();
744 let mut checks = Vec::new();
745 check_sdi_presence(&info, &target, &mut checks);
746 assert_eq!(checks.len(), 1);
747 assert_eq!(checks[0].severity, Severity::Warning);
748 }
749
750 #[test]
751 fn test_check_vendor_mariadb() {
752 let info = TablespaceInfo {
753 page_size: 16384,
754 fsp_flags: 0,
755 space_id: 1,
756 row_format: None,
757 has_sdi: false,
758 is_encrypted: false,
759 vendor: VendorInfo::mariadb(MariaDbFormat::FullCrc32),
760 mysql_version_id: None,
761 has_compressed_pages: false,
762 has_instant_columns: false,
763 };
764 let target = MysqlVersion::parse("8.4.0").unwrap();
765 let mut checks = Vec::new();
766 check_vendor_compatibility(&info, &target, &mut checks);
767 assert_eq!(checks.len(), 1);
768 assert_eq!(checks[0].severity, Severity::Error);
769 assert!(checks[0].message.contains("MariaDB"));
770 }
771
772 #[test]
773 fn test_check_vendor_percona() {
774 let info = TablespaceInfo {
775 page_size: 16384,
776 fsp_flags: 0,
777 space_id: 1,
778 row_format: None,
779 has_sdi: true,
780 is_encrypted: false,
781 vendor: VendorInfo::percona(),
782 mysql_version_id: None,
783 has_compressed_pages: false,
784 has_instant_columns: false,
785 };
786 let target = MysqlVersion::parse("8.4.0").unwrap();
787 let mut checks = Vec::new();
788 check_vendor_compatibility(&info, &target, &mut checks);
789 assert_eq!(checks.len(), 1);
790 assert_eq!(checks[0].severity, Severity::Info);
791 }
792
793 #[test]
794 fn test_check_row_format_compressed_84() {
795 let info = TablespaceInfo {
796 page_size: 16384,
797 fsp_flags: 0,
798 space_id: 1,
799 row_format: Some("COMPRESSED".to_string()),
800 has_sdi: true,
801 is_encrypted: false,
802 vendor: VendorInfo::mysql(),
803 mysql_version_id: None,
804 has_compressed_pages: false,
805 has_instant_columns: false,
806 };
807 let target = MysqlVersion::parse("8.4.0").unwrap();
808 let mut checks = Vec::new();
809 check_row_format(&info, &target, &mut checks);
810 assert_eq!(checks.len(), 1);
811 assert_eq!(checks[0].severity, Severity::Warning);
812 assert!(checks[0].message.contains("COMPRESSED"));
813 }
814
815 #[test]
816 fn test_check_row_format_redundant_90() {
817 let info = TablespaceInfo {
818 page_size: 16384,
819 fsp_flags: 0,
820 space_id: 1,
821 row_format: Some("REDUNDANT".to_string()),
822 has_sdi: true,
823 is_encrypted: false,
824 vendor: VendorInfo::mysql(),
825 mysql_version_id: None,
826 has_compressed_pages: false,
827 has_instant_columns: false,
828 };
829 let target = MysqlVersion::parse("9.0.0").unwrap();
830 let mut checks = Vec::new();
831 check_row_format(&info, &target, &mut checks);
832 assert_eq!(checks.len(), 1);
833 assert_eq!(checks[0].severity, Severity::Warning);
834 assert!(checks[0].message.contains("REDUNDANT"));
835 }
836
837 #[test]
838 fn test_check_encryption_old_mysql() {
839 let info = TablespaceInfo {
840 page_size: 16384,
841 fsp_flags: 0,
842 space_id: 1,
843 row_format: None,
844 has_sdi: false,
845 is_encrypted: true,
846 vendor: VendorInfo::mysql(),
847 mysql_version_id: None,
848 has_compressed_pages: false,
849 has_instant_columns: false,
850 };
851 let target = MysqlVersion::parse("5.6.0").unwrap();
852 let mut checks = Vec::new();
853 check_encryption(&info, &target, &mut checks);
854 assert_eq!(checks.len(), 1);
855 assert_eq!(checks[0].severity, Severity::Error);
856 }
857
858 #[test]
859 fn test_build_compat_report_compatible() {
860 let info = TablespaceInfo {
861 page_size: 16384,
862 fsp_flags: 0,
863 space_id: 1,
864 row_format: Some("DYNAMIC".to_string()),
865 has_sdi: true,
866 is_encrypted: false,
867 vendor: VendorInfo::mysql(),
868 mysql_version_id: Some(80032),
869 has_compressed_pages: false,
870 has_instant_columns: false,
871 };
872 let target = MysqlVersion::parse("8.4.0").unwrap();
873 let report = build_compat_report(&info, &target, "test.ibd");
874 assert!(report.compatible);
875 assert_eq!(report.summary.errors, 0);
876 assert_eq!(report.source_version, Some("8.0.32".to_string()));
877 }
878
879 #[test]
880 fn test_build_compat_report_incompatible() {
881 let info = TablespaceInfo {
882 page_size: 16384,
883 fsp_flags: 0,
884 space_id: 1,
885 row_format: None,
886 has_sdi: false,
887 is_encrypted: false,
888 vendor: VendorInfo::mariadb(MariaDbFormat::FullCrc32),
889 mysql_version_id: None,
890 has_compressed_pages: false,
891 has_instant_columns: false,
892 };
893 let target = MysqlVersion::parse("8.4.0").unwrap();
894 let report = build_compat_report(&info, &target, "test.ibd");
895 assert!(!report.compatible);
896 assert!(report.summary.errors > 0);
897 }
898}